diff --git a/.env b/.env index e6924f2..0e02e14 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -API_URL=http://node.shoy.publicvm.com/ +API_URL=https://node.shoy.publicvm.com/ giphyApiKey=Ahjpgfo4LVqCACHRcwj0eoMlY5s7u1Uq -Socket_Url=https://app-dbef67eb-9a2e-44fa-abff-3e8b83204d9c.cleverapps.io +Socket_Url=https://node.shoy.publicvm.com serverClientId=1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ffafe51..1f09463 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cmake.sourceDirectory": "C:/Users/kerol/Cross_Platform/linux" + "cmake.sourceDirectory": "D:/ThirdYear/SWE/project/Cross_Platform/linux" } \ No newline at end of file diff --git a/README.md b/README.md index a7bc535..9378874 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,34 @@ +## Localization Setup + +- Added `flutter_localizations` and `intl` to `pubspec.yaml`. +- Created `l10n.yaml` to configure Flutter's gen-l10n. +- Added ARB files in `lib/l10n/`: + - `app_en.arb` + - `app_ar.arb` +- Wired delegates and supported locales in `lib/main.dart`. + +### How to add strings +- Edit ARB files under `lib/l10n/` and add new keys. +- Keep the same keys across languages. + +### Generate localization code +- Run: + + ```powershell + flutter gen-l10n + ``` + +- Import and use generated `AppLocalizations` in widgets: + + ```dart + import 'package:lite_x/l10n/app_localizations.dart'; + + Text(AppLocalizations.of(context)!.trendsTitle) + ``` + +### Switching locales +- By default, the app supports `en` and `ar` and follows system locale. +- You can set a specific locale on `MaterialApp.router` by providing `locale: const Locale('ar')`. # lite_x Lite X is a Flutter/Riverpod client for the X-like backend that exposes a complete Tweets API surface. The app now exercises every Tweets Interaction endpoint (create/update/delete, likes, retweets, bookmarks, replies, quotes, mentions, summaries, liked tweets, user timelines, and search) through dedicated repositories, view models, and screens. diff --git a/firebase.json b/firebase.json index f486d99..b684bcc 100644 --- a/firebase.json +++ b/firebase.json @@ -1 +1 @@ -{"flutter":{"platforms":{"android":{"default":{"projectId":"litex-3c6f1","appId":"1:123824690535:android:fc6ea2d45764d44a960bc2","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"litex-3c6f1","configurations":{"android":"1:123824690535:android:fc6ea2d45764d44a960bc2","ios":"1:123824690535:ios:7d4e4fe266a006fe960bc2","macos":"1:123824690535:ios:7d4e4fe266a006fe960bc2","web":"1:123824690535:web:5c24b3d2f16411d1960bc2","windows":"1:123824690535:web:70728cc3cfda8de2960bc2"}}}}}} \ No newline at end of file +{"flutter":{"platforms":{"android":{"default":{"projectId":"psychic-fin-474008-h8","appId":"1:112144721859:android:227c69fccfe2ec4c813f76","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"psychic-fin-474008-h8","configurations":{"android":"1:112144721859:android:227c69fccfe2ec4c813f76","ios":"1:112144721859:ios:990931c2c90bbd22813f76","macos":"1:112144721859:ios:990931c2c90bbd22813f76","web":"1:112144721859:web:cd55e199eb8ef04e813f76","windows":"1:112144721859:web:01d0e76c4bc65596813f76"}}}}}} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 022c8d2..9d1a9ea 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,58 +1,70 @@ - + - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Lite X - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - lite_x - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - - NSPhotoLibraryUsageDescription - Allow access to your photo library to select and upload images. - NSPhotoLibraryAddUsageDescription - Allow the app to save photos to your photo library. - NSCameraUsageDescription - Allow access to the camera to take profile photos and capture media. - NSMicrophoneUsageDescription - Allow access to the microphone to record audio for media uploads. - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Lite X + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + lite_x + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSPhotoLibraryUsageDescription + Pick an image from gallery + NSCameraUsageDescription + Pick an image from Camera + NSMicrophoneUsageDescription + enable microphone + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.Artemsia.lite-x + CFBundleURLSchemes + + myapp + + + + FlutterDeepLinkingEnabled + + \ No newline at end of file diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..28e23bc --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,8 @@ +# Flutter localization configuration +# See: https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false +untranslated-messages-file: build/untranslated_messages.txt diff --git a/lib/core/classes/PickedImage.dart b/lib/core/classes/PickedImage.dart index 49c98a2..3f2ba6a 100644 --- a/lib/core/classes/PickedImage.dart +++ b/lib/core/classes/PickedImage.dart @@ -42,3 +42,21 @@ Future> pickImages({int maxImages = 4}) async { return []; } } + +Future pickVideo() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? video = await picker.pickVideo(source: ImageSource.gallery); + + if (video != null) { + return PickedImage( + file: File(video.path), + name: video.name, + path: video.path, + ); + } + return null; + } catch (e) { + return null; + } +} diff --git a/lib/core/models/usermodel.dart b/lib/core/models/usermodel.dart index 555fb80..7c7993e 100644 --- a/lib/core/models/usermodel.dart +++ b/lib/core/models/usermodel.dart @@ -20,7 +20,7 @@ class UserModel { final String username; @HiveField(4) - final String? photo; + final String? photo; // media Id @HiveField(5) final String? bio; @@ -40,7 +40,7 @@ class UserModel { @HiveField(10) final Set interests; @HiveField(11) - final String? localProfilePhotoPath; + final String? localProfilePhotoPath; // path of local profile photo UserModel({ required this.name, diff --git a/lib/core/providers/unseenChatsCountProvider.dart b/lib/core/providers/unseenChatsCountProvider.dart new file mode 100644 index 0000000..31f764f --- /dev/null +++ b/lib/core/providers/unseenChatsCountProvider.dart @@ -0,0 +1,3 @@ +import 'package:flutter_riverpod/legacy.dart'; + +final unseenChatsCountProvider = StateProvider((ref) => 0); diff --git a/lib/core/routes/AppRouter.dart b/lib/core/routes/AppRouter.dart index d43296a..e0e63d4 100644 --- a/lib/core/routes/AppRouter.dart +++ b/lib/core/routes/AppRouter.dart @@ -19,11 +19,12 @@ import 'package:lite_x/features/auth/view/screens/Create_Account/Password_Screen import 'package:lite_x/features/auth/view/screens/Create_Account/Upload_Profile_Photo_Screen.dart'; import 'package:lite_x/features/auth/view/screens/Create_Account/Verification_Screen.dart'; import 'package:lite_x/features/auth/view/screens/Log_In/VerificationForgot_Screen.dart'; -import 'package:lite_x/features/chat/view/screens/Search_Direct_messages.dart'; +import 'package:lite_x/features/auth/view/screens/Oauth/SetBirthdate.dart'; import 'package:lite_x/features/chat/view/screens/Search_User_Group.dart'; import 'package:lite_x/features/chat/view/screens/chat_Screen.dart'; import 'package:lite_x/features/chat/view/screens/conversations_screen.dart'; import 'package:lite_x/features/explore/view/explore_screen.dart'; +import 'package:lite_x/features/trends/view/screens/trends_screen.dart'; import 'package:lite_x/features/home/view/screens/tweet_screen.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; @@ -51,36 +52,12 @@ import 'package:lite_x/features/settings/screens/UserName_Screen.dart'; import 'package:lite_x/features/settings/screens/YourAccount_Screen.dart'; import 'package:lite_x/features/settings/screens/AccountInformation_Screen.dart'; import 'package:lite_x/features/settings/screens/ChangePassword_Screen.dart'; +import 'package:lite_x/features/notifications/view/screens/Notification_Screen.dart'; class Approuter { static final GoRouter router = GoRouter( - // initialLocation: "/appshell", initialLocation: "/splash", - // initialExtra: ProfileModel( - // id: "", - // username: "hazememam", - // displayName: "Hazem Emam", - // email: "hazem@gmail.com", - // bio: "Hello from hazem emam ", - // avatarUrl: - // "https://images.pexels.com/photos/31510092/pexels-photo-31510092.jpeg", - // bannerUrl: - // "https://images.pexels.com/photos/1765033/pexels-photo-1765033.jpeg", - // followersCount: 15, - // followingCount: 20, - // tweetsCount: 15, - // isVerified: false, - // joinedDate: formatDate(DateTime(2004, 8, 21), DateFormatType.fullDate), - // website: "https://google.cof", - // location: "cairo", - // postCount: 2, - // birthDate: formatDate(DateTime(2004, 8, 21), DateFormatType.fullDate), - // isFollowing: false, - // isFollower: false, - // protectedAccount: false, - // isBlockedByMe: true, - // isMutedByMe: false, - // ), + // initialLocation: "/trends", routes: [ GoRoute( name: RouteConstants.splash, @@ -184,6 +161,14 @@ class Approuter { transitionsBuilder: _slideRightTransitionBuilder, ), ), + GoRoute( + name: RouteConstants.setbirthdate, + path: "/setbirthdate", + pageBuilder: (context, state) => CustomTransitionPage( + child: const Setbirthdate(), + transitionsBuilder: _slideRightTransitionBuilder, + ), + ), GoRoute( name: RouteConstants.ForgotpasswordScreen, path: "/ForgotpasswordScreen", @@ -279,14 +264,6 @@ class Approuter { ), ), - GoRoute( - name: RouteConstants.SearchDirectMessages, - path: "/SearchDirectMessages", - pageBuilder: (context, state) => CustomTransitionPage( - child: const SearchDirectMessages(), - transitionsBuilder: _slideRightTransitionBuilder, - ), - ), GoRoute( name: RouteConstants.Interests, path: "/Interests", @@ -366,6 +343,8 @@ class Approuter { subtitle: extraData['subtitle'], profileImage: extraData['avatarUrl'], isGroup: extraData['isGroup'] ?? false, + recipientFollowersCount: + extraData['recipientFollowersCount'] ?? 0, ), transitionsBuilder: _slideRightTransitionBuilder, ); @@ -385,7 +364,7 @@ class Approuter { name: RouteConstants.SearchScreen, path: "/searchScreen", pageBuilder: (context, state) => CustomTransitionPage( - child: SearchScreen(), + child: SearchScreen(extra: state.extra as Map?), transitionsBuilder: _slideRightTransitionBuilder, ), ), @@ -397,6 +376,14 @@ class Approuter { transitionsBuilder: _slideRightTransitionBuilder, ), ), + GoRoute( + name: RouteConstants.TrendsScreen, + path: "/trends", + pageBuilder: (context, state) => CustomTransitionPage( + child: const TrendsScreen(), + transitionsBuilder: _slideRightTransitionBuilder, + ), + ), GoRoute( name: RouteConstants.ProfilePhotoScreen, path: "/profilePhotoScreen", @@ -453,6 +440,14 @@ class Approuter { transitionsBuilder: _slideRightTransitionBuilder, ), ), + GoRoute( + name: RouteConstants.notifications, + path: "/notifications", + pageBuilder: (context, state) => CustomTransitionPage( + child: const NotificationScreen(), + transitionsBuilder: _slideRightTransitionBuilder, + ), + ), ], redirect: (context, state) { return null; diff --git a/lib/core/routes/Route_Constants.dart b/lib/core/routes/Route_Constants.dart index 7dc85b6..da1c993 100644 --- a/lib/core/routes/Route_Constants.dart +++ b/lib/core/routes/Route_Constants.dart @@ -19,6 +19,7 @@ class RouteConstants { static String changePasswordScreen = "changePasswordScreen"; static String Interests = "Interests"; static String usernamesettings = "usernamesettings"; + static String setbirthdate = "setbirthdate"; static String FollowingFollowersScreen = "FollowingFollowersScreen"; static String BirthDateScreen = "BirthDateScreen"; @@ -45,11 +46,17 @@ class RouteConstants { "VerifyChangeEmailProfileScreen"; // search feature - static String SearchScreen = "SearchScreen"; + static String SearchScreen = "searchScreen"; // explore feature static String ExploreScreen = "ExploreScreen"; static String ExploreProfileScreen = "ExploreProfileScreen"; + + // trends feature + static String TrendsScreen = "TrendsScreen"; + + static String TweetDetailsScreen = "TweetDetailsScreen"; + static String notifications = "notifications"; } diff --git a/lib/core/services/deep_link_service.dart b/lib/core/services/deep_link_service.dart index 1ec4d1a..59ab16b 100644 --- a/lib/core/services/deep_link_service.dart +++ b/lib/core/services/deep_link_service.dart @@ -21,7 +21,6 @@ class DeepLinkService { } }); - // إضافة مراقب لحالة التطبيق WidgetsBinding.instance.addObserver(_observer); } diff --git a/lib/core/utils.dart b/lib/core/utils.dart index e3850cf..97525a2 100644 --- a/lib/core/utils.dart +++ b/lib/core/utils.dart @@ -72,31 +72,31 @@ String? passwordValidator(String? value) { if (value == null || value.isEmpty) { return 'Password is required'; } - if (value.length < 8) { - return 'Password must be at least 8 characters'; + if (value.length < 9) { + return 'Password must be at least 9 characters'; } if (value.length > 256) { return 'Password must be less than 256 characters'; } final capitalLetters = RegExp(r'[A-Z]'); - if (capitalLetters.allMatches(value).length < 3) { - return 'Password must contain at least 3 uppercase letters'; + if (capitalLetters.allMatches(value).length < 1) { + return 'Password must contain at least 1 uppercase letter'; } final lowercaseLetters = RegExp(r'[a-z]'); - if (lowercaseLetters.allMatches(value).length < 3) { - return 'Password must contain at least 3 lowercase letters'; + if (lowercaseLetters.allMatches(value).length < 1) { + return 'Password must contain at least 1 lowercase letter'; } final symbols = RegExp(r'[!@#\$%^&*(),.?":{}|<>_\-=+;]'); - if (symbols.allMatches(value).length < 3) { - return 'Password must contain at least 3 symbols'; + if (symbols.allMatches(value).length < 1) { + return 'Password must contain at least 1 symbol'; } final numbers = RegExp(r'\d'); - if (numbers.allMatches(value).length < 3) { - return 'Password must contain at least 3 numbers'; + if (numbers.allMatches(value).length < 1) { + return 'Password must contain at least 1 number'; } return null; diff --git a/lib/core/view/screen/app_shell.dart b/lib/core/view/screen/app_shell.dart index 86128f0..f7107c8 100644 --- a/lib/core/view/screen/app_shell.dart +++ b/lib/core/view/screen/app_shell.dart @@ -6,6 +6,7 @@ import 'package:lite_x/features/chat/view/screens/conversations_screen.dart'; import 'package:lite_x/features/home/view/screens/home_screen.dart'; import 'package:lite_x/features/profile/view/screens/explore_profile_screen.dart'; import 'package:lite_x/features/shared/widgets/bottom_navigation.dart'; +import 'package:lite_x/features/notifications/view/screens/Notification_Screen.dart'; // Provider for managing which tab is selected final shellNavigationProvider = StateProvider((ref) => 0); @@ -30,24 +31,9 @@ class AppShell extends ConsumerWidget { // _buildSearchScreen(), // Index 1 - Search ExploreProfileScreen(), _buildCommunitiesScreen(), // Index 2 - Communities - _buildNotificationsScreen(), // Index 3 - Notifications + NotificationScreen(), // Index 3 - Notifications ConversationsScreen(), - //// Index 4 - Messages - // ListView( - // children: [ - // Padding( - // padding: const EdgeInsets.all(24), - // child: Text( - // "Nothing to see here -- yet.", - // style: TextStyle( - // color: Colors.white, - // fontWeight: FontWeight.bold, - // fontSize: 35, - // ), - // ), - // ), - // ], - // ), + ], ), bottomNavigationBar: AnimatedContainer( @@ -65,16 +51,6 @@ class AppShell extends ConsumerWidget { ); } - // Placeholder screens - you'll create these later - Widget _buildSearchScreen() { - return const Center( - child: Text( - 'Search Screen', - style: TextStyle(color: Colors.white, fontSize: 24), - ), - ); - } - Widget _buildNotificationsScreen() { return const Center( child: Text( diff --git a/lib/features/auth/repositories/auth_remote_repository.dart b/lib/features/auth/repositories/auth_remote_repository.dart index 263e914..8474d8c 100644 --- a/lib/features/auth/repositories/auth_remote_repository.dart +++ b/lib/features/auth/repositories/auth_remote_repository.dart @@ -1,4 +1,3 @@ -// ignore_for_file: unused_catch_clause import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -32,10 +31,9 @@ class AuthRemoteRepository { try { final baseUrl = dotenv.env["API_URL"]!; final authUrl = "${baseUrl}oauth2/authorize/github"; - final fullUrl = "$authUrl?redirect_uri=${baseUrl}login/success"; final opened = await launchUrl( - Uri.parse(fullUrl), + Uri.parse(authUrl), mode: LaunchMode.externalApplication, ); @@ -50,7 +48,7 @@ class AuthRemoteRepository { } final token = uri.queryParameters["token"]; - final refresh = uri.queryParameters["refresh"]; + final refresh = uri.queryParameters["refresh-token"]; final userRaw = uri.queryParameters["user"]; if (token == null || refresh == null || userRaw == null) { @@ -76,7 +74,7 @@ class AuthRemoteRepository { final _googleSignIn = signIn.GoogleSignIn( serverClientId: - "1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com", + "https://1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com", scopes: ['email', 'https://www.googleapis.com/auth/userinfo.profile'], ); @@ -134,7 +132,7 @@ class AuthRemoteRepository { data: {'name': name, 'email': email, 'dateOfBirth': dateOfBirth}, ); return right(response.data['message'] ?? 'Verification email sent'); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Signup failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -154,7 +152,7 @@ class AuthRemoteRepository { final message = response.data['message'] ?? 'Verified successfully'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Email verification failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -171,11 +169,12 @@ class AuthRemoteRepository { 'api/auth/finalize_signup', data: {'email': email, 'password': password}, ); - + print("asermohamed${response.data['tokens']}"); final user = UserModel.fromMap(response.data['user']); final tokens = TokensModel.fromMap(response.data['tokens']); + return right((user, tokens)); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Signup failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -235,7 +234,7 @@ class AuthRemoteRepository { print("MEDIA ID AFTER UPLOAD: $mediaId"); return right({'mediaId': mediaId, 'keyName': newMediaKey}); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Upload failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -269,6 +268,19 @@ class AuthRemoteRepository { } } + //-----------------------------------------------------------------------updateprofilephoto----------------------------------------------------------------------------------// + Future> updateProfilePhoto( + String userId, + String mediaId, + ) async { + try { + await _dio.patch("api/users/profile-picture/$userId/$mediaId"); + return const Right(()); + } catch (e) { + return Left(AppFailure(message: "couldn't update profile picture")); + } + } + //-------------------------------------------------------------------------------------------------------------------------------------------------------// Future> updateUsername({ required UserModel currentUser, @@ -283,14 +295,32 @@ class AuthRemoteRepository { final updatedUser = currentUser.copyWith(username: newUsername); final newtokens = TokensModel.fromMap_update(response.data['tokens']); return right((updatedUser, newtokens)); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Failed to update username')); } catch (e) { return left(AppFailure(message: e.toString())); } } - //---------------------------------------------------------google sign up--------------------------------------------------------------------------// + //---------------------------------------------------------setbirthdate-------------------------------------------------------------------------// + Future> setbirthdate({ + required String day, + required String month, + required String year, + }) async { + try { + final response = await _dio.post( + 'api/auth/set-birthdate', + data: {'day': day, 'month': month, 'year': year}, + ); + final message = response.data['message'] as String; + return right(message); + } on DioException { + return left(AppFailure(message: 'Failed to set birthdate')); + } catch (e) { + return left(AppFailure(message: e.toString())); + } + } //-------------------------------------------------FCM Token Registration-----------------------------------------------------------------------------------------// Future> registerFcmToken({ @@ -339,15 +369,17 @@ class AuthRemoteRepository { final user = UserModel.fromMap(response.data['user']); final tokens = TokensModel.fromMap_login(response.data); - // print(tokens); + print("asermohamed${user.id}"); + print("asermohamed${tokens.accessToken}"); return right((user, tokens)); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Login failed')); } catch (e) { return left(AppFailure(message: "Wrong Password")); } } + //-----------------------------------------------check email-------------------------------------------------------------------------------------// Future> check_email({required String email}) async { try { final response = await _dio.post( @@ -355,13 +387,31 @@ class AuthRemoteRepository { data: {'email': email}, ); return right(response.data['exists'] ?? false); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Email check failed')); } catch (e) { return left(AppFailure(message: e.toString())); } } + //--------------------------------------suggest usernames--------------------------------------// + Future>> suggest_usernames({ + required String username, + }) async { + try { + final response = await _dio.post( + 'api/auth/suggest-usernames', + data: {'name': username}, + ); + final suggestions = List.from(response.data['suggestions'] ?? []); + return right(suggestions); + } on DioException { + return left(AppFailure(message: 'Username suggestions failed')); + } catch (e) { + return left(AppFailure(message: e.toString())); + } + } + //--------------------------------------forgetpassword---------------------------------------------------------------// Future> forget_password({ required String email, @@ -373,7 +423,7 @@ class AuthRemoteRepository { ); final message = response.data['message'] ?? 'Reset code sent'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Forget password failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -391,7 +441,7 @@ class AuthRemoteRepository { ); final message = response.data['message'] ?? 'Reset code verified'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Verify reset code failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -410,7 +460,7 @@ class AuthRemoteRepository { final user = UserModel.fromMap(response.data['user']); final tokens = TokensModel.fromMap_reset_password(response.data); return right((user, tokens)); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Reset password failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -437,7 +487,7 @@ class AuthRemoteRepository { final message = response.data['message'] ?? 'Password updated successfully'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'update password failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -455,7 +505,7 @@ class AuthRemoteRepository { ); final message = response.data['message'] ?? 'Email updated successfully'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'update email failed')); } catch (e) { return left(AppFailure(message: e.toString())); @@ -474,7 +524,7 @@ class AuthRemoteRepository { final message = response.data['message'] ?? 'updated email successfully'; return right(message); - } on DioException catch (e) { + } on DioException { return left(AppFailure(message: 'Email update failed')); } catch (e) { return left(AppFailure(message: e.toString())); diff --git a/lib/features/auth/view/screens/Create_Account/CreateAccount_Screen.dart b/lib/features/auth/view/screens/Create_Account/CreateAccount_Screen.dart index ef94e27..329eeed 100644 --- a/lib/features/auth/view/screens/Create_Account/CreateAccount_Screen.dart +++ b/lib/features/auth/view/screens/Create_Account/CreateAccount_Screen.dart @@ -267,7 +267,7 @@ class _CreateAccountScreenState extends ConsumerState { color: Palette.textWhite, ), ), - const SizedBox(height: 150), + const SizedBox(height: 100), CustomTextField( controller: _nameController, labelText: 'Name', @@ -279,7 +279,7 @@ class _CreateAccountScreenState extends ConsumerState { context, ).requestFocus(_emailFocus), ), - const SizedBox(height: 10), + const SizedBox(height: 15), CustomTextField( controller: _emailController, labelText: 'Email', @@ -289,7 +289,7 @@ class _CreateAccountScreenState extends ConsumerState { focusNode: _emailFocus, validationState: _emailState, ), - const SizedBox(height: 25), + const SizedBox(height: 15), CustomTextField( controller: _dobController, labelText: 'Date of birth', @@ -303,7 +303,7 @@ class _CreateAccountScreenState extends ConsumerState { ), ), _buildNextButton(isLoading), - const SizedBox(height: 15), + const SizedBox(height: 5), ], ), ), @@ -322,7 +322,6 @@ class _CreateAccountScreenState extends ConsumerState { padding: const EdgeInsets.all(10), alignment: Alignment.centerRight, child: SizedBox( - width: 90, child: ElevatedButton( onPressed: (_isFormValid && !isLoading) ? _handleNext : null, style: ElevatedButton.styleFrom( @@ -330,7 +329,7 @@ class _CreateAccountScreenState extends ConsumerState { disabledBackgroundColor: Palette.textWhite.withOpacity(0.5), foregroundColor: Palette.background, disabledForegroundColor: Palette.border, - minimumSize: const Size(0, 40), + minimumSize: const Size(0, 50), ), child: const Text( 'Next', diff --git a/lib/features/auth/view/screens/Create_Account/Password_Screen.dart b/lib/features/auth/view/screens/Create_Account/Password_Screen.dart index ed53692..0c785d9 100644 --- a/lib/features/auth/view/screens/Create_Account/Password_Screen.dart +++ b/lib/features/auth/view/screens/Create_Account/Password_Screen.dart @@ -177,7 +177,7 @@ class _PasswordScreenState extends ConsumerState { valueListenable: _isFormValid, builder: (context, isValid, child) { return SizedBox( - width: 120, + // width: 120, child: ElevatedButton( onPressed: (isValid && !isLoading) ? _handleSignUp : null, style: ElevatedButton.styleFrom( @@ -185,7 +185,7 @@ class _PasswordScreenState extends ConsumerState { disabledBackgroundColor: Palette.textWhite.withOpacity(0.5), foregroundColor: Palette.background, disabledForegroundColor: Palette.border, - minimumSize: const Size(0, 38), + minimumSize: const Size(0, 50), ), child: isLoading ? const SizedBox( diff --git a/lib/features/auth/view/screens/Create_Account/UserName_Screen.dart b/lib/features/auth/view/screens/Create_Account/UserName_Screen.dart index 354c7ca..eb091d7 100644 --- a/lib/features/auth/view/screens/Create_Account/UserName_Screen.dart +++ b/lib/features/auth/view/screens/Create_Account/UserName_Screen.dart @@ -1,4 +1,4 @@ -import 'dart:math'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -24,6 +24,8 @@ class _UsernameScreenState extends ConsumerState { final _usernameController = TextEditingController(); final _isFormValid = ValueNotifier(false); List suggestions = []; + Timer? _debounce; + bool _isSuggestionLoading = false; @override void initState() { @@ -33,20 +35,43 @@ class _UsernameScreenState extends ConsumerState { } void _validateForm() { - _isFormValid.value = + final isValid = _usernameController.text.trim().isNotEmpty && - _usernameController.text.trim().length >= 3; + _usernameController.text.trim().length > 3; + + _isFormValid.value = isValid; + + if (_usernameController.text.trim().length > 3) { + _fetchSuggestions(_usernameController.text.trim()); + } else { + if (suggestions.isNotEmpty || _isSuggestionLoading) { + setState(() { + suggestions = []; + _isSuggestionLoading = false; + }); + } + } } - void generateSuggestions(String name) { - final clean = name.replaceAll(" ", "").toLowerCase(); + void _fetchSuggestions(String name) { + if (_debounce?.isActive ?? false) _debounce!.cancel(); + setState(() { - suggestions = [ - "${clean}${Random().nextInt(50)}", - "${clean}_x", - "${clean}_${DateTime.now().year}", - "${clean}_${Random().nextInt(9999)}", - ]; + _isSuggestionLoading = true; + suggestions = []; + }); + + _debounce = Timer(const Duration(milliseconds: 500), () async { + final fetchedSuggestions = await ref + .read(authViewModelProvider.notifier) + .suggestUsernames(username: name); + + if (mounted) { + setState(() { + suggestions = fetchedSuggestions.take(4).toList(); + _isSuggestionLoading = false; + }); + } }); } @@ -80,6 +105,8 @@ class _UsernameScreenState extends ConsumerState { @override void dispose() { + _debounce?.cancel(); + _usernameController.removeListener(_validateForm); _usernameController.dispose(); _isFormValid.dispose(); super.dispose(); @@ -101,12 +128,11 @@ class _UsernameScreenState extends ConsumerState { backgroundColor: Palette.textPrimary, ), ); - ref.read(authViewModelProvider.notifier).resetState(); } }); final authState = ref.watch(authViewModelProvider); - final isLoading = authState.isLoading; + final isFormSubmitting = authState.isLoading; return Scaffold( backgroundColor: Palette.background, @@ -117,7 +143,7 @@ class _UsernameScreenState extends ConsumerState { elevation: 0, ), body: AbsorbPointer( - absorbing: isLoading, + absorbing: isFormSubmitting, child: Stack( children: [ Center( @@ -159,18 +185,10 @@ class _UsernameScreenState extends ConsumerState { controller: _usernameController, labelText: 'Username', validator: usernameValidator, - onChanged: (value) { - if (value.isNotEmpty) { - generateSuggestions(value); - } else { - setState(() { - suggestions = []; - }); - } - }, ), - if (suggestions.isNotEmpty) ...[ - const SizedBox(height: 24), + if (suggestions.isNotEmpty && + !_isSuggestionLoading) ...[ + const SizedBox(height: 20), Wrap( spacing: 1, children: suggestions.asMap().entries.map(( @@ -189,7 +207,7 @@ class _UsernameScreenState extends ConsumerState { Container( padding: const EdgeInsets.only( left: 0, - right: 10, + right: 8, top: 5, bottom: 5, ), @@ -219,11 +237,11 @@ class _UsernameScreenState extends ConsumerState { ); }).toList(), ), - const SizedBox(height: 14), + const SizedBox(height: 4), GestureDetector( onTap: () { - generateSuggestions( - _usernameController.text, + _fetchSuggestions( + _usernameController.text.trim(), ); }, child: const Text( @@ -245,7 +263,7 @@ class _UsernameScreenState extends ConsumerState { ), ), ), - if (isLoading) + if (isFormSubmitting) Container( color: Colors.black.withOpacity(0.5), child: const Center(child: Loader()), @@ -293,7 +311,6 @@ class _UsernameScreenState extends ConsumerState { disabledBackgroundColor: Palette.textWhite.withOpacity(0.6), foregroundColor: Palette.background, disabledForegroundColor: Palette.border, - minimumSize: const Size(0, 38), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(25), ), diff --git a/lib/features/auth/view/screens/Create_Account/Verification_Screen.dart b/lib/features/auth/view/screens/Create_Account/Verification_Screen.dart index 337efb1..27970df 100644 --- a/lib/features/auth/view/screens/Create_Account/Verification_Screen.dart +++ b/lib/features/auth/view/screens/Create_Account/Verification_Screen.dart @@ -45,12 +45,14 @@ class _VerificationScreenState extends ConsumerState { if (!_formKey.currentState!.validate()) { return; } + FocusManager.instance.primaryFocus?.unfocus(); ref .read(authViewModelProvider.notifier) .verifySignupEmail(email: email, code: _codeController.text.trim()); } void _resendCode() { + FocusManager.instance.primaryFocus?.unfocus(); ref .read(authViewModelProvider.notifier) .createAccount(name: name, email: email, dateOfBirth: dateOfBirth); @@ -82,100 +84,105 @@ class _VerificationScreenState extends ConsumerState { context.goNamed(RouteConstants.passwordscreen); authViewModel.resetState(); } else if (next.type == AuthStateType.error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - next.message ?? 'Invalid verification code', - style: TextStyle(color: Palette.background), + FocusManager.instance.primaryFocus?.unfocus(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + next.message ?? 'Invalid verification code', + style: const TextStyle(color: Palette.background), + ), + backgroundColor: Palette.textWhite, ), - backgroundColor: Palette.textWhite, - ), - ); + ); + } authViewModel.resetState(); } }); final authState = ref.watch(authViewModelProvider); final isLoading = authState.isLoading; - return Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - title: buildXLogo(size: 36), - centerTitle: true, + return GestureDetector( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: Scaffold( backgroundColor: Palette.background, - elevation: 0, - ), - body: AbsorbPointer( - absorbing: isLoading, - - child: Stack( - children: [ - Center( - child: Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration(color: Palette.background), - child: Column( - children: [ - Expanded( - child: Form( - key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'We sent you a code', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Palette.textWhite, - ), - ), - const SizedBox(height: 12), - Text( - 'Enter it below to verify $email.', - style: const TextStyle( - fontSize: 15, - color: Palette.textSecondary, - ), - ), - const SizedBox(height: 32), - CustomTextField( - controller: _codeController, - labelText: 'Verification code', - keyboardType: TextInputType.number, - validator: verificationCodeValidator, - ), - const SizedBox(height: 24), - GestureDetector( - onTap: _resendCode, - child: const Text( - 'Didn\'t receive an email?', + appBar: AppBar( + title: buildXLogo(size: 36), + centerTitle: true, + backgroundColor: Palette.background, + elevation: 0, + ), + body: AbsorbPointer( + absorbing: isLoading, + child: Stack( + children: [ + Center( + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration(color: Palette.background), + child: Column( + children: [ + Expanded( + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'We sent you a code', style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Palette.textWhite, + ), + ), + const SizedBox(height: 12), + Text( + 'Enter it below to verify $email.', + style: const TextStyle( fontSize: 15, - color: Palette.primary, + color: Palette.textSecondary, + ), + ), + const SizedBox(height: 32), + CustomTextField( + controller: _codeController, + labelText: 'Verification code', + keyboardType: TextInputType.number, + validator: verificationCodeValidator, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _resendCode, + child: const Text( + 'Didn\'t receive an email?', + style: TextStyle( + fontSize: 15, + color: Palette.primary, + ), ), ), - ), - ], + ], + ), ), ), ), - ), - _buildNextButton(isLoading), - const SizedBox(height: 15), - ], + _buildNextButton(isLoading), + const SizedBox(height: 15), + ], + ), ), ), - ), - if (isLoading) - Container(color: Colors.black, child: const Loader()), - ], + if (isLoading) + Container(color: Colors.black, child: const Loader()), + ], + ), ), ), ); diff --git a/lib/features/auth/view/screens/Intro_Screen.dart b/lib/features/auth/view/screens/Intro_Screen.dart index ccee92a..cdf523a 100644 --- a/lib/features/auth/view/screens/Intro_Screen.dart +++ b/lib/features/auth/view/screens/Intro_Screen.dart @@ -1,17 +1,13 @@ -// ignore_for_file: unused_import - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lite_x/core/routes/Route_Constants.dart'; import 'package:lite_x/core/theme/palette.dart'; import 'package:lite_x/core/view/widgets/Loader.dart'; -import 'package:lite_x/features/auth/repositories/auth_remote_repository.dart'; import 'package:lite_x/features/auth/view/widgets/buildTermsText.dart'; import 'package:lite_x/features/auth/view/widgets/buildXLogo.dart'; import 'package:lite_x/features/auth/view_model/auth_state.dart'; import 'package:lite_x/features/auth/view_model/auth_view_model.dart'; -import 'package:google_sign_in/google_sign_in.dart'; class IntroScreen extends ConsumerWidget { const IntroScreen({super.key}); @@ -41,14 +37,11 @@ class IntroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final size = MediaQuery.of(context).size; - - // Listen to auth state changes ref.listen(authViewModelProvider, (previous, next) { final authViewModel = ref.read(authViewModelProvider.notifier); if (next.type == AuthStateType.authenticated) { - context.goNamed(RouteConstants.homescreen); - authViewModel.resetState(); + context.goNamed(RouteConstants.setbirthdate); } else if (next.type == AuthStateType.error) { _showErrorToast( context, @@ -109,7 +102,7 @@ class IntroScreen extends ConsumerWidget { ), SizedBox(height: size.height * 0.15), _buildAuthButtons(context, ref), - const SizedBox(height: 25), + const SizedBox(height: 20), buildTermsText(), const SizedBox(height: 5), _buildLoginSection(context), diff --git a/lib/features/auth/view/screens/Oauth/SetBirthdate.dart b/lib/features/auth/view/screens/Oauth/SetBirthdate.dart new file mode 100644 index 0000000..446b3e8 --- /dev/null +++ b/lib/features/auth/view/screens/Oauth/SetBirthdate.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:lite_x/core/routes/Route_Constants.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/core/utils.dart'; +import 'package:lite_x/core/view/widgets/Loader.dart'; +import 'package:lite_x/features/auth/view/widgets/CustomTextField.dart'; +import 'package:lite_x/features/auth/view/widgets/buildXLogo.dart'; +import 'package:lite_x/features/auth/view_model/auth_view_model.dart'; +import 'package:lite_x/features/auth/view_model/auth_state.dart'; + +class Setbirthdate extends ConsumerStatefulWidget { + const Setbirthdate({super.key}); + + @override + ConsumerState createState() => _SetbirthdateState(); +} + +class _SetbirthdateState extends ConsumerState { + final _dobController = TextEditingController(); + final _isFormValid = ValueNotifier(false); + @override + void initState() { + super.initState(); + _dobController.addListener(_validateForm); + } + + @override + void dispose() { + _dobController.dispose(); + _isFormValid.dispose(); + super.dispose(); + } + + void _validateForm() { + final isValid = _dobController.text.trim().isNotEmpty; + _isFormValid.value = isValid; + } + + Future _selectDate(BuildContext context) async { + FocusScope.of(context).unfocus(); + + final picked = await showDatePicker( + context: context, + initialDate: DateTime(DateTime.now().year - 18), + firstDate: DateTime(1950), + lastDate: DateTime.now(), + builder: (context, child) { + return Theme( + data: ThemeData.dark().copyWith( + colorScheme: const ColorScheme.dark( + primary: Palette.primary, + onPrimary: Palette.textWhite, + surface: Palette.background, + onSurface: Palette.textWhite, + ), + dialogBackgroundColor: Palette.background, + ), + child: child!, + ); + }, + ); + + if (picked != null && mounted) { + _dobController.text = DateFormat('MM/dd/yyyy').format(picked); + } + } + + void _handleSignUp() { + if (_dobController.text.trim().isEmpty) return; + + FocusScope.of(context).unfocus(); + ref + .read(authViewModelProvider.notifier) + .Setbirthdate(birthDate: _dobController.text.trim()); + } + + void _showErrorToast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: Palette.textWhite, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + backgroundColor: Palette.greycolor, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: EdgeInsets.symmetric( + horizontal: MediaQuery.of(context).size.width * 0.15, + vertical: MediaQuery.of(context).size.height * 0.4, + ), + duration: const Duration(seconds: 3), + ), + ); + } + + @override + Widget build(BuildContext context) { + ref.listen(authViewModelProvider, (previous, next) async { + if (next.type == AuthStateType.success) { + context.goNamed(RouteConstants.UserNameScreen); + } else if (next.type == AuthStateType.error) { + _showErrorToast(next.message ?? 'An error occurred'); + } + }); + + final authState = ref.watch(authViewModelProvider); + final isLoading = authState.isLoading; + + return Stack( + children: [ + Scaffold( + backgroundColor: Palette.background, + appBar: AppBar( + title: buildXLogo(size: 36), + centerTitle: true, + backgroundColor: Palette.background, + elevation: 0, + ), + body: AbsorbPointer( + absorbing: isLoading, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "What's your birth date?", + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Palette.textWhite, + ), + ), + const SizedBox(height: 12), + Text( + "This won't be public.", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Palette.textWhite.withOpacity(0.5), + ), + ), + const SizedBox(height: 32), + CustomTextField( + controller: _dobController, + labelText: 'Date of birth', + readOnly: true, + onTap: () => _selectDate(context), + validator: dobValidator, + ), + ], + ), + ), + ), + _buildSignUpButton(isLoading), + const SizedBox(height: 15), + ], + ), + ), + ), + if (isLoading) + Container( + color: Colors.black.withOpacity(0.5), + child: const Loader(), + ), + ], + ); + } + + Widget _buildSignUpButton(bool isLoading) { + return Container( + padding: const EdgeInsets.all(10), + alignment: Alignment.centerRight, + child: ValueListenableBuilder( + valueListenable: _isFormValid, + builder: (context, isValid, child) { + return SizedBox( + width: 120, + child: ElevatedButton( + onPressed: (isValid && !isLoading) ? _handleSignUp : null, + style: ElevatedButton.styleFrom( + backgroundColor: Palette.textWhite, + disabledBackgroundColor: Palette.textWhite.withOpacity(0.5), + foregroundColor: Palette.background, + disabledForegroundColor: Palette.border, + minimumSize: const Size(0, 38), + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Palette.background, + ), + ), + ) + : const Text( + 'Sign up', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/auth/view_model/auth_view_model.dart b/lib/features/auth/view_model/auth_view_model.dart index e0286d8..8c49aa4 100644 --- a/lib/features/auth/view_model/auth_view_model.dart +++ b/lib/features/auth/view_model/auth_view_model.dart @@ -7,6 +7,7 @@ import 'package:lite_x/features/auth/repositories/auth_local_repository.dart'; import 'package:lite_x/features/auth/repositories/auth_remote_repository.dart'; import 'package:lite_x/features/auth/view_model/auth_state.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; +import 'package:lite_x/features/chat/repositories/chat_local_repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'auth_view_model.g.dart'; @@ -14,11 +15,13 @@ part 'auth_view_model.g.dart'; class AuthViewModel extends _$AuthViewModel { late AuthRemoteRepository _authRemoteRepository; late AuthLocalRepository _authLocalRepository; + late ChatLocalRepository _chatLocalRepository; @override AuthState build() { _authRemoteRepository = ref.read(authRemoteRepositoryProvider); _authLocalRepository = ref.read(authLocalRepositoryProvider); + _chatLocalRepository = ref.read(chatLocalRepositoryProvider); Future(() async { await Future.delayed(const Duration(milliseconds: 300)); await _checkAuthStatus(); @@ -185,9 +188,7 @@ class AuthViewModel extends _$AuthViewModel { result.fold( (failure) => print("FCM: Failed to register token: ${failure.message}"), - (message) => print( - "FCM: Token registered successfully: $message", - ), // change later + (message) => print("FCM: Token registered successfully: $message"), ); } else { print('User declined or has not accepted permission'); @@ -210,6 +211,7 @@ class AuthViewModel extends _$AuthViewModel { email: email, password: password, ); + print("login result:$result"); result.fold((failure) => state = AuthState.error(failure.message), ( data, ) async { @@ -220,6 +222,10 @@ class AuthViewModel extends _$AuthViewModel { ]); ref.read(currentUserProvider.notifier).adduser(user); state = AuthState.authenticated('Login successful'); + if (!Platform.environment.containsKey('FLUTTER_TEST')) { + _registerFcmToken(); + _listenForFcmTokenRefresh(); + } }); } @@ -228,6 +234,7 @@ class AuthViewModel extends _$AuthViewModel { try { await _authLocalRepository.clearTokens(); await _authLocalRepository.clearUser(); + await _chatLocalRepository.clearAll(); ref.read(currentUserProvider.notifier).clearUser(); state = AuthState.unauthenticated(); } catch (e) { @@ -377,6 +384,54 @@ class AuthViewModel extends _$AuthViewModel { ); } + //------------------------------------------------suggest-usernames-------------------------------------------// + Future> suggestUsernames({required String username}) async { + final result = await _authRemoteRepository.suggest_usernames( + username: username, + ); + return result.fold((failure) { + print("Username suggestion failed: ${failure.message}"); + return []; + }, (suggestions) => suggestions); + } + + //-----------------------------------------------------------setbirthdate-------------------------------------------------------------------------// + Future Setbirthdate({required String birthDate}) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + state = AuthState.error('User not found'); + return; + } + + final parts = birthDate.split('/'); + if (parts.length != 3) { + state = AuthState.error('Invalid date format'); + return; + } + + final month = parts[0]; + final day = parts[1]; + final year = parts[2]; + + final result = await _authRemoteRepository.setbirthdate( + day: day, + month: month, + year: year, + ); + + await result.fold( + (failure) async { + state = AuthState.error(failure.message); + }, + (message) async { + final updated = currentUser.copyWith(dob: '$month/$day/$year'); + ref.read(currentUserProvider.notifier).state = updated; + + state = AuthState.success('Birthdate set successfully'); + }, + ); + } + //-------------------------------------------------Token Management--------------------------------------------------------------------------------------// String? getAccessToken() { final tokens = _authLocalRepository.getTokens(); @@ -436,16 +491,29 @@ class AuthViewModel extends _$AuthViewModel { final localPath = file.path; final currentUser = ref.read(currentUserProvider); + if (currentUser != null) { - final updatedUser = currentUser.copyWith( - photo: mediaId, - localProfilePhotoPath: localPath, + final updateResult = await _authRemoteRepository + .updateProfilePhoto(currentUser.id, mediaId); + + updateResult.fold( + (failure) async { + state = AuthState.error("Uploaded but backend update failed"); + }, + (_) async { + final updatedUser = currentUser.copyWith( + photo: mediaId, + localProfilePhotoPath: localPath, + ); + + await _authLocalRepository.saveUser(updatedUser); + ref.read(currentUserProvider.notifier).adduser(updatedUser); + + state = AuthState.success( + "Profile photo updated successfully", + ); + }, ); - - await _authLocalRepository.saveUser(updatedUser); - ref.read(currentUserProvider.notifier).adduser(updatedUser); - - state = AuthState.success("Profile photo uploaded and saved"); } }, ); @@ -491,6 +559,8 @@ class AuthViewModel extends _$AuthViewModel { ref.read(currentUserProvider.notifier).adduser(user); state = AuthState.authenticated('Social login successful'); + _registerFcmToken(); + _listenForFcmTokenRefresh(); }, ); } diff --git a/lib/features/auth/view_model/auth_view_model.g.dart b/lib/features/auth/view_model/auth_view_model.g.dart index 1ed2ed1..1109c12 100644 --- a/lib/features/auth/view_model/auth_view_model.g.dart +++ b/lib/features/auth/view_model/auth_view_model.g.dart @@ -41,7 +41,7 @@ final class AuthViewModelProvider } } -String _$authViewModelHash() => r'73a355ed6a76f0c11f190556b7e016a5df57704f'; +String _$authViewModelHash() => r'924e5a98fc7048caca4ebb4164728767ec8beae9'; abstract class _$AuthViewModel extends $Notifier { AuthState build(); diff --git a/lib/features/chat/models/conversationmodel.dart b/lib/features/chat/models/conversationmodel.dart index 9b3d537..988b39f 100644 --- a/lib/features/chat/models/conversationmodel.dart +++ b/lib/features/chat/models/conversationmodel.dart @@ -14,31 +14,26 @@ class ConversationModel extends HiveObject { DateTime createdAt; @HiveField(3) DateTime updatedAt; + @HiveField(4) - String? groupName; - @HiveField(5) - String? groupPhotoKey; - @HiveField(6) - String? groupDescription; - @HiveField(7) List participantIds; - @HiveField(8) + @HiveField(5) String? lastMessageContent; - @HiveField(9) + @HiveField(6) DateTime? lastMessageTime; - @HiveField(10) + @HiveField(7) String? lastMessageSenderId; - @HiveField(11) + @HiveField(8) int unseenCount; - @HiveField(12) + @HiveField(9) String? dmPartnerUserId; - @HiveField(13) + @HiveField(10) String? dmPartnerName; - @HiveField(14) + @HiveField(11) String? dmPartnerUsername; - @HiveField(15) + @HiveField(12) String? dmPartnerProfileKey; - @HiveField(16) + @HiveField(13) String? lastMessageType; ConversationModel({ @@ -46,14 +41,11 @@ class ConversationModel extends HiveObject { required this.isDMChat, required this.createdAt, required this.updatedAt, - this.groupName, - this.groupPhotoKey, - this.groupDescription, required this.participantIds, this.lastMessageContent, this.lastMessageTime, this.lastMessageSenderId, - this.unseenCount = 0, + this.unseenCount = 1, // this.dmPartnerUserId, this.dmPartnerName, this.dmPartnerUsername, @@ -90,19 +82,6 @@ class ConversationModel extends HiveObject { } final unseenCount = json['unseenMessagesCount'] as int? ?? 0; - Map? chatGroup; - - if (json['chatGroup'] is List) { - if ((json['chatGroup'] as List).isNotEmpty) { - chatGroup = (json['chatGroup'] as List).first; - } else { - chatGroup = null; - } - } else if (json['chatGroup'] is Map) { - chatGroup = json['chatGroup'] as Map; - } else { - chatGroup = null; - } final isDm = json['DMChat'] as bool; String? dmPartnerUserId; @@ -129,9 +108,6 @@ class ConversationModel extends HiveObject { isDMChat: isDm, createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), - groupName: chatGroup?['name'] as String?, - groupPhotoKey: chatGroup?['photo'] as String?, - groupDescription: chatGroup?['description'] as String?, participantIds: participantIds, lastMessageContent: lastMessageContent, lastMessageTime: lastMessageTime, @@ -148,19 +124,11 @@ class ConversationModel extends HiveObject { bool get isGroup => !isDMChat; String getDisplayName() { - if (isDMChat) { - return dmPartnerName ?? dmPartnerUsername ?? "Unknown User"; - } else { - return groupName ?? "Group Chat"; - } + return dmPartnerName ?? dmPartnerUsername ?? "Unknown User"; } String? getDisplayImageKey() { - if (isDMChat) { - return dmPartnerProfileKey; - } else { - return groupPhotoKey; - } + return dmPartnerProfileKey; } String? getOtherParticipantId(String currentUserId) { @@ -173,7 +141,7 @@ class ConversationModel extends HiveObject { @override String toString() { - return 'ConversationModel(id: $id, isDMChat: $isDMChat, createdAt: $createdAt, updatedAt: $updatedAt, groupName: $groupName, groupPhotoKey: $groupPhotoKey, groupDescription: $groupDescription, participantIds: $participantIds, lastMessageContent: $lastMessageContent, lastMessageTime: $lastMessageTime, lastMessageSenderId: $lastMessageSenderId, unseenCount: $unseenCount, dmPartnerUserId: $dmPartnerUserId, dmPartnerName: $dmPartnerName, dmPartnerUsername: $dmPartnerUsername, dmPartnerProfileKey: $dmPartnerProfileKey, lastMessageType: $lastMessageType)'; + return 'ConversationModel(id: $id, isDMChat: $isDMChat, createdAt: $createdAt, updatedAt: $updatedAt, participantIds: $participantIds, lastMessageContent: $lastMessageContent, lastMessageTime: $lastMessageTime, lastMessageSenderId: $lastMessageSenderId, unseenCount: $unseenCount, dmPartnerUserId: $dmPartnerUserId, dmPartnerName: $dmPartnerName, dmPartnerUsername: $dmPartnerUsername, dmPartnerProfileKey: $dmPartnerProfileKey, lastMessageType: $lastMessageType)'; } @override @@ -185,9 +153,6 @@ class ConversationModel extends HiveObject { other.isDMChat == isDMChat && other.createdAt == createdAt && other.updatedAt == updatedAt && - other.groupName == groupName && - other.groupPhotoKey == groupPhotoKey && - other.groupDescription == groupDescription && listEquals(other.participantIds, participantIds) && other.lastMessageContent == lastMessageContent && other.lastMessageTime == lastMessageTime && @@ -206,9 +171,6 @@ class ConversationModel extends HiveObject { isDMChat.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ - groupName.hashCode ^ - groupPhotoKey.hashCode ^ - groupDescription.hashCode ^ participantIds.hashCode ^ lastMessageContent.hashCode ^ lastMessageTime.hashCode ^ @@ -245,9 +207,7 @@ class ConversationModel extends HiveObject { isDMChat: isDMChat ?? this.isDMChat, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - groupName: groupName ?? this.groupName, - groupPhotoKey: groupPhotoKey ?? this.groupPhotoKey, - groupDescription: groupDescription ?? this.groupDescription, + participantIds: participantIds ?? this.participantIds, lastMessageContent: lastMessageContent ?? this.lastMessageContent, lastMessageTime: lastMessageTime ?? this.lastMessageTime, @@ -267,9 +227,7 @@ class ConversationModel extends HiveObject { 'isDMChat': isDMChat, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), - 'groupName': groupName, - 'groupPhotoKey': groupPhotoKey, - 'groupDescription': groupDescription, + 'participantIds': participantIds, 'lastMessageContent': lastMessageContent, 'lastMessageTime': lastMessageTime?.millisecondsSinceEpoch, @@ -289,13 +247,7 @@ class ConversationModel extends HiveObject { isDMChat: map['isDMChat'] as bool, createdAt: DateTime.parse(map['createdAt'] as String), updatedAt: DateTime.parse(map['updatedAt'] as String), - groupName: map['groupName'] != null ? map['groupName'] as String : null, - groupPhotoKey: map['groupPhotoKey'] != null - ? map['groupPhotoKey'] as String - : null, - groupDescription: map['groupDescription'] != null - ? map['groupDescription'] as String - : null, + participantIds: List.from(map['participantIds'] as List), lastMessageContent: map['lastMessageContent'] != null ? map['lastMessageContent'] as String diff --git a/lib/features/chat/models/conversationmodel.g.dart b/lib/features/chat/models/conversationmodel.g.dart index 98a785d..25c8420 100644 --- a/lib/features/chat/models/conversationmodel.g.dart +++ b/lib/features/chat/models/conversationmodel.g.dart @@ -21,26 +21,23 @@ class ConversationModelAdapter extends TypeAdapter { isDMChat: fields[1] as bool, createdAt: fields[2] as DateTime, updatedAt: fields[3] as DateTime, - groupName: fields[4] as String?, - groupPhotoKey: fields[5] as String?, - groupDescription: fields[6] as String?, - participantIds: (fields[7] as List).cast(), - lastMessageContent: fields[8] as String?, - lastMessageTime: fields[9] as DateTime?, - lastMessageSenderId: fields[10] as String?, - unseenCount: fields[11] == null ? 0 : (fields[11] as num).toInt(), - dmPartnerUserId: fields[12] as String?, - dmPartnerName: fields[13] as String?, - dmPartnerUsername: fields[14] as String?, - dmPartnerProfileKey: fields[15] as String?, - lastMessageType: fields[16] as String?, + participantIds: (fields[4] as List).cast(), + lastMessageContent: fields[5] as String?, + lastMessageTime: fields[6] as DateTime?, + lastMessageSenderId: fields[7] as String?, + unseenCount: fields[8] == null ? 1 : (fields[8] as num).toInt(), + dmPartnerUserId: fields[9] as String?, + dmPartnerName: fields[10] as String?, + dmPartnerUsername: fields[11] as String?, + dmPartnerProfileKey: fields[12] as String?, + lastMessageType: fields[13] as String?, ); } @override void write(BinaryWriter writer, ConversationModel obj) { writer - ..writeByte(17) + ..writeByte(14) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -50,30 +47,24 @@ class ConversationModelAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.updatedAt) ..writeByte(4) - ..write(obj.groupName) - ..writeByte(5) - ..write(obj.groupPhotoKey) - ..writeByte(6) - ..write(obj.groupDescription) - ..writeByte(7) ..write(obj.participantIds) - ..writeByte(8) + ..writeByte(5) ..write(obj.lastMessageContent) - ..writeByte(9) + ..writeByte(6) ..write(obj.lastMessageTime) - ..writeByte(10) + ..writeByte(7) ..write(obj.lastMessageSenderId) - ..writeByte(11) + ..writeByte(8) ..write(obj.unseenCount) - ..writeByte(12) + ..writeByte(9) ..write(obj.dmPartnerUserId) - ..writeByte(13) + ..writeByte(10) ..write(obj.dmPartnerName) - ..writeByte(14) + ..writeByte(11) ..write(obj.dmPartnerUsername) - ..writeByte(15) + ..writeByte(12) ..write(obj.dmPartnerProfileKey) - ..writeByte(16) + ..writeByte(13) ..write(obj.lastMessageType); } diff --git a/lib/features/chat/models/messagemodel.dart b/lib/features/chat/models/messagemodel.dart index 48f50e6..8b1580b 100644 --- a/lib/features/chat/models/messagemodel.dart +++ b/lib/features/chat/models/messagemodel.dart @@ -50,6 +50,7 @@ class MessageModel extends HiveObject { Map toApiRequest({List? recipientIds}) { return { + "createdAt": createdAt.toIso8601String() + "Z", "chatId": chatId, "data": {"content": content}, if (recipientIds != null) "recipientId": recipientIds, @@ -57,6 +58,21 @@ class MessageModel extends HiveObject { } factory MessageModel.fromApiResponse(Map json) { + final user = json['createdMessage']['user'] as Map?; + return MessageModel( + id: json['createdMessage']['id'] as String, + chatId: json['createdMessage']['chatId'] as String, + userId: json['createdMessage']['userId'] as String, + content: json['createdMessage']['content'] as String?, + createdAt: DateTime.parse(json['createdMessage']['createdAt'] as String), + status: json['createdMessage']['status'] as String? ?? 'PENDING', + senderUsername: user?['username'] as String?, + senderName: user?['name'] as String?, + senderProfileMediaKey: user?['profileMediaId'] as String?, + messageType: 'text', + ); + } + factory MessageModel.fromLoadMessages(Map json) { final user = json['user'] as Map?; return MessageModel( id: json['id'] as String, @@ -71,7 +87,6 @@ class MessageModel extends HiveObject { messageType: 'text', ); } - MessageModel copyWith({ String? id, String? chatId, @@ -83,7 +98,6 @@ class MessageModel extends HiveObject { String? senderName, String? senderProfileMediaKey, String? messageType, - String? localId, }) { return MessageModel( id: id ?? this.id, diff --git a/lib/features/chat/models/usersearchmodel.dart b/lib/features/chat/models/usersearchmodel.dart index 2806ff3..15369fa 100644 --- a/lib/features/chat/models/usersearchmodel.dart +++ b/lib/features/chat/models/usersearchmodel.dart @@ -32,13 +32,24 @@ class UserSearchModel extends HiveObject { }); factory UserSearchModel.fromMap(Map map) { + String? profileImage; + if (map["profileMedia"] != null) { + if (map["profileMedia"] is Map) { + profileImage = map["profileMedia"]["keyName"]; + } else if (map["profileMedia"] is String) { + profileImage = map["profileMedia"]; + } + } + return UserSearchModel( id: map["id"] ?? "", username: map["username"] ?? "", name: map["name"] ?? "", bio: map["bio"], - profileMedia: map["profileMedia"], - followers: map["_count"]?["followers"] ?? 0, + profileMedia: profileImage, + followers: (map["_count"] != null && map["_count"]["followers"] != null) + ? map["_count"]["followers"] + : 0, ); } } diff --git a/lib/features/chat/providers/activeChatIdProvider.dart b/lib/features/chat/providers/activeChatIdProvider.dart new file mode 100644 index 0000000..59ca0bc --- /dev/null +++ b/lib/features/chat/providers/activeChatIdProvider.dart @@ -0,0 +1,11 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'activeChatIdProvider.g.dart'; + +@riverpod +class ActiveChat extends _$ActiveChat { + @override + String? build() => null; + + void setActive(String? id) => state = id; +} diff --git a/lib/features/search/view_model/search_view_model.g.dart b/lib/features/chat/providers/activeChatIdProvider.g.dart similarity index 50% rename from lib/features/search/view_model/search_view_model.g.dart rename to lib/features/chat/providers/activeChatIdProvider.g.dart index 8f79ec9..0fd91eb 100644 --- a/lib/features/search/view_model/search_view_model.g.dart +++ b/lib/features/chat/providers/activeChatIdProvider.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'search_view_model.dart'; +part of 'activeChatIdProvider.dart'; // ************************************************************************** // RiverpodGenerator @@ -9,52 +9,51 @@ part of 'search_view_model.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(SearchViewModel) -const searchViewModelProvider = SearchViewModelProvider._(); +@ProviderFor(ActiveChat) +const activeChatProvider = ActiveChatProvider._(); -final class SearchViewModelProvider - extends $NotifierProvider { - const SearchViewModelProvider._() +final class ActiveChatProvider extends $NotifierProvider { + const ActiveChatProvider._() : super( from: null, argument: null, retry: null, - name: r'searchViewModelProvider', - isAutoDispose: false, + name: r'activeChatProvider', + isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$searchViewModelHash(); + String debugGetCreateSourceHash() => _$activeChatHash(); @$internal @override - SearchViewModel create() => SearchViewModel(); + ActiveChat create() => ActiveChat(); /// {@macro riverpod.override_with_value} - Override overrideWithValue(SearchState value) { + Override overrideWithValue(String? value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$searchViewModelHash() => r'3b9f4cb58d93e52298e4e42513a2cac8bee5cf50'; +String _$activeChatHash() => r'23f0395134ca4d8af5a88f7ad3482566e0783170'; -abstract class _$SearchViewModel extends $Notifier { - SearchState build(); +abstract class _$ActiveChat extends $Notifier { + String? build(); @$mustCallSuper @override void runBuild() { final created = build(); - final ref = this.ref as $Ref; + final ref = this.ref as $Ref; final element = ref.element as $ClassProviderElement< - AnyNotifier, - SearchState, + AnyNotifier, + String?, Object?, Object? >; diff --git a/lib/features/chat/providers/audiorecordernotifier.dart b/lib/features/chat/providers/audiorecordernotifier.dart deleted file mode 100644 index 0ce103e..0000000 --- a/lib/features/chat/providers/audiorecordernotifier.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:lite_x/core/classes/AudioRecorderUtil.dart'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; - -part 'audiorecordernotifier.g.dart'; - -enum RecorderStatus { idle, recording, reviewing } - -@riverpod -class AudioRecorderNotifier extends _$AudioRecorderNotifier { - final _recorder = AudioRecorderUtil(); - DateTime? _recordingStartTime; - Duration? _actualRecordingDuration; - static const maxDuration = Duration(seconds: 140); - - @override - AudioRecorderState build() => const AudioRecorderState(); - - Future startRecording() async { - final success = await _recorder.startRecording(); - if (success) { - _recordingStartTime = DateTime.now(); - _actualRecordingDuration = null; - state = state.copyWith( - status: RecorderStatus.recording, - remainingDuration: maxDuration, // 140 seconds - ); - } - return success; - } - - Future stopRecording() async { - if (state.status != RecorderStatus.recording) return; - - final path = await _recorder.stopRecording(); - if (path == null) { - await _resetToIdle(); - return; - } - final elapsed = _recordingStartTime != null - ? DateTime.now().difference(_recordingStartTime!) - : Duration.zero; - - _actualRecordingDuration = elapsed; - - state = state.copyWith( - status: RecorderStatus.reviewing, - recordingPath: path, - remainingDuration: elapsed, - ); - _recordingStartTime = null; - } - - Future cancelRecording() async { - if (state.status != RecorderStatus.recording) return; - - await _recorder.cancelRecording(); - await _resetToIdle(); - _recordingStartTime = null; - _actualRecordingDuration = null; - } - - Future cancelReview() async { - if (state.status != RecorderStatus.reviewing) return; - - await _deleteRecordingFile(); - await _resetToIdle(); - _actualRecordingDuration = null; - } - - String? sendRecording() { - if (state.status != RecorderStatus.reviewing) return null; - - final path = state.recordingPath; - _resetToIdle(); - _actualRecordingDuration = null; - return path; - } - - void updateRecordingDuration() { - if (_recordingStartTime == null || - state.status != RecorderStatus.recording) { - return; - } - - final elapsed = DateTime.now().difference(_recordingStartTime!); - final remaining = maxDuration - elapsed; - if (remaining == Duration.zero || remaining.isNegative) { - stopRecording(); - } else { - state = state.copyWith(remainingDuration: remaining); - } - } - - void updateReviewPosition(Duration currentPosition) { - if (state.status != RecorderStatus.reviewing || - _actualRecordingDuration == null) { - return; - } - final remaining = _actualRecordingDuration! - currentPosition; - - if (remaining == Duration.zero || remaining.isNegative) { - state = state.copyWith(remainingDuration: _actualRecordingDuration!); - } else { - state = state.copyWith(remainingDuration: remaining); - } - } - - void resetReviewPosition() { - if (state.status == RecorderStatus.reviewing && - _actualRecordingDuration != null) { - state = state.copyWith(remainingDuration: _actualRecordingDuration!); - } - } - - Future _deleteRecordingFile() async { - try { - final path = state.recordingPath; - if (path == null) return; - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - debugPrint('Error deleting recording file: $e'); - } - } - - Future _resetToIdle() async { - state = const AudioRecorderState( - status: RecorderStatus.idle, - remainingDuration: Duration.zero, - recordingPath: null, - ); - } - - void dispose() { - _recorder.dispose(); - } -} - -class AudioRecorderState { - final RecorderStatus status; - final Duration remainingDuration; - final String? recordingPath; - - const AudioRecorderState({ - this.status = RecorderStatus.idle, - this.remainingDuration = Duration.zero, - this.recordingPath, - }); - - AudioRecorderState copyWith({ - RecorderStatus? status, - Duration? remainingDuration, - String? recordingPath, - bool clearPath = false, - }) { - return AudioRecorderState( - status: status ?? this.status, - remainingDuration: remainingDuration ?? this.remainingDuration, - recordingPath: clearPath ? null : (recordingPath ?? this.recordingPath), - ); - } -} diff --git a/lib/features/chat/providers/audiorecordernotifier.g.dart b/lib/features/chat/providers/audiorecordernotifier.g.dart deleted file mode 100644 index c2f8adb..0000000 --- a/lib/features/chat/providers/audiorecordernotifier.g.dart +++ /dev/null @@ -1,64 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'audiorecordernotifier.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(AudioRecorderNotifier) -const audioRecorderProvider = AudioRecorderNotifierProvider._(); - -final class AudioRecorderNotifierProvider - extends $NotifierProvider { - const AudioRecorderNotifierProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'audioRecorderProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$audioRecorderNotifierHash(); - - @$internal - @override - AudioRecorderNotifier create() => AudioRecorderNotifier(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(AudioRecorderState value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$audioRecorderNotifierHash() => - r'43443ac8096028f1f2cb633063bff97bb266e55c'; - -abstract class _$AudioRecorderNotifier extends $Notifier { - AudioRecorderState build(); - @$mustCallSuper - @override - void runBuild() { - final created = build(); - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - AudioRecorderState, - Object?, - Object? - >; - element.handleValue(ref, created); - } -} diff --git a/lib/features/chat/repositories/chat_local_repository.dart b/lib/features/chat/repositories/chat_local_repository.dart index 66690af..5d5669e 100644 --- a/lib/features/chat/repositories/chat_local_repository.dart +++ b/lib/features/chat/repositories/chat_local_repository.dart @@ -1,10 +1,7 @@ -// ignore_for_file: unused_import - import 'package:hive_ce/hive.dart'; import 'package:lite_x/features/chat/models/conversationmodel.dart'; import 'package:lite_x/features/chat/models/messagemodel.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; - part 'chat_local_repository.g.dart'; @Riverpod(keepAlive: true) @@ -16,72 +13,117 @@ class ChatLocalRepository { final Box _conversationsBox = Hive.box( "conversationsBox", ); - final Box _messagesBox = Hive.box("messagesBox"); - // Save or update conversations - Future upsertConversations( - List conversations, - ) async { - for (ConversationModel conv in conversations) { - await _conversationsBox.put(conv.id, conv); - } - } - - // Load all cached conversations - List getAllConversations() { - return _conversationsBox.values.toList(); - } + List getCachedMessages(String chatId) { + final messages = _messagesBox.values + .where((msg) => msg.chatId == chatId) + .toList(); + messages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - // - ConversationModel? getConversationById(String id) { - return _conversationsBox.get(id); + return messages; } - // save Message Future saveMessage(MessageModel message) async { await _messagesBox.put(message.id, message); + if (message.status != "READ") { + await _enforceCacheLimit(message.chatId); + } } - // Save list of messages (when loading chat history) - Future saveMessages(List messages) async { - for (MessageModel msg in messages) { - await _messagesBox.put(msg.id, msg); + Future saveInitialMessages(List messages) async { + final Map entries = { + for (var msg in messages) msg.id: msg, + }; + await _messagesBox.putAll(entries); + + if (messages.isNotEmpty) { + await _enforceCacheLimit(messages.first.chatId); } } - // get messages by chat_id - List getMessagesForChat(String chatId) { - return _messagesBox.values.where((msg) => msg.chatId == chatId).toList(); + Future replaceTempWithServerMessage({ + required String tempId, + required MessageModel serverMessage, + }) async { + if (_messagesBox.containsKey(tempId)) { + await _messagesBox.delete(tempId); + } + + await _messagesBox.put(serverMessage.id, serverMessage); + await _enforceCacheLimit(serverMessage.chatId); } - // update message status Future markMessagesAsRead(String chatId, String myUserId) async { - for (MessageModel msg in _messagesBox.values) { - if (msg.chatId == chatId && msg.userId == myUserId) { - msg.status = "READ"; - await msg.save(); - } + final messagesToUpdate = _messagesBox.values.where( + (msg) => + msg.chatId == chatId && + msg.userId == myUserId && + msg.status != "READ", + ); + + for (var msg in messagesToUpdate) { + msg.status = "READ"; + await msg.save(); } } - // When user sends a message -> mark as sent immediately Future markMessageAsSent(String messageId) async { final msg = _messagesBox.get(messageId); - if (msg != null) { + if (msg != null && msg.status != "READ") { msg.status = "SENT"; await msg.save(); } } - // DELETE MESSAGE - Future deleteMessage(String messageId) async { - await _messagesBox.delete(messageId); + Future _enforceCacheLimit(String chatId) async { + final chatMessages = _messagesBox.values + .where((msg) => msg.chatId == chatId) + .toList(); + + if (chatMessages.length <= 50) return; + chatMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + final messagesToDelete = chatMessages.sublist(50); + final keysToDelete = messagesToDelete.map((e) => e.id).toList(); + + if (keysToDelete.isNotEmpty) { + await _messagesBox.deleteAll(keysToDelete); + } + } + + List getPendingMessages(String chatId) { + return _messagesBox.values + .where((msg) => msg.chatId == chatId && msg.status == "SENDING") + .toList(); + } + + Future upsertConversations( + List conversations, + ) async { + for (ConversationModel conv in conversations) { + await _conversationsBox.put(conv.id, conv); + } + } + + List getAllConversations() { + return _conversationsBox.values.toList(); + } + + ConversationModel? getConversationById(String id) { + return _conversationsBox.get(id); } - // CLEAR ALL cache Future clearAll() async { await _conversationsBox.clear(); await _messagesBox.clear(); } + + Future deleteMessage(String messageId) async { + await _messagesBox.delete(messageId); + } + + Future deleteConversation(String conversationId) async { + await _conversationsBox.delete(conversationId); + } } diff --git a/lib/features/chat/repositories/chat_remote_repository.dart b/lib/features/chat/repositories/chat_remote_repository.dart index fbc88c6..3ad5120 100644 --- a/lib/features/chat/repositories/chat_remote_repository.dart +++ b/lib/features/chat/repositories/chat_remote_repository.dart @@ -17,39 +17,6 @@ ChatRemoteRepository chatRemoteRepository(Ref ref) { class ChatRemoteRepository { final Dio _dio; ChatRemoteRepository({required Dio dio}) : _dio = dio; - //--------------------------------------------------search users to choose to chat with him or them according to group or not ----------------------------------------// - Future>> searchUsers( - String query, - ) async { - try { - final response = await _dio.get( - "api/users/search", - queryParameters: {"query": query}, - ); - - final data = response.data as Map; - - final List list = data["users"] ?? []; - - final users = list - .map((e) => UserSearchModel.fromMap(e as Map)) - .toList(); - - return Right(users); - } on DioException catch (e) { - print("DIO RESPONSE DATA: ${e.response?.data}"); - final errorMessage = - e.response?.data["message"] ?? - e.response?.data["error"] ?? - "Failed to search users"; - - return Left(AppFailure(message: errorMessage)); - } catch (e) { - print("GENERAL ERROR: ${e.toString()}"); - return Left(AppFailure(message: e.toString())); - } - } - //----------------------------------------------------------------create chat ---------------------------------------------------------------------// Future> create_chat({ required List recipientIds, @@ -68,7 +35,7 @@ class ChatRemoteRepository { data, Current_UserId, ); - + print(conversation.id); return Right(conversation); } on DioException catch (e) { final errorMessage = @@ -81,8 +48,8 @@ class ChatRemoteRepository { } } - //----------------------------------------------------get inital messages from getchatinfo without timestemp-------------------------------// - Future>> getInitialChatMessages( + //----------------------------------------------------get last 50 messages from getchatinfo without timestemp-------------------------------// + Future>> getlastChatMessages( String chatId, ) async { try { @@ -93,7 +60,7 @@ class ChatRemoteRepository { final List messagesList = data['messages'] ?? []; final messages = messagesList - .map((msg) => MessageModel.fromApiResponse(msg)) + .map((msg) => MessageModel.fromLoadMessages(msg)) .toList(); return Right(messages); @@ -137,52 +104,17 @@ class ChatRemoteRepository { } } - //---------------------------------------------------------------update group info -------------------------------------------------------------------------------------// - Future> updateGroupInfo({ - required String chatId, - required String currentUserId, - String? groupName, - String? groupDescription, - String? groupPhotoKey, - }) async { - try { - final response = await _dio.put( - "api/dm/chat/$chatId/group", - data: { - if (groupName != null) "name": groupName, - if (groupDescription != null) "description": groupDescription, - if (groupPhotoKey != null) "photo": groupPhotoKey, - }, - ); - - final data = response.data as Map; - final updatedConversation = ConversationModel.fromApiResponse( - data, - currentUserId, - ); - - return Right(updatedConversation); - } on DioException catch (e) { - final errorMessage = - e.response?.data["message"] ?? - e.response?.data["error"] ?? - "Failed to update group info"; - return Left(AppFailure(message: errorMessage)); - } catch (e) { - return Left(AppFailure(message: e.toString())); - } - } - //-----------------------------------------------------------get messages of the conversation-------------------------------------------------------------------------// - Future>> getMessagesChat( - String chatId, { + Future>> getOlderMessagesChat({ + required String chatId, required DateTime lastMessageTimestamp, }) async { try { final response = await _dio.get( "api/dm/chat/$chatId/messages", queryParameters: { - "lastMessageTimestamp": lastMessageTimestamp.toIso8601String(), + "lastMessageTimestamp": lastMessageTimestamp.toIso8601String() + "Z", + "chatId": chatId, }, ); @@ -190,7 +122,7 @@ class ChatRemoteRepository { final messages = messagesList .map( - (msg) => MessageModel.fromApiResponse(msg as Map), + (msg) => MessageModel.fromLoadMessages(msg as Map), ) .toList(); @@ -206,44 +138,35 @@ class ChatRemoteRepository { } } - //----------------------------------------------------------------------get unseen count of one chat------------------------------------------------------------------------// - Future> getUnseenCountOfChat(String chatId) async { + //--------------------------------------------------search users to choose to chat with him or them ----------------------------------------// + Future>> searchUsers( + String query, + ) async { try { final response = await _dio.get( - "api/dm/chat/$chatId/unseen-messages-count", + "api/users/search", + queryParameters: {"query": query}, ); final data = response.data as Map; - final unseenCount = data["unseenMessagesCount"] as int? ?? 0; - return Right(unseenCount); - } on DioException catch (e) { - final errorMessage = - e.response?.data["message"] ?? - e.response?.data["error"] ?? - "Failed to get unseen count"; - return Left(AppFailure(message: errorMessage)); - } catch (e) { - return Left(AppFailure(message: e.toString())); - } - } - - //-----------------------------------------------------------------get unseen count of all messages of all chats --------------------------------------------------------------------------// - Future> getUnseenCountAllChats() async { - try { - final response = await _dio.get("api/dm/chat/all-unseen-messages-count"); + final List list = data["users"] ?? []; - final data = response.data as Map; - final totalUnseenCount = data["totalUnseenMessages"] as int? ?? 0; + final users = list + .map((e) => UserSearchModel.fromMap(e as Map)) + .toList(); - return Right(totalUnseenCount); + return Right(users); } on DioException catch (e) { + print("DIO RESPONSE DATA: ${e.response?.data}"); final errorMessage = e.response?.data["message"] ?? e.response?.data["error"] ?? - "Failed to get all unseen count"; + "Failed to search users"; + return Left(AppFailure(message: errorMessage)); } catch (e) { + print("GENERAL ERROR: ${e.toString()}"); return Left(AppFailure(message: e.toString())); } } @@ -275,19 +198,42 @@ class ChatRemoteRepository { } //------------------------------------------------------------------delete chat from conversions---------------------------------------------------------------------------// - Future> deleteChat(String chatId) async { + Future> deleteChat(String chatId) async { try { final response = await _dio.delete("api/dm/chat/$chatId"); - final data = response.data as Map; - final success = data["success"] as bool? ?? true; + if (response.statusCode == 200) { + final data = response.data as Map; + return Right(data["message"] ?? "Chat deleted successfully"); + } - return Right(success); + return Left(AppFailure(message: "Unexpected error")); } on DioException catch (e) { final errorMessage = e.response?.data["message"] ?? e.response?.data["error"] ?? "Failed to delete chat"; + + return Left(AppFailure(message: errorMessage)); + } catch (e) { + return Left(AppFailure(message: e.toString())); + } + } + + //-----------------------------------------------------------------get unseen count of all messages of all chats --------------------------------------------------------------------------// + Future> getUnseenCountAllChats() async { + try { + final response = await _dio.get("api/dm/chat/all-unseen-messages-count"); + + final data = response.data as Map; + final totalUnseenCount = data["totalUnseenMessages"] as int? ?? 0; + + return Right(totalUnseenCount); + } on DioException catch (e) { + final errorMessage = + e.response?.data["message"] ?? + e.response?.data["error"] ?? + "Failed to get all unseen count"; return Left(AppFailure(message: errorMessage)); } catch (e) { return Left(AppFailure(message: e.toString())); diff --git a/lib/features/chat/repositories/socket_repository.dart b/lib/features/chat/repositories/socket_repository.dart index 8ab23df..2133003 100644 --- a/lib/features/chat/repositories/socket_repository.dart +++ b/lib/features/chat/repositories/socket_repository.dart @@ -1,14 +1,10 @@ -// ignore_for_file: unused_import, unused_field - -import 'package:dio/dio.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:lite_x/core/constants/server_constants.dart'; -import 'package:lite_x/core/models/TokensModel.dart'; -import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/core/providers/unseenChatsCountProvider.dart'; import 'package:lite_x/features/auth/repositories/auth_local_repository.dart'; import 'package:lite_x/features/chat/providers/tokenStream.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:socket_io_client/socket_io_client.dart' as io; +import 'dart:async'; part 'socket_repository.g.dart'; @Riverpod(keepAlive: true) @@ -17,12 +13,33 @@ SocketRepository socketRepository(Ref ref) { } class SocketRepository { + final _newMessageController = + StreamController>.broadcast(); + final _messageAddedController = + StreamController>.broadcast(); + final _typingController = StreamController>.broadcast(); + final _messagesReadController = + StreamController>.broadcast(); + final _unseenChatsController = + StreamController>.broadcast(); + //-------------------------------------------------------------------------------// + Stream> get messagesReadStream => + _messagesReadController.stream; + Stream> get newMessageStream => + _newMessageController.stream; + Stream> get messageAddedStream => + _messageAddedController.stream; + Stream> get typingStream => _typingController.stream; + Stream> get unseenChatsStream => + _unseenChatsController.stream; + final Ref ref; io.Socket? _socket; final baseUrl = dotenv.env["API_URL"]!; SocketRepository({required this.ref}) { _initSocket(); _listenTokenChanges(); + _listenToUnseenChats(); } void _listenTokenChanges() { ref.listen(tokenStreamProvider, (previous, next) { @@ -57,6 +74,13 @@ class SocketRepository { _socket?.connect(); } + void _listenToUnseenChats() { + unseenChatsStream.listen((data) { + int count = data['count'] ?? 0; + ref.read(unseenChatsCountProvider.notifier).state = count; + }); + } + void _updateSocketToken(String newToken) { if (_socket == null) return; _socket?.io.options?['extraHeaders'] = { @@ -69,36 +93,58 @@ class SocketRepository { void _setupListeners() { _socket?.onConnect((_) { - print("SOCKET CONNECTED: ${_socket?.id}"); + print("SOCKET CONNECTED: ${_socket?.id}\n"); }); _socket?.onConnectError((data) { - print("Socket connect error: $data"); + print("Socket connect error: $data\n"); }); _socket?.onDisconnect((_) { - print("Socket disconnected"); + print("Socket disconnected\n"); }); _socket?.on('authenticated', (data) { - print("Authenticated: $data"); + print("Authenticated: $data\n"); }); _socket?.on('auth-error', (data) { - print(" Auth error: $data"); + print(" Auth error: $data\n"); }); _socket?.on('new-message', (data) { - print(" New message: $data"); + if (data != null && !_newMessageController.isClosed) { + _newMessageController.add(Map.from(data)); + } }); _socket?.on("messages-read", (data) { - print("Messages read event: $data"); + if (data != null && !_messagesReadController.isClosed) { + _messagesReadController.add(Map.from(data)); + } }); - _socket?.on("message-added", (data) { - print("Message added event: $data"); + _socket?.on('message-added', (data) { + if (data != null && !_messageAddedController.isClosed) { + _messageAddedController.add(Map.from(data)); + } }); + _socket?.on('user-typing', (data) { + if (data != null && !_typingController.isClosed) { + _typingController.add(Map.from(data)); + } + }); + _socket?.on('unseen-chats-count', (data) { + print("Unseen chats count: $data\n"); + if (data != null && !_unseenChatsController.isClosed) { + _unseenChatsController.add(Map.from(data)); + } + }); // New listener for unseen chats count } + void sendOpenMessageTab() { + print("sending open-message-tab"); + _socket?.emit('open-message-tab'); + } // to be zero when user opens chat tab + void sendTyping(String chatId, bool isTyping) { _socket?.emit('typing', {'chatId': chatId, 'isTyping': isTyping}); } @@ -116,7 +162,7 @@ class SocketRepository { void onNewMessage(Function(dynamic data) callback) { _socket?.on("new-message", (data) { callback(data); - }); // receiver + }); } // Mark chat opened (make all messages READ) @@ -133,15 +179,13 @@ class SocketRepository { _socket?.on("message-added", (data) => callback(data)); } //for sender - void disposeListeners() { - _socket?.off('new-message'); - _socket?.off('user-typing'); - _socket?.off('messages-read'); - _socket?.off('message-added'); - } - void dispose() { _socket?.disconnect(); _socket?.dispose(); + _newMessageController.close(); + _messageAddedController.close(); + _typingController.close(); + _messagesReadController.close(); + _unseenChatsController.close(); } } diff --git a/lib/features/chat/view/screens/Search_Direct_messages.dart b/lib/features/chat/view/screens/Search_Direct_messages.dart deleted file mode 100644 index b110631..0000000 --- a/lib/features/chat/view/screens/Search_Direct_messages.dart +++ /dev/null @@ -1,167 +0,0 @@ -// ignore_for_file: dead_code - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lite_x/core/theme/Palette.dart'; - -class SearchDirectMessages extends ConsumerStatefulWidget { - const SearchDirectMessages({super.key}); - - @override - ConsumerState createState() => - _SearchDirectMessagesState(); -} - -class _SearchDirectMessagesState extends ConsumerState { - late final TextEditingController _searchController; - String _searchQuery = ''; - - @override - void initState() { - super.initState(); - _searchController = TextEditingController(); - _searchController.addListener(() { - setState(() { - _searchQuery = _searchController.text; - }); - }); - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bool isQueryEmpty = _searchQuery.isEmpty; - final searchBarHintColor = Colors.grey[400]; - - return DefaultTabController( - length: 4, - child: Scaffold( - backgroundColor: Palette.background, - appBar: AppBar( - backgroundColor: Palette.background, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => context.pop(), - ), - title: TextField( - controller: _searchController, - autofocus: true, - style: const TextStyle(color: Palette.textWhite, fontSize: 16), - - decoration: InputDecoration( - hintText: isQueryEmpty ? 'Search Direct Messages' : '', - hintStyle: TextStyle(color: searchBarHintColor, fontSize: 16), - filled: false, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - suffixIcon: isQueryEmpty - ? null - : IconButton( - icon: Icon( - Icons.clear, - color: Palette.inputBorderFocused, - ), - onPressed: () => _searchController.clear(), - ), - ), - ), - ), - body: Column( - children: [ - const Divider(color: Color(0xFF38444D), height: 0.5), - if (!isQueryEmpty) - Column( - children: [ - TabBar( - tabAlignment: TabAlignment.start, - isScrollable: true, - dividerColor: Colors.transparent, - indicatorWeight: 1, - indicatorColor: Palette.inputBorderFocused, - labelColor: Palette.textWhite, - unselectedLabelColor: searchBarHintColor, - labelStyle: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - unselectedLabelStyle: const TextStyle( - fontWeight: FontWeight.normal, - ), - tabs: const [ - Tab(text: 'All'), - Tab(text: 'People'), - Tab(text: 'Groups'), - Tab(text: 'Messages'), - ], - ), - ], - ), - const Divider(color: Color(0xFF38444D), height: 0.5), - Expanded( - child: isQueryEmpty - ? _buildUnsearched(searchBarHintColor!) - : _buildSearchResults(_searchQuery), - ), - ], - ), - ), - ); - } - - Widget _buildUnsearched(Color hintColor) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 17.0), - child: Align( - alignment: Alignment.topLeft, - child: Text( - 'Try searching for people, groups or messages', - style: TextStyle(color: hintColor, fontSize: 16), - ), - ), - ); - } - - Widget _buildSearchResults(String query) { - return TabBarView( - children: [ - _buildResultsList(query), - _buildResultsList(query), - _buildResultsList(query), - _buildResultsList(query), - ], - ); - } - - Widget _buildResultsList(String query) { - final bool hasResults = false; - - if (hasResults) { - return ListView.builder(itemBuilder: (context, index) {}); - } - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - 'No results for "$query"', - textAlign: TextAlign.start, - style: const TextStyle( - color: Colors.white, - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/chat/view/screens/Search_User_Group.dart b/lib/features/chat/view/screens/Search_User_Group.dart index 79edd7e..4791cea 100644 --- a/lib/features/chat/view/screens/Search_User_Group.dart +++ b/lib/features/chat/view/screens/Search_User_Group.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -19,6 +20,7 @@ class SearchUserGroup extends ConsumerStatefulWidget { class _SearchUserGroupState extends ConsumerState { late final TextEditingController _searchController; Timer? _debounce; + CancelToken? _cancelToken; String _searchQuery = ''; bool _isGrouping = false; @@ -35,24 +37,29 @@ class _SearchUserGroupState extends ConsumerState { @override void dispose() { _debounce?.cancel(); + _cancelToken?.cancel(); _searchController.dispose(); super.dispose(); } void _onSearchChanged() { - final query = _searchController.text; - - if (query == _searchQuery) return; + final rawQuery = _searchController.text; + final query = rawQuery.trim(); + if (query == _searchQuery && rawQuery.isNotEmpty) return; _searchQuery = query; _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 200), () async { + if (_cancelToken != null && !_cancelToken!.isCancelled) { + _cancelToken!.cancel(); + } + + _debounce = Timer(const Duration(milliseconds: 600), () async { if (!mounted) return; if (query.isNotEmpty) { final users = await ref .read(conversationsViewModelProvider.notifier) .searchUsers(query); - // if (!mounted) return; + ref.read(searchResultsProvider.notifier).state = users; } else { ref.read(searchResultsProvider.notifier).state = []; @@ -83,39 +90,13 @@ class _SearchUserGroupState extends ConsumerState { 'subtitle': "${user.username}", 'avatarUrl': user.profileMedia, 'isGroup': false, + 'recipientFollowersCount': user.followers, }, ); }); } } - void _createGroup() async { - if (_selectedUsers.isEmpty) return; - final result = await ref - .read(conversationsViewModelProvider.notifier) - .createChat( - isDMChat: false, - recipientIds: _selectedUsers.map((u) => u.id).toList(), - ); - result.fold( - (failure) => ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(failure.message))), - (chatModel) { - context.pushNamed( - RouteConstants.ChatScreen, - pathParameters: {'chatId': chatModel.id}, - extra: { - 'title': chatModel.groupName ?? "Group A", - 'subtitle': "${_selectedUsers.length + 1} members", - 'avatarUrl': chatModel.groupPhotoKey, - 'isGroup': true, - }, - ); - }, - ); - } - bool isValidHttpUrl(String? url) { if (url == null) return false; final uri = Uri.tryParse(url); @@ -135,21 +116,7 @@ class _SearchUserGroupState extends ConsumerState { icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => context.pop(), ), - actions: _isGrouping && _selectedUsers.isNotEmpty - ? [ - TextButton( - onPressed: _createGroup, - child: Text( - "Create", - style: TextStyle( - color: Palette.textWhite, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ] - : null, + actions: null, ), body: Column( children: [ @@ -232,26 +199,7 @@ class _SearchUserGroupState extends ConsumerState { ), ), const Divider(color: Color(0xFF38444D), height: 0.2), - if (!_isGrouping) - ListTile( - leading: const Icon( - Icons.groups_outlined, - color: Palette.primary, - ), - title: Text( - 'Create a group', - style: TextStyle( - color: Palette.primary, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - onTap: () { - setState(() { - _isGrouping = true; - }); - }, - ), + Expanded( child: users.isEmpty ? Center( @@ -303,9 +251,10 @@ class _SearchUserGroupState extends ConsumerState { style: const TextStyle(color: Colors.grey), ), trailing: _isGrouping - ? (isSelected - ? Icon(Icons.check, color: Colors.grey[600]) - : null) + ? Icon( + isSelected ? Icons.check : null, + color: Colors.grey[600], + ) : null, onTap: () => _onUserTapped(user), ); diff --git a/lib/features/chat/view/screens/chat_Screen.dart b/lib/features/chat/view/screens/chat_Screen.dart index f01a3f5..ff1c6ef 100644 --- a/lib/features/chat/view/screens/chat_Screen.dart +++ b/lib/features/chat/view/screens/chat_Screen.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/core/theme/Palette.dart'; -import 'package:lite_x/features/chat/models/messagemodel.dart'; - +import 'package:lite_x/features/chat/providers/activeChatIdProvider.dart'; import 'package:lite_x/features/chat/view/widgets/chat/MessageAppBar.dart'; import 'package:lite_x/features/chat/view/widgets/chat/MessageBubble.dart'; import 'package:lite_x/features/chat/view/widgets/chat/MessageOptionsSheet.dart'; @@ -11,16 +10,14 @@ import 'package:lite_x/features/chat/view/widgets/chat/TypingIndicator.dart'; import 'package:lite_x/features/chat/view/widgets/chat/message_input_bar.dart'; import 'package:lite_x/features/chat/view_model/chat/Chat_view_model.dart'; import 'package:lite_x/features/chat/view_model/conversions/Conversations_view_model.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ChatScreen extends ConsumerStatefulWidget { final String chatId; - final String title; // name of user or group name - final String? subtitle; // username or group count + final String title; // name of user + final String? subtitle; // username final String? profileImage; final bool isGroup; final int? recipientFollowersCount; - const ChatScreen({ super.key, required this.chatId, @@ -37,32 +34,40 @@ class ChatScreen extends ConsumerStatefulWidget { class _ChatScreenState extends ConsumerState { late String _currentUserId; - final ItemPositionsListener _itemPositionsListener = - ItemPositionsListener.create(); bool _isExiting = false; bool _showScrollToBottomButton = false; final ScrollController _scrollController = ScrollController(); - late ChatViewModel notifier; - + bool _isLoadingMore = false; ProviderSubscription? _chatSub; @override void initState() { super.initState(); _currentUserId = ref.read(currentUserProvider)!.id; - notifier = ref.read(chatViewModelProvider.notifier); + _scrollController.addListener(_onScroll); + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _setupChatSubscription(); ref.read(chatViewModelProvider.notifier).loadChat(widget.chatId); + ref.read(activeChatProvider.notifier).setActive(widget.chatId); ref .read(conversationsViewModelProvider.notifier) .markChatAsRead(widget.chatId); }); + } - _itemPositionsListener.itemPositions.addListener(_scrollListener); + @override + void dispose() { + _chatSub?.close(); + _chatSub = null; + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + + super.dispose(); } void _setupChatSubscription() { @@ -70,75 +75,92 @@ class _ChatScreenState extends ConsumerState { previous, next, ) { - if (_isExiting || !mounted) return; + if (!mounted || _isExiting) return; final notifier = ref.read(chatViewModelProvider.notifier); if (!notifier.isActiveChat(widget.chatId)) return; - final positions = _itemPositionsListener.itemPositions.value; - final isAtBottom = - positions.isEmpty || - positions.any((pos) => pos.index == 0 && pos.itemLeadingEdge < 0.1); - - final lastMsg = next.messages.lastOrNull; + final shouldAutoScroll = _shouldAutoScrollToBottom(previous, next); - if (lastMsg != null && (lastMsg.userId == _currentUserId || isAtBottom)) { + if (shouldAutoScroll) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_isExiting || !mounted) return; - _scrollToBottom(); + _scrollToBottom(animate: true); }); } }, fireImmediately: false); } - @override - void dispose() { - _isExiting = true; + bool _shouldAutoScrollToBottom(ChatState? previous, ChatState next) { + if (previous == null || previous.messages.isEmpty) return true; - _chatSub?.close(); - _chatSub = null; + if (previous.messages.length == next.messages.length) return false; - _itemPositionsListener.itemPositions.removeListener(_scrollListener); + final lastMsg = next.messages.lastOrNull; + if (lastMsg == null) return false; - try { - final savedNotifier = notifier; + final isMine = lastMsg.userId == _currentUserId; + final isNearBottom = _isScrolledToBottom(); - Future.microtask(() { - savedNotifier.sendTyping(false); - savedNotifier.exitChat(); - }); - } catch (e) { - debugPrint("Error during dispose: $e"); - } - - super.dispose(); + return isMine || isNearBottom; } - void _scrollListener() { - if (_isExiting || !mounted) return; + bool _isScrolledToBottom() { + if (!_scrollController.hasClients) return true; + + const threshold = 100.0; + final position = _scrollController.position; - final positions = _itemPositionsListener.itemPositions.value; - if (positions.isEmpty) return; + return position.pixels <= threshold; + } - final isBottomVisible = positions.any((pos) => pos.index == 0); - final shouldShowButton = !isBottomVisible; + void _onScroll() { + if (_isExiting || !mounted) return; + final shouldShowButton = !_isScrolledToBottom(); if (shouldShowButton != _showScrollToBottomButton) { setState(() { _showScrollToBottomButton = shouldShowButton; }); } + + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200 && + !_isLoadingMore) { + _loadMoreMessages(); + } } - void _scrollToBottom() { + Future _loadMoreMessages() async { + if (_isLoadingMore) return; + + final chatState = ref.read(chatViewModelProvider); + if (chatState.isLoadingHistory) return; + + setState(() { + _isLoadingMore = true; + }); + + await ref.read(chatViewModelProvider.notifier).loadOlderMessages(); + + setState(() { + _isLoadingMore = false; + }); + } + + void _scrollToBottom({bool animate = true}) { if (!mounted || _isExiting) return; if (!_scrollController.hasClients) return; - _scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); + if (animate) { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + _scrollController.jumpTo(0); + } } void _handleSendMessage(String text) { @@ -147,35 +169,16 @@ class _ChatScreenState extends ConsumerState { final currentUser = ref.read(currentUserProvider); if (currentUser == null || text.trim().isEmpty) return; - final tempId = DateTime.now().millisecondsSinceEpoch.toString(); - final message = MessageModel( - id: tempId, - chatId: widget.chatId, - userId: currentUser.id, - content: text.trim(), - createdAt: DateTime.now(), - status: 'PENDING', - messageType: 'text', - ); - - ref.read(chatViewModelProvider.notifier).sendMessage(message); + ref + .read(chatViewModelProvider.notifier) + .sendMessage(content: text.trim(), messageType: 'text'); WidgetsBinding.instance.addPostFrameCallback((_) { if (_isExiting || !mounted) return; - _scrollToBottom(); + _scrollToBottom(animate: true); }); } - void _handleDeleteMessage(MessageModel message, bool forEveryone) { - print( - 'Delete message ${message.id} for ${forEveryone ? "everyone" : "me"}', - ); - } - - void _handleEditMessage(MessageModel message) { - print('Edit message ${message.id}'); - } - @override Widget build(BuildContext context) { final chatState = ref.watch(chatViewModelProvider); @@ -198,18 +201,33 @@ class _ChatScreenState extends ConsumerState { Expanded( child: Stack( children: [ - if (chatState.isLoading) + if (chatState.isLoading && messages.isEmpty) const Center(child: CircularProgressIndicator()) else ListView.separated( controller: _scrollController, reverse: true, - padding: const EdgeInsets.symmetric(vertical: 10), - itemCount: messages.length + 1, + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 8, + ), + itemCount: + messages.length + (chatState.isLoadingHistory ? 2 : 1), separatorBuilder: (_, __) => const SizedBox(height: 6), itemBuilder: (context, index) { - if (index == messages.length) + if (chatState.isLoadingHistory && + index == messages.length + 1) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (index == messages.length) { return _buildProfileHeader(); + } final message = messages[messages.length - index - 1]; final isMe = message.userId == currentUser.id; @@ -223,36 +241,42 @@ class _ChatScreenState extends ConsumerState { context: context, message: message, isMe: isMe, - onDeleteForMe: () => - _handleDeleteMessage(message, false), - onDeleteForEveryone: () => - _handleDeleteMessage(message, true), - onEdit: () => _handleEditMessage(message), ); }, ); }, ), + if (chatState.isRecipientTyping) Positioned( bottom: 0, left: 0, right: 0, - child: TypingIndicator(userName: widget.title), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Theme.of(context).scaffoldBackgroundColor, + ], + ), + ), + child: TypingIndicator(userName: widget.title), + ), ), _buildScrollToBottomButton(), ], ), ), + SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: MessageInputBar( onSendMessage: _handleSendMessage, - onSendAudio: null, - onSendImage: null, - onSendGif: null, onTypingChanged: (isTyping) { ref.read(chatViewModelProvider.notifier).sendTyping(isTyping); }, @@ -273,9 +297,9 @@ class _ChatScreenState extends ConsumerState { CircleAvatar( radius: 45, backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), - // backgroundImage: widget.profileImage != null - // ? NetworkImage(widget.profileImage!) - // : null, + backgroundImage: widget.profileImage != null + ? NetworkImage(widget.profileImage!) + : null, child: widget.profileImage == null ? Text( widget.title[0].toUpperCase(), @@ -287,48 +311,50 @@ class _ChatScreenState extends ConsumerState { ) : null, ), - const SizedBox(height: 5), + const SizedBox(height: 8), Text( widget.title, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, + fontSize: 18, + fontWeight: FontWeight.w600, color: Palette.textPrimary, ), ), - if (widget.subtitle != null) + + if (widget.subtitle != null) ...[ + const SizedBox(height: 2), Text( '@${widget.subtitle}', - style: TextStyle( - fontSize: 16, - color: Color.fromARGB(255, 133, 139, 145), - ), + style: const TextStyle(fontSize: 15, color: Color(0xFF858B91)), ), - const SizedBox(height: 12), + ], - if (widget.recipientFollowersCount != null) + if (widget.recipientFollowersCount != null) ...[ + const SizedBox(height: 8), Text( '${widget.recipientFollowersCount} Followers', - style: TextStyle(fontSize: 14, color: Colors.grey[500]), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), + ], const SizedBox(height: 20), Divider( - thickness: 0.2, - color: Colors.grey[500], - indent: 10, - endIndent: 10, + thickness: 0.5, + color: Colors.grey[300], + indent: 20, + endIndent: 20, ), - const SizedBox(height: 4), + + const SizedBox(height: 8), Text( - 'Today', + _getConversationStartDate(), style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Palette.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey[600], ), ), ], @@ -336,20 +362,51 @@ class _ChatScreenState extends ConsumerState { ); } + String _getConversationStartDate() { + final chatState = ref.read(chatViewModelProvider); + if (chatState.messages.isEmpty) return 'Today'; + + final oldestMessage = chatState.messages.first; + final now = DateTime.now(); + final messageDate = oldestMessage.createdAt; + + final difference = now.difference(messageDate); + + if (difference.inDays == 0) { + return 'Today'; + } else if (difference.inDays == 1) { + return 'Yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else { + return '${messageDate.day}/${messageDate.month}/${messageDate.year}'; + } + } + Widget _buildScrollToBottomButton() { return Positioned( - bottom: 10.0, - right: 10.0, - child: AnimatedOpacity( - opacity: _showScrollToBottomButton ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: IgnorePointer( - ignoring: !_showScrollToBottomButton, - child: FloatingActionButton.small( - onPressed: _scrollToBottom, - tooltip: 'Scroll to bottom', - backgroundColor: Theme.of(context).primaryColor, - child: const Icon(Icons.arrow_downward, color: Colors.white), + bottom: 16.0, + right: 16.0, + child: AnimatedScale( + scale: _showScrollToBottomButton ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: AnimatedOpacity( + opacity: _showScrollToBottomButton ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: IgnorePointer( + ignoring: !_showScrollToBottomButton, + child: FloatingActionButton.small( + onPressed: () => _scrollToBottom(animate: true), + tooltip: 'Scroll to bottom', + backgroundColor: Theme.of(context).primaryColor, + elevation: 4, + child: const Icon( + Icons.keyboard_arrow_down_rounded, + color: Colors.white, + size: 24, + ), + ), ), ), ), diff --git a/lib/features/chat/view/widgets/chat/MessageAppBar.dart b/lib/features/chat/view/widgets/chat/MessageAppBar.dart index 3ca0097..8c6de0f 100644 --- a/lib/features/chat/view/widgets/chat/MessageAppBar.dart +++ b/lib/features/chat/view/widgets/chat/MessageAppBar.dart @@ -1,14 +1,11 @@ import 'package:flutter/material.dart'; import 'package:lite_x/core/theme/palette.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; class MessageAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; final String? profileImage; final String subtitle; final VoidCallback? onProfileTap; - final VoidCallback? onVideoCallTap; - final VoidCallback? onAudioCallTap; const MessageAppBar({ super.key, @@ -16,8 +13,6 @@ class MessageAppBar extends StatelessWidget implements PreferredSizeWidget { required this.subtitle, this.profileImage, this.onProfileTap, - this.onVideoCallTap, - this.onAudioCallTap, }); @override @@ -60,17 +55,6 @@ class MessageAppBar extends StatelessWidget implements PreferredSizeWidget { ], ), ), - actions: [ - IconButton( - icon: Icon(MdiIcons.videoPlusOutline, size: 28, color: Colors.white), - onPressed: onVideoCallTap, - ), - IconButton( - icon: Icon(MdiIcons.phoneOutline, size: 25, color: Colors.white), - onPressed: onAudioCallTap, - ), - const SizedBox(width: 4), - ], ); } } diff --git a/lib/features/chat/view/widgets/chat/MessageOptionsSheet.dart b/lib/features/chat/view/widgets/chat/MessageOptionsSheet.dart index 90b7c74..585017b 100644 --- a/lib/features/chat/view/widgets/chat/MessageOptionsSheet.dart +++ b/lib/features/chat/view/widgets/chat/MessageOptionsSheet.dart @@ -6,11 +6,9 @@ import 'package:lite_x/features/chat/models/messagemodel.dart'; class MessageOptionsSheet extends StatelessWidget { final MessageModel message; final bool isMe; - final VoidCallback? onDeleteForMe; final VoidCallback? onDeleteForEveryone; final VoidCallback? onEdit; - const MessageOptionsSheet({ super.key, required this.message, diff --git a/lib/features/chat/view/widgets/chat/message_input_bar.dart b/lib/features/chat/view/widgets/chat/message_input_bar.dart index 5456cf9..9f22514 100644 --- a/lib/features/chat/view/widgets/chat/message_input_bar.dart +++ b/lib/features/chat/view/widgets/chat/message_input_bar.dart @@ -1,25 +1,15 @@ import 'dart:async'; -import 'package:just_audio/just_audio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/core/classes/PickedImage.dart'; -import 'package:lite_x/features/chat/providers/audiorecordernotifier.dart'; -import 'package:giphy_get/giphy_get.dart'; import 'package:lite_x/core/theme/palette.dart'; class MessageInputBar extends ConsumerStatefulWidget { final Function(String text) onSendMessage; - final Function(String)? onSendAudio; - final Function(PickedImage)? onSendImage; - final Function(String)? onSendGif; final Function(bool isTyping)? onTypingChanged; const MessageInputBar({ super.key, required this.onSendMessage, - this.onSendAudio, - this.onSendImage, - this.onSendGif, + this.onTypingChanged, }); @@ -29,19 +19,12 @@ class MessageInputBar extends ConsumerStatefulWidget { class _MessageInputBarState extends ConsumerState with SingleTickerProviderStateMixin { - final String _giphyApiKey = dotenv.env["giphyApiKey"] ?? ""; late AnimationController _colorController; final TextEditingController _textController = TextEditingController(); Timer? _typingTimer; bool _isTyping = false; final FocusNode _focusNode = FocusNode(); - final AudioPlayer _audioPlayer = AudioPlayer(); - - Timer? _recordingTimer; - StreamSubscription? _playerStateSubscription; - StreamSubscription? _positionSubscription; - PickedImage? selectedImage; @override void initState() { super.initState(); @@ -49,51 +32,17 @@ class _MessageInputBarState extends ConsumerState vsync: this, duration: const Duration(seconds: 1), )..repeat(reverse: true); - _setupAudioPlayer(); _textController.addListener(_onTextChanged); } - Future _stopPlayback() async { - try { - await _audioPlayer.stop(); - await _audioPlayer.seek(Duration.zero); - ref.read(audioRecorderProvider.notifier).resetReviewPosition(); - } catch (e) { - debugPrint("Error stopping playback: $e"); - } - } - - void _setupAudioPlayer() { - _playerStateSubscription = _audioPlayer.playerStateStream.listen((state) { - if (!mounted) return; - - if (state.processingState == ProcessingState.completed) { - _stopPlayback(); - } - }); - - _positionSubscription = _audioPlayer.positionStream.listen((position) { - if (!mounted) return; - - final audioState = ref.read(audioRecorderProvider); - if (audioState.status == RecorderStatus.reviewing && - _audioPlayer.playing) { - ref.read(audioRecorderProvider.notifier).updateReviewPosition(position); - } - }); - } - @override void dispose() { - _recordingTimer?.cancel(); - _playerStateSubscription?.cancel(); - _positionSubscription?.cancel(); _typingTimer?.cancel(); _textController.removeListener(_onTextChanged); _textController.dispose(); _colorController.dispose(); _focusNode.dispose(); - _audioPlayer.dispose(); + super.dispose(); } @@ -134,184 +83,22 @@ class _MessageInputBarState extends ConsumerState _focusNode.requestFocus(); } - Future _selectImage() async { - selectedImage = await pickImage(); - if (selectedImage != null) { - widget.onSendImage?.call(selectedImage!); - } - } - - Future _startRecording() async { - try { - final success = await ref - .read(audioRecorderProvider.notifier) - .startRecording(); - if (success) { - _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) { - ref.read(audioRecorderProvider.notifier).updateRecordingDuration(); - } - }); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Failed to start recording. Please check microphone permissions.', - ), - backgroundColor: Palette.border, - ), - ); - } - } - } catch (e) { - debugPrint('Error starting recording: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Recording error: $e'), - backgroundColor: Palette.border, - ), - ); - } - } - } - - Future _stopRecording() async { - try { - _recordingTimer?.cancel(); - await ref.read(audioRecorderProvider.notifier).stopRecording(); - } catch (e) { - debugPrint('Error stopping recording: $e'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error stopping recording: $e'), - backgroundColor: Palette.border, - ), - ); - } - } - } - - Future _cancelRecording() async { - try { - _recordingTimer?.cancel(); - await ref.read(audioRecorderProvider.notifier).cancelRecording(); - } catch (e) { - debugPrint('Error canceling recording: $e'); - } - } - - Future _togglePlayback() async { - final path = ref.read(audioRecorderProvider).recordingPath; - if (path == null) return; - - try { - if (_audioPlayer.playing) { - await _audioPlayer.pause(); - } else { - if (_audioPlayer.processingState == ProcessingState.completed || - _audioPlayer.processingState == ProcessingState.idle) { - await _audioPlayer.setFilePath(path); - } - await _audioPlayer.play(); - } - } catch (e) { - debugPrint("Error toggling playback: $e"); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Playback error: $e'), - backgroundColor: Palette.border, - ), - ); - } - } - } - - Future _cancelReview() async { - await _stopPlayback(); - await ref.read(audioRecorderProvider.notifier).cancelReview(); - } - - Future _sendRecording() async { - await _stopPlayback(); - final path = ref.read(audioRecorderProvider.notifier).sendRecording(); - if (path != null) { - widget.onSendAudio?.call(path); - } - } - - Future _toggleGifPicker() async { - final gif = await GiphyGet.getGif( - context: context, - apiKey: _giphyApiKey, - lang: GiphyLanguage.english, - tabColor: Palette.kBrandBlue, - ); - if (gif != null) { - final gifUrl = gif.images?.original?.url; - if (gifUrl != null && gifUrl.isNotEmpty) { - widget.onSendGif?.call(gifUrl); - } - } - } - - String _formatRecordingDuration(Duration duration) { - return '${duration.inSeconds}s'; - } - - String _formatReviewDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - final minutes = duration.inMinutes.remainder(60); - final seconds = twoDigits(duration.inSeconds.remainder(60)); - return '$minutes:$seconds'; - } - @override Widget build(BuildContext context) { - final audioState = ref.watch(audioRecorderProvider); final theme = Theme.of(context); return Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( - color: audioState.status == RecorderStatus.idle - ? Palette.container_message_color - : Colors.black, + color: Palette.container_message_color, borderRadius: BorderRadius.circular(26), ), - child: switch (audioState.status) { - RecorderStatus.idle => _buildIdleView(theme), - RecorderStatus.recording => _buildRecordingView(audioState, theme), - RecorderStatus.reviewing => _buildReviewView(audioState, theme), - }, + child: _buildIdleView(theme), ); } Widget _buildIdleView(ThemeData theme) { return Row( children: [ - IconButton( - icon: const Icon( - Icons.image_outlined, - color: Palette.kDimIconwhite, - size: 26, - ), - onPressed: _selectImage, - tooltip: 'Send image', - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - ), - IconButton( - icon: const Icon( - Icons.gif_box_outlined, - color: Palette.kDimIconwhite, - size: 26, - ), - onPressed: _toggleGifPicker, - tooltip: 'Send GIF', - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - ), Expanded( child: TextField( maxLines: null, @@ -330,10 +117,9 @@ class _MessageInputBarState extends ConsumerState enabledBorder: InputBorder.none, disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 2, vertical: 8), + contentPadding: EdgeInsets.symmetric(horizontal: 2, vertical: 4), ), style: const TextStyle(color: Color(0xFFFFFFFF), fontSize: 16), - // onSubmitted: (_) => _handleSend(), ), ), ValueListenableBuilder( @@ -341,200 +127,14 @@ class _MessageInputBarState extends ConsumerState builder: (context, value, child) { final hastext = value.text.trim().isNotEmpty; return IconButton( - icon: Icon( - hastext ? Icons.send : Icons.graphic_eq, - color: hastext ? Palette.kBrandBlue : Palette.kBrandPurple, - size: 24, - ), - onPressed: hastext ? _handleSend : _startRecording, - onLongPress: !hastext ? _startRecording : null, - tooltip: hastext ? 'Send message' : 'Record audio', - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + icon: Icon(Icons.send, color: Palette.kBrandBlue, size: 24), + onPressed: hastext ? _handleSend : null, + tooltip: hastext ? 'Send message' : null, + constraints: const BoxConstraints(minWidth: 35, minHeight: 35), ); }, ), ], ); } - - Widget _buildRecordingView(AudioRecorderState audioState, ThemeData theme) { - return Container( - color: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - InkWell( - onTap: _cancelRecording, - child: const Text( - 'Cancel', - style: TextStyle(color: Palette.kDimIconwhite, fontSize: 14), - ), - ), - Expanded( - child: Container( - height: 33, - margin: const EdgeInsets.symmetric(horizontal: 10), - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Color(0xFFD1C4F8), Color(0xFFE0D7FF)], - ), - borderRadius: BorderRadius.circular(15), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - AnimatedBuilder( - animation: _colorController, - builder: (context, child) { - return Container( - width: 10, - height: 10, - margin: const EdgeInsets.only(right: 5), - decoration: BoxDecoration( - color: Color.lerp( - const Color(0xFFF04F78), - const Color(0xFF8E24AA), - _colorController.value, - ), - shape: BoxShape.circle, - ), - ); - }, - ), - const Text( - 'Recording', - style: TextStyle( - color: Color.fromARGB(255, 141, 108, 182), - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - _formatRecordingDuration(audioState.remainingDuration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ), - GestureDetector( - onTap: _stopRecording, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: const Color.fromARGB(255, 239, 57, 103), - width: 2.2, - ), - ), - child: const Center( - child: Icon( - size: 14, - Icons.stop_rounded, - color: Color(0xFFF04F78), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildReviewView(AudioRecorderState audioState, ThemeData theme) { - return Container( - color: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - InkWell( - onTap: _cancelReview, - child: const Text( - 'Cancel', - style: TextStyle(color: Palette.kDimIconwhite, fontSize: 14), - ), - ), - Expanded( - child: Container( - height: 33, - margin: const EdgeInsets.symmetric(horizontal: 10), - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color.fromARGB(255, 169, 145, 255), - Color.fromARGB(255, 163, 141, 244), - ], - ), - borderRadius: BorderRadius.circular(15), - ), - child: GestureDetector( - onTap: _togglePlayback, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - _audioPlayer.playing ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 25, - ), - const SizedBox(width: 5), - Text( - _audioPlayer.playing ? 'Playing' : 'Play audio', - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - Text( - _formatReviewDuration(audioState.remainingDuration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - GestureDetector( - onTap: _sendRecording, - child: Container( - width: 30, - height: 30, - decoration: const BoxDecoration( - color: Color(0xFF8A6BFE), - shape: BoxShape.circle, - ), - child: const Icon(Icons.send, color: Colors.black, size: 18), - ), - ), - ], - ), - ); - } } diff --git a/lib/features/chat/view/widgets/conversation_tile.dart b/lib/features/chat/view/widgets/conversation_tile.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/chat/view/widgets/conversion/SearchField.dart b/lib/features/chat/view/widgets/conversion/SearchField.dart deleted file mode 100644 index 933bcd3..0000000 --- a/lib/features/chat/view/widgets/conversion/SearchField.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lite_x/core/theme/Palette.dart'; - -class SearchField extends StatelessWidget { - final String hintText; - final VoidCallback? onTap; - - const SearchField({super.key, required this.hintText, this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 11), - height: 40, - decoration: BoxDecoration( - color: Palette.cardBackground, - borderRadius: BorderRadius.circular(24), - ), - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Row( - children: [ - Expanded( - child: Text( - hintText, - style: const TextStyle( - color: Color.fromARGB(255, 85, 88, 92), - fontSize: 18, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/chat/view/widgets/conversion/conversation_tile.dart b/lib/features/chat/view/widgets/conversion/conversation_tile.dart index 6b059fe..6ca71ad 100644 --- a/lib/features/chat/view/widgets/conversion/conversation_tile.dart +++ b/lib/features/chat/view/widgets/conversion/conversation_tile.dart @@ -14,7 +14,8 @@ class ConversationTile extends StatelessWidget { final bool isUnread; final int unseenCount; final bool isDMChat; - + final int recipientFollowersCount; + final VoidCallback? onLongPress; const ConversationTile({ super.key, required this.name, @@ -27,6 +28,8 @@ class ConversationTile extends StatelessWidget { this.isUnread = false, this.unseenCount = 0, this.isDMChat = true, + this.recipientFollowersCount = 0, + this.onLongPress, }); @override @@ -41,9 +44,11 @@ class ConversationTile extends StatelessWidget { 'avatarUrl': avatarUrl, 'subtitle': username, 'isGroup': isDMChat, + 'recipientFollowersCount': recipientFollowersCount, }, ); }, + onLongPress: onLongPress, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: const BoxDecoration( @@ -134,21 +139,21 @@ class ConversationTile extends StatelessWidget { const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, + horizontal: 4, + vertical: 4, ), decoration: BoxDecoration( color: Palette.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - unseenCount > 99 ? '99+' : unseenCount.toString(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + borderRadius: BorderRadius.circular(20), ), + // child: Text( + // unseenCount > 99 ? '99+' : unseenCount.toString(), + // style: const TextStyle( + // fontSize: 12, + // fontWeight: FontWeight.bold, + // color: Colors.white, + // ), + // ), ), ], ], diff --git a/lib/features/chat/view/widgets/conversion/conversations_list.dart b/lib/features/chat/view/widgets/conversion/conversations_list.dart index cdcd2ee..967bcd5 100644 --- a/lib/features/chat/view/widgets/conversion/conversations_list.dart +++ b/lib/features/chat/view/widgets/conversion/conversations_list.dart @@ -1,8 +1,5 @@ -// ignore_for_file: dead_code, unused_local_variable - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/core/theme/Palette.dart'; import 'package:lite_x/features/chat/view/widgets/conversion/conversation_tile.dart'; import 'package:lite_x/features/chat/view/widgets/conversion/empty_inbox.dart'; @@ -32,6 +29,105 @@ class _ConversationsListState extends ConsumerState { super.dispose(); } + void _showDeleteConfirmation(BuildContext context, String chatId) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Palette.textSecondary, + title: const Text( + "Delete conversation?", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + content: const Text( + "This conversation will be deleted from everyone inbox", + style: TextStyle(color: Colors.white), + ), + actions: [ + TextButton( + child: const Text( + "Delete", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + Navigator.pop(ctx); + final result = await ref + .read(conversationsViewModelProvider.notifier) + .deleteChat(chatId); + if (mounted) { + result.fold( + (failure) => messenger.showSnackBar( + SnackBar(content: Text(failure.message)), + ), + (success) => messenger.showSnackBar( + SnackBar( + content: Text( + success, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + }, + ), + TextButton( + child: const Text( + "Cancel", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ); + } + + void _showOptionsMenu(BuildContext parentContext, String chatId) { + showDialog( + context: parentContext, + builder: (dialogContext) => SimpleDialog( + backgroundColor: Palette.textSecondary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _buildMenuOption("Delete conversation", () { + Navigator.pop(dialogContext); + + _showDeleteConfirmation(parentContext, chatId); + }), + ], + ), + ); + } + + Widget _buildMenuOption(String text, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + width: double.maxFinite, + child: Text( + text, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + String _getDisplayMessage(String? content, String? messageType) { if (messageType == null || messageType == 'text') { return content ?? ''; @@ -54,7 +150,6 @@ class _ConversationsListState extends ConsumerState { @override Widget build(BuildContext context) { final conversationsAsync = ref.watch(conversationsViewModelProvider); - final currentUser = ref.watch(currentUserProvider); return conversationsAsync.when( data: (conversations) { @@ -64,12 +159,13 @@ class _ConversationsListState extends ConsumerState { return RefreshIndicator( onRefresh: () async { - await ref + return ref .read(conversationsViewModelProvider.notifier) .loadConversations(); }, child: ListView.builder( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), itemCount: conversations.length, itemBuilder: (context, index) { final conversation = conversations[index]; @@ -105,6 +201,9 @@ class _ConversationsListState extends ConsumerState { isUnread: isUnread, unseenCount: conversation.unseenCount, isDMChat: conversation.isDMChat, + onLongPress: () { + _showOptionsMenu(context, conversation.id); + }, ); }, ), diff --git a/lib/features/chat/view/widgets/conversion/conversion_app_bar.dart b/lib/features/chat/view/widgets/conversion/conversion_app_bar.dart index 6902302..5750a48 100644 --- a/lib/features/chat/view/widgets/conversion/conversion_app_bar.dart +++ b/lib/features/chat/view/widgets/conversion/conversion_app_bar.dart @@ -1,12 +1,8 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/core/routes/Route_Constants.dart'; import 'package:lite_x/core/theme/Palette.dart'; -import 'package:lite_x/features/chat/view/widgets/conversion/SearchField.dart'; class ConversationAppBar extends ConsumerWidget implements PreferredSizeWidget { const ConversationAppBar({super.key}); @@ -48,21 +44,6 @@ class ConversationAppBar extends ConsumerWidget implements PreferredSizeWidget { ), ), ), - Expanded( - child: SearchField( - hintText: 'Search Direct Messages', - onTap: () { - context.pushNamed(RouteConstants.SearchDirectMessages); - }, - ), - ), - IconButton( - icon: const Icon( - Icons.settings_outlined, - color: Color.fromARGB(174, 255, 255, 255), - ), - onPressed: () {}, - ), ], ), ), diff --git a/lib/features/chat/view_model/chat/Chat_view_model.dart b/lib/features/chat/view_model/chat/Chat_view_model.dart index 6e7409f..c05d631 100644 --- a/lib/features/chat/view_model/chat/Chat_view_model.dart +++ b/lib/features/chat/view_model/chat/Chat_view_model.dart @@ -1,37 +1,48 @@ +import 'dart:async'; import 'package:lite_x/core/models/usermodel.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/features/chat/models/messagemodel.dart'; import 'package:lite_x/features/chat/repositories/chat_local_repository.dart'; import 'package:lite_x/features/chat/repositories/chat_remote_repository.dart'; import 'package:lite_x/features/chat/repositories/socket_repository.dart'; +import 'package:lite_x/features/chat/view_model/conversions/Conversations_view_model.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; part 'Chat_view_model.g.dart'; class ChatState { final List messages; final bool isRecipientTyping; final bool isLoading; + final bool isLoadingHistory; + final bool hasMoreHistory; ChatState({ this.messages = const [], this.isRecipientTyping = false, this.isLoading = false, + this.isLoadingHistory = false, + this.hasMoreHistory = true, }); ChatState copyWith({ List? messages, bool? isRecipientTyping, bool? isLoading, + bool? isLoadingHistory, + bool? hasMoreHistory, }) { return ChatState( messages: messages ?? this.messages, isRecipientTyping: isRecipientTyping ?? this.isRecipientTyping, isLoading: isLoading ?? this.isLoading, + isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory, + hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory, ); } } -@Riverpod(keepAlive: true) +@riverpod class ChatViewModel extends _$ChatViewModel { late ChatRemoteRepository _chatRemoteRepository; late ChatLocalRepository _chatLocalRepository; @@ -39,7 +50,13 @@ class ChatViewModel extends _$ChatViewModel { UserModel? _currentUser; String? _activeChatId; String? _myUserId; - + bool _isDisposed = false; // + final List _historyBuffer = []; + final Set _loadedMessageIds = {}; + StreamSubscription? _msgSub; + StreamSubscription? _ackSub; + StreamSubscription? _typingSub; + StreamSubscription? _readSub; @override ChatState build() { _chatRemoteRepository = ref.watch(chatRemoteRepositoryProvider); @@ -50,7 +67,12 @@ class ChatViewModel extends _$ChatViewModel { _setupSocketListeners(); ref.onDispose(() { - _socketRepository.disposeListeners(); + print("Disposing ChatViewModel and cancelling subscriptions"); + _isDisposed = true; + _msgSub?.cancel(); + _ackSub?.cancel(); + _typingSub?.cancel(); + _readSub?.cancel(); }); return ChatState(isLoading: false); } @@ -58,67 +80,50 @@ class ChatViewModel extends _$ChatViewModel { bool isActiveChat(String chatId) => _activeChatId == chatId; void _setupSocketListeners() { - print("Setting up Socket Listeners in ViewModel..."); - _socketRepository.onNewMessage((data) { - print("ViewModel Received Message Event"); - if (_activeChatId == null) return; - if (data['chatId'] == _activeChatId) { - handleIncomingMessage(data); - } + _msgSub = _socketRepository.newMessageStream.listen((data) { + if (_isDisposed) return; + print("New message received in ChatVM: $data"); + _handleIncomingMessage(data); }); - _socketRepository.onMessageAdded((data) { - if (_activeChatId == null) return; - handleMessageAdded(data); + + _ackSub = _socketRepository.messageAddedStream.listen((data) { + if (_isDisposed) return; + _handleMessageAck(data); }); - _socketRepository.onMessagesRead((data) { - if (_activeChatId == null) return; - handleMessagesRead(data); + _readSub = _socketRepository.messagesReadStream.listen((data) { + if (_isDisposed) return; + _handleMessagesRead(data); }); - _socketRepository.onTyping((data) { - if (_activeChatId == null) return; - handleTypingEvent(data); + _typingSub = _socketRepository.typingStream.listen((data) { + if (_isDisposed) return; + _handleTypingEvent(data); }); } - void handleMessageAdded(Map data) async { - final chatId = data["chatId"]; - final realMessageId = data["messageId"]; - if (_activeChatId != chatId) return; - - final index = state.messages.indexWhere( - (m) => m.status == "PENDING" && m.chatId == chatId, - ); - - if (index == -1) return; - - final tempMsg = state.messages[index]; - - final updatedMsg = tempMsg.copyWith(id: realMessageId, status: "SENT"); - final updated = [...state.messages]; - updated[index] = updatedMsg; - state = state.copyWith(messages: updated); - - _chatLocalRepository.saveMessage(updatedMsg); + List _sortMessages(List messages) { + final sorted = [...messages]; + sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return sorted; } - // load messages (offline first) Future loadChat(String chatId) async { - if (_currentUser == null) { - print("Error: No current user found"); - return; - } - - state = state.copyWith(isLoading: true); + if (_currentUser == null) return; + state = state.copyWith(isLoading: true, hasMoreHistory: true); _activeChatId = chatId; _myUserId = _currentUser!.id; + _historyBuffer.clear(); + _loadedMessageIds.clear(); + + final cachedMessages = _chatLocalRepository.getCachedMessages(chatId); + _loadedMessageIds.addAll(cachedMessages.map((m) => m.id)); - final localCached = _chatLocalRepository.getMessagesForChat(chatId); - state = state.copyWith(messages: localCached); + final sortedCache = _sortMessages(cachedMessages); + state = state.copyWith(messages: sortedCache); - final result = await _chatRemoteRepository.getInitialChatMessages(chatId); + final result = await _chatRemoteRepository.getlastChatMessages(chatId); if (_activeChatId != chatId) { state = state.copyWith(isLoading: false); @@ -127,159 +132,229 @@ class ChatViewModel extends _$ChatViewModel { await result.fold( (failure) async { - print("Failed to fetch messages: ${failure.message}"); + if (_isDisposed) return; // state = state.copyWith(isLoading: false); }, (serverMessages) async { - final mergedMessages = await _mergeAndReconcileMessages( - localCached, - serverMessages, - ); + if (_isDisposed) return; // + await _chatLocalRepository.saveInitialMessages(serverMessages); + await _reconcilePendingMessages(chatId, serverMessages); - await _chatLocalRepository.saveMessages(serverMessages); + final updatedCache = _chatLocalRepository.getCachedMessages(chatId); + _loadedMessageIds.clear(); + _loadedMessageIds.addAll(updatedCache.map((m) => m.id)); if (_activeChatId != chatId) { state = state.copyWith(isLoading: false); return; } - state = state.copyWith(messages: mergedMessages, isLoading: false); + final sortedUpdated = _sortMessages(updatedCache); + if (_isDisposed) return; // + state = state.copyWith(messages: sortedUpdated, isLoading: false); _socketRepository.openChat(chatId); }, ); } - //for matching messages not send while internet is off - MessageModel? _findMatchingServerMessage(pending, serverMessages) { - for (final srv in serverMessages) { - if (srv.content == pending.content && - srv.chatId == pending.chatId && - srv.userId == pending.userId) { - return srv; - } - } - return null; - } - - // reconcile the messages - Future> _mergeAndReconcileMessages( - List localMessages, + Future _reconcilePendingMessages( + String chatId, List serverMessages, ) async { - for (final srv in serverMessages) { - await _chatLocalRepository.deleteMessage(srv.id); - } - - final Map messageMap = {}; - - for (final srv in serverMessages) { - messageMap[srv.id] = srv; - await _chatLocalRepository.saveMessage(srv); - } - - for (final localMsg in localMessages) { - if (localMsg.status != "PENDING") continue; + final pendingMessages = _chatLocalRepository.getPendingMessages(chatId); + if (pendingMessages.isEmpty) return; - final match = _findMatchingServerMessage(localMsg, serverMessages); + for (final pending in pendingMessages) { + final match = _findMatchingServerMessage(pending, serverMessages); if (match != null) { - await _chatLocalRepository.deleteMessage(localMsg.id); - - messageMap[match.id] = match; + await _chatLocalRepository.replaceTempWithServerMessage( + tempId: pending.id, + serverMessage: match, + ); } else { - messageMap[localMsg.id] = localMsg; - await _chatLocalRepository.saveMessage(localMsg); + _socketRepository.sendMessage(pending.toApiRequest()); } } + } - final merged = messageMap.values.toList(); - merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - - return merged; + MessageModel? _findMatchingServerMessage( + MessageModel pending, + List serverMessages, + ) { + return serverMessages.cast().firstWhere( + (srv) => + srv!.content == pending.content && + srv.chatId == pending.chatId && + srv.userId == pending.userId && + srv.createdAt.difference(pending.createdAt).inSeconds.abs() < 60, + orElse: () => null, + ); } - // handle for receivers - void handleIncomingMessage(Map data) async { - if (_activeChatId == null) return; + Future sendMessage({ + required String content, + String messageType = 'text', + }) async { + if (_activeChatId == null || _myUserId == null) return; + + final tempId = 'temp_${const Uuid().v4()}'; + final timestamp = DateTime.now(); + + final localMessage = MessageModel( + id: tempId, + chatId: _activeChatId!, + userId: _myUserId!, + content: content, + messageType: messageType, + createdAt: timestamp, + status: 'SENDING', + ); - final msg = MessageModel.fromApiResponse(data); - if (msg.userId == _myUserId) { - return; - } + await _chatLocalRepository.saveMessage(localMessage); - final exists = state.messages.any((m) => m.id == msg.id); - if (exists) { - _updateLocalMessageWithServerId(msg); - return; - } + final updatedMessages = [...state.messages, localMessage]; + state = state.copyWith(messages: updatedMessages); + _loadedMessageIds.add(tempId); - final isActiveChat = - (msg.userId != _myUserId && msg.chatId == _activeChatId); + ref + .read(conversationsViewModelProvider.notifier) + .updateConversationAfterSending( + chatId: _activeChatId!, + content: content, + messageType: messageType, + timestamp: timestamp, + ); - if (isActiveChat) { - msg.status = "READ"; - } + _socketRepository.sendMessage(localMessage.toApiRequest()); + } - await _chatLocalRepository.saveMessage(msg); + void _handleMessageAck(Map data) async { + final chatId = data["chatId"] as String?; + final realMessageId = data["messageId"] as String?; - final updated = [...state.messages, msg]; - state = state.copyWith(messages: updated); - if (isActiveChat) { - _socketRepository.openChat(_activeChatId!); - } - } + if (chatId == null || realMessageId == null) return; + if (_activeChatId != chatId) return; - void _updateLocalMessageWithServerId(MessageModel serverMsg) { - final index = state.messages.lastIndexWhere( - (m) => m.status == "PENDING" && m.chatId == serverMsg.chatId, + final index = state.messages.indexWhere( + (m) => m.status == "SENDING" && m.chatId == chatId, ); if (index == -1) return; final tempMsg = state.messages[index]; - final updatedMsg = tempMsg.copyWith( - id: serverMsg.id, - status: serverMsg.status, - ); + final serverMessage = tempMsg.copyWith(id: realMessageId, status: "SENT"); - final updated = [...state.messages]; - updated[index] = updatedMsg; + await _chatLocalRepository.replaceTempWithServerMessage( + tempId: tempMsg.id, + serverMessage: serverMessage, + ); - _chatLocalRepository.saveMessage(updatedMsg); + _loadedMessageIds.remove(tempMsg.id); + _loadedMessageIds.add(realMessageId); - state = state.copyWith(messages: updated); + final updatedMessages = [...state.messages]; + updatedMessages[index] = serverMessage; + state = state.copyWith(messages: updatedMessages); } - // send message - Future sendMessage(MessageModel localMessage) async { - _chatLocalRepository.saveMessage(localMessage); + void _handleIncomingMessage(Map data) async { + final msg = MessageModel.fromApiResponse(data); - final isAlreadyInState = state.messages.any((m) => m.id == localMessage.id); + if (msg.userId == _myUserId) return; + if (_loadedMessageIds.contains(msg.id)) return; - if (!isAlreadyInState) { - final updated = [...state.messages, localMessage]; - state = state.copyWith(messages: updated); - } + final isActiveChat = (_activeChatId == msg.chatId); + final finalMsg = msg.copyWith(status: isActiveChat ? "READ" : msg.status); - _socketRepository.sendMessage(localMessage.toApiRequest()); + await _chatLocalRepository.saveMessage(finalMsg); + + print("is in chat ${isActiveChat}"); + if (isActiveChat) { + final updatedMessages = [...state.messages, finalMsg]; + if (_isDisposed || _activeChatId != msg.chatId) return; // + state = state.copyWith(messages: updatedMessages); + _loadedMessageIds.add(msg.id); + _socketRepository.openChat(_activeChatId!); // to decrease unseen count + } } - // handle read status event - void handleMessagesRead(Map data) { - final chatId = data['chatId']; + void _handleMessagesRead(Map data) async { + final chatId = data['chatId'] as String?; if (chatId == null || chatId != _activeChatId) return; - _chatLocalRepository.markMessagesAsRead(chatId, _myUserId!); - final updated = _chatLocalRepository.getMessagesForChat(chatId); - state = state.copyWith(messages: updated); + + final messagesBeforeUpdate = [...state.messages]; + + await _chatLocalRepository.markMessagesAsRead(chatId, _myUserId!); + + final updatedMessages = messagesBeforeUpdate.map((msg) { + if (msg.chatId == chatId && + msg.userId == _myUserId && + msg.status != "READ") { + return msg.copyWith(status: "READ"); + } + return msg; + }).toList(); + + state = state.copyWith(messages: updatedMessages); + } + + Future loadOlderMessages() async { + if (_activeChatId == null || + state.isLoadingHistory || + !state.hasMoreHistory) { + return; + } + + final allCurrentMessages = [...state.messages, ..._historyBuffer]; + if (allCurrentMessages.isEmpty) return; + + final sortedCurrent = _sortMessages(allCurrentMessages); + final oldestMessage = sortedCurrent.first; + final lastTimestamp = oldestMessage.createdAt; + + state = state.copyWith(isLoadingHistory: true); + + final result = await _chatRemoteRepository.getOlderMessagesChat( + chatId: _activeChatId!, + lastMessageTimestamp: lastTimestamp, + ); + + await result.fold( + (failure) async { + if (_isDisposed) return; // + state = state.copyWith(isLoadingHistory: false); + }, + (olderMessages) async { + if (_isDisposed) return; // + if (olderMessages.isEmpty) { + state = state.copyWith( + isLoadingHistory: false, + hasMoreHistory: false, + ); + return; + } + + final newMessages = olderMessages + .where((msg) => !_loadedMessageIds.contains(msg.id)) + .toList(); + + _historyBuffer.addAll(newMessages); + _loadedMessageIds.addAll(newMessages.map((m) => m.id)); + + final allMessages = [..._historyBuffer, ...state.messages]; + + state = state.copyWith(messages: allMessages, isLoadingHistory: false); + }, + ); } - // typing indicator void sendTyping(bool isTyping) { if (_activeChatId == null) return; _socketRepository.sendTyping(_activeChatId!, isTyping); } - void handleTypingEvent(dynamic data) { + void _handleTypingEvent(dynamic data) { if (_activeChatId == null) return; final typingChatId = data['chatId'] as String?; @@ -291,16 +366,11 @@ class ChatViewModel extends _$ChatViewModel { state = state.copyWith(isRecipientTyping: isTyping); } - void exitChat() { - final previousChatId = _activeChatId; - _activeChatId = null; - - if (previousChatId != null) { - state = ChatState( - isRecipientTyping: false, - messages: [], - isLoading: false, - ); - } - } + // void exitChat() { + // if (_activeChatId == null) return; + // _activeChatId = null; + // _historyBuffer.clear(); + // _loadedMessageIds.clear(); + // state = ChatState(isRecipientTyping: false, messages: [], isLoading: false); + // } } diff --git a/lib/features/chat/view_model/chat/Chat_view_model.g.dart b/lib/features/chat/view_model/chat/Chat_view_model.g.dart index 192dfbc..c02811f 100644 --- a/lib/features/chat/view_model/chat/Chat_view_model.g.dart +++ b/lib/features/chat/view_model/chat/Chat_view_model.g.dart @@ -20,7 +20,7 @@ final class ChatViewModelProvider argument: null, retry: null, name: r'chatViewModelProvider', - isAutoDispose: false, + isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @@ -41,7 +41,7 @@ final class ChatViewModelProvider } } -String _$chatViewModelHash() => r'99961fd224f15c454600f531ebf8235255c288ec'; +String _$chatViewModelHash() => r'efd91655f15519eedbcdff93437fb216f0038fba'; abstract class _$ChatViewModel extends $Notifier { ChatState build(); diff --git a/lib/features/chat/view_model/conversions/Conversations_view_model.dart b/lib/features/chat/view_model/conversions/Conversations_view_model.dart index 2ce0683..b4f00ff 100644 --- a/lib/features/chat/view_model/conversions/Conversations_view_model.dart +++ b/lib/features/chat/view_model/conversions/Conversations_view_model.dart @@ -1,5 +1,4 @@ -// ignore_for_file: unused_field - +import 'dart:async'; import 'package:fpdart/fpdart.dart'; import 'package:lite_x/core/classes/AppFailure.dart'; import 'package:lite_x/core/models/usermodel.dart'; @@ -7,6 +6,7 @@ import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/features/chat/models/conversationmodel.dart'; import 'package:lite_x/features/chat/models/messagemodel.dart'; import 'package:lite_x/features/chat/models/usersearchmodel.dart'; +import 'package:lite_x/features/chat/providers/activeChatIdProvider.dart'; import 'package:lite_x/features/chat/repositories/chat_local_repository.dart'; import 'package:lite_x/features/chat/repositories/chat_remote_repository.dart'; import 'package:lite_x/features/chat/repositories/socket_repository.dart'; @@ -20,7 +20,7 @@ class ConversationsViewModel extends _$ConversationsViewModel { late final SocketRepository _socketRepository; UserModel? _currentUser; bool _listening = false; - + StreamSubscription? _messageSub; @override AsyncValue> build() { _chatRemoteRepository = ref.watch(chatRemoteRepositoryProvider); @@ -32,7 +32,7 @@ class ConversationsViewModel extends _$ConversationsViewModel { _listening = true; ref.onDispose(() { - _socketRepository.disposeListeners(); + _messageSub?.cancel(); _listening = false; }); } @@ -40,10 +40,19 @@ class ConversationsViewModel extends _$ConversationsViewModel { } void _listenToNewMessages() { - _socketRepository.onNewMessage((data) { + _messageSub = _socketRepository.newMessageStream.listen((data) { try { + print("New message received: $data"); + + final serverUnseenCount = data["unseenMessagesCount"] as int? ?? 0; + print("new count $serverUnseenCount"); + final newMsg = MessageModel.fromApiResponse(data); + final activeChatId = ref.read(activeChatProvider); + final bool isChatOpen = (activeChatId == newMsg.chatId); + final bool isMe = newMsg.userId == _currentUser?.id; + final currentConversations = state.maybeWhen( data: (list) => List.from(list), orElse: () => _chatLocalRepository.getAllConversations(), @@ -51,26 +60,37 @@ class ConversationsViewModel extends _$ConversationsViewModel { final idx = currentConversations.indexWhere( (chat) => chat.id == newMsg.chatId, - ); // check if conversation already exist or not + ); if (idx != -1) { - // conversation exists final chat = currentConversations[idx]; - final bool isMe = newMsg.userId == _currentUser?.id; - - final int newCount = isMe ? chat.unseenCount : (chat.unseenCount + 1); + int finalUnseenCount; + if (isMe) { + finalUnseenCount = chat.unseenCount; + } else if (isChatOpen) { + finalUnseenCount = 0; + _socketRepository.openChat(newMsg.chatId); + } else { + if (serverUnseenCount > 0) { + finalUnseenCount = serverUnseenCount; + } else { + finalUnseenCount = chat.unseenCount + 1; + } + } final updatedChat = chat.copyWith( lastMessageContent: newMsg.content, lastMessageType: newMsg.messageType, lastMessageTime: newMsg.createdAt, - unseenCount: newCount, + unseenCount: finalUnseenCount, lastMessageSenderId: newMsg.userId, ); currentConversations[idx] = updatedChat; } else { - //handle dm only + int initialCount = (isMe || isChatOpen) + ? 0 + : (serverUnseenCount > 0 ? serverUnseenCount : 1); final created = ConversationModel( id: newMsg.chatId, isDMChat: true, @@ -80,16 +100,14 @@ class ConversationsViewModel extends _$ConversationsViewModel { newMsg.userId, if (_currentUser != null) _currentUser!.id, ], - dmPartnerUserId: newMsg.userId, dmPartnerName: newMsg.senderName ?? "Unknown", dmPartnerUsername: newMsg.senderUsername, dmPartnerProfileKey: newMsg.senderProfileMediaKey, - lastMessageContent: newMsg.content, lastMessageType: newMsg.messageType, lastMessageTime: newMsg.createdAt, - unseenCount: (newMsg.userId == _currentUser?.id) ? 0 : 1, + unseenCount: initialCount, lastMessageSenderId: newMsg.userId, ); @@ -97,14 +115,22 @@ class ConversationsViewModel extends _$ConversationsViewModel { _chatRemoteRepository .getChatInfo(newMsg.chatId, _currentUser!.id) .then((result) { - result.fold((l) => null, (chat) async { - await _chatLocalRepository.upsertConversations([chat]); + result.fold((l) => null, (serverChat) async { + final mergedChat = serverChat.copyWith( + unseenCount: initialCount, + lastMessageContent: newMsg.content, + lastMessageType: newMsg.messageType, + lastMessageTime: newMsg.createdAt, + lastMessageSenderId: newMsg.userId, + ); + + await _chatLocalRepository.upsertConversations([mergedChat]); final currentList = state.value ?? []; final refreshed = List.from(currentList) - ..removeWhere((c) => c.id == chat.id) - ..add(chat); + ..removeWhere((c) => c.id == mergedChat.id) + ..add(mergedChat); refreshed.sort((a, b) { final aTime = a.lastMessageTime ?? a.updatedAt; @@ -112,18 +138,14 @@ class ConversationsViewModel extends _$ConversationsViewModel { return bTime.compareTo(aTime); }); - state = AsyncValue.data(refreshed); + state = AsyncValue.data([...refreshed]); }); }); } + _sortConversations(currentConversations); - currentConversations.sort((a, b) { - final aTime = a.lastMessageTime ?? a.updatedAt; - final bTime = b.lastMessageTime ?? b.updatedAt; - return bTime.compareTo(aTime); - }); + state = AsyncValue.data([...currentConversations]); - state = AsyncValue.data(currentConversations); _chatLocalRepository.upsertConversations(currentConversations); } catch (e) { print("Error handling new-message socket: $e"); @@ -131,6 +153,42 @@ class ConversationsViewModel extends _$ConversationsViewModel { }); } + void updateConversationAfterSending({ + required String chatId, + required String content, + required String messageType, + required DateTime timestamp, + }) { + if (_currentUser == null) return; + final currentList = state.value ?? []; + final index = currentList.indexWhere((c) => c.id == chatId); + + if (index != -1) { + final chat = currentList[index]; + + final updatedChat = chat.copyWith( + lastMessageContent: content, + lastMessageType: messageType, + lastMessageTime: timestamp, + lastMessageSenderId: _currentUser!.id, + ); + + final updatedList = List.from(currentList); + updatedList[index] = updatedChat; + _sortConversations(updatedList); + state = AsyncValue.data([...updatedList]); + _chatLocalRepository.upsertConversations([updatedChat]); + } + } + + void _sortConversations(List list) { + list.sort((a, b) { + final aTime = a.lastMessageTime ?? a.updatedAt; + final bTime = b.lastMessageTime ?? b.updatedAt; + return bTime.compareTo(aTime); + }); + } + void markChatAsRead(String chatId) { state.whenData((currentList) { final updatedList = currentList.map((chat) { @@ -139,8 +197,8 @@ class ConversationsViewModel extends _$ConversationsViewModel { } return chat; }).toList(); + state = AsyncValue.data([...updatedList]); - state = AsyncValue.data(updatedList); final chat = updatedList.firstWhere((c) => c.id == chatId); _chatLocalRepository.upsertConversations([chat]); }); @@ -181,7 +239,7 @@ class ConversationsViewModel extends _$ConversationsViewModel { return bTime.compareTo(aTime); }); - state = AsyncValue.data(updatedList); + state = AsyncValue.data([...updatedList]); return Right(serverChat); }); @@ -210,28 +268,55 @@ class ConversationsViewModel extends _$ConversationsViewModel { final cachedConversations = ref .read(chatLocalRepositoryProvider) .getAllConversations(); - - state = AsyncValue.data(cachedConversations); + if (state.value == null || state.value!.isEmpty) { + state = AsyncValue.data([...cachedConversations]); + } final result = await _chatRemoteRepository.getuserchats(_currentUser!.id); - final conversations = result.fold((failure) { - print("Error loading conversations: ${failure.message}"); - return cachedConversations; - }, (convs) => convs); - conversations.sort( - (a, b) => (b.lastMessageTime ?? b.updatedAt).compareTo( - a.lastMessageTime ?? a.updatedAt, - ), + result.fold( + (failure) { + print("Error loading conversations: ${failure.message}"); + }, + (serverConversations) async { + final serverIds = serverConversations.map((c) => c.id).toSet(); + final localConversations = _chatLocalRepository.getAllConversations(); + + for (var localConv in localConversations) { + if (!serverIds.contains(localConv.id)) { + await _chatLocalRepository.deleteConversation(localConv.id); + } + } + await _chatLocalRepository.upsertConversations(serverConversations); + _sortConversations(serverConversations); + state = AsyncValue.data([...serverConversations]); + }, ); - await ref - .read(chatLocalRepositoryProvider) - .upsertConversations(conversations); - - state = AsyncValue.data(conversations); } catch (e, st) { print("Conversation Load Failed: $e"); state = AsyncValue.error(e, st); } } + + Future> deleteChat(String chatId) async { + if (_currentUser == null) { + return Left(AppFailure(message: "No current user found")); + } + + final result = await _chatRemoteRepository.deleteChat(chatId); + print("Delete chat result: $result"); + return result.fold( + (failure) { + return Left(failure); + }, + (successMessage) async { + await _chatLocalRepository.deleteConversation(chatId); + final currentList = state.value ?? []; + final updatedList = List.from(currentList) + ..removeWhere((chat) => chat.id == chatId); + state = AsyncValue.data([...updatedList]); + return Right(successMessage); + }, + ); + } } diff --git a/lib/features/chat/view_model/conversions/Conversations_view_model.g.dart b/lib/features/chat/view_model/conversions/Conversations_view_model.g.dart index 9ef92a7..f6fe94f 100644 --- a/lib/features/chat/view_model/conversions/Conversations_view_model.g.dart +++ b/lib/features/chat/view_model/conversions/Conversations_view_model.g.dart @@ -48,7 +48,7 @@ final class ConversationsViewModelProvider } String _$conversationsViewModelHash() => - r'02617aac71dc6741834e63d07ab48be13bb9fbd1'; + r'bfee74aa41c86afff7fea22093b0cb68b113c1c2'; abstract class _$ConversationsViewModel extends $Notifier>> { diff --git a/lib/features/home/models/tweet_model.dart b/lib/features/home/models/tweet_model.dart index dad9104..10d3711 100644 --- a/lib/features/home/models/tweet_model.dart +++ b/lib/features/home/models/tweet_model.dart @@ -178,14 +178,19 @@ class TweetModel extends HiveObject { authorName: user?['name']?.toString() ?? + json['name']?.toString() ?? json['authorName']?.toString() ?? 'Unknown User', authorUsername: user?['username']?.toString() ?? + json['username']?.toString() ?? json['authorUsername']?.toString() ?? 'unknown', authorAvatar: - _extractProfileMedia(user) ?? json['authorAvatar']?.toString() ?? '', + _extractProfileMedia(user) ?? + json['profileMediaKey']?.toString() ?? + json['authorAvatar']?.toString() ?? + '', createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : DateTime.now(), diff --git a/lib/features/home/repositories/home_repository.dart b/lib/features/home/repositories/home_repository.dart index 40048ff..c259815 100644 --- a/lib/features/home/repositories/home_repository.dart +++ b/lib/features/home/repositories/home_repository.dart @@ -28,10 +28,17 @@ class HomeRepository { ); final List tweetsData; - if (response.data is List) { + // Handle response format: { "user": {...}, "recommendations": [...] } + if (response.data is Map) { + if (response.data['recommendations'] != null) { + tweetsData = response.data['recommendations'] as List; + } else if (response.data['data'] != null) { + tweetsData = response.data['data'] as List; + } else { + return []; + } + } else if (response.data is List) { tweetsData = response.data as List; - } else if (response.data is Map && response.data['data'] != null) { - tweetsData = response.data['data'] as List; } else { return []; } @@ -53,6 +60,8 @@ class HomeRepository { int page = 1, int limit = 20, }) async { + // Return mock data if enabled + try { final response = await _dio.get( 'api/home/timeline', @@ -60,10 +69,15 @@ class HomeRepository { ); final List tweetsData; - if (response.data is List) { + // Handle response format: { "data": [...], "nextCursor": "..." } + if (response.data is Map) { + if (response.data['data'] != null) { + tweetsData = response.data['data'] as List; + } else { + return []; + } + } else if (response.data is List) { tweetsData = response.data as List; - } else if (response.data is Map && response.data['data'] != null) { - tweetsData = response.data['data'] as List; } else { return []; } diff --git a/lib/features/home/repositories/mock_home_data.dart b/lib/features/home/repositories/mock_home_data.dart new file mode 100644 index 0000000..ed9a25a --- /dev/null +++ b/lib/features/home/repositories/mock_home_data.dart @@ -0,0 +1,60 @@ +import 'package:lite_x/features/home/models/tweet_model.dart'; + +/// Mock data for testing when endpoints are offline +class MockHomeData { + static const bool useMockData = false; // Set to true to use mock data + + static List getMockForYouTweets() { + final now = DateTime.now(); + + return [ + TweetModel( + id: 'mock-foryou-1', + content: ' Welcome to the For You feed! This is a mock tweet for testing purposes.', + authorName: 'Test User', + authorUsername: 'testuser', + authorAvatar: '', + userId: 'user-1', + createdAt: now.subtract(const Duration(hours: 2)), + likes: 42, + retweets: 15, + replies: 8, + quotes: 3, + bookmarks: 5, + isLiked: false, + isRetweeted: false, + isBookmarked: false, + tweetType: 'TWEET', + images: [], + replyIds: [], + ), + ]; + } + + static List getMockTimelineTweets() { + final now = DateTime.now(); + + return [ + TweetModel( + id: 'mock-timeline-1', + content: ' This is your Following/Timeline feed! Here you see tweets from people you follow.', + authorName: 'Your Friend', + authorUsername: 'yourfriend', + authorAvatar: '', + userId: 'user-10', + createdAt: now.subtract(const Duration(minutes: 30)), + likes: 25, + retweets: 8, + replies: 4, + quotes: 1, + bookmarks: 2, + isLiked: false, + isRetweeted: false, + isBookmarked: false, + tweetType: 'TWEET', + images: [], + replyIds: [], + ), + ]; + } +} diff --git a/lib/features/home/view/screens/create_post_screen.dart b/lib/features/home/view/screens/create_post_screen.dart index 2338a41..91266cd 100644 --- a/lib/features/home/view/screens/create_post_screen.dart +++ b/lib/features/home/view/screens/create_post_screen.dart @@ -61,7 +61,8 @@ class _CreatePostScreenState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); bool _isPosting = false; - final List _selectedImages = []; + final List _selectedMedia = + []; // Changed from _selectedImages to support both images and videos PostPrivacy _selectedPrivacy = PostPrivacy.everyone; @override @@ -91,14 +92,14 @@ class _CreatePostScreenState extends ConsumerState { try { List mediaIds = []; - if (_selectedImages.isNotEmpty) { - final uploadedIds = await upload_media(_selectedImages); + if (_selectedMedia.isNotEmpty) { + final uploadedIds = await upload_media(_selectedMedia); mediaIds = uploadedIds.where((id) => id.isNotEmpty).toList(); - if (mediaIds.length != _selectedImages.length && mounted) { + if (mediaIds.length != _selectedMedia.length && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Some images failed to upload. Try again.'), + content: Text('Some media files failed to upload. Try again.'), behavior: SnackBarBehavior.floating, backgroundColor: Colors.orange, ), @@ -106,7 +107,7 @@ class _CreatePostScreenState extends ConsumerState { } if (mediaIds.isEmpty) { - throw Exception('Unable to upload selected images.'); + throw Exception('Unable to upload selected media.'); } } @@ -122,7 +123,7 @@ class _CreatePostScreenState extends ConsumerState { if (mounted) { setState(() { _textController.clear(); - _selectedImages.clear(); + _selectedMedia.clear(); }); Navigator.pop(context, true); ScaffoldMessenger.of(context).showSnackBar( @@ -157,11 +158,11 @@ class _CreatePostScreenState extends ConsumerState { } Future _pickImage() async { - final remainingSlots = 4 - _selectedImages.length; + final remainingSlots = 4 - _selectedMedia.length; if (remainingSlots <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Maximum 4 images allowed per post.'), + content: Text('Maximum 4 media files allowed per post.'), behavior: SnackBarBehavior.floating, ), ); @@ -174,29 +175,110 @@ class _CreatePostScreenState extends ConsumerState { setState(() { for (final picked in pickedList) { if (picked.file != null) { - _selectedImages.add(picked.file!); + _selectedMedia.add(picked.file!); } } }); } - void _removeImage(int index) { - if (index < 0 || index >= _selectedImages.length) return; + Future _pickVideo() async { + final remainingSlots = 4 - _selectedMedia.length; + if (remainingSlots <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maximum 4 media files allowed per post.'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + final picked = await pickVideo(); + if (picked == null || picked.file == null) return; + + setState(() { + _selectedMedia.add(picked.file!); + }); + } + + void _showMediaPicker() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + ListTile( + leading: const Icon(Icons.image, color: Color(0xFF1D9BF0)), + title: const Text('Photo', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickImage(); + }, + ), + ListTile( + leading: const Icon(Icons.videocam, color: Color(0xFF1D9BF0)), + title: const Text('Video', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickVideo(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + void _removeMedia(int index) { + if (index < 0 || index >= _selectedMedia.length) return; setState(() { - _selectedImages.removeAt(index); + _selectedMedia.removeAt(index); }); } - Widget _buildSelectedImagesPreview() { - if (_selectedImages.isEmpty) return const SizedBox.shrink(); - final crossAxisCount = _selectedImages.length == 1 ? 1 : 2; - final double aspectRatio = _selectedImages.length == 1 ? 16 / 9 : 1; + bool _isVideoFile(File file) { + final extension = file.path.split('.').last.toLowerCase(); + return [ + 'mp4', + 'mov', + 'avi', + 'webm', + 'mkv', + 'flv', + 'wmv', + 'mpeg', + 'mpg', + '3gp', + 'm4v', + ].contains(extension); + } + + Widget _buildSelectedMediaPreview() { + if (_selectedMedia.isEmpty) return const SizedBox.shrink(); + final crossAxisCount = _selectedMedia.length == 1 ? 1 : 2; + final double aspectRatio = _selectedMedia.length == 1 ? 16 / 9 : 1; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_selectedImages.length} / 4 photos', + '${_selectedMedia.length} / 4 media files', style: TextStyle(color: Colors.grey[500], fontSize: 13), ), const SizedBox(height: 8), @@ -209,22 +291,65 @@ class _CreatePostScreenState extends ConsumerState { mainAxisSpacing: 8, childAspectRatio: aspectRatio, ), - itemCount: _selectedImages.length, + itemCount: _selectedMedia.length, itemBuilder: (context, index) { - final file = _selectedImages[index]; + final file = _selectedMedia[index]; + final isVideo = _isVideoFile(file); + return Stack( children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.file(file, fit: BoxFit.cover), + child: isVideo + ? Container( + color: Colors.grey[900], + child: const Center( + child: Icon( + Icons.play_circle_outline, + color: Colors.white, + size: 64, + ), + ), + ) + : Image.file(file, fit: BoxFit.cover), ), ), + if (isVideo) + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam, color: Colors.white, size: 12), + SizedBox(width: 4), + Text( + 'Video', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), Positioned( top: 8, right: 8, child: InkWell( - onTap: () => _removeImage(index), + onTap: () => _removeMedia(index), child: Container( decoration: const BoxDecoration( color: Colors.black54, @@ -485,11 +610,11 @@ class _CreatePostScreenState extends ConsumerState { ), ], ), - if (_selectedImages.isNotEmpty) ...[ + if (_selectedMedia.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.only(left: 52), - child: _buildSelectedImagesPreview(), + child: _buildSelectedMediaPreview(), ), ], const SizedBox(height: 12), @@ -539,10 +664,10 @@ class _CreatePostScreenState extends ConsumerState { children: [ IconButton( icon: const Icon( - Icons.image_outlined, + Icons.perm_media_outlined, color: Color(0xFF1D9BF0), ), - onPressed: _isPosting ? null : _pickImage, + onPressed: _isPosting ? null : _showMediaPicker, iconSize: 20, padding: const EdgeInsets.all(8), constraints: const BoxConstraints(), diff --git a/lib/features/home/view/screens/home_screen.dart b/lib/features/home/view/screens/home_screen.dart index 3e8a3d8..ba84b2e 100644 --- a/lib/features/home/view/screens/home_screen.dart +++ b/lib/features/home/view/screens/home_screen.dart @@ -13,10 +13,7 @@ import 'package:lite_x/features/home/view/screens/create_post_screen.dart'; import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart'; import 'package:lite_x/features/home/view/widgets/profile_side_drawer.dart'; import 'package:lite_x/features/home/view/widgets/expandable_fab.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; import 'package:lite_x/features/profile/view/screens/profile_screen.dart'; -import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_posts_list.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -114,22 +111,17 @@ class _HomeScreenState extends ConsumerState super.build(context); final homeState = ref.watch(homeViewModelProvider); final currentFeed = homeState.currentFeed; - final tweets = currentFeed == FeedType.forYou - ? homeState.forYouTweets - : homeState.followingTweets; + final tweets = + homeState.tweets; // Use the tweets field that switches correctly final feedName = currentFeed == FeedType.forYou ? "For You" : "Following"; - final currentUserName = ref.watch(currentUserProvider)?.username ?? ""; - final profileData = ref.watch(profileDataProvider(currentUserName)); return Scaffold( key: _scaffoldKey, backgroundColor: Colors.black, drawer: const ProfileSideDrawer(), body: RefreshIndicator( onRefresh: () async { - ref.read(homeViewModelProvider.notifier).refreshTweets(); - // ignore: unused_result - await ref.refresh(profilePostsProvider(currentUserName)); + await ref.read(homeViewModelProvider.notifier).refreshTweets(); }, backgroundColor: Colors.grey[900], color: Colors.white, @@ -152,57 +144,11 @@ class _HomeScreenState extends ConsumerState collapseMode: CollapseMode.pin, ), ), - - // _buildSliverTweetList( - // context, - // tweets, - // homeState.isLoading, - // feedName, - // ), - SliverFillRemaining( - child: profileData.when( - data: (res) { - return res.fold( - (l) { - return RefreshIndicator( - onRefresh: () async { - // ignore: unused_result - await ref.refresh( - profileDataProvider(currentUserName), - ); - }, - child: ListView( - children: [Center(child: Text(l.message))], - ), - ); - }, - (data) { - return ProfilePostsList( - profile: data, - tabType: ProfileTabType.Posts, - ); - }, - ); - }, - error: (err, _) { - return RefreshIndicator( - onRefresh: () async { - // ignore: unused_result - await ref.refresh(profileDataProvider(currentUserName)); - }, - child: ListView( - children: [ - Center(child: Text("Can't get profile posts")), - ], - ), - ); - }, - loading: () { - return ListView( - children: [Center(child: CircularProgressIndicator())], - ); - }, - ), + _buildSliverTweetList( + context, + tweets, + homeState.isLoading, + feedName, ), ], ), @@ -257,6 +203,48 @@ class _HomeScreenState extends ConsumerState bool isLoading, String feedType, ) { + // Show error if present + final homeState = ref.watch(homeViewModelProvider); + if (homeState.error != null && tweets.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 50.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red[400]), + const SizedBox(height: 16), + Text( + 'Error loading tweets', + style: TextStyle(color: Colors.grey[400], fontSize: 18), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Text( + homeState.error!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500], fontSize: 14), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.read(homeViewModelProvider.notifier).refreshTweets(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1DA1F2), + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + if (isLoading && tweets.isEmpty) { return const SliverToBoxAdapter( child: Padding( diff --git a/lib/features/home/view/screens/quote_composer_screen.dart b/lib/features/home/view/screens/quote_composer_screen.dart index cb7d418..72c9cfc 100644 --- a/lib/features/home/view/screens/quote_composer_screen.dart +++ b/lib/features/home/view/screens/quote_composer_screen.dart @@ -24,7 +24,7 @@ class _QuoteComposerScreenState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); bool _isPosting = false; - final List _selectedImages = []; + final List _selectedMedia = []; String? _getPhotoUrl(String? photo) { if (photo == null || photo.isEmpty) return null; @@ -70,7 +70,7 @@ class _QuoteComposerScreenState extends ConsumerState { if (mounted) { setState(() { - _selectedImages.clear(); + _selectedMedia.clear(); _textController.clear(); }); ScaffoldMessenger.of(context).showSnackBar( @@ -100,14 +100,14 @@ class _QuoteComposerScreenState extends ConsumerState { } Future> _uploadSelectedImages() async { - if (_selectedImages.isEmpty) return []; - final uploadedIds = await upload_media(_selectedImages); + if (_selectedMedia.isEmpty) return []; + final uploadedIds = await upload_media(_selectedMedia); final mediaIds = uploadedIds.where((id) => id.isNotEmpty).toList(); - if (mediaIds.length != _selectedImages.length && mounted) { + if (mediaIds.length != _selectedMedia.length && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Some images failed to upload. Try again.'), + content: Text('Some media files failed to upload. Try again.'), backgroundColor: Colors.orange, behavior: SnackBarBehavior.floating, ), @@ -115,17 +115,61 @@ class _QuoteComposerScreenState extends ConsumerState { } if (mediaIds.isEmpty) { - throw Exception('Unable to upload selected images.'); + throw Exception('Unable to upload selected media.'); } return mediaIds; } + void _showMediaPicker() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + ListTile( + leading: const Icon(Icons.image, color: Color(0xFF1D9BF0)), + title: const Text('Photo', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickImage(); + }, + ), + ListTile( + leading: const Icon(Icons.videocam, color: Color(0xFF1D9BF0)), + title: const Text('Video', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickVideo(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + Future _pickImage() async { - final remainingSlots = 4 - _selectedImages.length; + final remainingSlots = 4 - _selectedMedia.length; if (remainingSlots <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Maximum 4 images allowed per quote.'), + content: Text('Maximum 4 media files allowed per quote.'), behavior: SnackBarBehavior.floating, ), ); @@ -138,22 +182,59 @@ class _QuoteComposerScreenState extends ConsumerState { setState(() { for (final picked in pickedList) { if (picked.file != null) { - _selectedImages.add(picked.file!); + _selectedMedia.add(picked.file!); } } }); } - void _removeImage(int index) { - if (index < 0 || index >= _selectedImages.length) return; + Future _pickVideo() async { + final remainingSlots = 4 - _selectedMedia.length; + if (remainingSlots <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maximum 4 media files allowed per quote.'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + final picked = await pickVideo(); + if (picked == null || picked.file == null) return; + + setState(() { + _selectedMedia.add(picked.file!); + }); + } + + void _removeMedia(int index) { + if (index < 0 || index >= _selectedMedia.length) return; setState(() { - _selectedImages.removeAt(index); + _selectedMedia.removeAt(index); }); } + bool _isVideoFile(File file) { + final extension = file.path.split('.').last.toLowerCase(); + return [ + 'mp4', + 'mov', + 'avi', + 'webm', + 'mkv', + 'flv', + 'wmv', + 'mpeg', + 'mpg', + '3gp', + 'm4v', + ].contains(extension); + } + Widget _buildSelectedImagesPreview() { - if (_selectedImages.isEmpty) return const SizedBox.shrink(); - final crossAxisCount = _selectedImages.length == 1 ? 1 : 2; + if (_selectedMedia.isEmpty) return const SizedBox.shrink(); + final crossAxisCount = _selectedMedia.length == 1 ? 1 : 2; return GridView.builder( shrinkWrap: true, @@ -162,23 +243,52 @@ class _QuoteComposerScreenState extends ConsumerState { crossAxisCount: crossAxisCount, crossAxisSpacing: 8, mainAxisSpacing: 8, - childAspectRatio: _selectedImages.length == 1 ? 16 / 9 : 1.0, + childAspectRatio: _selectedMedia.length == 1 ? 16 / 9 : 1.0, ), - itemCount: _selectedImages.length, + itemCount: _selectedMedia.length, itemBuilder: (context, index) { + final file = _selectedMedia[index]; + final isVideo = _isVideoFile(file); + return Stack( children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.file(_selectedImages[index], fit: BoxFit.cover), + child: Image.file(file, fit: BoxFit.cover), ), ), + if (isVideo) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.black26, + ), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.play_circle_outline, + color: Colors.white, + size: 48, + ), + SizedBox(height: 4), + Text( + 'Video', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ), + ), + ), Positioned( top: 8, right: 8, child: InkWell( - onTap: () => _removeImage(index), + onTap: () => _removeMedia(index), child: Container( decoration: const BoxDecoration( color: Colors.black54, @@ -318,7 +428,7 @@ class _QuoteComposerScreenState extends ConsumerState { ), ], ), - if (_selectedImages.isNotEmpty) ...[ + if (_selectedMedia.isNotEmpty) ...[ const SizedBox(height: 12), _buildSelectedImagesPreview(), ], @@ -326,12 +436,12 @@ class _QuoteComposerScreenState extends ConsumerState { Row( children: [ IconButton( - onPressed: _isPosting ? null : _pickImage, - icon: const Icon(Icons.image_outlined, color: Colors.blue), + onPressed: _isPosting ? null : _showMediaPicker, + icon: const Icon(Icons.perm_media_outlined, color: Colors.blue), ), const SizedBox(width: 4), Text( - '${_selectedImages.length} / 4 photos', + '${_selectedMedia.length} / 4 media', style: TextStyle(color: Colors.grey[500], fontSize: 13), ), ], diff --git a/lib/features/home/view/screens/reply_composer_screen.dart b/lib/features/home/view/screens/reply_composer_screen.dart index 5af6a6e..0f4eea9 100644 --- a/lib/features/home/view/screens/reply_composer_screen.dart +++ b/lib/features/home/view/screens/reply_composer_screen.dart @@ -9,6 +9,45 @@ import 'package:lite_x/features/home/view_model/home_view_model.dart'; import 'package:lite_x/features/media/upload_media.dart'; import 'package:lite_x/features/home/providers/user_profile_provider.dart'; +enum PostPrivacy { + everyone, + following, + mentioned; + + String get label { + switch (this) { + case PostPrivacy.everyone: + return 'Everyone can reply'; + case PostPrivacy.following: + return 'People you follow'; + case PostPrivacy.mentioned: + return 'Only mentioned users'; + } + } + + IconData get icon { + switch (this) { + case PostPrivacy.everyone: + return Icons.public; + case PostPrivacy.following: + return Icons.people; + case PostPrivacy.mentioned: + return Icons.alternate_email; + } + } + + String get apiValue { + switch (this) { + case PostPrivacy.everyone: + return 'EVERYONE'; + case PostPrivacy.following: + return 'FOLLOWINGS'; + case PostPrivacy.mentioned: + return 'MENTIONED'; + } + } +} + class ReplyComposerScreen extends ConsumerStatefulWidget { final TweetModel replyingToTweet; @@ -23,7 +62,8 @@ class _ReplyComposerScreenState extends ConsumerState { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); bool _isPosting = false; - final List _selectedImages = []; + final List _selectedMedia = []; + PostPrivacy _selectedPrivacy = PostPrivacy.everyone; String? _getPhotoUrl(String? photo) { if (photo == null || photo.isEmpty) return null; @@ -62,13 +102,13 @@ class _ReplyComposerScreenState extends ConsumerState { .createPost( content: _textController.text.trim(), replyToId: widget.replyingToTweet.id, - replyControl: "EVERYONE", + replyControl: _selectedPrivacy.apiValue, mediaIds: mediaIds, ); if (mounted) { setState(() { - _selectedImages.clear(); + _selectedMedia.clear(); _textController.clear(); }); ScaffoldMessenger.of(context).showSnackBar( @@ -98,14 +138,14 @@ class _ReplyComposerScreenState extends ConsumerState { } Future> _uploadSelectedImages() async { - if (_selectedImages.isEmpty) return []; - final uploadedIds = await upload_media(_selectedImages); + if (_selectedMedia.isEmpty) return []; + final uploadedIds = await upload_media(_selectedMedia); final mediaIds = uploadedIds.where((id) => id.isNotEmpty).toList(); - if (mediaIds.length != _selectedImages.length && mounted) { + if (mediaIds.length != _selectedMedia.length && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Some images failed to upload. Try again.'), + content: Text('Some media files failed to upload. Try again.'), behavior: SnackBarBehavior.floating, backgroundColor: Colors.orange, ), @@ -113,17 +153,61 @@ class _ReplyComposerScreenState extends ConsumerState { } if (mediaIds.isEmpty) { - throw Exception('Unable to upload selected images.'); + throw Exception('Unable to upload selected media.'); } return mediaIds; } + void _showMediaPicker() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + ListTile( + leading: const Icon(Icons.image, color: Color(0xFF1D9BF0)), + title: const Text('Photo', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickImage(); + }, + ), + ListTile( + leading: const Icon(Icons.videocam, color: Color(0xFF1D9BF0)), + title: const Text('Video', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + _pickVideo(); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + Future _pickImage() async { - final remainingSlots = 4 - _selectedImages.length; + final remainingSlots = 4 - _selectedMedia.length; if (remainingSlots <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Maximum 4 images allowed per reply.'), + content: Text('Maximum 4 media files allowed per reply.'), behavior: SnackBarBehavior.floating, ), ); @@ -136,28 +220,149 @@ class _ReplyComposerScreenState extends ConsumerState { setState(() { for (final picked in pickedList) { if (picked.file != null) { - _selectedImages.add(picked.file!); + _selectedMedia.add(picked.file!); } } }); } - void _removeImage(int index) { - if (index < 0 || index >= _selectedImages.length) return; + Future _pickVideo() async { + final remainingSlots = 4 - _selectedMedia.length; + if (remainingSlots <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Maximum 4 media files allowed per reply.'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + final picked = await pickVideo(); + if (picked == null || picked.file == null) return; + setState(() { - _selectedImages.removeAt(index); + _selectedMedia.add(picked.file!); }); } + void _removeMedia(int index) { + if (index < 0 || index >= _selectedMedia.length) return; + setState(() { + _selectedMedia.removeAt(index); + }); + } + + bool _isVideoFile(File file) { + final extension = file.path.split('.').last.toLowerCase(); + return [ + 'mp4', + 'mov', + 'avi', + 'webm', + 'mkv', + 'flv', + 'wmv', + 'mpeg', + 'mpg', + '3gp', + 'm4v', + ].contains(extension); + } + + void _showPrivacyOptions() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Who can reply?', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Choose who can reply to this post.', + style: TextStyle(color: Colors.grey, fontSize: 14), + ), + ), + const SizedBox(height: 16), + ...PostPrivacy.values.map((privacy) { + return ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: privacy == _selectedPrivacy + ? Colors.blue.withOpacity(0.2) + : Colors.grey[800], + shape: BoxShape.circle, + ), + child: Icon( + privacy.icon, + color: privacy == _selectedPrivacy + ? Colors.blue + : Colors.grey, + size: 20, + ), + ), + title: Text( + privacy.label, + style: const TextStyle(color: Colors.white, fontSize: 16), + ), + trailing: privacy == _selectedPrivacy + ? const Icon(Icons.check_circle, color: Colors.blue) + : null, + onTap: () { + setState(() { + _selectedPrivacy = privacy; + }); + Navigator.pop(context); + }, + ); + }).toList(), + const SizedBox(height: 16), + ], + ), + ), + ); + } + Widget _buildSelectedImagesPreview() { - if (_selectedImages.isEmpty) return const SizedBox.shrink(); - final crossAxisCount = _selectedImages.length == 1 ? 1 : 2; + if (_selectedMedia.isEmpty) return const SizedBox.shrink(); + final crossAxisCount = _selectedMedia.length == 1 ? 1 : 2; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_selectedImages.length} / 4 photos', + '${_selectedMedia.length} / 4 media files', style: TextStyle(color: Colors.grey[500], fontSize: 13), ), const SizedBox(height: 8), @@ -168,26 +373,66 @@ class _ReplyComposerScreenState extends ConsumerState { crossAxisCount: crossAxisCount, crossAxisSpacing: 8, mainAxisSpacing: 8, - childAspectRatio: _selectedImages.length == 1 ? 16 / 9 : 1.0, + childAspectRatio: _selectedMedia.length == 1 ? 16 / 9 : 1.0, ), - itemCount: _selectedImages.length, + itemCount: _selectedMedia.length, itemBuilder: (context, index) { + final file = _selectedMedia[index]; + final isVideo = _isVideoFile(file); return Stack( children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.file( - _selectedImages[index], - fit: BoxFit.cover, - ), + child: isVideo + ? Container( + color: Colors.grey[900], + child: const Center( + child: Icon( + Icons.play_circle_outline, + color: Colors.white, + size: 48, + ), + ), + ) + : Image.file(file, fit: BoxFit.cover), ), ), + if (isVideo) + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam, color: Colors.white, size: 12), + SizedBox(width: 4), + Text( + 'Video', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), Positioned( top: 8, right: 8, child: InkWell( - onTap: () => _removeImage(index), + onTap: () => _removeMedia(index), child: Container( decoration: const BoxDecoration( color: Colors.black54, @@ -246,13 +491,40 @@ class _ReplyComposerScreenState extends ConsumerState { _buildReplyingToTweet(), const SizedBox(height: 8), _buildReplyComposer(userPhotoUrl), - if (_selectedImages.isNotEmpty) ...[ + if (_selectedMedia.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.only(left: 52), child: _buildSelectedImagesPreview(), ), ], + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(left: 52), + child: InkWell( + onTap: _showPrivacyOptions, + borderRadius: BorderRadius.circular(20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _selectedPrivacy.icon, + color: const Color(0xFF1D9BF0), + size: 16, + ), + const SizedBox(width: 4), + Text( + _selectedPrivacy.label, + style: const TextStyle( + color: Color(0xFF1D9BF0), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), ], ), ), @@ -361,7 +633,7 @@ class _ReplyComposerScreenState extends ConsumerState { const SizedBox(width: 4), Flexible( child: Text( - widget.replyingToTweet.authorUsername, + '@${widget.replyingToTweet.authorUsername}', style: TextStyle(color: Colors.grey[500], fontSize: 15), overflow: TextOverflow.ellipsis, ), @@ -386,7 +658,7 @@ class _ReplyComposerScreenState extends ConsumerState { children: [ const TextSpan(text: 'Replying to '), TextSpan( - text: widget.replyingToTweet.authorUsername, + text: '@${widget.replyingToTweet.authorUsername}', style: const TextStyle(color: Color(0xFF1D9BF0)), ), ], @@ -454,10 +726,10 @@ class _ReplyComposerScreenState extends ConsumerState { children: [ IconButton( icon: const Icon( - Icons.image_outlined, + Icons.perm_media_outlined, color: Color(0xFF1D9BF0), ), - onPressed: _isPosting ? null : _pickImage, + onPressed: _isPosting ? null : _showMediaPicker, iconSize: 20, padding: const EdgeInsets.all(8), constraints: const BoxConstraints(), diff --git a/lib/features/home/view/screens/reply_thread_screen.dart b/lib/features/home/view/screens/reply_thread_screen.dart index 6383c72..b016ba7 100644 --- a/lib/features/home/view/screens/reply_thread_screen.dart +++ b/lib/features/home/view/screens/reply_thread_screen.dart @@ -6,6 +6,7 @@ import 'package:lite_x/features/home/models/tweet_model.dart'; import 'package:lite_x/features/home/repositories/home_repository.dart'; import 'package:lite_x/features/home/view/screens/reply_composer_screen.dart'; import 'package:lite_x/features/home/view/widgets/media_gallery.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; import 'package:timeago/timeago.dart' as timeago; class ReplyThreadScreen extends ConsumerStatefulWidget { @@ -26,6 +27,8 @@ class _ReplyThreadScreenState extends ConsumerState { bool isLoading = true; String? currentUserId; final Map _viewCounts = {}; + bool isFollowing = false; + bool isFollowLoading = false; @override void initState() { @@ -42,6 +45,77 @@ class _ReplyThreadScreenState extends ConsumerState { } } + Future _toggleFollow() async { + final currentReplyTweet = allTweets[widget.pathTweetIds.last]; + if (currentReplyTweet == null || isFollowLoading) return; + + setState(() { + isFollowLoading = true; + }); + + try { + final username = currentReplyTweet.authorUsername; + + if (isFollowing) { + final unfollowFunc = ref.read(unFollowControllerProvider); + final result = await unfollowFunc(username); + result.fold( + (failure) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to unfollow: ${failure.message}'), + backgroundColor: Colors.red, + ), + ); + } + }, + (_) { + if (mounted) { + setState(() { + isFollowing = false; + }); + } + }, + ); + } else { + final followFunc = ref.read(followControllerProvider); + final result = await followFunc(username); + result.fold( + (failure) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to follow: ${failure.message}'), + backgroundColor: Colors.red, + ), + ); + } + }, + (_) { + if (mounted) { + setState(() { + isFollowing = true; + }); + } + }, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { + isFollowLoading = false; + }); + } + } + } + Future _toggleLike(String tweetId) async { // Find tweet in allTweets or childReplies TweetModel? tweet = allTweets[tweetId]; @@ -396,19 +470,26 @@ class _ReplyThreadScreenState extends ConsumerState { ), ), const SizedBox(width: 6), - Text( - tweet.authorUsername, - style: TextStyle( - color: Colors.grey[600], - fontSize: 15, + Flexible( + child: Text( + '@${tweet.authorUsername}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 15, + ), + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 6), - Text( - '· ${timeago.format(tweet.createdAt, locale: 'en_short')}', - style: TextStyle( - color: Colors.grey[600], - fontSize: 15, + Flexible( + fit: FlexFit.loose, + child: Text( + '· ${timeago.format(tweet.createdAt, locale: 'en_short')}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 15, + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -486,14 +567,27 @@ class _ReplyThreadScreenState extends ConsumerState { ), ), const SizedBox(width: 4), - Text( - '@${reply.authorUsername}', - style: TextStyle(color: Colors.grey[600], fontSize: 15), + Flexible( + child: Text( + '@${reply.authorUsername}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 15, + ), + overflow: TextOverflow.ellipsis, + ), ), const SizedBox(width: 4), - Text( - '· ${timeago.format(reply.createdAt, locale: 'en_short')}', - style: TextStyle(color: Colors.grey[600], fontSize: 15), + Flexible( + fit: FlexFit.loose, + child: Text( + '· ${timeago.format(reply.createdAt, locale: 'en_short')}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 15, + ), + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -504,11 +598,14 @@ class _ReplyThreadScreenState extends ConsumerState { 'Replying to ', style: TextStyle(color: Colors.grey[600], fontSize: 15), ), - Text( - replyingTo, - style: const TextStyle( - color: Colors.blue, - fontSize: 15, + Flexible( + child: Text( + '@$replyingTo', + style: const TextStyle( + color: Colors.blue, + fontSize: 15, + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -591,9 +688,12 @@ class _ReplyThreadScreenState extends ConsumerState { 'Replying to ', style: TextStyle(color: Colors.grey[600], fontSize: 15), ), - Text( - replyingTo, - style: const TextStyle(color: Colors.blue, fontSize: 15), + Flexible( + child: Text( + '@$replyingTo', + style: const TextStyle(color: Colors.blue, fontSize: 15), + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -701,19 +801,42 @@ class _ReplyThreadScreenState extends ConsumerState { } else { return Row( children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: const Text( - 'Follow', - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 15, + GestureDetector( + onTap: isFollowLoading ? null : _toggleFollow, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isFollowing ? Colors.transparent : Colors.white, + border: isFollowing + ? Border.all(color: Colors.grey[700]!, width: 1) + : null, + borderRadius: BorderRadius.circular(20), ), + child: isFollowLoading + ? const SizedBox( + width: 60, + height: 20, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.grey, + ), + ), + ), + ), + ) + : Text( + isFollowing ? 'Following' : 'Follow', + style: TextStyle( + color: isFollowing ? Colors.white : Colors.black, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), ), ), const SizedBox(width: 6), diff --git a/lib/features/home/view/screens/tweet_screen.dart b/lib/features/home/view/screens/tweet_screen.dart index 3b08680..0b9603c 100644 --- a/lib/features/home/view/screens/tweet_screen.dart +++ b/lib/features/home/view/screens/tweet_screen.dart @@ -2,6 +2,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/features/home/models/tweet_model.dart'; @@ -12,6 +13,7 @@ import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart'; import 'package:lite_x/features/home/view/widgets/media_gallery.dart'; import 'package:lite_x/features/home/view/widgets/tweet_summary_dialog.dart'; import 'package:lite_x/features/profile/view/screens/profile_screen.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; import 'package:lite_x/features/home/view_model/home_view_model.dart'; import 'package:timeago/timeago.dart' as timeago; @@ -30,6 +32,8 @@ class _TweetDetailScreenState extends ConsumerState { bool isLoading = true; String? currentUserId; int? _viewCount; + bool isFollowing = false; + bool isFollowLoading = false; @override void initState() { @@ -58,6 +62,76 @@ class _TweetDetailScreenState extends ConsumerState { return username.startsWith('@') ? username.substring(1) : username; } + Future _toggleFollow() async { + if (mainTweet == null || isFollowLoading) return; + + setState(() { + isFollowLoading = true; + }); + + try { + final username = mainTweet!.authorUsername; + + if (isFollowing) { + final unfollowFunc = ref.read(unFollowControllerProvider); + final result = await unfollowFunc(username); + result.fold( + (failure) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to unfollow: ${failure.message}'), + backgroundColor: Colors.red, + ), + ); + } + }, + (_) { + if (mounted) { + setState(() { + isFollowing = false; + }); + } + }, + ); + } else { + final followFunc = ref.read(followControllerProvider); + final result = await followFunc(username); + result.fold( + (failure) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to follow: ${failure.message}'), + backgroundColor: Colors.red, + ), + ); + } + }, + (_) { + if (mounted) { + setState(() { + isFollowing = true; + }); + } + }, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { + isFollowLoading = false; + }); + } + } + } + Future _loadTweetData() async { final cachedTweet = _findCachedTweet(widget.tweetId); @@ -304,11 +378,48 @@ class _TweetDetailScreenState extends ConsumerState { Future _showSummaryDialog() async { if (mainTweet == null) return; + // Show loading dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFF1DA1F2).withOpacity(0.3), + width: 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF1DA1F2)), + ), + const SizedBox(height: 16), + Text( + 'Generating AI insights...', + style: TextStyle(color: Colors.grey[400], fontSize: 14), + ), + ], + ), + ), + ), + ); + try { final repository = ref.read(homeRepositoryProvider); final summary = await repository.getTweetSummary(mainTweet!.id); if (mounted) { + // Close loading dialog + Navigator.of(context).pop(); + + // Show summary dialog showDialog( context: context, builder: (context) => TweetSummaryDialog(summary: summary), @@ -316,6 +427,9 @@ class _TweetDetailScreenState extends ConsumerState { } } catch (e) { if (mounted) { + // Close loading dialog + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to load summary: $e'), @@ -760,19 +874,40 @@ class _TweetDetailScreenState extends ConsumerState { ], ); } else { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: const Text( - 'Follow', - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.bold, - fontSize: 15, + return GestureDetector( + onTap: isFollowLoading ? null : _toggleFollow, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isFollowing ? Colors.transparent : Colors.white, + border: isFollowing + ? Border.all(color: Colors.grey[700]!, width: 1) + : null, + borderRadius: BorderRadius.circular(20), ), + child: isFollowLoading + ? const SizedBox( + width: 60, + height: 20, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.grey), + ), + ), + ), + ) + : Text( + isFollowing ? 'Following' : 'Follow', + style: TextStyle( + color: isFollowing ? Colors.white : Colors.black, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), ), ); } @@ -1057,14 +1192,14 @@ class _TweetDetailScreenState extends ConsumerState { textDirection: _textDirectionFor(mainTweet!.content), ), - if (mainTweet!.quotedTweet != null) ...[ - const SizedBox(height: 16), - _buildQuotedTweet(mainTweet!.quotedTweet!), - ], if (mainTweet!.images.isNotEmpty) ...[ const SizedBox(height: 16), MediaGallery(urls: mainTweet!.images, borderRadius: 16), ], + if (mainTweet!.quotedTweet != null) ...[ + const SizedBox(height: 16), + _buildQuotedTweet(mainTweet!.quotedTweet!), + ], ], ); } @@ -1258,8 +1393,8 @@ class _TweetDetailScreenState extends ConsumerState { color: mainTweet!.isLiked ? Colors.pink : Colors.grey[600]!, onTap: _toggleLike, ), - _buildIconButton( - icon: Icons.auto_awesome, + _buildSvgIconButton( + svgPath: 'assets/svg/grok.svg', color: const Color(0xFF1DA1F2), onTap: _showSummaryDialog, ), @@ -1295,6 +1430,26 @@ class _TweetDetailScreenState extends ConsumerState { ); } + Widget _buildSvgIconButton({ + required String svgPath, + required Color color, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(8), + child: SvgPicture.asset( + svgPath, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), + ), + ); + } + Widget _buildReplyCard(TweetModel reply) { final profileTap = () => _openProfileFromUsername(reply.authorUsername); return InkWell( @@ -1392,14 +1547,21 @@ class _TweetDetailScreenState extends ConsumerState { ), ), const SizedBox(width: 4), - Text( - '@${reply.authorUsername}', - style: TextStyle(color: Colors.grey[600], fontSize: 15), + Flexible( + child: Text( + '@${reply.authorUsername}', + style: TextStyle(color: Colors.grey[600], fontSize: 15), + overflow: TextOverflow.ellipsis, + ), ), const SizedBox(width: 4), - Text( - '· ${timeago.format(reply.createdAt, locale: 'en_short')}', - style: TextStyle(color: Colors.grey[600], fontSize: 15), + Flexible( + fit: FlexFit.loose, + child: Text( + '· ${timeago.format(reply.createdAt, locale: 'en_short')}', + style: TextStyle(color: Colors.grey[600], fontSize: 15), + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -1441,11 +1603,14 @@ class _TweetDetailScreenState extends ConsumerState { 'Replying to ', style: TextStyle(color: Colors.grey[600], fontSize: 15), ), - GestureDetector( - onTap: () => _openProfileFromUsername(mainTweet!.authorUsername), - child: Text( - mainTweet!.authorUsername, - style: const TextStyle(color: Colors.blue, fontSize: 15), + Flexible( + child: GestureDetector( + onTap: () => _openProfileFromUsername(mainTweet!.authorUsername), + child: Text( + '@${mainTweet!.authorUsername}', + style: const TextStyle(color: Colors.blue, fontSize: 15), + overflow: TextOverflow.ellipsis, + ), ), ), ], diff --git a/lib/features/home/view/widgets/home_app_bar.dart b/lib/features/home/view/widgets/home_app_bar.dart index 482f7c9..edc264e 100644 --- a/lib/features/home/view/widgets/home_app_bar.dart +++ b/lib/features/home/view/widgets/home_app_bar.dart @@ -27,7 +27,6 @@ class HomeAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(appBarSettingsProvider); - return Container( color: Colors.black, child: SafeArea( diff --git a/lib/features/home/view/widgets/home_tab_bar.dart b/lib/features/home/view/widgets/home_tab_bar.dart index 32a83c1..0a4f81a 100644 --- a/lib/features/home/view/widgets/home_tab_bar.dart +++ b/lib/features/home/view/widgets/home_tab_bar.dart @@ -27,6 +27,7 @@ class _HomeTabBarState extends ConsumerState ); final initialFeed = ref.read(homeViewModelProvider).currentFeed; + // For You is at position 0 (left), Following is at position 1 (right) _animationController.value = initialFeed == FeedType.following ? 1.0 : 0.0; } diff --git a/lib/features/home/view/widgets/inline_video_player.dart b/lib/features/home/view/widgets/inline_video_player.dart new file mode 100644 index 0000000..81eac9a --- /dev/null +++ b/lib/features/home/view/widgets/inline_video_player.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +/// Inline video player that auto-plays and can be used within feed items +class InlineVideoPlayer extends StatefulWidget { + final String videoUrl; + final double? height; + final bool autoPlay; + final VoidCallback? onTap; + + const InlineVideoPlayer({ + super.key, + required this.videoUrl, + this.height, + this.autoPlay = true, + this.onTap, + }); + + @override + State createState() => _InlineVideoPlayerState(); +} + +class _InlineVideoPlayerState extends State { + late VideoPlayerController _controller; + bool _isInitialized = false; + bool _hasError = false; + bool _isVisible = false; + bool _isMuted = true; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + _controller = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl), + httpHeaders: { + 'Accept': '*/*', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + 'Range': 'bytes=0-', + }, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + allowBackgroundPlayback: false, + ), + ); + + _controller.setLooping(true); + _controller.setVolume(0.0); // Start muted + + await _controller.initialize(); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + + // Auto-play only if visible and autoPlay is enabled + if (widget.autoPlay && _isVisible) { + _controller.play(); + } + } + } catch (e) { + print('❌ Inline video error: $e'); + if (mounted) { + setState(() { + _hasError = true; + }); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onVisibilityChanged(VisibilityInfo info) { + if (!_isInitialized || !mounted) return; + + final isVisible = + info.visibleFraction > 0.5; // Play when more than 50% visible + + if (isVisible != _isVisible) { + if (mounted) { + setState(() { + _isVisible = isVisible; + }); + } + + if (widget.autoPlay && mounted) { + if (isVisible) { + _controller.play(); + } else { + _controller.pause(); + } + } + } + } + + @override + Widget build(BuildContext context) { + if (_hasError) { + return Container( + height: widget.height ?? 200, + color: Colors.grey[900], + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 48), + SizedBox(height: 8), + Text( + 'Failed to load video', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ), + ); + } + + if (!_isInitialized) { + return Container( + height: widget.height ?? 200, + color: Colors.grey[900], + child: const Center( + child: CircularProgressIndicator(color: Colors.blue), + ), + ); + } + + return VisibilityDetector( + key: Key('video_${widget.videoUrl}'), + onVisibilityChanged: _onVisibilityChanged, + child: GestureDetector( + onTap: () { + if (widget.onTap != null) { + widget.onTap!(); + } else { + if (!mounted) return; + setState(() { + if (_controller.value.isPlaying) { + _controller.pause(); + } else { + _controller.play(); + } + }); + } + }, + child: Container( + height: widget.height, + color: Colors.black, + child: Stack( + alignment: Alignment.center, + children: [ + AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ), + // Play/Pause indicator + AnimatedOpacity( + opacity: _controller.value.isPlaying ? 0.0 : 0.7, + duration: const Duration(milliseconds: 300), + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(12), + child: Icon( + _controller.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + color: Colors.white, + size: 40, + ), + ), + ), + // Video badge + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.videocam, color: Colors.white, size: 12), + SizedBox(width: 4), + Text( + 'Video', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + // Volume control button + Positioned( + bottom: 8, + right: 8, + child: GestureDetector( + onTap: () { + if (!mounted) return; + setState(() { + _isMuted = !_isMuted; + _controller.setVolume(_isMuted ? 0.0 : 1.0); + }); + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black87, + shape: BoxShape.circle, + ), + child: Icon( + _isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + size: 16, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/home/view/widgets/media_gallery.dart b/lib/features/home/view/widgets/media_gallery.dart index 7ce25d7..4efc265 100644 --- a/lib/features/home/view/widgets/media_gallery.dart +++ b/lib/features/home/view/widgets/media_gallery.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'image_viewer_screen.dart'; +import 'media_viewer_screen.dart'; +import 'inline_video_player.dart'; -/// Renders up to four images in a Twitter-style grid while falling back to a +/// Renders up to four images/videos in a Twitter-style grid while falling back to a /// placeholder when URLs are still being resolved. class MediaGallery extends StatelessWidget { final List urls; @@ -22,17 +23,29 @@ class MediaGallery extends StatelessWidget { final media = urls.where((url) => url.isNotEmpty).take(4).toList(); if (media.isEmpty) return const SizedBox.shrink(); + // Convert media keys to full URLs + final fullUrls = media.map(_getFullMediaUrl).toList(); + return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: Container( width: double.infinity, constraints: BoxConstraints(minHeight: minHeight, maxHeight: maxHeight), color: Colors.grey[900], - child: _buildLayout(context, media), + child: _buildLayout(context, fullUrls), ), ); } + String _getFullMediaUrl(String mediaKey) { + // If it's already a full URL, return it + if (mediaKey.startsWith('http://') || mediaKey.startsWith('https://')) { + return mediaKey; + } + // Otherwise, construct the full URL + return 'https://litex.siematworld.online/media/$mediaKey'; + } + Widget _buildLayout(BuildContext context, List images) { if (images.length == 1) { return _buildNetworkImage(context, images.first, 0, images); @@ -117,13 +130,33 @@ class MediaGallery extends StatelessWidget { return _buildPendingMediaPlaceholder(); } + final isVideo = _isVideoUrl(url); + + // For videos, show inline auto-playing player + if (isVideo) { + return InlineVideoPlayer( + videoUrl: url, + autoPlay: true, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + MediaViewerScreen(mediaUrls: allImages, initialIndex: index), + ), + ); + }, + ); + } + + // For images, show with tap to open viewer return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => - ImageViewerScreen(imageUrls: allImages, initialIndex: index), + MediaViewerScreen(mediaUrls: allImages, initialIndex: index), ), ); }, @@ -152,6 +185,28 @@ class MediaGallery extends StatelessWidget { ); } + bool _isVideoUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + + final path = uri.path.toLowerCase(); + final videoExtensions = [ + '.mp4', + '.mov', + '.avi', + '.webm', + '.mkv', + '.flv', + '.wmv', + '.mpeg', + '.mpg', + '.3gp', + '.m4v', + ]; + + return videoExtensions.any((ext) => path.endsWith(ext)); + } + Widget _buildPendingMediaPlaceholder() { return Container( color: Colors.grey[900], diff --git a/lib/features/home/view/widgets/media_viewer_screen.dart b/lib/features/home/view/widgets/media_viewer_screen.dart new file mode 100644 index 0000000..3d12fc8 --- /dev/null +++ b/lib/features/home/view/widgets/media_viewer_screen.dart @@ -0,0 +1,413 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class MediaViewerScreen extends StatefulWidget { + final List mediaUrls; + final int initialIndex; + + const MediaViewerScreen({ + super.key, + required this.mediaUrls, + this.initialIndex = 0, + }); + + @override + State createState() => _MediaViewerScreenState(); +} + +class _MediaViewerScreenState extends State { + late PageController _pageController; + late int _currentIndex; + final Map _videoControllers = {}; + final Map _videoInitialized = {}; + final Map _videoErrors = {}; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + + // Initialize current video if it's a video + if (_isVideo(widget.mediaUrls[_currentIndex])) { + _initializeVideo(_currentIndex); + } + } + + @override + void dispose() { + _pageController.dispose(); + // Dispose all video controllers + for (var controller in _videoControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + bool _isVideo(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + final path = uri.path.toLowerCase(); + return [ + '.mp4', + '.mov', + '.avi', + '.webm', + '.mkv', + '.flv', + '.wmv', + '.mpeg', + '.mpg', + '.3gp', + '.m4v', + ].any((ext) => path.endsWith(ext)); + } + + Future _initializeVideo(int index) async { + if (_videoControllers.containsKey(index)) return; + + try { + final controller = VideoPlayerController.networkUrl( + Uri.parse(widget.mediaUrls[index]), + httpHeaders: { + 'Accept': '*/*', + 'User-Agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36', + 'Range': 'bytes=0-', + }, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: false, + allowBackgroundPlayback: false, + ), + ); + + controller.setLooping(true); + controller.setVolume(0.0); // Start muted + + await controller.initialize(); + + if (mounted) { + setState(() { + _videoControllers[index] = controller; + _videoInitialized[index] = true; + _videoErrors[index] = false; + }); + + // Auto-play if it's the current page + if (index == _currentIndex) { + controller.play(); + } + } + } catch (e) { + print('❌ Video initialization error: $e'); + if (mounted) { + setState(() { + _videoErrors[index] = true; + }); + } + } + } + + void _onPageChanged(int index) { + // Pause previous video + if (_isVideo(widget.mediaUrls[_currentIndex]) && + _videoControllers.containsKey(_currentIndex)) { + _videoControllers[_currentIndex]?.pause(); + } + + setState(() { + _currentIndex = index; + }); + + // Initialize and play new video + if (_isVideo(widget.mediaUrls[index])) { + if (!_videoControllers.containsKey(index)) { + _initializeVideo(index); + } else { + _videoControllers[index]?.play(); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: Text( + '${_currentIndex + 1} / ${widget.mediaUrls.length}', + style: const TextStyle(color: Colors.white), + ), + centerTitle: true, + ), + body: PageView.builder( + controller: _pageController, + itemCount: widget.mediaUrls.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + final isVideo = _isVideo(widget.mediaUrls[index]); + + if (isVideo) { + return _buildVideoPlayer(index); + } else { + return _buildImageViewer(index); + } + }, + ), + ); + } + + Widget _buildImageViewer(int index) { + return InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: Center( + child: Image.network( + widget.mediaUrls[index], + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + color: const Color(0xFF1DA1F2), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.white, size: 48), + SizedBox(height: 16), + Text( + 'Failed to load image', + style: TextStyle(color: Colors.white), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _buildVideoPlayer(int index) { + final hasError = _videoErrors[index] == true; + final isInitialized = _videoInitialized[index] == true; + + if (hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + const Text( + 'Failed to load video', + style: TextStyle(color: Colors.white), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _videoErrors[index] = false; + _videoInitialized[index] = false; + }); + _initializeVideo(index); + }, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (!isInitialized) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF1DA1F2)), + ); + } + + final controller = _videoControllers[index]!; + + return VideoPlayerWithControls(controller: controller); + } +} + +class VideoPlayerWithControls extends StatefulWidget { + final VideoPlayerController controller; + + const VideoPlayerWithControls({super.key, required this.controller}); + + @override + State createState() => + _VideoPlayerWithControlsState(); +} + +class _VideoPlayerWithControlsState extends State { + bool _showControls = true; + bool _isMuted = true; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_videoListener); + } + + @override + void dispose() { + widget.controller.removeListener(_videoListener); + super.dispose(); + } + + void _videoListener() { + if (mounted) { + setState(() {}); + } + } + + void _toggleMute() { + setState(() { + _isMuted = !_isMuted; + widget.controller.setVolume(_isMuted ? 0.0 : 1.0); + }); + } + + void _togglePlayPause() { + setState(() { + if (widget.controller.value.isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = duration.inHours; + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + + if (hours > 0) { + return '$hours:${twoDigits(minutes)}:${twoDigits(seconds)}'; + } + return '${twoDigits(minutes)}:${twoDigits(seconds)}'; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _showControls = !_showControls; + }); + }, + child: Stack( + alignment: Alignment.center, + children: [ + Center( + child: AspectRatio( + aspectRatio: widget.controller.value.aspectRatio, + child: VideoPlayer(widget.controller), + ), + ), + + // Controls overlay + AnimatedOpacity( + opacity: _showControls ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Container( + color: Colors.black45, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + + // Play/Pause control + IconButton( + onPressed: _togglePlayPause, + icon: Icon( + widget.controller.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + size: 48, + ), + color: Colors.white, + ), + + const SizedBox(height: 20), + + // Progress bar and time + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + VideoProgressIndicator( + widget.controller, + allowScrubbing: true, + colors: const VideoProgressColors( + playedColor: Color(0xFF1DA1F2), + bufferedColor: Colors.grey, + backgroundColor: Colors.white24, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(widget.controller.value.position), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + Text( + _formatDuration(widget.controller.value.duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 16), + ], + ), + ), + ), + + // Mute/Unmute button (top right) + Positioned( + top: 16, + right: 16, + child: IconButton( + onPressed: _toggleMute, + icon: Icon( + _isMuted ? Icons.volume_off : Icons.volume_up, + size: 28, + ), + color: Colors.white, + style: IconButton.styleFrom(backgroundColor: Colors.black54), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/view/widgets/profile_avatar.dart b/lib/features/home/view/widgets/profile_avatar.dart index cc504ba..85dcca6 100644 --- a/lib/features/home/view/widgets/profile_avatar.dart +++ b/lib/features/home/view/widgets/profile_avatar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/features/home/providers/user_profile_provider.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; class ProfileAvatar extends ConsumerWidget { final GlobalKey scaffoldKey; @@ -27,63 +28,7 @@ class ProfileAvatar extends ConsumerWidget { return GestureDetector( onTap: () => _openDrawer(), - child: Container( - width: 32, - height: 32, - child: profileState.when( - data: (profile) { - // Use profile photo from API if available, otherwise fall back to user photo - final photoUrl = - profile?.profilePhotoUrl ?? _getPhotoUrl(user?.photo); - - print( - '🖼️ ProfileAvatar - Profile Photo URL: ${profile?.profilePhotoUrl}', - ); - print('🖼️ ProfileAvatar - User Photo: ${user?.photo}'); - print('🖼️ ProfileAvatar - Final Photo URL: $photoUrl'); - - return CircleAvatar( - radius: 16, - backgroundColor: Colors.grey[800], - backgroundImage: photoUrl != null ? NetworkImage(photoUrl) : null, - child: photoUrl == null - ? Icon(Icons.person, color: Colors.grey[400], size: 18) - : null, - ); - }, - loading: () { - // Show user photo while loading profile from API - final photoUrl = _getPhotoUrl(user?.photo); - return CircleAvatar( - radius: 16, - backgroundColor: Colors.grey[800], - backgroundImage: photoUrl != null ? NetworkImage(photoUrl) : null, - child: photoUrl == null - ? const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : null, - ); - }, - error: (_, __) { - // Fall back to user photo on error - final photoUrl = _getPhotoUrl(user?.photo); - return CircleAvatar( - radius: 16, - backgroundColor: Colors.grey[800], - backgroundImage: photoUrl != null ? NetworkImage(photoUrl) : null, - child: photoUrl == null - ? Icon(Icons.person, color: Colors.grey[400], size: 18) - : null, - ); - }, - ), - ), + child: BuildSmallProfileImage(radius: 20, username: user?.username ?? ""), ); } diff --git a/lib/features/home/view/widgets/profile_side_drawer.dart b/lib/features/home/view/widgets/profile_side_drawer.dart index 6e028b1..206e298 100644 --- a/lib/features/home/view/widgets/profile_side_drawer.dart +++ b/lib/features/home/view/widgets/profile_side_drawer.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:lite_x/core/models/usermodel.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/features/home/providers/user_profile_provider.dart'; +import 'package:lite_x/features/profile/models/profile_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; class ProfileSideDrawer extends ConsumerWidget { const ProfileSideDrawer({super.key}); @@ -22,13 +25,17 @@ class ProfileSideDrawer extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(currentUserProvider); - final profileState = ref.watch(userProfileProvider); + final profileState = ref.watch(profileDataProvider(user?.username ?? "")); return Drawer( backgroundColor: Colors.black, child: SafeArea( child: profileState.when( - data: (profile) => _buildDrawerContent(context, ref, user, profile), + data: (either) => either.fold( + (l) => _buildDrawerContent(context, ref, user, null), + (profileData) => + _buildDrawerContent(context, ref, user, profileData), + ), loading: () => _buildLoadingDrawer(context, ref, user), error: (_, __) => _buildDrawerContent(context, ref, user, null), ), @@ -39,69 +46,68 @@ class ProfileSideDrawer extends ConsumerWidget { Widget _buildDrawerContent( BuildContext context, WidgetRef ref, - dynamic user, - dynamic profile, + UserModel? user, + ProfileModel? profileData, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 32, - backgroundColor: Colors.grey[850], - backgroundImage: profile?.profilePhotoUrl != null - ? NetworkImage(profile!.profilePhotoUrl!) - : (_getPhotoUrl(user?.photo) != null - ? NetworkImage(_getPhotoUrl(user!.photo)!) - : null), - child: - profile?.profilePhotoUrl == null && - _getPhotoUrl(user?.photo) == null - ? Icon(Icons.person, color: Colors.grey[500], size: 32) - : null, - ), - const SizedBox(height: 12), - Text( - profile?.name ?? user?.name ?? 'User', - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, + child: InkWell( + onTap: () { + context.push("/profilescreen/${user?.username}"); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BuildSmallProfileImage( + radius: 35, + username: user?.username ?? "", ), - ), - const SizedBox(height: 4), - Row( - children: [ - Text( - '@${profile?.username ?? user?.username ?? 'username'}', - style: TextStyle(color: Colors.grey[500], fontSize: 14), + const SizedBox(height: 12), + Text( + profileData?.displayName ?? user?.name ?? 'User', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, ), - if (profile?.verified == true) ...[ - const SizedBox(width: 4), - Icon( - Icons.verified, - color: const Color(0xFF1DA1F2), - size: 16, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '@${profileData?.username ?? user?.username ?? 'username'}', + style: TextStyle(color: Colors.grey[500], fontSize: 14), + overflow: TextOverflow.ellipsis, ), + if (profileData?.isVerified == true) ...[ + const SizedBox(width: 4), + Icon( + Icons.verified, + color: const Color(0xFF1DA1F2), + size: 16, + ), + ], ], - ], - ), - const SizedBox(height: 12), - Row( - children: [ - _buildStat( - '${profile?.followingCount ?? user?.interests.length ?? 0}', - 'Following', - ), - const SizedBox(width: 16), - _buildStat('${profile?.followersCount ?? 0}', 'Followers'), - ], - ), - ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildStat( + '${profileData?.followingCount ?? user?.interests.length ?? 0}', + 'Following', + ), + const SizedBox(width: 16), + _buildStat( + '${profileData?.followersCount ?? 0}', + 'Followers', + ), + ], + ), + ], + ), ), ), const Divider(color: Color(0xFF1f1f1f), height: 1), diff --git a/lib/features/home/view/widgets/tweet_summary_dialog.dart b/lib/features/home/view/widgets/tweet_summary_dialog.dart index db24362..b50e804 100644 --- a/lib/features/home/view/widgets/tweet_summary_dialog.dart +++ b/lib/features/home/view/widgets/tweet_summary_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../models/tweet_summary.dart'; class TweetSummaryDialog extends StatelessWidget { @@ -9,113 +10,204 @@ class TweetSummaryDialog extends StatelessWidget { @override Widget build(BuildContext context) { return Dialog( - backgroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: const BorderSide(color: Color(0xFF1DA1F2), width: 2), - ), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Grok AI Header - Row( + backgroundColor: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxWidth: 500), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [const Color(0xFF1E1E1E), Colors.black], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + width: 1.5, + color: const Color(0xFF1DA1F2).withOpacity(0.3), + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1DA1F2).withOpacity(0.2), + blurRadius: 24, + spreadRadius: 0, + ), + ], + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(28.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF1DA1F2).withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.auto_awesome, - color: Color(0xFF1DA1F2), - size: 24, - ), - ), - const SizedBox(width: 12), - const Text( - 'Tweet Insights', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), + // Grok AI Header with SVG icon + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF1DA1F2).withOpacity(0.3), + const Color(0xFF1DA1F2).withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFF1DA1F2).withOpacity(0.4), + width: 1, + ), + ), + child: SvgPicture.asset( + 'assets/svg/grok.svg', + width: 28, + height: 28, + colorFilter: const ColorFilter.mode( + Color(0xFF1DA1F2), + BlendMode.srcIn, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'AI Insights', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + Text( + 'Powered by Grok', + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], ), - ], - ), - const SizedBox(height: 24), + const SizedBox(height: 28), - // AI Summary Text (if available) - if (summary.summary != null && summary.summary!.isNotEmpty) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF1DA1F2).withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFF1DA1F2).withOpacity(0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + // AI Summary Text (if available) + if (summary.summary != null && summary.summary!.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF1DA1F2).withOpacity(0.15), + const Color(0xFF1DA1F2).withOpacity(0.05), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFF1DA1F2).withOpacity(0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.auto_awesome, - color: Color(0xFF1DA1F2), - size: 16, + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF1DA1F2).withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: SvgPicture.asset( + 'assets/svg/grok.svg', + width: 14, + height: 14, + colorFilter: const ColorFilter.mode( + Color(0xFF1DA1F2), + BlendMode.srcIn, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'AI Summary', + style: TextStyle( + color: const Color(0xFF1DA1F2), + fontSize: 13, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], ), - const SizedBox(width: 8), + const SizedBox(height: 16), Text( - 'AI Summary', - style: TextStyle( - color: Colors.grey[400], - fontSize: 12, - fontWeight: FontWeight.w600, + summary.summary!, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.6, + letterSpacing: 0.2, ), ), ], ), - const SizedBox(height: 12), - Text( - summary.summary!, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - height: 1.5, - ), - ), - ], - ), - ), - const SizedBox(height: 20), - ], + ), + const SizedBox(height: 24), + ], - // Metrics Grid - _buildMetricsGrid(), + // Metrics Grid + _buildMetricsGrid(), - const SizedBox(height: 20), + const SizedBox(height: 24), - // Close Button - TextButton( - onPressed: () => Navigator.of(context).pop(), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF1DA1F2), - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + // Close Button + Container( + width: double.infinity, + height: 48, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF1DA1F2), Color(0xFF1890D5)], + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1DA1F2).withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.of(context).pop(), + borderRadius: BorderRadius.circular(24), + child: const Center( + child: Text( + 'Close', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + ), + ), ), - ), - child: const Text( - 'Close', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), + ], ), - ], + ), ), ), ); @@ -127,31 +219,50 @@ class TweetSummaryDialog extends StatelessWidget { 'icon': Icons.visibility_outlined, 'label': 'Views', 'value': summary.views, + 'color': const Color(0xFF1DA1F2), + }, + { + 'icon': Icons.favorite_border, + 'label': 'Likes', + 'value': summary.likes, + 'color': const Color(0xFFF91880), }, - {'icon': Icons.favorite_border, 'label': 'Likes', 'value': summary.likes}, { 'icon': Icons.chat_bubble_outline, 'label': 'Replies', 'value': summary.replies, + 'color': const Color(0xFF1DA1F2), + }, + { + 'icon': Icons.repeat, + 'label': 'Retweets', + 'value': summary.retweets, + 'color': const Color(0xFF00BA7C), + }, + { + 'icon': Icons.format_quote, + 'label': 'Quotes', + 'value': summary.quotes, + 'color': const Color(0xFF1DA1F2), }, - {'icon': Icons.repeat, 'label': 'Retweets', 'value': summary.retweets}, - {'icon': Icons.format_quote, 'label': 'Quotes', 'value': summary.quotes}, { 'icon': Icons.bookmark_border, 'label': 'Bookmarks', 'value': summary.bookmarks, + 'color': const Color(0xFF1DA1F2), }, ]; return Wrap( - spacing: 16, - runSpacing: 16, + spacing: 12, + runSpacing: 12, children: metrics .map( (metric) => _buildMetricCard( icon: metric['icon'] as IconData, label: metric['label'] as String, value: metric['value'] as int, + color: metric['color'] as Color, ), ) .toList(), @@ -162,29 +273,58 @@ class TweetSummaryDialog extends StatelessWidget { required IconData icon, required String label, required int value, + required Color color, }) { return Container( - width: 100, - padding: const EdgeInsets.all(12), + width: 95, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[800]!, width: 1), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.grey[900]!, Colors.grey[850]!], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey[800]!.withOpacity(0.5), width: 1), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: const Color(0xFF1DA1F2), size: 28), - const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(height: 10), Text( _formatNumber(value), style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, + letterSpacing: 0.5, ), ), const SizedBox(height: 4), - Text(label, style: TextStyle(color: Colors.grey[400], fontSize: 12)), + Text( + label, + style: TextStyle( + color: Colors.grey[400], + fontSize: 11, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), ], ), ); diff --git a/lib/features/home/view/widgets/video_player_screen.dart b/lib/features/home/view/widgets/video_player_screen.dart new file mode 100644 index 0000000..39084bd --- /dev/null +++ b/lib/features/home/view/widgets/video_player_screen.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final String videoUrl; + + const VideoPlayerScreen({super.key, required this.videoUrl}); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + late VideoPlayerController _controller; + bool _isInitialized = false; + bool _hasError = false; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + print('🎥 Initializing video from URL: ${widget.videoUrl}'); + + _controller = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl), + httpHeaders: { + 'Accept': '*/*', + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + 'Range': 'bytes=0-', + }, + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + allowBackgroundPlayback: false, + ), + ); + + // Listen for errors during playback + _controller.addListener(() { + if (_controller.value.hasError && mounted) { + print( + '❌ Video playback error: ${_controller.value.errorDescription}', + ); + setState(() { + _hasError = true; + _errorMessage = + _controller.value.errorDescription ?? 'Unknown playback error'; + }); + } + }); + + await _controller.initialize(); + + if (mounted) { + print('✅ Video initialized successfully'); + print('Video duration: ${_controller.value.duration}'); + print('Video size: ${_controller.value.size}'); + setState(() { + _isInitialized = true; + }); + // Auto-play the video + _controller.play(); + } + } catch (e) { + print('❌ Video initialization error: $e'); + if (mounted) { + setState(() { + _hasError = true; + _errorMessage = e.toString(); + }); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: _hasError + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 64), + const SizedBox(height: 16), + const Text( + 'Failed to load video', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _errorMessage, + style: const TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _hasError = false; + _isInitialized = false; + }); + _initializeVideo(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1D9BF0), + ), + child: const Text('Retry'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () async { + // Fallback: open in external browser + final uri = Uri.parse(widget.videoUrl); + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + } + }, + child: const Text( + 'Open in Browser', + style: TextStyle(color: Color(0xFF1D9BF0)), + ), + ), + ], + ) + : !_isInitialized + ? const CircularProgressIndicator(color: Colors.blue) + : AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(_controller), + // Play/Pause overlay + GestureDetector( + onTap: () { + setState(() { + if (_controller.value.isPlaying) { + _controller.pause(); + } else { + _controller.play(); + } + }); + }, + child: Container( + color: Colors.transparent, + child: Center( + child: AnimatedOpacity( + opacity: _controller.value.isPlaying ? 0.0 : 1.0, + duration: const Duration(milliseconds: 300), + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(16), + child: Icon( + _controller.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + color: Colors.white, + size: 48, + ), + ), + ), + ), + ), + ), + // Video progress bar + Positioned( + bottom: 0, + left: 0, + right: 0, + child: VideoProgressIndicator( + _controller, + allowScrubbing: true, + colors: const VideoProgressColors( + playedColor: Color(0xFF1D9BF0), + bufferedColor: Colors.grey, + backgroundColor: Colors.white24, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/home/view_model/home_state.dart b/lib/features/home/view_model/home_state.dart index 9e94e34..decf1e1 100644 --- a/lib/features/home/view_model/home_state.dart +++ b/lib/features/home/view_model/home_state.dart @@ -18,7 +18,7 @@ class HomeState { this.isLoading = false, this.error, this.isRefreshing = false, - this.currentFeed = FeedType.following, + this.currentFeed = FeedType.forYou, // Default to For You feed }); HomeState copyWith({ diff --git a/lib/features/home/view_model/home_view_model.dart b/lib/features/home/view_model/home_view_model.dart index ce44b9b..3e16410 100644 --- a/lib/features/home/view_model/home_view_model.dart +++ b/lib/features/home/view_model/home_view_model.dart @@ -21,7 +21,7 @@ class HomeViewModel extends Notifier { forYouTweets: [], followingTweets: [], isLoading: true, - currentFeed: FeedType.following, + currentFeed: FeedType.forYou, // Default to For You feed ); } @@ -56,17 +56,26 @@ class HomeViewModel extends Notifier { Future switchFeed(FeedType feedType) async { if (state.currentFeed == feedType) return; - // Update current feed immediately to show the correct cached tweets - state = state.copyWith(currentFeed: feedType); - final cachedTweets = feedType == FeedType.forYou ? state.forYouTweets : state.followingTweets; if (cachedTweets.isNotEmpty) { // Use cached tweets without reloading - state = state.copyWith(tweets: cachedTweets); + state = state.copyWith( + currentFeed: feedType, + tweets: cachedTweets, + isLoading: false, + ); } else { + // Show loading indicator and clear tweets while fetching + state = state.copyWith( + currentFeed: feedType, + tweets: [], + isLoading: true, + error: null, + ); + // Load fresh tweets if cache is empty await loadTweets(feedType: feedType); } diff --git a/lib/features/media/download_media.dart b/lib/features/media/download_media.dart index 33c1b88..1f836a2 100644 --- a/lib/features/media/download_media.dart +++ b/lib/features/media/download_media.dart @@ -4,16 +4,20 @@ import 'package:lite_x/features/media/view_model/providers.dart'; Future> getMediaUrls(List ids) async { // print("start getting media---------*****"); final container = ProviderContainer(); - final List urls = []; - for (int i = 0; i < ids.length; i++) { + // Fetch all media URLs in parallel + final urlFutures = ids.map((id) async { try { - final url = await container.read(mediaUrlProvider(ids[i]).future); - urls.add(url); + final url = await container.read(mediaUrlProvider(id).future); + print(url + "\n******************************"); + return url; } catch (e) { - urls.add(""); + return ""; } - } + }).toList(); + + // Wait for all requests to complete + final urls = await Future.wait(urlFutures); // print( // "end getting media---------***** ${urls.isNotEmpty ? urls[0] : 'empty'}", diff --git a/lib/features/media/models/shared.dart b/lib/features/media/models/shared.dart new file mode 100644 index 0000000..57b0ca9 --- /dev/null +++ b/lib/features/media/models/shared.dart @@ -0,0 +1,23 @@ +const Map _mediaTypes = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + + 'mp4': 'video/mp4', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', + 'webm': 'video/webm', + 'mkv': 'video/x-matroska', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + 'mpeg': 'video/mpeg', + 'mpg': 'video/mpeg', + '3gp': 'video/3gpp', + 'm4v': 'video/x-m4v', +}; +String getMediaType(String filePath) { + final extension = filePath.split('.').last.toLowerCase(); + return _mediaTypes[extension] ?? 'image/jpeg'; +} diff --git a/lib/features/media/repository/media_repo_impl.dart b/lib/features/media/repository/media_repo_impl.dart index 253ef5f..f6c680c 100644 --- a/lib/features/media/repository/media_repo_impl.dart +++ b/lib/features/media/repository/media_repo_impl.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; +import 'package:lite_x/features/media/models/shared.dart'; import 'package:lite_x/features/profile/models/shared.dart'; import 'package:lite_x/features/media/models/confirm_upload_model.dart'; import 'package:lite_x/features/media/models/request_upload_model.dart'; @@ -9,9 +10,7 @@ import 'package:lite_x/features/media/repository/media_repo.dart'; class MediaRepoImpL implements MediaRepo { Dio _dio; - MediaRepoImpL(Dio d) : _dio = d { - - } + MediaRepoImpL(Dio d) : _dio = d {} Future> requestUpload( String fileName, @@ -51,7 +50,7 @@ class MediaRepoImpL implements MediaRepo { data: Stream.fromIterable([fileBytes]), options: Options( headers: { - 'Content-Type': _getMediaType(mediaFile.path), + 'Content-Type': getMediaType(mediaFile.path), 'Content-Length': fileBytes.length, }, ), @@ -77,18 +76,6 @@ class MediaRepoImpL implements MediaRepo { } } -const Map _mediaTypes = { - 'jpg': 'image/jpg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'webp': 'image/webp', -}; -String _getMediaType(String filePath) { - final extension = filePath.split('.').last.toLowerCase(); - return _mediaTypes[extension] ?? 'image/jpeg'; -} - // Future>> uploadProfilePhoto({ // required PickedImage pickedImage, // }) async { diff --git a/lib/features/media/test.dart b/lib/features/media/test.dart index fcd6734..d68994e 100644 --- a/lib/features/media/test.dart +++ b/lib/features/media/test.dart @@ -1,7 +1,5 @@ import 'dart:io'; - - void main() async { List files = []; files.add(File("./images.jpeg")); diff --git a/lib/features/media/upload_media.dart b/lib/features/media/upload_media.dart index 9d9b64a..d7fc8e9 100644 --- a/lib/features/media/upload_media.dart +++ b/lib/features/media/upload_media.dart @@ -3,21 +3,21 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lite_x/features/media/models/confirm_upload_model.dart'; import 'package:lite_x/features/media/models/request_upload_model.dart'; +import 'package:lite_x/features/media/models/shared.dart'; import 'package:lite_x/features/media/view_model/providers.dart'; Future> upload_media(List files) async { final container = ProviderContainer(); final limitedFiles = files.take(4).toList(); - final List ids = []; - for (int i = 0; i < limitedFiles.length; i++) { - final file = limitedFiles[i]; + // Process all files in parallel + final uploadFutures = limitedFiles.map((file) async { bool fail = false; final fileName = file.path.split(Platform.pathSeparator).last; - final fileType = _getMediaType(file.path); + final fileType = getMediaType(file.path); // request upload - + final requestUpload = container.read(requestUploadProvider); final requestUploadResponse = await requestUpload(fileName, fileType); RequestUploadModel requestUploadModel = RequestUploadModel( @@ -33,8 +33,7 @@ Future> upload_media(List files) async { }, ); if (fail) { - ids.add(""); - continue; + return ""; } // upload @@ -44,11 +43,10 @@ Future> upload_media(List files) async { fail = true; }, (res) {}); if (fail) { - ids.add(""); - continue; + return ""; } - //confirm upload + // confirm upload final confirmUpload = container.read(confirmUploadProvider); final confirmUploadResponse = await confirmUpload( requestUploadModel.keyName, @@ -69,24 +67,14 @@ Future> upload_media(List files) async { }, ); if (fail) { - ids.add(""); - continue; + return ""; } - ids.add(confirmUploadModel.id); - } + return confirmUploadModel.id; + }).toList(); + + // Wait for all uploads to complete + final ids = await Future.wait(uploadFutures); container.dispose(); return ids; } - -const Map _mediaTypes = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'webp': 'image/webp', -}; -String _getMediaType(String filePath) { - final extension = filePath.split('.').last.toLowerCase(); - return _mediaTypes[extension] ?? 'image/jpeg'; -} diff --git a/lib/features/notifications/mentions_model.dart b/lib/features/notifications/mentions_model.dart new file mode 100644 index 0000000..cba52cd --- /dev/null +++ b/lib/features/notifications/mentions_model.dart @@ -0,0 +1,346 @@ +class MediaInfo { + final String url; + final String keyName; + + MediaInfo({required this.url, required this.keyName}); + + factory MediaInfo.fromJson(Map json) { + return MediaInfo( + url: json['url'], + keyName: json['keyName'], + ); + } + + Map toJson() { + return { + 'url': url, + 'keyName': keyName, + }; + } + + MediaInfo copyWith({ + String? url, + String? keyName, + }) { + return MediaInfo( + url: url ?? this.url, + keyName: keyName ?? this.keyName, + ); + } + + @override + String toString() => 'MediaInfo(url: $url, keyName: $keyName)'; +} + +class Tweet { + final String id; + final String content; + final String createdAt; + final int likesCount; + final int retweetCount; + final int repliesCount; + final int quotesCount; + final String replyControl; + final String? parentId; + final String tweetType; + final TweetUser user; + final List mediaIds; + final bool isLiked; + final bool isRetweeted; + final bool isBookmarked; + + Tweet({ + required this.id, + required this.content, + required this.createdAt, + required this.likesCount, + required this.retweetCount, + required this.repliesCount, + required this.quotesCount, + required this.replyControl, + this.parentId, + required this.tweetType, + required this.user, + required this.mediaIds, + required this.isLiked, + required this.isRetweeted, + required this.isBookmarked, + }); + + factory Tweet.fromJson(Map json) { + return Tweet( + id: json['id'], + content: json['content'], + createdAt: json['createdAt'], + likesCount: json['likesCount'], + retweetCount: json['retweetCount'], + repliesCount: json['repliesCount'], + quotesCount: json['quotesCount'], + replyControl: json['replyControl'], + parentId: json['parentId'], + tweetType: json['tweetType'], + user: TweetUser.fromJson(json['user']), + mediaIds: List.from(json['mediaIds'] ?? []), + isLiked: json['isLiked'], + isRetweeted: json['isRetweeted'], + isBookmarked: json['isBookmarked'], + ); + } + + Map toJson() { + return { + 'id': id, + 'content': content, + 'createdAt': createdAt, + 'likesCount': likesCount, + 'retweetCount': retweetCount, + 'repliesCount': repliesCount, + 'quotesCount': quotesCount, + 'replyControl': replyControl, + 'parentId': parentId, + 'tweetType': tweetType, + 'user': user.toJson(), + 'mediaIds': mediaIds, + 'isLiked': isLiked, + 'isRetweeted': isRetweeted, + 'isBookmarked': isBookmarked, + }; + } + + Tweet copyWith({ + String? id, + String? content, + String? createdAt, + int? likesCount, + int? retweetCount, + int? repliesCount, + int? quotesCount, + String? replyControl, + String? parentId, + String? tweetType, + TweetUser? user, + List? mediaIds, + bool? isLiked, + bool? isRetweeted, + bool? isBookmarked, + }) { + return Tweet( + id: id ?? this.id, + content: content ?? this.content, + createdAt: createdAt ?? this.createdAt, + likesCount: likesCount ?? this.likesCount, + retweetCount: retweetCount ?? this.retweetCount, + repliesCount: repliesCount ?? this.repliesCount, + quotesCount: quotesCount ?? this.quotesCount, + replyControl: replyControl ?? this.replyControl, + parentId: parentId ?? this.parentId, + tweetType: tweetType ?? this.tweetType, + user: user ?? this.user, + mediaIds: mediaIds ?? this.mediaIds, + isLiked: isLiked ?? this.isLiked, + isRetweeted: isRetweeted ?? this.isRetweeted, + isBookmarked: isBookmarked ?? this.isBookmarked, + ); + } +} + +class TweetUser { + final String id; + final String name; + final String username; + final TweetMedia? profileMedia; + final bool verified; + final bool protectedAccount; + + TweetUser({ + required this.id, + required this.name, + required this.username, + this.profileMedia, + required this.verified, + required this.protectedAccount, + }); + + factory TweetUser.fromJson(Map json) { + return TweetUser( + id: json['id'], + name: json['name'], + username: json['username'], + profileMedia: json['profileMedia'] != null + ? TweetMedia.fromJson(json['profileMedia']) + : null, + verified: json['verified'], + protectedAccount: json['protectedAccount'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'username': username, + 'profileMedia': profileMedia?.toJson(), + 'verified': verified, + 'protectedAccount': protectedAccount, + }; + } + + TweetUser copyWith({ + String? id, + String? name, + String? username, + TweetMedia? profileMedia, + bool? verified, + bool? protectedAccount, + }) { + return TweetUser( + id: id ?? this.id, + name: name ?? this.name, + username: username ?? this.username, + profileMedia: profileMedia ?? this.profileMedia, + verified: verified ?? this.verified, + protectedAccount: protectedAccount ?? this.protectedAccount, + ); + } +} + +class TweetMedia { + final String id; + + TweetMedia({required this.id}); + + factory TweetMedia.fromJson(Map json) { + return TweetMedia(id: json['id']); + } + + Map toJson() { + return {'id': id}; + } + + TweetMedia copyWith({String? id}) { + return TweetMedia(id: id ?? this.id); + } +} + +class MentionItem { + final String id; + final String content; + final String createdAt; + final int likesCount; + final int retweetCount; + final int repliesCount; + final int quotesCount; + final String replyControl; + final String? parentId; + final String tweetType; + final TweetUser user; + final List mediaIds; // original media IDs + final List mediaUrls; // list of MediaInfo objects with actual URLs + final bool isLiked; + final bool isRetweeted; + final bool isBookmarked; + + MentionItem({ + required this.id, + required this.content, + required this.createdAt, + required this.likesCount, + required this.retweetCount, + required this.repliesCount, + required this.quotesCount, + required this.replyControl, + this.parentId, + required this.tweetType, + required this.user, + required this.mediaIds, + required this.mediaUrls, + required this.isLiked, + required this.isRetweeted, + required this.isBookmarked, + }); + + factory MentionItem.fromJson(Map json) { + return MentionItem( + id: json['id'], + content: json['content'], + createdAt: json['createdAt'], + likesCount: json['likesCount'], + retweetCount: json['retweetCount'], + repliesCount: json['repliesCount'], + quotesCount: json['quotesCount'], + replyControl: json['replyControl'], + parentId: json['parentId'], + tweetType: json['tweetType'], + user: TweetUser.fromJson(json['user']), + mediaIds: List.from(json['mediaIds'] ?? []), + mediaUrls: (json['mediaUrls'] as List?) + ?.map((e) => MediaInfo.fromJson(e)) + .toList() ?? + [], + isLiked: json['isLiked'], + isRetweeted: json['isRetweeted'], + isBookmarked: json['isBookmarked'], + ); + } + + Map toJson() { + return { + 'id': id, + 'content': content, + 'createdAt': createdAt, + 'likesCount': likesCount, + 'retweetCount': retweetCount, + 'repliesCount': repliesCount, + 'quotesCount': quotesCount, + 'replyControl': replyControl, + 'parentId': parentId, + 'tweetType': tweetType, + 'user': user.toJson(), + 'mediaIds': mediaIds, + 'mediaUrls': mediaUrls.map((e) => e.toJson()).toList(), + 'isLiked': isLiked, + 'isRetweeted': isRetweeted, + 'isBookmarked': isBookmarked, + }; + } + + MentionItem copyWith({ + String? id, + String? content, + String? createdAt, + int? likesCount, + int? retweetCount, + int? repliesCount, + int? quotesCount, + String? replyControl, + String? parentId, + String? tweetType, + TweetUser? user, + List? mediaIds, + List? mediaUrls, + bool? isLiked, + bool? isRetweeted, + bool? isBookmarked, + }) { + return MentionItem( + id: id ?? this.id, + content: content ?? this.content, + createdAt: createdAt ?? this.createdAt, + likesCount: likesCount ?? this.likesCount, + retweetCount: retweetCount ?? this.retweetCount, + repliesCount: repliesCount ?? this.repliesCount, + quotesCount: quotesCount ?? this.quotesCount, + replyControl: replyControl ?? this.replyControl, + parentId: parentId ?? this.parentId, + tweetType: tweetType ?? this.tweetType, + user: user ?? this.user, + mediaIds: mediaIds ?? this.mediaIds, + mediaUrls: mediaUrls ?? this.mediaUrls, + isLiked: isLiked ?? this.isLiked, + isRetweeted: isRetweeted ?? this.isRetweeted, + isBookmarked: isBookmarked ?? this.isBookmarked, + ); + } + + +} diff --git a/lib/features/notifications/mentions_provider.dart b/lib/features/notifications/mentions_provider.dart new file mode 100644 index 0000000..b1e14ed --- /dev/null +++ b/lib/features/notifications/mentions_provider.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'mentions_model.dart'; +import 'repositories/mentions_repository.dart'; + +final mentionsRepositoryProvider = Provider((ref) { + return MentionsRepository(ref); +}); + +class MentionsController extends AsyncNotifier> { + Future refresh() async { + state = const AsyncValue.loading(); + + state = await AsyncValue.guard(() async { + final repo = ref.read(mentionsRepositoryProvider); + return await repo.fetchMentions(); + }); + } + + @override + Future> build() async { + final repo = ref.read(mentionsRepositoryProvider); + final items = await repo.fetchMentions(); + return items; + } +} + +final mentionsProvider = + AsyncNotifierProvider>( + () => MentionsController(), +); diff --git a/lib/features/notifications/mentions_view_model.dart b/lib/features/notifications/mentions_view_model.dart new file mode 100644 index 0000000..95c13cc --- /dev/null +++ b/lib/features/notifications/mentions_view_model.dart @@ -0,0 +1,33 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import './repositories/mentions_repository.dart'; +import 'mentions_model.dart'; +import './mentions_provider.dart'; + +part 'mentions_view_model.g.dart'; + +@riverpod +class MentionsViewModel extends _$MentionsViewModel { + @override + Future> build() async { + return _fetchMentions(); + } + + Future> _fetchMentions() async { + final repo = ref.read(mentionsRepositoryProvider); + return repo.fetchMentions(); + } + + Future refresh() async { + if (!ref.mounted) return; + state = const AsyncLoading(); + + final result = await AsyncValue.guard(() async { + return _fetchMentions(); + }); + + if (ref.mounted) { + state = result; + } + } +} + diff --git a/lib/features/notifications/mentions_view_model.g.dart b/lib/features/notifications/mentions_view_model.g.dart new file mode 100644 index 0000000..d8d7fac --- /dev/null +++ b/lib/features/notifications/mentions_view_model.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mentions_view_model.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(MentionsViewModel) +const mentionsViewModelProvider = MentionsViewModelProvider._(); + +final class MentionsViewModelProvider + extends $AsyncNotifierProvider> { + const MentionsViewModelProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'mentionsViewModelProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$mentionsViewModelHash(); + + @$internal + @override + MentionsViewModel create() => MentionsViewModel(); +} + +String _$mentionsViewModelHash() => r'b4b7fee9bd14e0e9c5690b33009f2aff02480c2f'; + +abstract class _$MentionsViewModel extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/notifications/notification_fcm_service.dart b/lib/features/notifications/notification_fcm_service.dart new file mode 100644 index 0000000..59a9571 --- /dev/null +++ b/lib/features/notifications/notification_fcm_service.dart @@ -0,0 +1,89 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:lite_x/core/routes/AppRouter.dart'; +import 'package:lite_x/core/routes/Route_Constants.dart'; + +class NotificationFcmService { + NotificationFcmService._internal(); + + static final NotificationFcmService _instance = + NotificationFcmService._internal(); + + factory NotificationFcmService() => _instance; + + bool _initialized = false; + + void Function()? notificationsRefreshCallback; + void Function()? mentionsRefreshCallback; + + Future init() async { + if (_initialized) return; + _initialized = true; + + final token = await FirebaseMessaging.instance.getToken(); + print('FCM device token: $token'); + + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + print('FCM onMessage: ${message.messageId}, data: ${message.data}'); + _handleDataUpdate(message); + }); + + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + print('FCM onMessageOpenedApp: ${message.messageId}, data: ${message.data}'); + _handleDataUpdate(message); + _handleNavigation(message); + }); + + final initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + print('FCM getInitialMessage: ${initialMessage.messageId}, data: ${initialMessage.data}'); + _handleDataUpdate(initialMessage); + _handleNavigation(initialMessage); + } + } + + void _handleDataUpdate(RemoteMessage message) { + final data = message.data; + final type = data['type'] as String?; + + switch (type) { + case 'notifications': + notificationsRefreshCallback?.call(); + break; + case 'mention': + mentionsRefreshCallback?.call(); + break; + default: + break; + } + } + + void _handleNavigation(RemoteMessage message) { + final data = message.data; + final type = data['type'] as String?; + + if (type == null) { + Approuter.router.goNamed(RouteConstants.notifications); + return; + } + + switch (type) { + case 'notifications': + Approuter.router.goNamed(RouteConstants.notifications); + break; + case 'tweet': + final tweetId = data['tweetId'] as String?; + if (tweetId != null) { + Approuter.router.goNamed( + RouteConstants.TweetDetailsScreen, + pathParameters: {'tweetId': tweetId}, + ); + } else { + Approuter.router.goNamed(RouteConstants.notifications); + } + break; + default: + Approuter.router.goNamed(RouteConstants.notifications); + break; + } + } +} diff --git a/lib/features/notifications/notification_model.dart b/lib/features/notifications/notification_model.dart new file mode 100644 index 0000000..67ee992 --- /dev/null +++ b/lib/features/notifications/notification_model.dart @@ -0,0 +1,319 @@ +class MediaInfo { + final String url; + final String keyName; + + MediaInfo({required this.url, required this.keyName}); + + factory MediaInfo.fromJson(Map json) { + return MediaInfo(url: json['url'], keyName: json['keyName']); + } + + MediaInfo copyWith({String? url, String? keyName}) { + return MediaInfo(url: url ?? this.url, keyName: keyName ?? this.keyName); + } + + @override + String toString() => 'MediaInfo(url: $url, keyName: $keyName)'; +} + +class Actor { + final String name; + final String username; + final String profileMediaId; + final MediaInfo? media; + + Actor({ + required this.name, + required this.username, + required this.profileMediaId, + this.media, + }); + + factory Actor.fromJson(Map json) { + return Actor( + name: json['name'] ?? '', + username: json['username']?.toString() ?? '', + profileMediaId: json['profileMediaId']?.toString() ?? '', + ); + } + + Actor copyWith({ + String? name, + String? username, + String? profileMediaId, + MediaInfo? media, + }) { + return Actor( + name: name ?? this.name, + username: username ?? this.username, + profileMediaId: profileMediaId ?? this.profileMediaId, + media: media ?? this.media, + ); + } + + @override + String toString() => + 'Actor(name: $name, username: $username, profileMediaId: $profileMediaId, media: $media)'; +} + +class Notification { + final String id; + final String title; + final String body; + final bool isRead; + final String createdAt; + final String userId; + final String? tweetId; + final String actorId; + final Actor actor; + + Notification({ + required this.id, + required this.title, + required this.body, + required this.isRead, + required this.createdAt, + required this.userId, + this.tweetId, + required this.actorId, + required this.actor, + }); + + factory Notification.fromJson(Map json) { + return Notification( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + body: json['body']?.toString() ?? '', + isRead: json['isRead'] ?? false, + createdAt: json['createdAt']?.toString() ?? '', + userId: json['userId']?.toString() ?? '', + tweetId: json['tweetId']?.toString(), + actorId: json['actorId']?.toString() ?? '', + actor: Actor.fromJson(json['actor'] ?? {}), + ); + } + + Notification copyWith({ + String? id, + String? title, + String? body, + bool? isRead, + String? createdAt, + String? userId, + String? tweetId, + String? actorId, + Actor? actor, + }) { + return Notification( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + isRead: isRead ?? this.isRead, + createdAt: createdAt ?? this.createdAt, + userId: userId ?? this.userId, + tweetId: tweetId, + actorId: actorId ?? this.actorId, + actor: actor ?? this.actor, + ); + } + + @override + String toString() => + 'Notification(id: $id, title: $title, isRead: $isRead, actor: $actor)'; +} + +class EmbeddedTweetUser { + final String id; + final String name; + final String username; + final String profileMediaId; + final bool verified; + final bool protectedAccount; + + EmbeddedTweetUser({ + required this.id, + required this.name, + required this.username, + required this.profileMediaId, + required this.verified, + required this.protectedAccount, + }); + + factory EmbeddedTweetUser.fromJson(Map json) { + return EmbeddedTweetUser( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + username: json['username']?.toString() ?? '', + profileMediaId: (json['profileMedia'] is Map + ? (json['profileMedia']['id']?.toString()) + : json['profileMedia']?.toString()) ?? + '', + verified: json['verified'] as bool? ?? false, + protectedAccount: json['protectedAccount'] as bool? ?? false, + ); + } + + @override + String toString() => + 'EmbeddedTweetUser(id: $id, name: $name, username: $username, profileMediaId: $profileMediaId, verified: $verified, protectedAccount: $protectedAccount)'; +} + +class EmbeddedTweet { + final String id; + final String content; + final String createdAt; + final int likesCount; + final int retweetCount; + final int repliesCount; + final int quotesCount; + final String replyControl; + final String? parentId; + final String tweetType; + final EmbeddedTweetUser user; + final List mediaIds; + final bool isLiked; + final bool isRetweeted; + final bool isBookmarked; + + EmbeddedTweet({ + required this.id, + required this.content, + required this.createdAt, + required this.likesCount, + required this.retweetCount, + required this.repliesCount, + required this.quotesCount, + required this.replyControl, + required this.parentId, + required this.tweetType, + required this.user, + required this.mediaIds, + required this.isLiked, + required this.isRetweeted, + required this.isBookmarked, + }); + + factory EmbeddedTweet.fromJson(Map json) { + final userJson = (json['user'] is Map) + ? (json['user'] as Map).cast() + : {}; + + final mediaIdsRaw = json['mediaIds']; + final mediaIds = []; + if (mediaIdsRaw is List) { + for (final m in mediaIdsRaw) { + mediaIds.add(m.toString()); + } + } + + return EmbeddedTweet( + id: json['id']?.toString() ?? '', + content: json['content']?.toString() ?? '', + createdAt: json['createdAt']?.toString() ?? '', + likesCount: (json['likesCount'] as num?)?.toInt() ?? 0, + retweetCount: (json['retweetCount'] as num?)?.toInt() ?? 0, + repliesCount: (json['repliesCount'] as num?)?.toInt() ?? 0, + quotesCount: (json['quotesCount'] as num?)?.toInt() ?? 0, + replyControl: json['replyControl']?.toString() ?? 'EVERYONE', + parentId: json['parentId']?.toString(), + tweetType: json['tweetType']?.toString() ?? 'TWEET', + user: EmbeddedTweetUser.fromJson(userJson), + mediaIds: mediaIds, + isLiked: json['isLiked'] as bool? ?? false, + isRetweeted: json['isRetweeted'] as bool? ?? false, + isBookmarked: json['isBookmarked'] as bool? ?? false, + ); + } + + @override + String toString() => + 'EmbeddedTweet(id: $id, content: $content, likesCount: $likesCount, retweetCount: $retweetCount, repliesCount: $repliesCount, isLiked: $isLiked, isRetweeted: $isRetweeted, isBookmarked: $isBookmarked)'; +} + +class NotificationItem { + final String id; + final String title; + final String body; + final bool isRead; + final String mediaUrl; + final String? tweetId; + final String createdAt; + final Actor actor; + final String? targetUsername; + final String? quotedAuthor; + final String? quotedContent; + final int repliesCount; + final int repostsCount; + final int likesCount; + final bool isLiked; + final bool isRetweeted; + final bool isBookmarked; + final EmbeddedTweet? tweet; + + NotificationItem({ + required this.id, + required this.title, + required this.body, + required this.isRead, + required this.mediaUrl, + this.tweetId, + required this.createdAt, + required this.actor, + this.targetUsername, + this.quotedAuthor, + this.quotedContent, + this.repliesCount = 0, + this.repostsCount = 0, + this.likesCount = 0, + this.isLiked = false, + this.isRetweeted = false, + this.isBookmarked = false, + this.tweet, + }); + + NotificationItem copyWith({ + String? id, + String? title, + String? body, + bool? isRead, + String? mediaUrl, + String? tweetId, + String? createdAt, + Actor? actor, + String? targetUsername, + String? quotedAuthor, + String? quotedContent, + int? repliesCount, + int? repostsCount, + int? likesCount, + bool? isLiked, + bool? isRetweeted, + bool? isBookmarked, + EmbeddedTweet? tweet, + }) { + return NotificationItem( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + isRead: isRead ?? this.isRead, + mediaUrl: mediaUrl ?? this.mediaUrl, + tweetId: tweetId ?? this.tweetId, + createdAt: createdAt ?? this.createdAt, + actor: actor ?? this.actor, + targetUsername: targetUsername ?? this.targetUsername, + quotedAuthor: quotedAuthor ?? this.quotedAuthor, + quotedContent: quotedContent ?? this.quotedContent, + repliesCount: repliesCount ?? this.repliesCount, + repostsCount: repostsCount ?? this.repostsCount, + likesCount: likesCount ?? this.likesCount, + isLiked: isLiked ?? this.isLiked, + isRetweeted: isRetweeted ?? this.isRetweeted, + isBookmarked: isBookmarked ?? this.isBookmarked, + tweet: tweet ?? this.tweet, + ); + } + + @override + String toString() => + 'NotificationItem(title: $title, body: $body, isRead: $isRead, mediaUrl: $mediaUrl, tweetId: $tweetId, actor: $actor, targetUsername: $targetUsername, isLiked: $isLiked, isRetweeted: $isRetweeted, tweet: $tweet)'; +} diff --git a/lib/features/notifications/notification_provider.dart b/lib/features/notifications/notification_provider.dart new file mode 100644 index 0000000..0eef335 --- /dev/null +++ b/lib/features/notifications/notification_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/features/notifications/repositories/notification_repository.dart'; +import 'notification_model.dart'; + +final notificationRepositoryProvider = Provider((ref) { + return NotificationRepository(ref); +}); + +class NotificationsController extends AsyncNotifier> { + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repo = ref.read(notificationRepositoryProvider); + return await repo.fetchNotifications(); + }); + } + + @override + Future> build() async { + final repo = ref.read(notificationRepositoryProvider); + final items = await repo.fetchNotifications(); + return items; + } +} + +final notificationsProvider = + AsyncNotifierProvider>( + () => NotificationsController(), + ); diff --git a/lib/features/notifications/notification_view_model.dart b/lib/features/notifications/notification_view_model.dart new file mode 100644 index 0000000..7cae473 --- /dev/null +++ b/lib/features/notifications/notification_view_model.dart @@ -0,0 +1,81 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import './repositories/notification_repository.dart'; +import 'notification_model.dart'; +import './notification_provider.dart'; + +part 'notification_view_model.g.dart'; + +@riverpod +class NotificationViewModel extends _$NotificationViewModel { + @override + Future> build() async { + return _fetchNotifications(); + } + + Future> _fetchNotifications() async { + final repo = ref.read(notificationRepositoryProvider); + return repo.fetchNotifications(); + } + + Future refresh() async { + if (!ref.mounted) return; + state = const AsyncLoading(); + + final result = await AsyncValue.guard(() async { + return _fetchNotifications(); + }); + + if (ref.mounted) { + state = result; + } + } + + Future markAsRead(String id) async { + if (!ref.mounted) return; + + final current = state.value; + if (current == null) return; + + final updated = current.map((n) { + if (n.id == id) { + return n.copyWith(isRead: true); + } + return n; + }).toList(); + + if (ref.mounted) { + state = AsyncData(updated); + } + } + + void updateTweetInteractions( + String tweetId, { + int? likesCount, + int? repostsCount, + bool? isLiked, + bool? isRetweeted, + bool? isBookmarked, + }) { + if (!ref.mounted) return; + + final current = state.value; + if (current == null) return; + + final updated = current.map((n) { + if (n.tweetId == tweetId) { + return n.copyWith( + likesCount: likesCount ?? n.likesCount, + repostsCount: repostsCount ?? n.repostsCount, + isLiked: isLiked ?? n.isLiked, + isRetweeted: isRetweeted ?? n.isRetweeted, + isBookmarked: isBookmarked ?? n.isBookmarked, + ); + } + return n; + }).toList(); + + if (ref.mounted) { + state = AsyncData(updated); + } + } +} diff --git a/lib/features/notifications/notification_view_model.g.dart b/lib/features/notifications/notification_view_model.g.dart new file mode 100644 index 0000000..5136791 --- /dev/null +++ b/lib/features/notifications/notification_view_model.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_view_model.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NotificationViewModel) +const notificationViewModelProvider = NotificationViewModelProvider._(); + +final class NotificationViewModelProvider + extends + $AsyncNotifierProvider> { + const NotificationViewModelProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationViewModelProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationViewModelHash(); + + @$internal + @override + NotificationViewModel create() => NotificationViewModel(); +} + +String _$notificationViewModelHash() => + r'0816b2401903ff8eb24b2e4d923e7f38fc29289a'; + +abstract class _$NotificationViewModel + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/notifications/repositories/mentions_repository.dart b/lib/features/notifications/repositories/mentions_repository.dart new file mode 100644 index 0000000..b6c5f0a --- /dev/null +++ b/lib/features/notifications/repositories/mentions_repository.dart @@ -0,0 +1,139 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/core/providers/current_user_provider.dart'; +import '../mentions_model.dart'; + +class MentionsRepository { + final Ref ref; + + MentionsRepository(this.ref); + + Future> fetchMentions() async { + final dio = ref.read(dioProvider); + + // Get current user to fetch their mentions + final currentUser = ref.read(currentUserProvider); + if (currentUser == null || currentUser.username.isEmpty) { + throw Exception('User not logged in or username not available'); + } + + try { + // 1. Fetch mentions + final resp = await dio.get('/api/tweets/users/${currentUser.username}/mentioned'); + + if (resp.statusCode != 200) { + throw Exception("Failed to load mentions"); + } + + // Handle both List and Map responses + List data; + if (resp.data is List) { + data = resp.data as List; + } else if (resp.data is Map) { + final mapData = resp.data as Map; + // Try common keys for the tweets array + if (mapData.containsKey('tweets')) { + data = mapData['tweets'] as List; + } else if (mapData.containsKey('data')) { + data = mapData['data'] as List; + } else if (mapData.containsKey('items')) { + data = mapData['items'] as List; + } else { + // If no common key found, try to get the first list value + final listValue = mapData.values.firstWhere( + (value) => value is List, + orElse: () => throw Exception('No tweets array found in response'), + ); + data = listValue as List; + } + } else { + throw Exception('Unexpected response type: ${resp.data.runtimeType}'); + } + + // 2. Parse tweets and resolve media URLs + List mentionItems = []; + + for (var tweetData in data) { + try { + // Parse the tweet + final tweet = Tweet.fromJson(tweetData); + + // 3. Resolve profile media URL if exists + // Note: TweetMedia.id will store the resolved URL (not the original ID) + TweetMedia? resolvedProfileMedia; + if (tweet.user.profileMedia != null) { + try { + final profileMediaResp = await dio.get( + '/api/media/download-request/${tweet.user.profileMedia!.id}', + ); + + if (profileMediaResp.statusCode == 200) { + final media = MediaInfo.fromJson(profileMediaResp.data); + // Store the resolved URL in TweetMedia.id for UI consumption + resolvedProfileMedia = TweetMedia(id: media.url); + } else { + // Keep original if fetch fails + resolvedProfileMedia = tweet.user.profileMedia; + } + } catch (e) { + // If profile media fetch fails, keep original + resolvedProfileMedia = tweet.user.profileMedia; + } + } + + // 4. Resolve all media URLs for tweet media + List mediaUrls = []; + for (var mediaId in tweet.mediaIds) { + try { + final mediaResp = await dio.get( + '/api/media/download-request/$mediaId', + ); + + if (mediaResp.statusCode == 200) { + final media = MediaInfo.fromJson(mediaResp.data); + mediaUrls.add(media); + } + } catch (e) { + // If media fetch fails, skip this media + } + } + + // 5. Create MentionItem with resolved URLs + // Update user with resolved profile media (URL stored in id field) + final updatedUser = tweet.user.copyWith( + profileMedia: resolvedProfileMedia, + ); + + final mentionItem = MentionItem( + id: tweet.id, + content: tweet.content, + createdAt: tweet.createdAt, + likesCount: tweet.likesCount, + retweetCount: tweet.retweetCount, + repliesCount: tweet.repliesCount, + quotesCount: tweet.quotesCount, + replyControl: tweet.replyControl, + parentId: tweet.parentId, + tweetType: tweet.tweetType, + user: updatedUser, + mediaIds: tweet.mediaIds, + mediaUrls: mediaUrls, + isLiked: tweet.isLiked, + isRetweeted: tweet.isRetweeted, + isBookmarked: tweet.isBookmarked, + ); + + mentionItems.add(mentionItem); + } catch (e) { + // If parsing fails for one tweet, skip it and continue + continue; + } + } + + return mentionItems; + } catch (e) { + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/features/notifications/repositories/notification_repository.dart b/lib/features/notifications/repositories/notification_repository.dart new file mode 100644 index 0000000..d62d1df --- /dev/null +++ b/lib/features/notifications/repositories/notification_repository.dart @@ -0,0 +1,156 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import '../notification_model.dart'; + +class NotificationRepository { + final Ref ref; + + NotificationRepository(this.ref); + + Future> fetchNotifications() async { + + final dio = ref.read(dioProvider); + + try { + final resp = await dio.get('api/notifications'); + + if (resp.statusCode != 200) { + throw Exception("Failed to load notifications"); + } + + List data; + if (resp.data is List) { + data = resp.data as List; + } else if (resp.data is Map) { + final mapData = resp.data as Map; + if (mapData.containsKey('notifications')) { + data = mapData['notifications'] as List; + } else if (mapData.containsKey('data')) { + data = mapData['data'] as List; + } else if (mapData.containsKey('items')) { + data = mapData['items'] as List; + } else { + final listValue = mapData.values.firstWhere( + (value) => value is List, + orElse: () => + throw Exception('No notifications array found in response'), + ); + data = listValue as List; + } + } else { + throw Exception('Unexpected response type: ${resp.data.runtimeType}'); + } + + List notifications = []; + for (int i = 0; i < data.length; i++) { + try { + final notification = Notification.fromJson( + (data[i] as Map).cast(), + ); + notifications.add(notification); + } catch (e) { + rethrow; + } + } + + List items = []; + + for (var notification in notifications) { + String mediaUrl = ''; + final profileMediaId = notification.actor.profileMediaId; + if (profileMediaId.isNotEmpty && profileMediaId != 'null') { + try { + final mediaResp = await dio.get( + 'api/media/download-request/$profileMediaId', + ); + + if (mediaResp.statusCode == 200) { + final media = MediaInfo.fromJson(mediaResp.data); + mediaUrl = media.url; + } + } catch (e) { + // ignore media fetch errors for now + } + } + + int repliesCount = 0; + int repostsCount = 0; + int likesCount = 0; + bool isLiked = false; + bool isRetweeted = false; + EmbeddedTweet? embeddedTweet; + String? quotedAuthor; + String? quotedContent; + + final tweetId = notification.tweetId; + if (tweetId != null && tweetId.isNotEmpty) { + try { + final tweetResp = await dio.get('api/tweets/$tweetId'); + + if (tweetResp.statusCode == 200 && tweetResp.data is Map) { + final tweetJson = + (tweetResp.data as Map).cast(); + + embeddedTweet = EmbeddedTweet.fromJson(tweetJson); + repliesCount = embeddedTweet.repliesCount; + repostsCount = embeddedTweet.retweetCount; + likesCount = embeddedTweet.likesCount; + isLiked = embeddedTweet.isLiked; + isRetweeted = embeddedTweet.isRetweeted; + + if (notification.title == 'QUOTE' && + embeddedTweet.parentId != null && + embeddedTweet.parentId!.isNotEmpty) { + try { + final parentResp = await dio.get( + 'api/tweets/${embeddedTweet.parentId}', + ); + + if (parentResp.statusCode == 200 && parentResp.data is Map) { + final parentJson = (parentResp.data as Map) + .cast(); + final parentTweet = EmbeddedTweet.fromJson(parentJson); + quotedAuthor = parentTweet.user.name; + quotedContent = parentTweet.content; + } + } catch (_) { + // ignore parent tweet fetch errors for now + } + } + } + } catch (_) { + // ignore tweet fetch errors for now + } + } + + final item = NotificationItem( + id: notification.id, + title: notification.title, + body: notification.body, + isRead: notification.isRead, + mediaUrl: mediaUrl, + tweetId: notification.tweetId, + createdAt: notification.createdAt, + actor: notification.actor, + targetUsername: null, + quotedAuthor: quotedAuthor, + quotedContent: quotedContent, + repliesCount: repliesCount, + repostsCount: repostsCount, + likesCount: likesCount, + isLiked: isLiked, + isRetweeted: isRetweeted, + isBookmarked: embeddedTweet?.isBookmarked ?? false, + tweet: embeddedTweet, + ); + + items.add(item); + } + + return items; + } catch (e) { + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/screens/Notification_Screen.dart b/lib/features/notifications/view/screens/Notification_Screen.dart new file mode 100644 index 0000000..38f2105 --- /dev/null +++ b/lib/features/notifications/view/screens/Notification_Screen.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/home/view/widgets/profile_side_drawer.dart'; +import '../widgets/notification_tabs.dart'; +import '../widgets/status_bar.dart'; + +class NotificationScreen extends StatefulWidget { + const NotificationScreen({super.key}); + + @override + State createState() => _NotificationScreenState(); +} + +class _NotificationScreenState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + backgroundColor: Palette.background, + drawer: const ProfileSideDrawer(), + body: SafeArea( + child: Column( + children: [ + Statusbar(scaffoldKey: _scaffoldKey), + const Expanded( + child: NotificationTabs(), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/widgets/card/all_tweet_card.dart b/lib/features/notifications/view/widgets/card/all_tweet_card.dart new file mode 100644 index 0000000..7581985 --- /dev/null +++ b/lib/features/notifications/view/widgets/card/all_tweet_card.dart @@ -0,0 +1,822 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/home/repositories/home_repository.dart'; +import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart'; +import 'package:lite_x/features/home/view/screens/tweet_screen.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; + +import '../../../notification_model.dart'; +import '../../../notification_view_model.dart'; + +class AllTweetCardWidget extends ConsumerStatefulWidget { + final NotificationItem notification; + + const AllTweetCardWidget({super.key, required this.notification}); + + @override + ConsumerState createState() => _AllTweetCardWidgetState(); +} + +class _AllTweetCardWidgetState extends ConsumerState { + late bool _liked; + late bool _retweeted; + late int _likesCount; + late int _repostsCount; + bool _processingLike = false; + bool _processingRetweet = false; + late bool _bookmarked; + bool _processingBookmark = false; + bool _handlingQuote = false; + + NotificationItem get notification => widget.notification; + + bool get _isSystemAlert => notification.title == 'LOGIN'; + bool get _isRetweet => + notification.title == 'RETWEET' || notification.title == 'REPOST'; + bool get _isLike => notification.title == 'LIKE'; + bool get _isFollow => notification.title == 'FOLLOW'; + bool get _hasTweetLink => _tweetId != null; + + TextStyle get _nameStyle => const TextStyle( + fontFamily: 'SF Pro Text', + fontWeight: FontWeight.w600, + color: Palette.textPrimary, + ); + + TextStyle get _secondaryStyle => const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textSecondary, + fontSize: 14, + ); + + TextStyle get _bodyStyle => const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textPrimary, + fontSize: 14, + height: 1.4, + ); + + String? get _tweetId { + final id = notification.tweetId; + if (id == null || id.isEmpty) return null; + return id; + } + + @override + void initState() { + super.initState(); + _hydrateCounts(); + } + + @override + void didUpdateWidget(AllTweetCardWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.notification.id != widget.notification.id || + oldWidget.notification.likesCount != widget.notification.likesCount || + oldWidget.notification.repostsCount != + widget.notification.repostsCount || + oldWidget.notification.isLiked != widget.notification.isLiked || + oldWidget.notification.isRetweeted != widget.notification.isRetweeted) { + _hydrateCounts(); + } + } + + void _hydrateCounts() { + _liked = notification.isLiked; + _retweeted = notification.isRetweeted; + _likesCount = notification.likesCount; + _repostsCount = notification.repostsCount; + _bookmarked = notification.isBookmarked; + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _openTweetDetail() { + final tweetId = _tweetId; + if (tweetId == null) { + _showSnack('Tweet is no longer available.'); + return; + } + + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => TweetDetailScreen(tweetId: tweetId)), + ); + } + + void _openUserProfile() { + final username = notification.actor.username; + if (username.isEmpty) { + _showSnack('User profile not available'); + return; + } + Navigator.of(context).pushNamed('/profile', arguments: {'username': username}); + } + + Future _toggleLike() async { + if (_processingLike) return; + final tweetId = _tweetId; + if (tweetId == null) { + _showSnack('Tweet is no longer available.'); + return; + } + + _processingLike = true; + final previousLiked = _liked; + final previousCount = _likesCount; + final newState = !previousLiked; + + setState(() { + _liked = newState; + _likesCount = newState + ? previousCount + 1 + : (previousCount - 1 < 0 ? 0 : previousCount - 1); + }); + + final vm = ref.read(notificationViewModelProvider.notifier); + final currentTweetId = tweetId; + if (currentTweetId != null) { + vm.updateTweetInteractions( + currentTweetId, + likesCount: _likesCount, + isLiked: _liked, + ); + } + + try { + final dio = ref.read(dioProvider); + if (previousLiked) { + await dio.delete('api/tweets/$tweetId/likes'); + } else { + await dio.post('api/tweets/$tweetId/likes'); + } + } catch (_) { + if (mounted) { + setState(() { + _liked = previousLiked; + _likesCount = previousCount; + }); + final currentTweetId = tweetId; + if (currentTweetId != null) { + ref.read(notificationViewModelProvider.notifier).updateTweetInteractions( + currentTweetId, + likesCount: previousCount, + isLiked: previousLiked, + ); + } + } + _showSnack('Unable to ${previousLiked ? 'unlike' : 'like'} right now.'); + } finally { + _processingLike = false; + } + } + + Future _toggleRetweet() async { + if (_processingRetweet) return; + final tweetId = _tweetId; + if (tweetId == null) { + _showSnack('Tweet is no longer available.'); + return; + } + + _processingRetweet = true; + final previousState = _retweeted; + final previousCount = _repostsCount; + final newState = !previousState; + + setState(() { + _retweeted = newState; + _repostsCount = newState + ? previousCount + 1 + : (previousCount - 1 < 0 ? 0 : previousCount - 1); + }); + + final vm = ref.read(notificationViewModelProvider.notifier); + final currentTweetId = tweetId; + if (currentTweetId != null) { + vm.updateTweetInteractions( + currentTweetId, + repostsCount: _repostsCount, + isRetweeted: _retweeted, + ); + } + + try { + final dio = ref.read(dioProvider); + if (previousState) { + await dio.delete('api/tweets/$tweetId/retweets'); + } else { + await dio.post('api/tweets/$tweetId/retweets'); + } + } catch (_) { + if (mounted) { + setState(() { + _retweeted = previousState; + _repostsCount = previousCount; + }); + final currentTweetId = tweetId; + if (currentTweetId != null) { + ref.read(notificationViewModelProvider.notifier).updateTweetInteractions( + currentTweetId, + repostsCount: previousCount, + isRetweeted: previousState, + ); + } + } + _showSnack('Unable to ${previousState ? 'undo' : 'send'} repost.'); + } finally { + _processingRetweet = false; + } + } + + Future _toggleBookmark() async { + if (_processingBookmark) return; + final tweetId = _tweetId; + if (tweetId == null) { + _showSnack('Tweet is no longer available.'); + return; + } + + _processingBookmark = true; + final previousBookmarked = _bookmarked; + final newState = !previousBookmarked; + + setState(() { + _bookmarked = newState; + }); + + try { + final dio = ref.read(dioProvider); + if (previousBookmarked) { + await dio.delete('api/tweets/$tweetId/bookmark'); + } else { + await dio.post('api/tweets/$tweetId/bookmark'); + } + + final vm = ref.read(notificationViewModelProvider.notifier); + vm.updateTweetInteractions( + tweetId, + isBookmarked: _bookmarked, + ); + } catch (_) { + if (mounted) { + setState(() { + _bookmarked = previousBookmarked; + }); + ref.read(notificationViewModelProvider.notifier).updateTweetInteractions( + tweetId, + isBookmarked: previousBookmarked, + ); + } + _showSnack('Unable to update bookmark right now.'); + } finally { + _processingBookmark = false; + } + } + + Future _openQuoteComposer() async { + if (_handlingQuote) return; + final tweetId = _tweetId; + if (tweetId == null) { + _showSnack('Tweet is no longer available.'); + return; + } + + _handlingQuote = true; + try { + final repository = ref.read(homeRepositoryProvider); + final tweet = await repository.getTweetById(tweetId); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => QuoteComposerScreen(quotedTweet: tweet), + ), + ); + } catch (_) { + _showSnack('Unable to open quote composer.'); + } finally { + _handlingQuote = false; + } + } + + void _showRetweetMenu() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext modalContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + ListTile( + leading: Icon( + Icons.repeat, + color: _retweeted ? Colors.green : Colors.grey[300], + ), + title: Text( + _retweeted ? 'Undo Repost' : 'Repost', + style: TextStyle( + color: _retweeted ? Colors.green : Colors.grey[300], + fontSize: 16, + ), + ), + onTap: () { + Navigator.pop(modalContext); + _toggleRetweet(); + }, + ), + ListTile( + leading: Icon(Icons.edit_outlined, color: Colors.grey[300]), + title: const Text( + 'Quote', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + onTap: () { + Navigator.pop(modalContext); + _openQuoteComposer(); + }, + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + Widget _cardShell({ + required Widget child, + EdgeInsetsGeometry? padding, + VoidCallback? onTap, + }) { + final content = Container( + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + padding: padding ?? const EdgeInsets.symmetric(vertical: 12.0), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.white, width: 0.5), + ), + ), + child: child, + ); + + if (onTap == null) { + return content; + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: content, + ); + } + + Widget _buildBadgeIcon({required IconData icon, required Color color}) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.black, + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 30), + ); + } + + Widget _buildBrandBadge() { + return Container( + width: 36, + height: 36, + alignment: Alignment.center, + child: const Text( + 'X', + style: TextStyle( + fontFamily: 'SF Pro Display', + fontWeight: FontWeight.w700, + color: Palette.textPrimary, + fontSize: 24, + ), + ), + ); + } + + Widget _buildTimestampText() { + return Text( + _formatTimestamp(notification.createdAt), + style: const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textTertiary, + fontSize: 12, + ), + ); + } + + String _formatTimestamp(String createdAt) { + try { + final dateTime = DateTime.parse(createdAt); + final now = DateTime.now(); + final difference = now.difference(dateTime); + if (difference.inDays > 7) { + return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + } else if (difference.inDays > 0) { + return '${difference.inDays}d'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m'; + } else { + return 'now'; + } + } catch (_) { + return createdAt; + } + } + + String _formatHandle(String value) { + if (value.isEmpty) return '@you'; + return value.startsWith('@') ? value : '@$value'; + } + + String _actorHandle() { + if (notification.actor.username.isNotEmpty) { + return _formatHandle(notification.actor.username); + } + final sanitized = notification.actor.name.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9_]'), + '_', + ); + return _formatHandle(sanitized); + } + + String _getActionText() { + final target = _formatHandle( + notification.targetUsername ?? notification.actor.username, + ); + + switch (notification.title) { + case 'RETWEET': + case 'REPOST': + final reposts = _repostsCount.clamp(1, 99).toInt(); + return 'retweeted $reposts of your posts'; + case 'LIKE': + return 'liked your post'; + case 'FOLLOW': + return 'followed you'; + case 'REPLY': + return 'replied to your post'; + case 'QUOTE': + return 'quoted your post'; + default: + return 'Replying to $target'; + } + } + + bool _hasQuotedTweet() { + return (notification.quotedAuthor?.isNotEmpty ?? false) && + (notification.quotedContent?.isNotEmpty ?? false); + } + + + Widget _metricButton({ + required IconData icon, + int? count, + Color color = Palette.textTertiary, + VoidCallback? onTap, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Row( + children: [ + Icon(icon, size: 16, color: color), + if (count != null && count > 0) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + color: color, + fontFamily: 'SF Pro Text', + ), + ), + ], + ], + ), + ); + } + + Widget _buildMetricsRow() { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _metricButton( + icon: Icons.mode_comment_outlined, + count: notification.repliesCount, + onTap: _openTweetDetail, + ), + _metricButton( + icon: Icons.repeat, + count: _repostsCount, + color: _retweeted ? Palette.retweet : Palette.textTertiary, + onTap: _showRetweetMenu, + ), + _metricButton( + icon: Icons.favorite, + count: _likesCount, + color: _liked ? Palette.like : Palette.textTertiary, + onTap: _toggleLike, + ), + _metricButton( + icon: + _bookmarked ? Icons.bookmark : Icons.bookmark_border, + color: + _bookmarked ? Palette.primary : Palette.textTertiary, + onTap: _toggleBookmark, + ), + _metricButton( + icon: Icons.ios_share_outlined, + onTap: _openQuoteComposer, + ), + ], + ), + ); + } + + Widget _buildQuotedTweet() { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Palette.textTertiary.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(notification.quotedAuthor ?? '', style: _nameStyle), + const SizedBox(height: 4), + Text(notification.quotedContent ?? '', style: _secondaryStyle), + ], + ), + ); + } + + Widget _buildAlertCard() { + return _cardShell( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBrandBadge(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + notification.body, + style: _bodyStyle, + ), + ), + const SizedBox(width: 12), + _buildTimestampText(), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActivityCard({required IconData icon, required Color color}) { + final description = _getActionText(); + + // Prefer tweet content as snapshot; fall back to notification body + final String snapshotText; + if (notification.tweet != null && + notification.tweet!.content.isNotEmpty) { + snapshotText = notification.tweet!.content; + } else { + snapshotText = notification.body; + } + + return _cardShell( + onTap: _hasTweetLink ? _openTweetDetail : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBadgeIcon(icon: icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: RichText( + text: TextSpan( + text: '${notification.actor.name} ', + style: _nameStyle, + children: [ + TextSpan( + text: description, + style: _secondaryStyle, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + _buildTimestampText(), + ], + ), + if (snapshotText.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + snapshotText, + style: _secondaryStyle, + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildFollowCard() { + final description = _getActionText(); + + return _cardShell( + onTap: _hasTweetLink ? _openTweetDetail : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BuildSmallProfileImage( + mediaId: notification.mediaUrl, + radius: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: RichText( + text: TextSpan( + text: '${notification.actor.name} ', + style: _nameStyle, + children: [ + TextSpan( + text: description, + style: _secondaryStyle, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + _buildTimestampText(), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildConversationCard() { + final String bodyText; + if (notification.tweet != null && notification.tweet!.content.isNotEmpty) { + bodyText = notification.tweet!.content; + } else { + bodyText = notification.body; + } + + return _cardShell( + onTap: _hasTweetLink ? _openTweetDetail : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _openUserProfile, + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: notification.mediaUrl, + radius: 20, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: GestureDetector( + onTap: _openUserProfile, + child: Text( + notification.actor.name, + style: _nameStyle, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: _openUserProfile, + child: Text( + '${_actorHandle()} · ${_formatTimestamp(notification.createdAt)}', + style: const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textTertiary, + fontSize: 13, + ), + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 2), + Text( + _getActionText(), + style: const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textSecondary, + fontSize: 13, + ), + ), + if (bodyText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(bodyText, style: _bodyStyle), + ], + if (_hasQuotedTweet()) _buildQuotedTweet(), + _buildMetricsRow(), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isSystemAlert) { + return _buildAlertCard(); + } + if (_isRetweet) { + return _buildActivityCard(icon: Icons.repeat, color: Palette.retweet); + } + if (_isLike) { + return _buildActivityCard(icon: Icons.favorite, color: Palette.like); + } + if (_isFollow) { + return _buildFollowCard(); + } + return _buildConversationCard(); + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/widgets/card/interaction_bar.dart b/lib/features/notifications/view/widgets/card/interaction_bar.dart new file mode 100644 index 0000000..9f2b53d --- /dev/null +++ b/lib/features/notifications/view/widgets/card/interaction_bar.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/features/home/repositories/home_repository.dart'; +import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart'; + +class InteractionBar extends ConsumerStatefulWidget { + final String tweetId; + final int repliesCount; + final int retweetCount; + final int likesCount; + final int quotesCount; + final bool isLiked; + final bool isRetweeted; + final bool isBookmarked; + final VoidCallback? onUpdate; + + const InteractionBar({ + super.key, + required this.tweetId, + this.repliesCount = 0, + this.retweetCount = 0, + this.likesCount = 0, + this.quotesCount = 0, + this.isLiked = false, + this.isRetweeted = false, + this.isBookmarked = false, + this.onUpdate, + }); + + @override + ConsumerState createState() => _InteractionBarState(); +} + +class _InteractionBarState extends ConsumerState { + bool _liked = false; + bool _retweeted = false; + bool _bookmarked = false; + int _likesCount = 0; + int _retweetsCount = 0; + bool _handlingQuote = false; + + @override + void initState() { + super.initState(); + _liked = widget.isLiked; + _retweeted = widget.isRetweeted; + _bookmarked = widget.isBookmarked; + _likesCount = widget.likesCount; + _retweetsCount = widget.retweetCount; + } + + @override + void didUpdateWidget(InteractionBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isLiked != widget.isLiked) { + _liked = widget.isLiked; + } + if (oldWidget.isRetweeted != widget.isRetweeted) { + _retweeted = widget.isRetweeted; + } + if (oldWidget.isBookmarked != widget.isBookmarked) { + _bookmarked = widget.isBookmarked; + } + if (oldWidget.likesCount != widget.likesCount) { + _likesCount = widget.likesCount; + } + if (oldWidget.retweetCount != widget.retweetCount) { + _retweetsCount = widget.retweetCount; + } + } + + Future _toggleLike() async { + final dio = ref.read(dioProvider); + final wasLiked = _liked; + final oldCount = _likesCount; + + // Optimistic update + setState(() { + _liked = !_liked; + _likesCount += _liked ? 1 : -1; + }); + + try { + if (wasLiked) { + await dio.delete('/api/tweets/${widget.tweetId}/likes'); + } else { + await dio.post('/api/tweets/${widget.tweetId}/likes'); + } + // Callback to refresh parent if provided + widget.onUpdate?.call(); + } catch (e) { + // Revert on error + if (mounted) { + setState(() { + _liked = wasLiked; + _likesCount = oldCount; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to ${wasLiked ? 'unlike' : 'like'} tweet'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _toggleRetweet() async { + final dio = ref.read(dioProvider); + final wasRetweeted = _retweeted; + final oldCount = _retweetsCount; + + // Optimistic update + setState(() { + _retweeted = !_retweeted; + _retweetsCount += _retweeted ? 1 : -1; + }); + + try { + if (wasRetweeted) { + await dio.delete('/api/tweets/${widget.tweetId}/retweets'); + } else { + await dio.post('/api/tweets/${widget.tweetId}/retweets'); + } + // Callback to refresh parent if provided + widget.onUpdate?.call(); + } catch (e) { + // Revert on error + if (mounted) { + setState(() { + _retweeted = wasRetweeted; + _retweetsCount = oldCount; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to ${wasRetweeted ? 'unretweet' : 'retweet'}', + ), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _toggleBookmark() async { + final dio = ref.read(dioProvider); + final wasBookmarked = _bookmarked; + + // Optimistic update + setState(() { + _bookmarked = !_bookmarked; + }); + + try { + if (wasBookmarked) { + await dio.delete('/api/tweets/${widget.tweetId}/bookmark'); + } else { + await dio.post('/api/tweets/${widget.tweetId}/bookmark'); + } + // Callback to refresh parent if provided + widget.onUpdate?.call(); + } catch (e) { + // Revert on error + if (mounted) { + setState(() { + _bookmarked = wasBookmarked; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update bookmark'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _handleReply() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Reply functionality coming soon')), + ); + } + + Future _handleQuote() async { + if (_handlingQuote) return; + _handlingQuote = true; + + try { + final repository = ref.read(homeRepositoryProvider); + final tweet = await repository.getTweetById(widget.tweetId); + + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => QuoteComposerScreen(quotedTweet: tweet), + ), + ); + widget.onUpdate?.call(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to open quote composer'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + _handlingQuote = false; + } + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildButton( + Icons.reply, + widget.repliesCount, + Palette.reply, + _handleReply, + ), + _buildButton( + Icons.repeat, + _retweetsCount, + _retweeted ? Palette.retweet : Palette.reply, + _toggleRetweet, + ), + _buildButton( + Icons.favorite, + _likesCount, + _liked ? Palette.like : Palette.reply, + _toggleLike, + ), + GestureDetector( + onTap: _toggleBookmark, + child: Icon( + _bookmarked ? Icons.bookmark : Icons.bookmark_border, + color: _bookmarked ? Palette.primary : Palette.reply, + size: 18, + ), + ), + _buildButton( + Icons.ios_share_outlined, + 0, + Palette.reply, + _handleQuote, + ), + ], + ); + } + + Widget _buildButton( + IconData icon, + int count, + Color color, + VoidCallback onTap, + ) { + return GestureDetector( + onTap: onTap, + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 4), + Text( + count > 0 ? count.toString() : '', + style: TextStyle(color: color, fontSize: 13), + ), + ], + ), + ); + } +} diff --git a/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart new file mode 100644 index 0000000..463ab61 --- /dev/null +++ b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/home/repositories/home_repository.dart'; +import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart'; +import 'package:lite_x/features/home/view/screens/tweet_screen.dart'; +import '../../../mentions_model.dart'; + +class MentionTweetCard extends ConsumerStatefulWidget { + final MentionItem mention; + + const MentionTweetCard({super.key, required this.mention}); + + @override + ConsumerState createState() => _MentionTweetCardState(); +} + +class _MentionTweetCardState extends ConsumerState { + late bool _liked; + late bool _retweeted; + late int _likesCount; + late int _repostsCount; + bool _processingLike = false; + bool _processingRetweet = false; + late bool _bookmarked; + bool _processingBookmark = false; + bool _handlingQuote = false; + + MentionItem get mention => widget.mention; + + @override + void initState() { + super.initState(); + _hydrateCounts(); + } + + @override + void didUpdateWidget(MentionTweetCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mention.id != widget.mention.id || + oldWidget.mention.likesCount != widget.mention.likesCount || + oldWidget.mention.retweetCount != widget.mention.retweetCount || + oldWidget.mention.isLiked != widget.mention.isLiked || + oldWidget.mention.isRetweeted != widget.mention.isRetweeted) { + _hydrateCounts(); + } + } + + void _hydrateCounts() { + _liked = mention.isLiked; + _retweeted = mention.isRetweeted; + _likesCount = mention.likesCount; + _repostsCount = mention.retweetCount; + _bookmarked = mention.isBookmarked ?? false; + } + + void _showSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + void _openTweetDetail() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => TweetDetailScreen(tweetId: mention.id)), + ); + } + + void _openUserProfile() { + final username = mention.user.username; + if (username.isEmpty) { + _showSnack('User profile not available'); + return; + } + Navigator.of(context).pushNamed('/profile', arguments: {'username': username}); + } + + Future _openQuoteComposer() async { + if (_handlingQuote) return; + + _handlingQuote = true; + try { + final repository = ref.read(homeRepositoryProvider); + final tweet = await repository.getTweetById(mention.id); + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => QuoteComposerScreen(quotedTweet: tweet), + ), + ); + } catch (_) { + _showSnack('Unable to open quote composer.'); + } finally { + _handlingQuote = false; + } + } + + void _showRetweetMenu() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E1E), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext modalContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[600], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + ListTile( + leading: Icon( + Icons.repeat, + color: _retweeted ? Colors.green : Colors.grey[300], + ), + title: Text( + _retweeted ? 'Undo Repost' : 'Repost', + style: TextStyle( + color: _retweeted ? Colors.green : Colors.grey[300], + fontSize: 16, + ), + ), + onTap: () { + Navigator.pop(modalContext); + _toggleRetweet(); + }, + ), + ListTile( + leading: Icon(Icons.edit_outlined, color: Colors.grey[300]), + title: const Text( + 'Quote', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + onTap: () { + Navigator.pop(modalContext); + _openQuoteComposer(); + }, + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + Future _toggleLike() async { + if (_processingLike) return; + + _processingLike = true; + final previousLiked = _liked; + final previousCount = _likesCount; + final newState = !previousLiked; + + setState(() { + _liked = newState; + _likesCount = newState + ? (previousCount + 1) + : (previousCount - 1 < 0 ? 0 : previousCount - 1); + }); + + try { + final dio = ref.read(dioProvider); + if (previousLiked) { + await dio.delete('api/tweets/${mention.id}/likes'); + } else { + await dio.post('api/tweets/${mention.id}/likes'); + } + } catch (_) { + if (mounted) { + setState(() { + _liked = previousLiked; + _likesCount = previousCount; + }); + } + _showSnack('Unable to ${previousLiked ? 'unlike' : 'like'} right now.'); + } finally { + _processingLike = false; + } + } + + Future _toggleRetweet() async { + if (_processingRetweet) return; + + _processingRetweet = true; + final previousState = _retweeted; + final previousCount = _repostsCount; + final newState = !previousState; + + setState(() { + _retweeted = newState; + _repostsCount = newState + ? (previousCount + 1) + : (previousCount - 1 < 0 ? 0 : previousCount - 1); + }); + + try { + final dio = ref.read(dioProvider); + if (previousState) { + await dio.delete('api/tweets/${mention.id}/retweets'); + } else { + await dio.post('api/tweets/${mention.id}/retweets'); + } + } catch (_) { + if (mounted) { + setState(() { + _retweeted = previousState; + _repostsCount = previousCount; + }); + } + _showSnack('Unable to ${previousState ? 'undo' : 'send'} repost.'); + } finally { + _processingRetweet = false; + } + } + + Future _toggleBookmark() async { + if (_processingBookmark) return; + + _processingBookmark = true; + final previousBookmarked = _bookmarked; + final newState = !previousBookmarked; + + setState(() { + _bookmarked = newState; + }); + + try { + final dio = ref.read(dioProvider); + if (previousBookmarked) { + await dio.delete('api/tweets/${mention.id}/bookmark'); + } else { + await dio.post('api/tweets/${mention.id}/bookmark'); + } + } catch (_) { + if (mounted) { + setState(() { + _bookmarked = previousBookmarked; + }); + } + _showSnack('Unable to update bookmark right now.'); + } finally { + _processingBookmark = false; + } + } + + String _formatTimestamp(String createdAt) { + try { + final dateTime = DateTime.parse(createdAt); + final now = DateTime.now(); + final difference = now.difference(dateTime); + if (difference.inDays > 7) { + return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + } else if (difference.inDays > 0) { + return '${difference.inDays}d'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m'; + } else { + return 'now'; + } + } catch (e) { + return createdAt; + } + } + + String _formatHandle(String value) { + if (value.isEmpty) return '@you'; + return value.startsWith('@') ? value : '@$value'; + } + + TextStyle get _nameStyle => const TextStyle( + fontFamily: 'SF Pro Text', + fontWeight: FontWeight.w600, + color: Palette.textPrimary, + ); + + TextStyle get _bodyStyle => const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textPrimary, + fontSize: 14, + height: 1.4, + ); + + Widget _cardShell({ + required Widget child, + EdgeInsetsGeometry? padding, + VoidCallback? onTap, + }) { + final content = Container( + margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + padding: padding ?? const EdgeInsets.symmetric(vertical: 12.0), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.white, width: 0.5), + ), + ), + child: child, + ); + + if (onTap == null) { + return content; + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: content, + ); + } + + Widget _metricButton({ + required IconData icon, + int? count, + Color color = Palette.textTertiary, + VoidCallback? onTap, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + if (count != null) ...[ + const SizedBox(width: 4), + Text( + count.toString(), + style: TextStyle( + fontSize: 12, + color: color, + fontFamily: 'SF Pro Text', + ), + ), + ], + ], + ), + ); + } + + Widget _buildMetricsRow() { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _metricButton( + icon: Icons.mode_comment_outlined, + count: mention.repliesCount, + onTap: _openTweetDetail, + ), + _metricButton( + icon: Icons.repeat, + count: _repostsCount, + color: _retweeted ? Palette.retweet : Palette.textTertiary, + onTap: _showRetweetMenu, + ), + _metricButton( + icon: Icons.favorite, + count: _likesCount, + color: _liked ? Palette.like : Palette.textTertiary, + onTap: _toggleLike, + ), + _metricButton( + icon: _bookmarked ? Icons.bookmark : Icons.bookmark_border, + color: _bookmarked ? Palette.primary : Palette.textTertiary, + onTap: _toggleBookmark, + ), + _metricButton( + icon: Icons.ios_share_outlined, + onTap: _openQuoteComposer, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final profileImageUrl = mention.user.profileMedia?.id; + final String bodyText = mention.content.isNotEmpty ? mention.content : ''; + + return _cardShell( + onTap: _openTweetDetail, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _openUserProfile, + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: profileImageUrl, + radius: 20, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: GestureDetector( + onTap: _openUserProfile, + child: Text( + mention.user.name, + style: _nameStyle, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: _openUserProfile, + child: Text( + '${_formatHandle(mention.user.username)} · ${_formatTimestamp(mention.createdAt)}', + style: const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textTertiary, + fontSize: 13, + ), + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 2), + const Text( + 'mentioned you', + style: TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textSecondary, + fontSize: 13, + ), + ), + if (bodyText.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(bodyText, style: _bodyStyle), + ], + if (mention.mediaUrls.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: mention.mediaUrls.length, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(right: 8.0), + width: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(mention.mediaUrls[index].url), + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ), + ], + _buildMetricsRow(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/notifications/view/widgets/empty/all_empty.dart b/lib/features/notifications/view/widgets/empty/all_empty.dart new file mode 100644 index 0000000..3cb180b --- /dev/null +++ b/lib/features/notifications/view/widgets/empty/all_empty.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; + +class AllEmptyStateWidget extends StatelessWidget { + const AllEmptyStateWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: 336, + height: 148, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + "Nothing to see here — yet", + style: TextStyle( + fontSize: 31, + fontWeight: FontWeight.w800, + color: Palette.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + "From likes to reposts and a whole lot more, this is where all the action happens.", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Palette.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/notifications/view/widgets/empty/mention_empty.dart b/lib/features/notifications/view/widgets/empty/mention_empty.dart new file mode 100644 index 0000000..699fd23 --- /dev/null +++ b/lib/features/notifications/view/widgets/empty/mention_empty.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; + +class MentionsEmptyStateWidget extends StatelessWidget { + const MentionsEmptyStateWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: 336, + height: 148, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + "Nothing to see here — yet", + style: TextStyle( + fontSize: 31, + fontWeight: FontWeight.w800, + color: Palette.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + "When someone mentions you, you'll find it here.", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Palette.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/widgets/empty/verified_empty.dart b/lib/features/notifications/view/widgets/empty/verified_empty.dart new file mode 100644 index 0000000..c9c7a69 --- /dev/null +++ b/lib/features/notifications/view/widgets/empty/verified_empty.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; + +class VerifiedEmptyStateWidget extends StatelessWidget { + const VerifiedEmptyStateWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: 336, + height: 388, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16.0), + child: Image.network( + 'https://abs.twimg.com/responsive-web/client-web/verification-check-800x400.v1.52677a99.png', + width: 336, + height: 168, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 12), + + Text( + "Nothing to see here — yet", + style: TextStyle( + fontSize: 31, + fontWeight: FontWeight.w800, + color: Palette.textPrimary, + ), + ), + const SizedBox(height: 6), + + Text( + "Likes, mentions, reposts, and a whole lot more — when it comes from a verified account, you'll find it here.", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Palette.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/notifications/view/widgets/notification_tabs.dart b/lib/features/notifications/view/widgets/notification_tabs.dart new file mode 100644 index 0000000..dab9261 --- /dev/null +++ b/lib/features/notifications/view/widgets/notification_tabs.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/notifications/notification_fcm_service.dart'; +import 'tabs/all_notifications.dart'; +import 'tabs/verified_notifications.dart'; +import 'tabs/mentions_notifications.dart'; +import '../../notification_view_model.dart'; +import '../../mentions_view_model.dart'; + +class NotificationTabs extends ConsumerStatefulWidget { + const NotificationTabs({super.key}); + + @override + ConsumerState createState() => _NotificationTabsState(); +} + +class _NotificationTabsState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + int selectedIndex = 0; + final tabs = ['All', 'Verified', 'Mentions']; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + + final fcmService = NotificationFcmService(); + fcmService.notificationsRefreshCallback = () { + ref.read(notificationViewModelProvider.notifier).refresh(); + }; + fcmService.mentionsRefreshCallback = () { + ref.read(mentionsViewModelProvider.notifier).refresh(); + }; + _tabController.addListener(() { + if (selectedIndex != _tabController.index) { + setState(() { + selectedIndex = _tabController.index; + }); + // Trigger refresh for the newly selected tab + switch (_tabController.index) { + case 0: + ref.read(notificationViewModelProvider.notifier).refresh(); + break; + case 1: + // Verified tab currently static; no provider to refresh + break; + case 2: + ref.read(mentionsViewModelProvider.notifier).refresh(); + break; + } + } + }); + } + + @override + void dispose() { + final fcmService = NotificationFcmService(); + fcmService.notificationsRefreshCallback = null; + fcmService.mentionsRefreshCallback = null; + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + decoration: BoxDecoration( + color: Palette.background, + border: Border( + bottom: BorderSide( + color: Palette.border, + width: 1, + ), + ), + ), + height: 53, + child: Row( + children: List.generate( + tabs.length, + (index) => Expanded( + child: GestureDetector( + onTap: () { + _tabController.animateTo(index); + }, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Container( + color: Colors.transparent, + height: 53, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: Text( + tabs[index], + style: TextStyle( + fontFamily: 'Inter', + fontWeight: selectedIndex == index + ? FontWeight.w700 + : FontWeight.w500, + fontSize: 15, + color: selectedIndex == index + ? Palette.textPrimary + : Palette.textSecondary, + ), + ), + ), + ), + if (selectedIndex == index) + Container( + width: 60, + height: 3, + decoration: BoxDecoration( + color: Palette.primary, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(1), + topRight: Radius.circular(1), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + AllTab(key: PageStorageKey('allTab')), + VerifiedTab(key: PageStorageKey('verifiedTab')), + MentionsTab(key: PageStorageKey('mentionsTab')), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/widgets/status_bar.dart b/lib/features/notifications/view/widgets/status_bar.dart new file mode 100644 index 0000000..97455ce --- /dev/null +++ b/lib/features/notifications/view/widgets/status_bar.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lite_x/core/models/usermodel.dart'; +import 'package:lite_x/core/providers/current_user_provider.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; + +class Statusbar extends ConsumerWidget { + final GlobalKey scaffoldKey; + + const Statusbar({super.key, required this.scaffoldKey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final UserModel? currentUser = ref.watch(currentUserProvider); + final avatarUrl = currentUser?.photo; + + return Container( + height: 53, + padding: const EdgeInsets.symmetric(horizontal: 16.0), + color: Palette.background, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => scaffoldKey.currentState?.openDrawer(), + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: avatarUrl, + radius: 20, + ), + ), + ), + ), + SizedBox(width: 12), + Text( + 'Notifications', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + fontSize: 17, + color: Palette.textWhite, + letterSpacing: -0.3, + ), + ), + ], + ), + GestureDetector( + onTap: () { + context.push("/settingandprivacyscreen"); + }, + child: Icon( + Icons.settings_outlined, + size: 20, + color: Palette.icons, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/notifications/view/widgets/tabs/all_notifications.dart b/lib/features/notifications/view/widgets/tabs/all_notifications.dart new file mode 100644 index 0000000..061ca3b --- /dev/null +++ b/lib/features/notifications/view/widgets/tabs/all_notifications.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/theme/palette.dart'; + +import '../empty/all_empty.dart'; +import '../card/all_tweet_card.dart'; +import '../../../notification_model.dart'; +import '../../../notification_view_model.dart'; + +class AllTab extends ConsumerStatefulWidget { + const AllTab({super.key}); + + @override + ConsumerState createState() => _AllTabState(); +} + +class _AllTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + final GlobalKey _listKey = GlobalKey(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(notificationViewModelProvider.notifier).refresh(); + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final state = ref.watch(notificationViewModelProvider); + + return Container( + color: Palette.background, + child: state.when( + data: (items) { + return RefreshIndicator( + onRefresh: () async { + await ref + .read(notificationViewModelProvider.notifier) + .refresh(); + }, + child: items.isEmpty + ? ListView( + padding: const EdgeInsets.symmetric(vertical: 8.0), + children: const [AllEmptyStateWidget()], + ) + : AnimatedList( + key: _listKey, + padding: const EdgeInsets.symmetric(vertical: 8.0), + initialItemCount: items.length, + itemBuilder: (context, index, animation) { + return _buildItem(items[index], animation); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Failed to load notifications', + style: TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.read(notificationViewModelProvider.notifier).refresh(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + Widget _buildItem(NotificationItem notification, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween(begin: const Offset(0, 0.2), end: Offset.zero) + .animate(animation), + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: AllTweetCardWidget(notification: notification), + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart new file mode 100644 index 0000000..2729186 --- /dev/null +++ b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import '../empty/mention_empty.dart'; +import '../card/mentions_tweet_card.dart'; +import '../../../mentions_view_model.dart'; + +class MentionsTab extends ConsumerStatefulWidget { + const MentionsTab({super.key}); + + @override + ConsumerState createState() => _MentionsTabState(); +} + +class _MentionsTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(mentionsViewModelProvider.notifier).refresh(); + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + final state = ref.watch(mentionsViewModelProvider); + + return Container( + color: Palette.background, + child: state.when( + data: (items) { + return RefreshIndicator( + onRefresh: () async { + await ref.read(mentionsViewModelProvider.notifier).refresh(); + }, + child: items.isEmpty + ? ListView( + padding: const EdgeInsets.symmetric(vertical: 8.0), + children: const [MentionsEmptyStateWidget()], + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: items.length, + itemBuilder: (context, index) { + return MentionTweetCard(mention: items[index]); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Failed to load mentions', + style: TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.read(mentionsViewModelProvider.notifier).refresh(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/features/notifications/view/widgets/tabs/verified_notifications.dart b/lib/features/notifications/view/widgets/tabs/verified_notifications.dart new file mode 100644 index 0000000..d512a32 --- /dev/null +++ b/lib/features/notifications/view/widgets/tabs/verified_notifications.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import '../../../notification_model.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import '../empty/verified_empty.dart'; + +class VerifiedTab extends StatefulWidget { + const VerifiedTab({super.key}); + + @override + State createState() => _VerifiedTabState(); +} + +class _VerifiedTabState extends State + with AutomaticKeepAliveClientMixin { + final List _notifications = []; // Set to empty for demo + + @override + Widget build(BuildContext context) { + super.build(context); + return Container( + color: Palette.background, + child: const VerifiedEmptyStateWidget(), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class _VerifiedNotificationCard extends StatefulWidget { + final NotificationItem notification; + + const _VerifiedNotificationCard({required this.notification}); + + @override + State<_VerifiedNotificationCard> createState() => + _VerifiedNotificationCardState(); +} + +class _VerifiedNotificationCardState extends State<_VerifiedNotificationCard> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _animation = CurvedAnimation(parent: _controller, curve: Curves.easeIn); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _animation, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Material( + color: Palette.cardBackground, + borderRadius: BorderRadius.circular(16.0), + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: () {}, + splashColor: Palette.primaryHover.withOpacity(0.2), + highlightColor: Palette.primaryHover.withOpacity(0.1), + child: Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + border: Border( + left: BorderSide(color: Palette.primary, width: 4.0), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Palette.primary.withOpacity(0.5), + blurRadius: 8.0, + spreadRadius: 1.0, + ), + ], + ), + child: CircleAvatar( + radius: 24, + backgroundImage: + widget.notification.mediaUrl.isNotEmpty + ? NetworkImage(widget.notification.mediaUrl) + : null, + backgroundColor: Palette.cardBackground, + child: widget.notification.mediaUrl.isEmpty + ? Icon(Icons.person, color: Palette.textPrimary) + : null, + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.notification.mediaUrl, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Palette.textWhite, + ), + ), + const SizedBox(width: 4.0), + Icon( + Icons.verified, + color: Palette.verified, + size: 16, + ), + ], + ), + Text( + widget.notification.body, + style: TextStyle(color: Palette.textSecondary), + ), + ], + ), + ), + Text( + widget.notification.mediaUrl, + style: TextStyle( + color: Palette.textTertiary, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/models/profile_tweet_model.dart b/lib/features/profile/models/profile_tweet_model.dart index 582a4ba..59c7bb5 100644 --- a/lib/features/profile/models/profile_tweet_model.dart +++ b/lib/features/profile/models/profile_tweet_model.dart @@ -22,6 +22,8 @@ class ProfileTweetModel { final TweetType type; final List mediaIds; final String parentId; + final String retweeterName; + final String retweeterUserName; ProfileTweetModel({ required this.id, @@ -45,6 +47,8 @@ class ProfileTweetModel { required this.type, required this.mediaIds, required this.parentId, + required this.retweeterName, + required this.retweeterUserName, }); factory ProfileTweetModel.fromJson(Map json) { @@ -83,6 +87,8 @@ class ProfileTweetModel { protectedAccount: json["user"]?["protectedAccount"] ?? false, verified: json["user"]?["verified"] ?? false, parentId: json["parentId"] ?? "", + retweeterName: json["retweeterName"] ?? "", + retweeterUserName: json["retweeterUserName"] ?? "", // mediaIds: meidaIds, ); } diff --git a/lib/features/profile/models/shared.dart b/lib/features/profile/models/shared.dart index bd7f0df..0f5d8d1 100644 --- a/lib/features/profile/models/shared.dart +++ b/lib/features/profile/models/shared.dart @@ -1,12 +1,9 @@ -import 'dart:ffi'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/features/media/download_media.dart'; import 'package:lite_x/features/media/view_model/providers.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; @@ -314,10 +311,10 @@ class BuildSmallProfileImage extends ConsumerStatefulWidget { required this.radius, super.key, this.mediaId, - this.userId, + this.username, }); String? mediaId; - String? userId; + String? username; double radius; @override @@ -348,8 +345,8 @@ class _BuildSmallProfileImageState }, ), ); - } else if (widget.userId != null) { - final profileData = ref.watch(profileDataProvider(widget.userId!)); + } else if (widget.username != null) { + final profileData = ref.watch(profileDataProvider(widget.username!)); return CircleAvatar( backgroundColor: Colors.grey, radius: widget.radius, @@ -482,6 +479,9 @@ class _InterActionsRowOfTweetState borderColor: Colors.blue, icon: Icon(Icons.check_circle, color: Colors.blue), ); + final currUser = ref.watch(currentUserProvider); + if (currUser != null) + ref.refresh(profilePostsProvider(currUser.username)); if (mounted) // ignore: unused_result ref.refresh( @@ -523,6 +523,10 @@ class _InterActionsRowOfTweetState borderColor: Colors.blue, icon: Icon(Icons.check_circle, color: Colors.blue), ); + final currUser = ref.watch(currentUserProvider); + if (currUser != null) + // ignore: unused_result + ref.refresh(profilePostsProvider(currUser.username)); if (mounted) // ignore: unused_result ref.refresh( @@ -573,32 +577,54 @@ class _InterActionsRowOfTweetState if (isLikedByMeLocal) { final unlike = ref.watch(unlikeTweetProvider); unlike(widget.tweet.id).then((res) { - res.fold((l) { - isLikedByMeLocal = true; - likesCount += 1; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, (r) {}); + res.fold( + (l) { + isLikedByMeLocal = true; + likesCount += 1; + showSmallPopUpMessage( + context: context, + message: l.message, + borderColor: Colors.red, + icon: Icon(Icons.error, color: Colors.red), + ); + if (mounted) setState(() {}); + }, + (r) { + ref.refresh( + profilePostsProvider(widget.tweet.userUserName), + ); + final currUser = ref.watch(currentUserProvider); + if (currUser != null) + ref.refresh(profilePostsProvider(currUser.username)); + }, + ); }); } else { final like = ref.watch(likeTweetProvider); like(widget.tweet.id).then((res) { - res.fold((l) { - isLikedByMeLocal = false; - likesCount -= 1; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, (r) {}); + res.fold( + (l) { + isLikedByMeLocal = false; + likesCount -= 1; + showSmallPopUpMessage( + context: context, + message: l.message, + borderColor: Colors.red, + icon: Icon(Icons.error, color: Colors.red), + ); + if (mounted) setState(() {}); + }, + (r) { + if (mounted) { + ref.refresh( + profilePostsProvider(widget.tweet.userUserName), + ); + final currUser = ref.watch(currentUserProvider); + if (currUser != null) + ref.refresh(profilePostsProvider(currUser.username)); + } + }, + ); }); } if (mounted) @@ -895,3 +921,37 @@ class _BuildProfileImageState extends ConsumerState { ); } } + +List convertJsonListToTweetList(List jsonList) { + List tweets = []; + for (int i = 0; i < jsonList.length; i++) { + print(jsonList[i]); + final Map json = jsonList[i] as Map; + if (json["tweetType"]?.toLowerCase() == "reply") continue; + + final String profilePhotoId = json["user"]?["profileMedia"]?["id"] ?? ""; + + final List tweetMediaIdsDynamic = json["tweetMedia"] ?? []; + final List tweetMediaIds = tweetMediaIdsDynamic + .map((media) => media["media"]?["id"] as String) + .toList(); + + // get timeAgo + final String createTime = json["createdAt"] ?? ""; + final String timeAgo = getTimeAgo(createTime); + + json["profileMediaId"] = profilePhotoId; + json["mediaIds"] = tweetMediaIds; + json["timeAgo"] = timeAgo; + + tweets.add(ProfileTweetModel.fromJson(json)); + } + return tweets; +} + +abstract class TrendsCategoriesTabs { + static String Global = "global"; + static String News = "news"; + static String Sports = "Sports"; + static String Entertainment = "entertainment"; +} diff --git a/lib/features/profile/repositories/profile_repo.dart b/lib/features/profile/repositories/profile_repo.dart index cf2bef9..0e90765 100644 --- a/lib/features/profile/repositories/profile_repo.dart +++ b/lib/features/profile/repositories/profile_repo.dart @@ -7,9 +7,14 @@ import 'package:lite_x/features/profile/models/shared.dart'; import 'package:dartz/dartz.dart'; import 'package:lite_x/features/profile/models/tweet_reply_model.dart'; import 'package:lite_x/features/profile/models/user_model.dart'; +import 'package:lite_x/features/trends/models/for_you_response_model.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; abstract class ProfileRepo { - Future> getProfileData(String userName,String currentUsername, + Future> getProfileData( + String userName, + String currentUsername, ); Future>> getFollowers(String userName); @@ -74,11 +79,8 @@ abstract class ProfileRepo { String tweetId, CreateReplyModel createreplyModel, ); - Future> retweetProfileTweet( - String tweetId, - ); + Future> retweetProfileTweet(String tweetId); Future> deleteRetweetProfileTweet(String tweetId); - // emain and password Future> changeEmailProfile(String newEmail); @@ -91,4 +93,8 @@ abstract class ProfileRepo { String newPassword, String confirmNewPassword, ); + + Future> getForYouTrends(); + Future> getTrenCategory(String catName); + Future>> getAvailableTrends(); } diff --git a/lib/features/profile/repositories/profile_repo_impl.dart b/lib/features/profile/repositories/profile_repo_impl.dart index 53d32f7..2485426 100644 --- a/lib/features/profile/repositories/profile_repo_impl.dart +++ b/lib/features/profile/repositories/profile_repo_impl.dart @@ -1,7 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/features/media/download_media.dart'; import 'package:lite_x/features/profile/models/create_reply_model.dart'; import 'package:lite_x/features/profile/models/create_tweet_model.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; @@ -13,7 +10,9 @@ import 'package:lite_x/features/profile/models/tweet_reply_model.dart'; import 'package:lite_x/features/profile/models/user_model.dart'; import 'package:lite_x/features/profile/repositories/profile_repo.dart'; import 'package:lite_x/features/profile/repositories/profile_storage_service.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:lite_x/features/trends/models/for_you_response_model.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; class ProfileRepoImpl implements ProfileRepo { Dio _dio; @@ -113,46 +112,15 @@ class ProfileRepoImpl implements ProfileRepo { String username, ) async { try { - // print("Start api ---------------------**"); final res = await _dio.get("api/tweets/users/$username"); - // print("end api ---------------------**"); - final List jsonList = res.data["data"] ?? []; - - List tweets = []; - for (int i = 0; i < jsonList.length; i++) { - final Map json = jsonList[i] as Map; - if (json["tweetType"]?.toLowerCase() == "reply") continue; - // get profile photo url and tweet medial urls - final String profilePhotoId = - json["user"]?["profileMedia"]?["id"] ?? ""; - - final List tweetMediaIdsDynamic = json["tweetMedia"] ?? []; - final List tweetMediaIds = tweetMediaIdsDynamic - .map((media) => media["mediaId"] as String) - .toList(); - - // final List userPhotoUrl = await getMediaUrls([profilePhotoId]); - // final String profilePhotoUrl = userPhotoUrl[0]; - - // get timeAgo - final String createTime = json["createdAt"] ?? ""; - final String timeAgo = getTimeAgo(createTime); + final List jsonList = res.data["data"] ?? []; - json["profileMediaId"] = profilePhotoId; - json["mediaIds"] = tweetMediaIds; - json["timeAgo"] = timeAgo; + final tweets = convertJsonListToTweetList(jsonList); - tweets.add(ProfileTweetModel.fromJson(json)); - // print( - // "id$i" + - // "${json['profileMediaId']}--------------------*****--------------", - // ); - } - // print("end repo posts ----------------**"); return Right(tweets); } catch (e) { - print(e.toString() + "--------------+++++++++++++++++"); + // print(e.toString() + "--------------+++++++++++++++++"); return Left(Failure('Failed to load profile posts')); } } @@ -171,7 +139,7 @@ class ProfileRepoImpl implements ProfileRepo { final List tweetMediaIdsDynamic = json["tweetMedia"] ?? []; final List tweetMediaIds = tweetMediaIdsDynamic - .map((media) => media["mediaId"] as String) + .map((media) => media["media"]?["id"] as String) .toList(); // final List userPhotoUrl = await getMediaUrls([profilePhotoId]); @@ -209,37 +177,7 @@ class ProfileRepoImpl implements ProfileRepo { // print("end api ---------------------**"); final List jsonList = res.data["data"] ?? []; - List tweets = []; - for (int i = 0; i < jsonList.length; i++) { - final Map json = jsonList[i] as Map; - if (json["tweetType"]?.toLowerCase() == "reply") continue; - // get profile photo url and tweet medial urls - final String profilePhotoId = - json["user"]?["profileMedia"]?["id"] ?? ""; - - final List tweetMediaIdsDynamic = json["tweetMedia"] ?? []; - final List tweetMediaIds = tweetMediaIdsDynamic - .map((media) => media["mediaId"] as String) - .toList(); - - // final List userPhotoUrl = await getMediaUrls([profilePhotoId]); - - // final String profilePhotoUrl = userPhotoUrl[0]; - - // get timeAgo - final String createTime = json["createdAt"] ?? ""; - final String timeAgo = getTimeAgo(createTime); - - json["profileMediaId"] = profilePhotoId; - json["mediaIds"] = tweetMediaIds; - json["timeAgo"] = timeAgo; - - tweets.add(ProfileTweetModel.fromJson(json)); - // print( - // "id$i" + - // "${json['profileMediaId']}--------------------*****--------------", - // ); - } + final tweets = convertJsonListToTweetList(jsonList); // print("end repo posts ----------------**"); return Right(tweets); } catch (e) { @@ -683,4 +621,89 @@ class ProfileRepoImpl implements ProfileRepo { return (Left(Failure("can't change password"))); } } + + // trends + Future> getForYouTrends() async { + try { + final response = await _dio.get("api/hashtags/categories"); + + final json = response.data; + final jsonCategories = json["categories"]; + final people = json["whoToFollow"]; + + final List peoplemodels = people + .map((p) { + p["photo"] = p["profileMedia"]?["id"]; + p["isFollowing"] = p["isFollowed"]; + print(p.toString()); + return UserModel.fromJson(p as Map); + }) + .toList() + .cast(); + + final List categories = jsonCategories + .map((c) => TrendCategory.fromJson(c)) + .toList() + .cast(); + + return Right( + ForYouResponseModel( + categories: categories, + suggestedUsers: peoplemodels, + ), + ); + } on DioException catch (_) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } catch (e) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } + } + + Future> getTrenCategory(String catName) async { + try { + final response = await _dio.get( + "api/hashtags/categories", + queryParameters: {"category": catName}, + ); + + final TrendCategory category = TrendCategory.fromJson(response.data); + return Right(category); + } on DioException catch (_) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } catch (e) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } + } + + Future>> getAvailableTrends() async { + try { + final response = await _dio.get("api/hashtags/trends"); + + final jsonList = response.data["trends"]; + if (jsonList != null) { + final List trends = jsonList + .map((t) => TrendModel.fromJson(t)) + .toList() + .cast(); + return Right(trends); + } + return Right([]); + } on DioException catch (e) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } catch (e) { + return (Left( + Failure("cannot get trends at this time, try again later..."), + )); + } + } } diff --git a/lib/features/profile/view/screens/explore_profile_screen.dart b/lib/features/profile/view/screens/explore_profile_screen.dart index ef2baf7..5bc4f35 100644 --- a/lib/features/profile/view/screens/explore_profile_screen.dart +++ b/lib/features/profile/view/screens/explore_profile_screen.dart @@ -26,13 +26,13 @@ class _ExploreProfileScreenState extends ConsumerState { leading: Padding( padding: EdgeInsets.all(5).copyWith(left: 10), child: BuildSmallProfileImage( - userId: currentUser?.username ?? "", + username: currentUser?.username ?? "", radius: 20, ), ), title: GestureDetector( onTap: () { - context.push("/profileSearchScreen"); + context.push("/searchScreen",extra: {'showResults': false}); }, child: Row( children: [ diff --git a/lib/features/profile/view/widgets/edit_profile/edit_profile_header.dart b/lib/features/profile/view/widgets/edit_profile/edit_profile_header.dart index 5f27062..bead338 100644 --- a/lib/features/profile/view/widgets/edit_profile/edit_profile_header.dart +++ b/lib/features/profile/view/widgets/edit_profile/edit_profile_header.dart @@ -9,7 +9,6 @@ import 'package:lite_x/features/media/view_model/providers.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; import 'package:lite_x/features/profile/view/widgets/edit_profile/controller/edit_profile_controller.dart'; -import 'package:provider/provider.dart'; class EditProfileHeader extends ConsumerWidget { final EditProfileController controller; diff --git a/lib/features/profile/view/widgets/following_followers/follower_card.dart b/lib/features/profile/view/widgets/following_followers/follower_card.dart index 0537910..fcb2ca5 100644 --- a/lib/features/profile/view/widgets/following_followers/follower_card.dart +++ b/lib/features/profile/view/widgets/following_followers/follower_card.dart @@ -53,23 +53,27 @@ class _FollowerCardState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 24, - backgroundColor: Colors.grey[300], - backgroundImage: null, - child: widget.user.image.isEmpty - ? Icon(Icons.person, size: 24, color: Colors.grey) - : ClipOval( - child: Image.network( - widget.user.image, - fit: BoxFit.cover, - height: 48, - width: 48, - errorBuilder: (context, error, stackTrace) { - return SizedBox(); - }, - ), - ), + // CircleAvatar( + // radius: 24, + // backgroundColor: Colors.grey[300], + // backgroundImage: null, + // child: widget.user.image.isEmpty + // ? Icon(Icons.person, size: 24, color: Colors.grey) + // : ClipOval( + // child: Image.network( + // widget.user.image, + // fit: BoxFit.cover, + // height: 48, + // width: 48, + // errorBuilder: (context, error, stackTrace) { + // return SizedBox(); + // }, + // ), + // ), + // ), + BuildSmallProfileImage( + radius: 25, + mediaId: widget.user.image, ), const SizedBox(width: 12), diff --git a/lib/features/profile/view/widgets/profile/profile_header.dart b/lib/features/profile/view/widgets/profile/profile_header.dart index 8b40bda..ab0facd 100644 --- a/lib/features/profile/view/widgets/profile/profile_header.dart +++ b/lib/features/profile/view/widgets/profile/profile_header.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:lite_x/features/media/view_model/providers.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; import 'package:lite_x/features/profile/view/widgets/profile/block_button.dart'; @@ -12,7 +11,6 @@ import 'package:lite_x/features/profile/view_model/providers.dart'; import 'package:top_snackbar_flutter/custom_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:cached_network_image/cached_network_image.dart'; class ProfileHeader extends ConsumerStatefulWidget { const ProfileHeader({ diff --git a/lib/features/profile/view/widgets/profile_search/explore_profile_screen_body.dart b/lib/features/profile/view/widgets/profile_search/explore_profile_screen_body.dart index 835d65f..4d82bb1 100644 --- a/lib/features/profile/view/widgets/profile_search/explore_profile_screen_body.dart +++ b/lib/features/profile/view/widgets/profile_search/explore_profile_screen_body.dart @@ -1,5 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/current_user_provider.dart'; +import 'package:lite_x/features/profile/models/profile_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:lite_x/features/trends/view/widgets/category_profile_trend_tab.dart'; +import 'package:lite_x/features/trends/view/widgets/for_you_profile_tab.dart'; +import 'package:lite_x/features/trends/view/widgets/trending_profile_tab.dart'; class Exploreprofilescreenbody extends ConsumerStatefulWidget { const Exploreprofilescreenbody({super.key}); @@ -13,1077 +20,136 @@ class _ExploreprofilescreenbodyState extends ConsumerState { @override Widget build(BuildContext context) { - return DefaultTabController( - length: 5, - child: Scaffold( - appBar: TabBar( - indicatorColor: Color(0xFF1DA1F2), - indicatorSize: TabBarIndicatorSize.label, - isScrollable: true, - tabAlignment: TabAlignment.start, - dividerHeight: 0.25, - labelColor: Colors.white, - unselectedLabelStyle: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - labelStyle: TextStyle(fontWeight: FontWeight.bold), - unselectedLabelColor: Colors.grey, - tabs: [ - Tab(text: 'For You'), - Tab(text: 'Trending'), - Tab(text: 'News'), - Tab(text: 'Sports'), - Tab(text: 'Entertainment'), - ], - ), - body: TabBarView( - children: [ - _BuildForYouTab(), - _BuildTrendingTab(), - _BuildNewsTab(), - _BuildSportsTab(), - _BuildEntertainmentTab(), - ], - ), - ), - ); - } -} - -Widget _BuildForYouTab() { - return ForYouProfileTab(); -} - -Widget _BuildTrendingTab() { - return TrendingProfileTab(); -} - -Widget _BuildNewsTab() { - return NewsProfileTab(); -} - -Widget _BuildSportsTab() { - return SportsProfileTab(); -} - -Widget _BuildEntertainmentTab() { - return EntertainmentProfileTab(); -} - -class ForYouProfileTab extends StatelessWidget { - const ForYouProfileTab({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(0), - children: [ - // Today's News Header - const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "Today's News", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - - // News Card 1 - _buildNewsCard( - title: "Ronaldo's Stunning Bicycle Kick Powers Al Nassr to 4-1 Win", - timeAgo: "12 hours ago", - category: "Sports", - postsCount: "66K posts", - avatars: 2, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 2 - _buildNewsCard( - title: "Artistic Quranic Verses Shared in Gentle Daily Reminders", - timeAgo: "3 hours ago", - category: "Other", - postsCount: "2K posts", - avatars: 1, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 3 - _buildNewsCard( - title: "Eze's Historic Hat-Trick Powers Arsenal to 4-1 Derby Rout", - timeAgo: "Trending now", - category: "Sports", - postsCount: "3.8K posts", - avatars: 3, - isTrending: true, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending in Egypt - _buildTrendingSection( - title: "Trending in Egypt", - topic: "#صناعات", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Sports Trending - _buildTrendingSection( - title: "Sports · Trending", - topic: "بيرنلي", - postsCount: "9,953 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Business Trending - _buildTrendingSection( - title: "Trending in Business & finance", - topic: "Substack", - postsCount: "32.5K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Technology Trending - _buildTrendingSection( - title: "Trending in Technology", - topic: "OpenAI", - postsCount: "125K posts", - ), - - const SizedBox(height: 80), - ], - ); - } - - Widget _buildNewsCard({ - required String title, - required String timeAgo, - required String category, - required String postsCount, - required int avatars, - bool isTrending = false, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.3, - ), - ), - const SizedBox(height: 12), - - // Bottom row with avatars and info - Row( - children: [ - // Avatar Stack - SizedBox( - width: avatars * 20.0 + 10, - height: 24, - child: Stack( - children: List.generate( - avatars, - (index) => Positioned( - left: index * 20.0, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.grey[800], - border: Border.all(color: Colors.black, width: 1.5), - ), - child: Center( - child: Icon( - Icons.person, - size: 14, - color: Colors.grey[600], - ), - ), - ), - ), - ), - ), - ), - const SizedBox(width: 12), - - // Time and category info - Expanded( - child: Text( - isTrending - ? "$timeAgo · $category · $postsCount" - : "$timeAgo · $category · $postsCount", - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildTrendingSection({ - required String title, - required String topic, - String? postsCount, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final currUser = ref.watch(currentUserProvider); + final asyncPm = ref.watch(profileDataProvider(currUser?.username ?? "")); + return asyncPm.when( + data: (res) { + return res.fold( + (l) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), children: [ - Text( - title, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - const SizedBox(height: 4), - Text( - topic, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, + Center(child: Text(l.message)), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh( + profileDataProvider(currUser?.username ?? ""), + ); + }, + icon: Icon(Icons.refresh), ), ), - if (postsCount != null) ...[ - const SizedBox(height: 4), - Text( - postsCount, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], ], - ), - ), - Icon(Icons.more_horiz, color: Colors.grey[600], size: 20), - ], - ), - ); - } -} - -class TrendingProfileTab extends StatelessWidget { - const TrendingProfileTab({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(0), - children: [ - // Trending Item 1 - _buildTrendingItem( - position: 1, - category: "Trending in Egypt", - topic: "#انزل_شارك_صوتك_امانه", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 2 - _buildTrendingItem( - position: 2, - category: "Trending in Egypt", - topic: "#صوتك_مهم", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 3 - _buildTrendingItem( - position: 3, - category: "Trending in Egypt", - topic: "#صوتك_لحماه_الوطن", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 4 - _buildTrendingItem( - position: 4, - category: "Trending in Egypt", - topic: "#افضح_المرتزقه", - postsCount: "3,587 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 5 - _buildTrendingItem( - position: 5, - category: "Trending in Egypt", - topic: "#في_حضوك_ياريس_انزل_وشارك", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 6 - _buildTrendingItem( - position: 6, - category: "Trending in Egypt", - topic: "اليوم الاتنين", - postsCount: "5,905 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 7 - _buildTrendingItem( - position: 7, - category: "Sports · Trending", - topic: "الونسو", - postsCount: "8,966 posts", - trendingWith: "تشابي", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 8 - _buildTrendingItem( - position: 8, - category: "Trending in Egypt", - topic: "رضا عبد العال", - postsCount: null, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 9 - _buildTrendingItem( - position: 9, - category: "Trending in Egypt", - topic: "الاهلي والزمالك", - postsCount: "12.3K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Item 10 - _buildTrendingItem( - position: 10, - category: "Sports · Trending", - topic: "محمد صلاح", - postsCount: "45.2K posts", - trendingWith: "ليفربول", - ), - - const SizedBox(height: 80), - ], - ); - } - - Widget _buildTrendingItem({ - required int position, - required String category, - required String topic, - String? postsCount, - String? trendingWith, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Position number - SizedBox( - width: 30, - child: Text( - "$position · ", - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ), - ), - - // Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category - Text( - category, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - const SizedBox(height: 2), - - // Topic - Text( - topic, - style: const TextStyle( - fontSize: 15, + ); + }, + (pm) { + return DefaultTabController( + length: 6, + child: Scaffold( + appBar: TabBar( + indicatorColor: Color(0xFF1DA1F2), + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + tabAlignment: TabAlignment.start, + dividerHeight: 0.25, + labelColor: Colors.white, + unselectedLabelStyle: TextStyle( fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - - // Posts count - if (postsCount != null) ...[ - const SizedBox(height: 2), - Text( - postsCount, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], - - // Trending with - if (trendingWith != null) ...[ - const SizedBox(height: 4), - Text( - "Trending with $trendingWith", - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], - ], - ), - ), - - // More options icon - Icon(Icons.more_horiz, color: Colors.grey[600], size: 20), - ], - ), - ); - } -} - -class NewsProfileTab extends StatelessWidget { - const NewsProfileTab({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(0), - children: [ - // News Item 1 - _buildNewsItem( - category: "Trending in Politics", - topic: "Ivanka Trump", - postsCount: "2,421 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 2 - _buildNewsItem( - category: "Trending in Business & finance", - topic: "Substack", - postsCount: "31.1K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 3 - _buildNewsItem( - category: "Trending in Politics", - topic: "Taiwan to China", - postsCount: "11.9K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 4 - _buildNewsItem( - category: "Trending in Technology", - topic: "Grok 4.1 Fast", - postsCount: "2,217 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 5 - _buildNewsItem( - category: "Trending in Business & finance", - topic: "Grayscale", - postsCount: "15.7K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 6 - _buildNewsItem( - category: "Trending in Business & finance", - topic: "Dogecoin ETF", - postsCount: "2,773 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 7 - _buildNewsItem( - category: "Trending in Business & finance", - topic: "DOGE ETF", - postsCount: "2,898 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 8 - _buildNewsItem( - category: "Trending in Politics", - topic: "Senate Republicans", - postsCount: "8,542 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 9 - _buildNewsItem( - category: "Trending in Technology", - topic: "ChatGPT Plus", - postsCount: "4,156 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Item 10 - _buildNewsItem( - category: "Trending in Business & finance", - topic: "Federal Reserve", - postsCount: "18.3K posts", - ), - - const SizedBox(height: 80), - ], - ); - } - - Widget _buildNewsItem({ - required String category, - required String topic, - required String postsCount, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category - Text( - category, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - const SizedBox(height: 4), - - // Topic - Text( - topic, - style: const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - - // Posts count - Text( - postsCount, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], - ), - ), - - // More options icon - Icon(Icons.more_horiz, color: Colors.grey[600], size: 20), - ], - ), - ); - } -} - -class SportsProfileTab extends StatelessWidget { - const SportsProfileTab({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(0), - children: [ - // News Card 1 - _buildNewsCard( - title: "Ronaldo's Stunning Bicycle Kick Seals Al Nassr Win at 40", - timeAgo: "21 hours ago", - category: "Sports", - postsCount: "303K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 2 - _buildNewsCard( - title: "Ronaldo's Stunning Bicycle Kick Powers Al Nassr to 4-1 Win", - timeAgo: "13 hours ago", - category: "Sports", - postsCount: "66K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 3 - _buildNewsCard( - title: - "Messi's Masterclass Sends Inter Miami to Eastern Conference Final", - timeAgo: "17 hours ago", - category: "Sports", - postsCount: "97K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 4 - _buildNewsCard( - title: "Eze's Historic Hat-Trick Powers Arsenal to 4-1 Derby Rout", - timeAgo: "Trending now", - category: "Sports", - postsCount: "4.7K posts", - avatars: 3, - isTrending: true, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 5 - _buildNewsCard( - title: "Real Madrid Draw at Elche Fuels Dressing Room Tension Rumors", - timeAgo: "4 hours ago", - category: "Other", - postsCount: "5.5K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 1 - _buildTrendingSection( - title: "Trending in Sports", - topic: "#IndianCricket", - postsCount: "5,789 posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 2 - _buildTrendingSection( - title: "Trending in Sports", - topic: "#Champions League", - postsCount: "12.4K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 3 - _buildTrendingSection( - title: "Trending in Sports", - topic: "Lakers", - postsCount: "8.2K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 4 - _buildTrendingSection( - title: "Trending in Sports", - topic: "NBA Playoffs", - postsCount: "15.6K posts", - ), - - const SizedBox(height: 80), - ], - ); - } - - Widget _buildNewsCard({ - required String title, - required String timeAgo, - required String category, - required String postsCount, - required int avatars, - bool isTrending = false, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.3, - ), - ), - const SizedBox(height: 12), - - // Bottom row with avatars and info - Row( - children: [ - // Avatar Stack - SizedBox( - width: avatars * 20.0 + 10, - height: 24, - child: Stack( - children: List.generate( - avatars, - (index) => Positioned( - left: index * 20.0, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getAvatarColor(index), - border: Border.all(color: Colors.black, width: 1.5), - ), - child: Center( - child: Icon( - Icons.person, - size: 14, - color: Colors.grey[300], - ), - ), - ), - ), ), + labelStyle: TextStyle(fontWeight: FontWeight.bold), + unselectedLabelColor: Colors.grey, + tabs: [ + Tab(text: 'For You'), + Tab(text: 'Trending'), + Tab(text: 'Global'), + Tab(text: 'News'), + Tab(text: 'Sports'), + Tab(text: 'Entertainment'), + ], + ), + body: TabBarView( + children: [ + _BuildForYouTab(pm), + _BuildTrendingTab(pm), + _BuildGlobalTab(pm), + _BuildNewsTab(pm), + _BuildSportsTab(pm), + _BuildEntertainmentTab(pm), + ], ), ), - const SizedBox(width: 12), - - // Time and category info - Expanded( - child: Text( - "$timeAgo · $category · $postsCount", - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - overflow: TextOverflow.ellipsis, - ), + ); + }, + ); + }, + error: (err, _) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center(child: Text("cannot get trends at this time...")), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(profileDataProvider(currUser?.username ?? "")); + }, + icon: Icon(Icons.refresh), ), - ], - ), - ], - ), - ); - } - - Widget _buildTrendingSection({ - required String title, - required String topic, - required String postsCount, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - const SizedBox(height: 4), - Text( - topic, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - postsCount, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], ), + ], + ); + }, + loading: () { + return Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Center(child: CircularProgressIndicator()), ), - Icon(Icons.more_horiz, color: Colors.grey[600], size: 20), - ], - ), + ); + }, ); } - - Color _getAvatarColor(int index) { - final colors = [ - Colors.blue[800]!, - Colors.red[800]!, - Colors.yellow[700]!, - Colors.green[800]!, - ]; - return colors[index % colors.length]; - } } -class EntertainmentProfileTab extends StatelessWidget { - const EntertainmentProfileTab({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(0), - children: [ - // News Card 1 - _buildNewsCard( - title: "New Season of Stranger Things Breaks Netflix Records", - timeAgo: "5 hours ago", - category: "Entertainment", - postsCount: "156K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 2 - _buildNewsCard( - title: "Taylor Swift Announces Surprise Album Drop at Midnight", - timeAgo: "2 hours ago", - category: "Music", - postsCount: "412K posts", - avatars: 3, - isTrending: true, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 3 - _buildNewsCard( - title: "Marvel Studios Reveals Phase 6 Movie Lineup", - timeAgo: "8 hours ago", - category: "Movies", - postsCount: "89K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 4 - _buildNewsCard( - title: "Grammy Awards 2025: Complete Winners List", - timeAgo: "Trending now", - category: "Music", - postsCount: "287K posts", - avatars: 3, - isTrending: true, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // News Card 5 - _buildNewsCard( - title: "HBO's New Drama Series Gets Renewed for Season 2", - timeAgo: "12 hours ago", - category: "TV Shows", - postsCount: "34K posts", - avatars: 3, - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 1 - _buildTrendingSection( - title: "Trending in Entertainment", - topic: "#Oscars2025", - postsCount: "178K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F336)), - - // Trending Section 2 - _buildTrendingSection( - title: "Trending in Music", - topic: "Beyoncé", - postsCount: "94.3K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 3 - _buildTrendingSection( - title: "Trending in Entertainment", - topic: "#TheLastOfUs", - postsCount: "67.8K posts", - ), - - const Divider(height: 1, color: Color(0xFF2F3336)), - - // Trending Section 4 - _buildTrendingSection( - title: "Trending in Movies", - topic: "Dune Part 3", - postsCount: "45.2K posts", - ), - - const SizedBox(height: 80), - ], - ); - } +Widget _BuildForYouTab(ProfileModel pm) { + return ForYouProfileTab(pm: pm); +} - Widget _buildNewsCard({ - required String title, - required String timeAgo, - required String category, - required String postsCount, - required int avatars, - bool isTrending = false, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - height: 1.3, - ), - ), - const SizedBox(height: 12), +Widget _BuildTrendingTab(ProfileModel pm) { + return TrendingProfileTab(); +} - // Bottom row with avatars and info - Row( - children: [ - // Avatar Stack - SizedBox( - width: avatars * 20.0 + 10, - height: 24, - child: Stack( - children: List.generate( - avatars, - (index) => Positioned( - left: index * 20.0, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _getAvatarColor(index), - border: Border.all(color: Colors.black, width: 1.5), - ), - child: Center( - child: Icon( - Icons.person, - size: 14, - color: Colors.grey[300], - ), - ), - ), - ), - ), - ), - ), - const SizedBox(width: 12), +Widget _BuildGlobalTab(ProfileModel pm) { + return CategoryProfileTrendTab( + pm: pm, + categoryName: TrendsCategoriesTabs.Global, + ); +} - // Time and category info - Expanded( - child: Text( - "$timeAgo · $category · $postsCount", - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ); - } +Widget _BuildNewsTab(ProfileModel pm) { + return CategoryProfileTrendTab( + pm: pm, + categoryName: TrendsCategoriesTabs.News, + ); +} - Widget _buildTrendingSection({ - required String title, - required String topic, - required String postsCount, - }) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - const SizedBox(height: 4), - Text( - topic, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - postsCount, - style: TextStyle(fontSize: 13, color: Colors.grey[600]), - ), - ], - ), - ), - Icon(Icons.more_horiz, color: Colors.grey[600], size: 20), - ], - ), - ); - } +Widget _BuildSportsTab(ProfileModel pm) { + return CategoryProfileTrendTab( + pm: pm, + categoryName: TrendsCategoriesTabs.Sports, + ); +} - Color _getAvatarColor(int index) { - final colors = [ - Colors.purple[800]!, - Colors.pink[800]!, - Colors.orange[700]!, - Colors.teal[800]!, - ]; - return colors[index % colors.length]; - } +Widget _BuildEntertainmentTab(ProfileModel pm) { + return CategoryProfileTrendTab( + pm: pm, + categoryName: TrendsCategoriesTabs.Entertainment, + ); } diff --git a/lib/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart b/lib/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart index 9cdd47f..075af71 100644 --- a/lib/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart +++ b/lib/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart @@ -1,15 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/features/media/view_model/providers.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:readmore/readmore.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/shared_tweet_components.dart'; class ProfileNormalTweetWidget extends ConsumerWidget implements ProfileTweet { const ProfileNormalTweetWidget({ @@ -20,652 +14,10 @@ class ProfileNormalTweetWidget extends ConsumerWidget implements ProfileTweet { final ProfileTweetModel profilePostModel; @override Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.all(8.0).copyWith(right: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - - children: [ - Padding( - padding: EdgeInsets.only(left: 8, right: 10, top: 2), - child: Column( - children: [ - GestureDetector( - onTap: () { - final currentUser = ref.watch(currentUserProvider); - final currentUserName = currentUser?.username ?? ""; - if (currentUserName == this.profileModel.username) { - context.pushReplacement( - "/profilescreen/${this.profileModel.username}", - ); - return; - } - context.push( - "/profilescreen/${this.profileModel.username}", - ); - }, - child: BuildSmallProfileImage( - mediaId: profilePostModel.profileMediaId, - radius: 20, - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - constraints: BoxConstraints(maxWidth: 120), - child: Text( - this.profilePostModel.userDisplayName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - "@${this.profilePostModel.userUserName}", - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - const SizedBox(width: 5), - Text( - "· ${profilePostModel.timeAgo}", - style: TextStyle(color: Colors.grey, fontSize: 16), - textAlign: TextAlign.start, - ), - ], - ), - ), - const SizedBox(width: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - "assets/svg/grok.svg", - width: 20, - height: 20, - colorFilter: const ColorFilter.mode( - Colors.grey, - BlendMode.srcIn, - ), - ), - SizedBox(width: 8), - GestureDetector( - onTap: () { - _openProfileTweetOptions( - context, - ref, - this.profilePostModel, - ); - }, - child: Icon( - Icons.more_vert, - color: Colors.grey, - size: 20, - ), - ), - ], - ), - ], - ), - InkWell( - onTap: () { - context.push( - "/tweetDetailsScreen/${this.profilePostModel.id}", - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - profilePostModel.text.isEmpty - ? const SizedBox(height: 15) - : Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ReadMoreText( - profilePostModel.text, - trimLines: 3, - colorClickableText: Colors.grey, - trimMode: TrimMode.Line, - trimCollapsedText: 'Show more', - trimExpandedText: ' show less', - style: TextStyle(fontSize: 16), - ), - ), - if (profilePostModel.mediaIds.isNotEmpty) - Container( - width: 350, - // height: 350, - constraints: BoxConstraints(maxHeight: 400), - - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - ), - // child: buildPhotoSection(profilePostModel.mediaIds), - child: TweetMediaGrid( - mediaIds: profilePostModel.mediaIds, - ), - clipBehavior: Clip.hardEdge, - ), - ], - ), - ), - InterActionsRowOfTweet(tweet: this.profilePostModel), - ], - ), - ), - ], - ), - ); - } -} - -// Widget buildPhotoSection(List photos) { -// // if (photos.isEmpty) return const SizedBox.shrink(); - -// // if (photos.length == 1) { -// // return Image.network(photos[0], fit: BoxFit.cover); -// // } - -// // if (photos.length == 2) { -// // return Row( -// // // crossAxisAlignment: CrossAxisAlignment.end, -// // children: [ -// // Expanded(child: Image.network(photos[0], fit: BoxFit.fill)), -// // Expanded(child: Image.network(photos[1], fit: BoxFit.fill)), -// // ], -// // ); -// // } else if (photos.length == 3) { -// // return Row( -// // crossAxisAlignment: CrossAxisAlignment.stretch, -// // children: [ -// // Expanded(child: Image.network(photos[0], fit: BoxFit.cover)), -// // const SizedBox(width: 4), -// // Expanded( -// // child: Column( -// // crossAxisAlignment: CrossAxisAlignment.stretch, -// // children: [ -// // Expanded(child: Image.network(photos[1], fit: BoxFit.cover)), -// // const SizedBox(height: 4), -// // Expanded(child: Image.network(photos[2], fit: BoxFit.cover)), -// // ], -// // ), -// // ), -// // ], -// // ); -// // } else if (photos.length == 4) { -// // return Row( -// // crossAxisAlignment: CrossAxisAlignment.stretch, -// // children: [ -// // Expanded( -// // child: Column( -// // crossAxisAlignment: CrossAxisAlignment.stretch, -// // children: [ -// // Expanded(child: Image.network(photos[0], fit: BoxFit.cover)), -// // const SizedBox(height: 4), -// // Expanded(child: Image.network(photos[3], fit: BoxFit.cover)), -// // ], -// // ), -// // ), -// // const SizedBox(width: 4), -// // Expanded( -// // child: Column( -// // crossAxisAlignment: CrossAxisAlignment.stretch, -// // children: [ -// // Expanded(child: Image.network(photos[1], fit: BoxFit.cover)), -// // const SizedBox(height: 4), -// // Expanded(child: Image.network(photos[2], fit: BoxFit.cover)), -// // ], -// // ), -// // ), -// // ], -// // ); -// // } - -// return const SizedBox.shrink(); -// } - -void _openProfileTweetOptions( - BuildContext context, - WidgetRef ref, - ProfileTweetModel tweet, -) async { - final currentUser = ref.watch(currentUserProvider); - final currneusername = currentUser?.username ?? ""; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.black, - - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ProfileTweetOptin( - text: "Pin to profile", - icon: Icons.push_pin_outlined, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - // TODO: make pin logic - context.pop(); - }, - ), - if (currneusername == tweet.userUserName) - ProfileTweetOptin( - text: "Delete post", - icon: Icons.delete, - onPress: () async { - final delete = await ref.watch(deleteTweetProvider); - final res = await delete(tweet.id); - res.fold( - (l) { - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - }, - (r) { - showSmallPopUpMessage( - context: context, - message: "Tweet deleted successfully", - borderColor: Colors.blue, - icon: Icon(Icons.check, color: Colors.blue), - ); - if (currentUser != null) - // ignore: unused_result - ref.refresh(profilePostsProvider(currentUser.username)); - }, - ); - // TODO: make delete post logic - context.pop(); - }, - ), - if (currneusername == tweet.userUserName) - ProfileTweetOptin( - text: "Change who can reply", - icon: Icons.mode_comment_outlined, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - - // TODO: make who can reply logic logic - context.pop(); - }, - ), - ProfileTweetOptin( - text: "Request Community Note", - icon: Icons.public, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - - // TODO: make who can reply logic logic - context.pop(); - }, - ), - ], - ); - }, - ); -} - -class ProfileTweetOptin extends StatelessWidget { - const ProfileTweetOptin({ - super.key, - required this.text, - required this.icon, - required this.onPress, - }); - final String text; - final IconData icon; - final Function onPress; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: 10, left: 10), - child: ListTile( - title: Text(text, style: TextStyle(fontSize: 18)), - leading: Icon(icon, color: Colors.grey, size: 25), - onTap: () { - onPress(); - }, - ), - ); - } -} - -class TweetMediaGrid extends ConsumerWidget { - const TweetMediaGrid({super.key, required this.mediaIds}); - final List mediaIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return _buildMediaGrid(mediaIds, ref); - } - - Widget _buildMediaSkeleton() { - return Container( - width: 350, - constraints: const BoxConstraints(maxHeight: 400), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[300], - ), - child: const Center(child: CircularProgressIndicator()), + return BasicTweetWidget( + profileModel: profileModel, + profilePostModel: profilePostModel, + withActions: true, ); } - - Widget _buildErrorWidget() { - return Container( - width: 350, - constraints: const BoxConstraints(maxHeight: 400), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[300], - ), - child: const Center(child: Icon(Icons.error_outline, color: Colors.grey)), - ); - } - - Widget _errorContainer(double height) { - return Container( - color: Colors.grey, - height: height, - child: Center(child: Text("can't load image")), - ); - } - - Widget _loadingContainer(double height, {double? value}) { - return Container( - color: Colors.grey, - height: 300, - child: Center( - child: CircularProgressIndicator(color: Colors.white, value: value), - ), - ); - } - - Widget _buildMediaGrid(List photos, WidgetRef ref) { - if (photos.isEmpty) return const SizedBox.shrink(); - - if (photos.length == 1) { - final mediaUrl = ref.watch(mediaUrlProvider(photos[0])); - return mediaUrl.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _errorContainer(300), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(300, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(300); - }, - loading: () { - return _loadingContainer(300); - }, - ); - } - - if (photos.length == 2) { - final mediaUrl0 = ref.watch(mediaUrlProvider(photos[0])); - final mediaUrl1 = ref.watch(mediaUrlProvider(photos[1])); - return Row( - children: [ - Expanded( - child: mediaUrl0.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _errorContainer(150), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(150, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(150); - }, - loading: () { - return _loadingContainer(150); - }, - ), - ), - Expanded( - child: mediaUrl1.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _errorContainer(150), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(150, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(150); - }, - loading: () { - return _loadingContainer(150); - }, - ), - ), - ], - ); - } else if (photos.length == 3) { - final mediaUrl0 = ref.watch(mediaUrlProvider(photos[0])); - final mediaUrl1 = ref.watch(mediaUrlProvider(photos[1])); - final mediaUrl2 = ref.watch(mediaUrlProvider(photos[2])); - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: mediaUrl0.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _errorContainer(300), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(300, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(300); - }, - loading: () { - return _loadingContainer(300); - }, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: mediaUrl1.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - const SizedBox(height: 4), - Expanded( - child: mediaUrl2.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - ], - ), - ), - ], - ); - } else if (photos.length == 4) { - final mediaUrl0 = ref.watch(mediaUrlProvider(photos[0])); - final mediaUrl1 = ref.watch(mediaUrlProvider(photos[1])); - final mediaUrl2 = ref.watch(mediaUrlProvider(photos[2])); - final mediaUrl3 = ref.watch(mediaUrlProvider(photos[3])); - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: mediaUrl0.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - const SizedBox(height: 4), - Expanded( - child: mediaUrl3.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - ], - ), - ), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: mediaUrl1.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - const SizedBox(height: 4), - Expanded( - child: mediaUrl2.when( - data: (url) { - return CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (context, url, error) => - _errorContainer(148), - progressIndicatorBuilder: (context, url, progress) => - _loadingContainer(148, value: progress.progress), - ); - }, - error: (err, _) { - return _errorContainer(148); - }, - loading: () { - return _loadingContainer(148); - }, - ), - ), - ], - ), - ), - ], - ); - } - - return const SizedBox.shrink(); - } } diff --git a/lib/features/profile/view/widgets/profile_tweets/profile_normar_tweet_quot.dart b/lib/features/profile/view/widgets/profile_tweets/profile_normar_tweet_quot.dart index bd57266..0fde829 100644 --- a/lib/features/profile/view/widgets/profile_tweets/profile_normar_tweet_quot.dart +++ b/lib/features/profile/view/widgets/profile_tweets/profile_normar_tweet_quot.dart @@ -1,14 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lite_x/core/providers/current_user_provider.dart'; import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:readmore/readmore.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/shared_tweet_components.dart'; class ProfileNormarTweetQuot extends ConsumerWidget implements ProfileTweet { const ProfileNormarTweetQuot({ @@ -19,817 +14,10 @@ class ProfileNormarTweetQuot extends ConsumerWidget implements ProfileTweet { final ProfileTweetModel profilePostModel; @override Widget build(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.all(8.0).copyWith(left: 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - - children: [ - Padding( - padding: EdgeInsets.only(left: 8, right: 10, top: 2), - child: Column( - children: [ - GestureDetector( - onTap: () { - final currentUser = ref.watch(currentUserProvider); - final currentUserName = currentUser?.username ?? ""; - if (currentUserName == this.profileModel.username) { - context.pushReplacement( - "/profilescreen/${this.profileModel.username}", - ); - return; - } - context.push( - "/profilescreen/${this.profileModel.username}", - ); - }, - child: BuildSmallProfileImage( - mediaId: profilePostModel.profileMediaId, - radius: 20, - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - constraints: BoxConstraints(maxWidth: 120), - child: Text( - this.profilePostModel.userDisplayName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - "@${profilePostModel.userUserName}", - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), - const SizedBox(width: 5), - Text( - "· ${profilePostModel.timeAgo}", - style: TextStyle(color: Colors.grey, fontSize: 16), - textAlign: TextAlign.start, - ), - ], - ), - ), - const SizedBox(width: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - "assets/svg/grok.svg", - width: 20, - height: 20, - colorFilter: const ColorFilter.mode( - Colors.grey, - BlendMode.srcIn, - ), - ), - SizedBox(width: 8), - GestureDetector( - onTap: () { - _openProfileTweetOptions( - context, - ref, - this.profilePostModel, - ); - }, - child: Icon( - Icons.more_vert, - color: Colors.grey, - size: 20, - ), - ), - ], - ), - ], - ), - InkWell( - onTap: () { - context.push( - "/tweetDetailsScreen/${this.profilePostModel.id}", - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - profilePostModel.text.isEmpty - ? const SizedBox(height: 15) - : Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ReadMoreText( - profilePostModel.text, - trimLines: 3, - colorClickableText: Colors.grey, - trimMode: TrimMode.Line, - trimCollapsedText: 'Show more', - trimExpandedText: ' show less', - style: TextStyle(fontSize: 16), - ), - ), - if (profilePostModel.mediaIds.isNotEmpty) - Container( - width: 350, - // height: 350, - constraints: BoxConstraints(maxHeight: 400), - - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - ), - // child: buildPhotoSection(profilePostModel.mediaIds), - child: TweetMediaGrid( - mediaIds: profilePostModel.mediaIds, - ), - clipBehavior: Clip.hardEdge, - ), - ], - ), - ), - - // InterActionsRowOfTweet(tweet: this.profilePostModel), - ], - ), - ), - ], - ), - ); - } -} - -Widget buildPhotoSection(List photos) { - // if (photos.isEmpty) return const SizedBox.shrink(); - - // if (photos.length == 1) { - // return Image.network(photos[0], fit: BoxFit.cover); - // } - - // if (photos.length == 2) { - // return Row( - // // crossAxisAlignment: CrossAxisAlignment.end, - // children: [ - // Expanded(child: Image.network(photos[0], fit: BoxFit.fill)), - // Expanded(child: Image.network(photos[1], fit: BoxFit.fill)), - // ], - // ); - // } else if (photos.length == 3) { - // return Row( - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Expanded(child: Image.network(photos[0], fit: BoxFit.cover)), - // const SizedBox(width: 4), - // Expanded( - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Expanded(child: Image.network(photos[1], fit: BoxFit.cover)), - // const SizedBox(height: 4), - // Expanded(child: Image.network(photos[2], fit: BoxFit.cover)), - // ], - // ), - // ), - // ], - // ); - // } else if (photos.length == 4) { - // return Row( - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Expanded( - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Expanded(child: Image.network(photos[0], fit: BoxFit.cover)), - // const SizedBox(height: 4), - // Expanded(child: Image.network(photos[3], fit: BoxFit.cover)), - // ], - // ), - // ), - // const SizedBox(width: 4), - // Expanded( - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Expanded(child: Image.network(photos[1], fit: BoxFit.cover)), - // const SizedBox(height: 4), - // Expanded(child: Image.network(photos[2], fit: BoxFit.cover)), - // ], - // ), - // ), - // ], - // ); - // } - - return const SizedBox.shrink(); -} - -class InterActionsRowOfTweet extends ConsumerStatefulWidget { - const InterActionsRowOfTweet({super.key, required this.tweet}); - final ProfileTweetModel tweet; - - @override - ConsumerState createState() => - _InterActionsRowOfTweetState(); -} - -class _InterActionsRowOfTweetState - extends ConsumerState { - late bool isLikedByMeLocal; - late int likesCount; - late bool isSavedByMeLocal; - @override - void initState() { - isLikedByMeLocal = widget.tweet.isLikedByMe; - likesCount = widget.tweet.likes; - isSavedByMeLocal = widget.tweet.isSavedByMe; - super.initState(); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - onTap: () { - // TODO: open replying page - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SvgPicture.asset( - "assets/svg/reply.svg", - width: 20, - height: 20, - colorFilter: const ColorFilter.mode( - Colors.grey, - BlendMode.srcIn, - ), - ), - if (widget.tweet.replies > 0) - Text( - Shared.formatCount(widget.tweet.replies), - style: TextStyle(color: Colors.grey, fontSize: 15), - ), - ], - ), - ), - GestureDetector( - onTap: () { - // TODO: do repost action - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - "assets/svg/repost.svg", - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - widget.tweet.isRepostedWithMe - ? Color(0XFF00B87B) - : Colors.grey, - BlendMode.srcIn, - ), - ), - if (widget.tweet.retweets > 0) - Text( - Shared.formatCount(widget.tweet.retweets), - style: TextStyle( - color: widget.tweet.isRepostedWithMe - ? Color(0XFF00B87B) - : Colors.grey, - fontSize: 15, - ), - ), - ], - ), - ), - GestureDetector( - onTap: () { - // TODO: do like action - if (isLikedByMeLocal) { - final unlike = ref.watch(unlikeTweetProvider); - unlike(widget.tweet.id).then((res) { - res.fold((l) { - isLikedByMeLocal = true; - likesCount += 1; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, (r) {}); - }); - } else { - final like = ref.watch(likeTweetProvider); - like(widget.tweet.id).then((res) { - res.fold((l) { - isLikedByMeLocal = false; - likesCount -= 1; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, (r) {}); - }); - } - if (mounted) - setState(() { - if (isLikedByMeLocal) - likesCount -= 1; - else - likesCount += 1; - isLikedByMeLocal = !isLikedByMeLocal; - }); - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - isLikedByMeLocal - ? "assets/svg/like_filled.svg" - : "assets/svg/like.svg", - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - isLikedByMeLocal ? Color(0XFFF6187E) : Colors.grey, - BlendMode.srcIn, - ), - ), - if (likesCount > 0) - Text( - Shared.formatCount(likesCount), - style: TextStyle( - color: isLikedByMeLocal ? Color(0XFFF6187E) : Colors.grey, - fontSize: 15, - ), - ), - ], - ), - ), - GestureDetector( - onTap: () { - // TODO: do activity action - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - "assets/svg/activity.svg", - width: 20, - height: 20, - colorFilter: ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ), - if (widget.tweet.activityNumber > 0) - Text( - Shared.formatCount(widget.tweet.activityNumber), - style: TextStyle(color: Colors.grey, fontSize: 15), - ), - ], - ), - ), - Row( - children: [ - GestureDetector( - onTap: () { - // TODO: do like action - if (isSavedByMeLocal) { - final unSave = ref.watch(unSaveTweetProvider); - unSave(widget.tweet.id).then((res) { - res.fold( - (l) { - isSavedByMeLocal = true; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, - (r) { - showSmallPopUpMessage( - context: context, - message: "Post removed from your Bookmarks", - borderColor: Colors.blue, - icon: Icon( - Icons.bookmark_remove, - color: Colors.blue, - ), - ); - }, - ); - }); - } else { - final save = ref.watch(saveTweetProvider); - save(widget.tweet.id).then((res) { - res.fold( - (l) { - isSavedByMeLocal = false; - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - if (mounted) setState(() {}); - }, - (r) { - showSmallPopUpMessage( - context: context, - message: "Post added to your Bookmarks", - borderColor: Colors.blue, - icon: Icon(Icons.bookmark_add, color: Colors.blue), - ); - }, - ); - }); - } - if (mounted) - setState(() { - isSavedByMeLocal = !isSavedByMeLocal; - }); - }, - child: SvgPicture.asset( - isSavedByMeLocal - ? "assets/svg/save_filled.svg" - : "assets/svg/save.svg", - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - isSavedByMeLocal ? Colors.blue : Colors.grey, - BlendMode.srcIn, - ), - ), - ), - SizedBox(width: 8), - GestureDetector( - onTap: () { - // TODO: do share action - }, - child: Icon(Icons.share_outlined, color: Colors.grey, size: 20), - ), - ], - ), - ], - ), - ); - } -} - -void _openProfileTweetOptions( - BuildContext context, - WidgetRef ref, - ProfileTweetModel tweet, -) async { - final currentUser = ref.watch(currentUserProvider); - final currneusername = currentUser?.username ?? ""; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.black, - - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ProfileTweetOptin( - text: "Pin to profile", - icon: Icons.push_pin_outlined, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - // TODO: make pin logic - context.pop(); - }, - ), - if (currneusername == tweet.userUserName) - ProfileTweetOptin( - text: "Delete post", - icon: Icons.delete, - onPress: () async { - final delete = await ref.watch(deleteTweetProvider); - final res = await delete(tweet.id); - res.fold( - (l) { - showSmallPopUpMessage( - context: context, - message: l.message, - borderColor: Colors.red, - icon: Icon(Icons.error, color: Colors.red), - ); - }, - (r) { - showSmallPopUpMessage( - context: context, - message: "Tweet deleted successfully", - borderColor: Colors.blue, - icon: Icon(Icons.check, color: Colors.blue), - ); - final currentUser = ref.watch(currentUserProvider); - if (currentUser != null) - // ignore: unused_result - ref.refresh(profilePostsProvider(currentUser.id)); - }, - ); - // TODO: make delete post logic - context.pop(); - }, - ), - if (currneusername == tweet.userUserName) - ProfileTweetOptin( - text: "Change who can reply", - icon: Icons.mode_comment_outlined, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - - // TODO: make who can reply logic logic - context.pop(); - }, - ), - ProfileTweetOptin( - text: "Request Community Note", - icon: Icons.public, - onPress: () async { - await Future.delayed(Duration(milliseconds: 100)); - - // TODO: make who can reply logic logic - context.pop(); - }, - ), - ], - ); - }, - ); -} - -class ProfileTweetOptin extends StatelessWidget { - const ProfileTweetOptin({ - super.key, - required this.text, - required this.icon, - required this.onPress, - }); - final String text; - final IconData icon; - final Function onPress; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: 10, left: 10), - child: ListTile( - title: Text(text, style: TextStyle(fontSize: 18)), - leading: Icon(icon, color: Colors.grey, size: 25), - onTap: () { - onPress(); - }, - ), - ); - } -} - -class TweetMediaGrid extends ConsumerWidget { - const TweetMediaGrid({super.key, required this.mediaIds}); - final List mediaIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mediaUrlsAsync = ref.watch(mediaUrlsProvider(mediaIds)); - - return mediaUrlsAsync.when( - loading: () => _buildMediaSkeleton(), - error: (error, stack) => _buildErrorWidget(), - data: (mediaUrls) => _buildMediaGrid(mediaUrls), + return BasicTweetWidget( + profileModel: profileModel, + profilePostModel: profilePostModel, + withActions: false, ); } - - Widget _buildMediaSkeleton() { - return Container( - width: 350, - constraints: const BoxConstraints(maxHeight: 400), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[300], - ), - child: const Center(child: CircularProgressIndicator()), - ); - } - - Widget _buildErrorWidget() { - return Container( - width: 350, - constraints: const BoxConstraints(maxHeight: 400), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[300], - ), - child: const Center(child: Icon(Icons.error_outline, color: Colors.grey)), - ); - } - - Widget _buildMediaGrid(List photos) { - if (photos.isEmpty) return const SizedBox.shrink(); - - if (photos.length == 1) { - return CachedNetworkImage( - imageUrl: photos[0] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 300, - child: Center(child: Text("can't load image")), - ), - ); - } - - if (photos.length == 2) { - return Row( - children: [ - Expanded( - child: CachedNetworkImage( - imageUrl: photos[0] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 150, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(right: 3), - ), - ), - ), - Expanded( - child: CachedNetworkImage( - imageUrl: photos[1] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 150, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - ], - ); - } else if (photos.length == 3) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: CachedNetworkImage( - imageUrl: photos[0] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 300, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: CachedNetworkImage( - imageUrl: photos[1] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - const SizedBox(height: 4), - Expanded( - child: CachedNetworkImage( - imageUrl: photos[2] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - ], - ), - ), - ], - ); - } else if (photos.length == 4) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: CachedNetworkImage( - imageUrl: photos[0] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - const SizedBox(height: 4), - Expanded( - child: CachedNetworkImage( - imageUrl: photos[3] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - ], - ), - ), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: CachedNetworkImage( - imageUrl: photos[1] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - const SizedBox(height: 4), - Expanded( - child: CachedNetworkImage( - imageUrl: photos[2] + "", - fit: BoxFit.cover, - errorWidget: (context, url, error) => Container( - color: Colors.grey, - height: 148, - child: Center(child: Text("can't load image")), - margin: EdgeInsets.only(left: 3), - ), - ), - ), - ], - ), - ), - ], - ); - } - - return const SizedBox.shrink(); - } } diff --git a/lib/features/profile/view/widgets/profile_tweets/profile_posts_list.dart b/lib/features/profile/view/widgets/profile_tweets/profile_posts_list.dart index 6840345..4750a4c 100644 --- a/lib/features/profile/view/widgets/profile_tweets/profile_posts_list.dart +++ b/lib/features/profile/view/widgets/profile_tweets/profile_posts_list.dart @@ -30,7 +30,7 @@ class ProfilePostsList extends ConsumerWidget { return asyncPosts.when( data: (either) { return either.fold( - (l) { + (l) { return RefreshIndicator( child: ListView( children: [ diff --git a/lib/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart b/lib/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart index c6b58c1..de1e050 100644 --- a/lib/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart +++ b/lib/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart @@ -4,7 +4,7 @@ import 'package:lite_x/features/profile/models/profile_model.dart'; import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; import 'package:lite_x/features/profile/models/shared.dart'; import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_normar_tweet_quot.dart' - hide InterActionsRowOfTweet; + hide InterActionsRowOfTweet, Padding; import 'package:lite_x/features/profile/view_model/providers.dart'; class ProfileQuoteWidget extends ConsumerWidget implements ProfileTweet { diff --git a/lib/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart b/lib/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart index dd2955b..0974117 100644 --- a/lib/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart +++ b/lib/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart @@ -32,7 +32,7 @@ class ProfileRetweetWidget extends StatelessWidget implements ProfileTweet { ), SizedBox(width: 5), Text( - "You reposted", + "${this.tweetModel.retweeterName} reposted", style: TextStyle( color: Colors.grey, fontSize: 14, diff --git a/lib/features/profile/view/widgets/profile_tweets/shared_tweet_components.dart b/lib/features/profile/view/widgets/profile_tweets/shared_tweet_components.dart new file mode 100644 index 0000000..e398ef1 --- /dev/null +++ b/lib/features/profile/view/widgets/profile_tweets/shared_tweet_components.dart @@ -0,0 +1,810 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lite_x/core/providers/current_user_provider.dart'; +import 'package:lite_x/features/media/view_model/providers.dart'; +import 'package:lite_x/features/profile/models/profile_model.dart'; +import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:video_player/video_player.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +class BasicTweetWidget extends ConsumerWidget implements ProfileTweet { + const BasicTweetWidget({ + required this.profileModel, + required this.profilePostModel, + required this.withActions, + }); + final ProfileModel profileModel; + final ProfileTweetModel profilePostModel; + final bool withActions; + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.all(8.0).copyWith(right: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + Padding( + padding: EdgeInsets.only(left: 8, right: 10, top: 2), + child: Column( + children: [ + GestureDetector( + onTap: () { + context.push( + "/profilescreen/${this.profilePostModel.userUserName}", + ); + }, + child: BuildSmallProfileImage( + mediaId: profilePostModel.profileMediaId, + radius: 20, + ), + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: BoxConstraints(maxWidth: 120), + child: Text( + this.profilePostModel.userDisplayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + "@${this.profilePostModel.userUserName}", + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + const SizedBox(width: 5), + Text( + "· ${profilePostModel.timeAgo}", + style: TextStyle(color: Colors.grey, fontSize: 16), + textAlign: TextAlign.start, + ), + ], + ), + ), + const SizedBox(width: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + "assets/svg/grok.svg", + width: 20, + height: 20, + colorFilter: const ColorFilter.mode( + Colors.grey, + BlendMode.srcIn, + ), + ), + SizedBox(width: 8), + GestureDetector( + onTap: () { + openProfileTweetOptions( + context, + ref, + this.profilePostModel, + ); + }, + child: Icon( + Icons.more_vert, + color: Colors.grey, + size: 20, + ), + ), + ], + ), + ], + ), + InkWell( + onTap: () { + context.push( + "/tweetDetailsScreen/${this.profilePostModel.id}", + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: double.infinity), + profilePostModel.text.isEmpty + ? const SizedBox(height: 15) + : Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ExpandableLinkedText( + text: profilePostModel.text, + ), + ), + if (profilePostModel.mediaIds.isNotEmpty) + Container( + width: 350, + constraints: BoxConstraints(maxHeight: 400), + + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + ), + child: TweetMediaGrid( + mediaIds: profilePostModel.mediaIds, + ), + clipBehavior: Clip.hardEdge, + ), + ], + ), + ), + if (this.withActions) + InterActionsRowOfTweet(tweet: this.profilePostModel), + ], + ), + ), + ], + ), + ); + } +} + +class VideoPlayerWidget extends StatefulWidget { + const VideoPlayerWidget({ + super.key, + required this.videoUrl, + required this.height, + this.isPlaying = true, + this.onPlay, + }); + + final String videoUrl; + final double height; + final bool isPlaying; + final VoidCallback? onPlay; + + @override + State createState() => _VideoPlayerWidgetState(); +} + +class _VideoPlayerWidgetState extends State { + late VideoPlayerController _controller; + bool _isInitialized = false; + bool _hasError = false; + bool _isMuted = true; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + try { + _controller = VideoPlayerController.networkUrl( + Uri.parse(widget.videoUrl), + ); + await _controller.initialize(); + _controller.setLooping(true); + _controller.setVolume(0.0); // Start muted + _controller.play(); + + if (mounted) { + setState(() { + _isInitialized = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _hasError = true; + }); + } + } + } + + void _toggleMute() { + if (!mounted) return; + setState(() { + _isMuted = !_isMuted; + try { + _controller.setVolume(_isMuted ? 0.0 : 1.0); + } catch (e) { + debugPrint('Error toggling mute: $e'); + } + }); + } + + @override + void dispose() { + try { + _controller.dispose(); + } catch (e) { + debugPrint('Error disposing video controller: $e'); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_hasError) { + return Container( + color: Colors.grey[800], + height: widget.height, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image, color: Colors.grey, size: 32), + SizedBox(height: 8), + Text( + 'Couldn\'t load video', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + } + + if (!_isInitialized) { + return Container( + color: Colors.grey[900], + height: widget.height, + child: Center(child: CircularProgressIndicator(color: Colors.white)), + ); + } + + return VisibilityDetector( + key: Key('video_${widget.videoUrl}'), + onVisibilityChanged: (VisibilityInfo info) { + if (!mounted) return; + try { + // Only auto-play/pause if this is the current playing video + if (info.visibleFraction > 0.5) { + // More than 50% visible - notify parent and play + widget.onPlay?.call(); + if (!_controller.value.isPlaying) { + _controller.play(); + } + } else { + // Less than 50% visible - pause + if (_controller.value.isPlaying) { + _controller.pause(); + } + } + } catch (e) { + debugPrint('Error in visibility detection: $e'); + } + }, + child: Stack( + alignment: Alignment.center, + children: [ + // Inline video player + SizedBox( + height: widget.height, + child: AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ), + ), + // Mute toggle icon (bottom-left corner) + Positioned( + bottom: 8, + left: 8, + child: GestureDetector( + onTap: _toggleMute, + child: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + _isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + size: 15, + ), + ), + ), + ), + ], + ), + ); + } +} + +class ProfileTweetOptin extends StatelessWidget { + const ProfileTweetOptin({ + super.key, + required this.text, + required this.icon, + required this.onPress, + }); + final String text; + final IconData icon; + final Function onPress; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: 10, left: 10), + child: ListTile( + title: Text(text, style: TextStyle(fontSize: 18)), + leading: Icon(icon, color: Colors.grey, size: 25), + onTap: () { + onPress(); + }, + ), + ); + } +} + +class TweetMediaGrid extends ConsumerStatefulWidget { + const TweetMediaGrid({super.key, required this.mediaIds}); + final List mediaIds; + + @override + ConsumerState createState() => _TweetMediaGridState(); +} + +class _TweetMediaGridState extends ConsumerState { + String? _currentPlayingVideoUrl; + + void _onVideoPlay(String videoUrl) { + setState(() { + _currentPlayingVideoUrl = videoUrl; + }); + } + + @override + Widget build(BuildContext context) { + return _buildMediaGrid(widget.mediaIds, ref); + } + + Widget _buildMediaSkeleton() { + return Container( + width: 350, + constraints: const BoxConstraints(maxHeight: 400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.grey[300], + ), + child: const Center(child: CircularProgressIndicator()), + ); + } + + Widget _buildErrorWidget() { + return Container( + width: 350, + constraints: const BoxConstraints(maxHeight: 400), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.grey[300], + ), + child: const Center(child: Icon(Icons.error_outline, color: Colors.grey)), + ); + } + + Widget _errorContainer(double height) { + return Container( + height: height, + color: Colors.grey[800], + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image, color: Colors.grey, size: 32), + SizedBox(height: 8), + Text('Couldn\'t load image', style: TextStyle(color: Colors.grey)), + ], + ), + ), + ); + } + + Widget _loadingContainer(double height, {double? value}) { + return Container( + height: height, + color: Colors.grey[900], + child: Center(child: CircularProgressIndicator(value: value)), + ); + } + + bool _isVideo(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + + final path = uri.path.toLowerCase(); + final videoExtensions = [ + '.mp4', + '.mov', + '.avi', + '.webm', + '.mkv', + '.flv', + '.wmv', + '.mpeg', + '.mpg', + '.3gp', + '.m4v', + ]; + + print(uri.path + "\n//////////*******************"); + + return videoExtensions.any((ext) => path.endsWith(ext)); + } + + Widget _buildMediaItem(String mediaId, WidgetRef ref, double height) { + final mediaUrl = ref.watch(mediaUrlProvider(mediaId)); + + return mediaUrl.when( + data: (url) { + if (url.isEmpty) { + return _errorContainer(height); + } + if (_isVideo(url)) { + return VideoPlayerWidget( + videoUrl: url, + height: height, + isPlaying: _currentPlayingVideoUrl == url, + onPlay: () => _onVideoPlay(url), + ); + } + return CachedNetworkImage( + imageUrl: url, + height: height, + fit: BoxFit.cover, + placeholder: (context, url) => _loadingContainer(height), + errorWidget: (context, url, error) { + debugPrint('Image load error for $mediaId: $error'); + return _errorContainer(height); + }, + ); + }, + loading: () => _loadingContainer(height), + error: (error, stack) { + debugPrint('Media URL fetch error for $mediaId: $error'); + return _errorContainer(height); + }, + ); + } + + Widget _buildMediaGrid(List photos, WidgetRef ref) { + if (photos.isEmpty) return const SizedBox.shrink(); + + if (photos.length == 1) { + return _buildMediaItem(photos[0], ref, 300); + } + + if (photos.length == 2) { + return Row( + children: [ + Expanded(child: _buildMediaItem(photos[0], ref, 150)), + Expanded(child: _buildMediaItem(photos[1], ref, 150)), + ], + ); + } else if (photos.length == 3) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildMediaItem(photos[0], ref, 300)), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildMediaItem(photos[1], ref, 148)), + const SizedBox(height: 4), + Expanded(child: _buildMediaItem(photos[2], ref, 148)), + ], + ), + ), + ], + ); + } else if (photos.length == 4) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildMediaItem(photos[0], ref, 148)), + const SizedBox(height: 4), + Expanded(child: _buildMediaItem(photos[3], ref, 148)), + ], + ), + ), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildMediaItem(photos[1], ref, 148)), + const SizedBox(height: 4), + Expanded(child: _buildMediaItem(photos[2], ref, 148)), + ], + ), + ), + ], + ); + } + + return const SizedBox.shrink(); + } +} + +void openProfileTweetOptions( + BuildContext context, + WidgetRef ref, + ProfileTweetModel tweet, +) async { + final currentUser = ref.watch(currentUserProvider); + final currneusername = currentUser?.username ?? ""; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.black, + + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ProfileTweetOptin( + text: "Pin to profile", + icon: Icons.push_pin_outlined, + onPress: () async { + await Future.delayed(Duration(milliseconds: 100)); + context.pop(); + }, + ), + if (currneusername == tweet.userUserName) + ProfileTweetOptin( + text: "Delete post", + icon: Icons.delete, + onPress: () async { + final delete = await ref.watch(deleteTweetProvider); + final res = await delete(tweet.id); + res.fold( + (l) { + showSmallPopUpMessage( + context: context, + message: l.message, + borderColor: Colors.red, + icon: Icon(Icons.error, color: Colors.red), + ); + }, + (r) { + showSmallPopUpMessage( + context: context, + message: "Tweet deleted successfully", + borderColor: Colors.blue, + icon: Icon(Icons.check, color: Colors.blue), + ); + if (currentUser != null) + ref.refresh(profilePostsProvider(currentUser.username)); + }, + ); + context.pop(); + }, + ), + if (currneusername == tweet.userUserName) + ProfileTweetOptin( + text: "Change who can reply", + icon: Icons.mode_comment_outlined, + onPress: () async { + await Future.delayed(Duration(milliseconds: 100)); + context.pop(); + }, + ), + ProfileTweetOptin( + text: "Request Community Note", + icon: Icons.public, + onPress: () async { + await Future.delayed(Duration(milliseconds: 100)); + context.pop(); + }, + ), + ], + ); + }, + ); +} + +class ExpandableLinkedText extends StatefulWidget { + const ExpandableLinkedText({ + super.key, + required this.text, + this.trimLines = 3, + }); + + final String text; + final int trimLines; + + @override + State createState() => _ExpandableLinkedTextState(); +} + +class _ExpandableLinkedTextState extends State { + bool _expanded = false; + String? _trimmed; + bool _isTrimmed = false; + + final _showMoreRecognizer = TapGestureRecognizer(); + final _showLessRecognizer = TapGestureRecognizer(); + + @override + void dispose() { + _showMoreRecognizer.dispose(); + _showLessRecognizer.dispose(); + super.dispose(); + } + + TextSpan _buildSpans(String displayText) { + final regex = RegExp(r'(@[A-Za-z0-9_]+|#[A-Za-z0-9_]+)'); + final matches = regex.allMatches(displayText); + + final List spans = []; + int currentIndex = 0; + + for (final m in matches) { + if (m.start > currentIndex) { + spans.add( + TextSpan( + text: displayText.substring(currentIndex, m.start), + style: TextStyle(fontSize: 16, color: Colors.white), + ), + ); + } + + final token = m.group(0)!; + spans.add( + TextSpan( + text: token, + style: TextStyle(color: Colors.blue, fontSize: 16), + recognizer: TapGestureRecognizer() + ..onTap = () { + if (token.contains("@")) { + try { + context.push("/profilescreen/${token.substring(1)}"); + } catch (e) {} + } else if (token.contains("#")) { + // TODO: goto hashtag screen + } + }, + ), + ); + + currentIndex = m.end; + } + + if (currentIndex < displayText.length) { + spans.add( + TextSpan( + text: displayText.substring(currentIndex), + style: TextStyle(fontSize: 16, color: Colors.white), + ), + ); + } + + return TextSpan(children: spans); + } + + // Binary search to find the largest substring that fits within trimLines + String _computeTrimmed( + String fullText, + TextStyle style, + double maxWidth, + int maxLines, + ) { + final tp = TextPainter(textDirection: TextDirection.ltr); + + int low = 0; + int high = fullText.length; + String fitted = fullText; + + while (low <= high) { + final mid = ((low + high) / 2).floor(); + final candidate = fullText.substring(0, mid).trim(); + tp.text = TextSpan(text: candidate + '... Show more', style: style); + tp.maxLines = maxLines; + tp.layout(maxWidth: maxWidth); + if (tp.didExceedMaxLines) { + high = mid - 1; + } else { + fitted = candidate; + low = mid + 1; + } + } + + return fitted; + } + + @override + Widget build(BuildContext context) { + final baseStyle = TextStyle(fontSize: 16, color: Colors.white); + + return LayoutBuilder( + builder: (context, constraints) { + if (!_expanded && _trimmed == null) { + final trimmed = _computeTrimmed( + widget.text, + baseStyle, + constraints.maxWidth, + widget.trimLines, + ); + _trimmed = trimmed; + _isTrimmed = trimmed.length < widget.text.length; + } + + if (!_isTrimmed) { + return RichText(text: _buildSpans(widget.text)); + } + + if (_expanded) { + final full = _buildSpans(widget.text); + final spans = [ + full, + TextSpan(text: ' ', style: baseStyle), + TextSpan( + text: 'show less', + style: TextStyle(color: Colors.grey, fontSize: 16), + recognizer: _showLessRecognizer + ..onTap = () { + setState(() { + _expanded = false; + }); + }, + ), + ]; + return RichText(text: TextSpan(children: spans)); + } else { + final display = _trimmed ?? widget.text; + final mainSpan = _buildSpans(display); + final spans = [ + mainSpan, + TextSpan(text: '... ', style: baseStyle), + TextSpan( + text: 'Show more', + style: TextStyle(color: Colors.grey, fontSize: 16), + recognizer: _showMoreRecognizer + ..onTap = () { + setState(() { + _expanded = true; + }); + }, + ), + ]; + return RichText(text: TextSpan(children: spans)); + } + }, + ); + } +} diff --git a/lib/features/profile/view_model/providers.dart b/lib/features/profile/view_model/providers.dart index 13e78bb..5768a01 100644 --- a/lib/features/profile/view_model/providers.dart +++ b/lib/features/profile/view_model/providers.dart @@ -12,6 +12,9 @@ import 'package:lite_x/features/profile/models/tweet_reply_model.dart'; import 'package:lite_x/features/profile/models/user_model.dart'; import 'package:lite_x/features/profile/repositories/profile_repo.dart'; import 'package:lite_x/features/profile/repositories/profile_repo_impl.dart'; +import 'package:lite_x/features/trends/models/for_you_response_model.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; final profileRepoProvider = Provider((ref) { return ProfileRepoImpl(ref.watch(dioProvider)); @@ -309,3 +312,27 @@ final myUserNameProvider = Provider((ref) { ); return Myusername; }); + +// trends + +final forYouTrendsProvider = + FutureProvider>((ref) async { + final repo = ref.watch(profileRepoProvider); + return repo.getForYouTrends(); + }); + +final trendCategoryProvider = + FutureProvider.family, String>(( + ref, + catName, + ) async { + final repo = ref.watch(profileRepoProvider); + return repo.getTrenCategory(catName); + }); + +final profileTrendsProvider = FutureProvider>>( + (ref) async { + final repo = ref.watch(profileRepoProvider); + return repo.getAvailableTrends(); + }, +); diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart new file mode 100644 index 0000000..4debf1b --- /dev/null +++ b/lib/features/search/data/search_repository.dart @@ -0,0 +1,225 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/dio_interceptor.dart'; +import 'package:lite_x/features/home/models/tweet_model.dart'; +import 'package:lite_x/features/home/repositories/home_repository.dart'; + +enum SearchTab { TOP, LATEST, PEOPLE, MEDIA } + +class SearchSuggestionUser { + final String id; + final String name; + final String userName; + final String? bio; + final String? avatarUrl; + final int followers; + final bool verified; + final bool isFollowing; + final bool isFollower; + + const SearchSuggestionUser({ + required this.id, + required this.name, + required this.userName, + required this.bio, + required this.avatarUrl, + required this.followers, + required this.verified, + required this.isFollowing, + required this.isFollower, + }); + + factory SearchSuggestionUser.fromJson(Map json) { + final count = json['_count'] as Map?; + final profileMedia = json['profileMedia']; + String? avatar; + if (profileMedia is Map) { + avatar = profileMedia['id']?.toString(); + } else if (profileMedia is String) { + avatar = profileMedia; + } + + return SearchSuggestionUser( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + userName: json['username']?.toString() ?? '', + bio: json['bio']?.toString(), + avatarUrl: avatar, + followers: (count?['followers'] as num?)?.toInt() ?? 0, + verified: json['verified'] as bool? ?? false, + isFollowing: json['isFollowing'] as bool? ?? false, + isFollower: json['isFollower'] as bool? ?? false, + ); + } +} + +class TweetSearchPage { + final List tweets; + final String? nextCursor; + + const TweetSearchPage({ + required this.tweets, + required this.nextCursor, + }); +} + +class UserSearchPage { + final List users; + final String? nextCursor; + + const UserSearchPage({ + required this.users, + required this.nextCursor, + }); +} + +final searchRepositoryProvider = Provider((ref) { + return SearchRepository(ref); +}); + +class SearchRepository { + final Ref _ref; + + SearchRepository(this._ref); + + Dio get _dio => _ref.read(dioProvider); + HomeRepository get _homeRepo => _ref.read(homeRepositoryProvider); + + Future searchUsers(String query, + {String? cursor, int limit = 20}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + return const UserSearchPage(users: [], nextCursor: null); + } + + try { + final response = await _dio.get( + 'api/users/search', + queryParameters: { + 'query': trimmed, + if (cursor != null && cursor.isNotEmpty) 'cursor': cursor + }, + ); + + dynamic data = response.data; + String? nextCursor; + List rawUsers; + + if (data is List) { + rawUsers = data; + } else if (data is Map) { + nextCursor = data['nextCursor']?.toString(); + final usersField = data['users'] ?? data['data'] ?? data['items']; + if (usersField is List) { + rawUsers = usersField; + } else { + rawUsers = const []; + } + } else { + rawUsers = const []; + } + + final users = rawUsers + .whereType() + .map((m) => Map.from(m)) + .map(SearchSuggestionUser.fromJson) + .toList(); + + return UserSearchPage(users: users, nextCursor: nextCursor); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future searchTweets({ + required String query, + required SearchTab tab, + String peopleFilter = 'ANYONE', + String? cursor, + int limit = 20, + }) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + return const TweetSearchPage(tweets: [], nextCursor: null); + } + + try { + final response = await _dio.get( + 'api/tweets/search', + queryParameters: { + 'query': trimmed, + 'searchTab': _mapTabToBackend(tab), + 'peopleFilter': peopleFilter, + 'limit': limit, + if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, + }, + ); + + dynamic data = response.data; + String? nextCursor; + List rawTweets; + + if (data is List) { + rawTweets = data; + } else if (data is Map) { + nextCursor = data['nextCursor']?.toString(); + final tweetsField = + data['data'] ?? data['tweets'] ?? data['items'] ?? data['results']; + if (tweetsField is List) { + rawTweets = tweetsField; + } else { + rawTweets = const []; + } + } else { + rawTweets = const []; + } + + final tweets = rawTweets + .whereType() + .map((m) => Map.from(m)) + .map(TweetModel.fromJson) + .toList(); + + return TweetSearchPage(tweets: tweets, nextCursor: nextCursor); + } on DioException catch (e) { + throw _handleError(e); + } + } + + Future toggleLike(String tweetId, bool isCurrentlyLiked) { + return _homeRepo.toggleLike(tweetId, isCurrentlyLiked); + } + + String _mapTabToBackend(SearchTab tab) { + switch (tab) { + case SearchTab.TOP: + return 'TOP'; + case SearchTab.LATEST: + return 'LATEST'; + case SearchTab.MEDIA: + return 'MEDIA'; + case SearchTab.PEOPLE: + return 'TOP'; + } + } + + String _handleError(DioException error) { + if (error.response != null) { + final statusCode = error.response!.statusCode; + final data = error.response!.data; + + String message = 'Unknown error'; + if (data is Map && data['message'] != null) { + message = data['message'].toString(); + } else if (data is Map && data['error'] != null) { + message = data['error'].toString(); + } else if (data is String) { + message = data; + } + + return 'Error $statusCode: $message'; + } + + return error.message ?? 'Network error'; + } +} diff --git a/lib/features/search/models/local_search_data_source.dart b/lib/features/search/models/local_search_data_source.dart deleted file mode 100644 index e60b061..0000000 --- a/lib/features/search/models/local_search_data_source.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:hive_ce/hive.dart'; -import 'package:lite_x/features/search/models/search_history_hive_model.dart'; -import './search_result_model.dart'; - -class LocalSearchDataSource { - final Box _box; - - LocalSearchDataSource(this._box); - - Future> readHistory() async { - return _box.values - .toList() - .reversed - .map((e) => SearchResultModel( - id: e.id, - name: e.name, - username: e.username, - isVerified: e.isVerified, - avatarUrl: e.avatarUrl, - )) - .toList(); - } - - Future saveToHistory(SearchResultModel item) async { - await deleteFromHistory(item.id); - await _box.add(SearchHistoryHiveModel( - id: item.id, - name: item.name, - username: item.username, - isVerified: item.isVerified, - avatarUrl: item.avatarUrl, - )); - } - - Future deleteFromHistory(String id) async { - final keysToDelete = _box.keys.where((key) { - final entry = _box.get(key); - return entry?.id == id; - }).toList(); - - for (final key in keysToDelete) { - await _box.delete(key); - } - } - - Future clearHistory() async { - await _box.clear(); - } -} diff --git a/lib/features/search/models/remote_search_data_source.dart b/lib/features/search/models/remote_search_data_source.dart deleted file mode 100644 index 2d5b1dc..0000000 --- a/lib/features/search/models/remote_search_data_source.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:async'; -import 'search_result_model.dart'; - -/// A mock data source that simulates backend search read & write operations. -class RemoteSearchDataSource { - /// Local mock dataset representing users in the system. - final List _mockUsers = [ - SearchResultModel( - id: '1', - name: 'Elon Musk', - username: '@elonmusk', - isVerified: true, - avatarUrl: 'https://i.pravatar.cc/150?img=1', - ), - SearchResultModel( - id: '2', - name: 'Jane Doe', - username: '@janedoe', - isVerified: false, - avatarUrl: 'https://i.pravatar.cc/150?img=2', - ), - SearchResultModel( - id: '3', - name: 'TechCrunch', - username: '@TechCrunch', - isVerified: true, - avatarUrl: 'https://i.pravatar.cc/150?img=3', - ), - SearchResultModel( - id: '4', - name: 'John Appleseed', - username: '@johnapple', - isVerified: false, - avatarUrl: 'https://i.pravatar.cc/150?img=4', - ), - SearchResultModel( - id: '5', - name: 'Flutter Devs', - username: '@flutterdev', - isVerified: true, - avatarUrl: 'https://i.pravatar.cc/150?img=5', - ), - SearchResultModel( - id: '6', - name: 'Dart Lang', - username: '@dart_lang', - isVerified: true, - avatarUrl: 'https://i.pravatar.cc/150?img=6', - ), - ]; - - /// Mock writable storage (like backend persistence or local cache) - final List _savedItems = []; - - /// Simulates reading data from a remote backend. - Future> search(String query) async { - await Future.delayed(const Duration(milliseconds: 400)); - - if (query.trim().isEmpty) return []; - - final lowerQuery = query.toLowerCase(); - - return _mockUsers - .where((user) => - user.name.toLowerCase().contains(lowerQuery) || - user.username.toLowerCase().contains(lowerQuery)) - .toList(); - } - - /// Simulates writing/saving an item (e.g., adding to search history). - Future saveItem(SearchResultModel item) async { - await Future.delayed(const Duration(milliseconds: 200)); - - // avoid duplicates - final exists = _savedItems.any((u) => u.id == item.id); - if (!exists) { - _savedItems.add(item); - } - } - - /// Simulates reading saved items (like from cache or database) - Future> readSavedItems() async { - await Future.delayed(const Duration(milliseconds: 200)); - return List.unmodifiable(_savedItems); - } - - /// Simulates deleting a saved item - Future deleteItem(String id) async { - await Future.delayed(const Duration(milliseconds: 200)); - _savedItems.removeWhere((u) => u.id == id); - } - - /// Clears all saved items (like clearing search history) - Future clearSaved() async { - await Future.delayed(const Duration(milliseconds: 200)); - _savedItems.clear(); - } -} diff --git a/lib/features/search/models/search_history_hive_model.dart b/lib/features/search/models/search_history_hive_model.dart index 71e1213..659bb34 100644 --- a/lib/features/search/models/search_history_hive_model.dart +++ b/lib/features/search/models/search_history_hive_model.dart @@ -1,97 +1,17 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert'; import 'package:hive_ce/hive.dart'; part 'search_history_hive_model.g.dart'; -@HiveType(typeId: 4) +@HiveType(typeId: 3) class SearchHistoryHiveModel extends HiveObject { @HiveField(0) - final String id; + String query; @HiveField(1) - final String name; - - @HiveField(2) - final String username; - - @HiveField(3) - final bool isVerified; - - @HiveField(4) - final String avatarUrl; + DateTime searchedAt; SearchHistoryHiveModel({ - required this.id, - required this.name, - required this.username, - required this.isVerified, - required this.avatarUrl, + required this.query, + required this.searchedAt, }); - - SearchHistoryHiveModel copyWith({ - String? id, - String? name, - String? username, - bool? isVerified, - String? avatarUrl, - }) { - return SearchHistoryHiveModel( - id: id ?? this.id, - name: name ?? this.name, - username: username ?? this.username, - isVerified: isVerified ?? this.isVerified, - avatarUrl: avatarUrl ?? this.avatarUrl, - ); - } - - Map toMap() { - return { - 'id': id, - 'name': name, - 'username': username, - 'isVerified': isVerified, - 'avatarUrl': avatarUrl, - }; - } - - factory SearchHistoryHiveModel.fromMap(Map map) { - return SearchHistoryHiveModel( - id: map['id'] ?? "", - name: map['name'] ?? "", - username: map['username'] ?? "", - isVerified: map['isVerified'] ?? false, - avatarUrl: map['avatarUrl'] ?? "", - ); - } - - String toJson() => json.encode(toMap()); - - factory SearchHistoryHiveModel.fromJson(String source) => - SearchHistoryHiveModel.fromMap(json.decode(source) as Map); - - @override - String toString() { - return 'SearchHistoryHiveModel(id: $id, name: $name, username: $username, isVerified: $isVerified, avatarUrl: $avatarUrl)'; - } - - @override - bool operator ==(covariant SearchHistoryHiveModel other) { - if (identical(this, other)) return true; - - return other.id == id && - other.name == name && - other.username == username && - other.isVerified == isVerified && - other.avatarUrl == avatarUrl; - } - - @override - int get hashCode { - return id.hashCode ^ - name.hashCode ^ - username.hashCode ^ - isVerified.hashCode ^ - avatarUrl.hashCode; - } -} \ No newline at end of file +} diff --git a/lib/features/search/models/search_history_hive_model.g.dart b/lib/features/search/models/search_history_hive_model.g.dart index e79e340..39beb7f 100644 --- a/lib/features/search/models/search_history_hive_model.g.dart +++ b/lib/features/search/models/search_history_hive_model.g.dart @@ -1,54 +1,33 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND +// Manually created adapter for SearchHistoryHiveModel +// This normally would be generated by build_runner. part of 'search_history_hive_model.dart'; -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class SearchHistoryHiveModelAdapter - extends TypeAdapter { +class SearchHistoryHiveModelAdapter extends TypeAdapter { @override - final typeId = 4; + final int typeId = 3; @override SearchHistoryHiveModel read(BinaryReader reader) { final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; + final fields = {}; + for (var i = 0; i < numOfFields; i++) { + final key = reader.readByte(); + fields[key] = reader.read(); + } return SearchHistoryHiveModel( - id: fields[0] as String, - name: fields[1] as String, - username: fields[2] as String, - isVerified: fields[3] as bool, - avatarUrl: fields[4] as String, + query: fields[0] as String? ?? '', + searchedAt: fields[1] as DateTime? ?? DateTime.now(), ); } @override void write(BinaryWriter writer, SearchHistoryHiveModel obj) { writer - ..writeByte(5) + ..writeByte(2) ..writeByte(0) - ..write(obj.id) + ..write(obj.query) ..writeByte(1) - ..write(obj.name) - ..writeByte(2) - ..write(obj.username) - ..writeByte(3) - ..write(obj.isVerified) - ..writeByte(4) - ..write(obj.avatarUrl); + ..write(obj.searchedAt); } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SearchHistoryHiveModelAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; } diff --git a/lib/features/search/models/search_result_model.dart b/lib/features/search/models/search_result_model.dart deleted file mode 100644 index 2c11356..0000000 --- a/lib/features/search/models/search_result_model.dart +++ /dev/null @@ -1,51 +0,0 @@ -class SearchResultModel { - final String id; - final String name; - final String username; - final bool isVerified; - final String avatarUrl; - - const SearchResultModel({ - required this.id, - required this.name, - required this.username, - required this.isVerified, - required this.avatarUrl, - }); - - factory SearchResultModel.fromJson(Map json) { - return SearchResultModel( - id: json['id'] ?? '', - name: json['name'] ?? '', - username: json['username'] ?? '', - isVerified: json['isVerified'] ?? false, - avatarUrl: json['avatarUrl'] ?? '', - ); - } - - Map toJson() { - return { - 'id': id, - 'name': name, - 'username': username, - 'isVerified': isVerified, - 'avatarUrl': avatarUrl, - }; - } - - SearchResultModel copyWith({ - String? id, - String? name, - String? username, - bool? isVerified, - String? avatarUrl, - }) { - return SearchResultModel( - id: id ?? this.id, - name: name ?? this.name, - username: username ?? this.username, - isVerified: isVerified ?? this.isVerified, - avatarUrl: avatarUrl ?? this.avatarUrl, - ); - } -} diff --git a/lib/features/search/providers.dart b/lib/features/search/providers.dart deleted file mode 100644 index 56bff5c..0000000 --- a/lib/features/search/providers.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'models/remote_search_data_source.dart'; -import 'models/local_search_data_source.dart'; -import 'repositories/remote_search_repository.dart'; -import 'repositories/local_search_repository.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:lite_x/features/search/models/search_history_hive_model.dart'; - -/// DataSource Providers -final remoteSearchDataSourceProvider = Provider((ref) { - return RemoteSearchDataSource(); -}); - -final localSearchDataSourceProvider = Provider((ref) { - final box = Hive.box('search_history'); - return LocalSearchDataSource(box); -}); - -/// Repository Providers -final remoteSearchRepositoryProvider = Provider((ref) { - final dataSource = ref.read(remoteSearchDataSourceProvider); - return RemoteSearchRepository(dataSource); -}); - -final localSearchRepositoryProvider = Provider((ref) { - final dataSource = ref.read(localSearchDataSourceProvider); - return LocalSearchRepository(dataSource); -}); diff --git a/lib/features/search/providers/search_providers.dart b/lib/features/search/providers/search_providers.dart new file mode 100644 index 0000000..bd07f86 --- /dev/null +++ b/lib/features/search/providers/search_providers.dart @@ -0,0 +1,206 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:lite_x/features/home/models/tweet_model.dart'; +import 'package:lite_x/features/search/data/search_repository.dart'; + +class SearchParams { + final String query; + final SearchTab tab; + + const SearchParams({required this.query, required this.tab}); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SearchParams && + other.query == query && + other.tab == tab; + } + + @override + int get hashCode => Object.hash(query, tab); +} + +class SearchResultsState { + final List tweets; + final bool isLoading; + final bool isLoadingMore; + final String? error; + final String? nextCursor; + + const SearchResultsState({ + required this.tweets, + required this.isLoading, + required this.isLoadingMore, + required this.error, + required this.nextCursor, + }); + + factory SearchResultsState.initial() { + return const SearchResultsState( + tweets: [], + isLoading: false, + isLoadingMore: false, + error: null, + nextCursor: null, + ); + } + + SearchResultsState copyWith({ + List? tweets, + bool? isLoading, + bool? isLoadingMore, + String? error, + String? nextCursor, + }) { + return SearchResultsState( + tweets: tweets ?? this.tweets, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + error: error, + nextCursor: nextCursor ?? this.nextCursor, + ); + } +} + +class SearchResultsNotifier extends StateNotifier { + final SearchRepository _repository; + final SearchParams _params; + + SearchResultsNotifier(this._repository, this._params) + : super(SearchResultsState.initial()) { + _loadInitial(); + } + + Future _loadInitial() async { + final query = _params.query.trim(); + if (query.isEmpty) { + state = SearchResultsState.initial(); + return; + } + + state = state.copyWith(isLoading: true, error: null, nextCursor: null); + + try { + final page = await _repository.searchTweets( + query: query, + tab: _params.tab, + ); + state = state.copyWith( + tweets: page.tweets, + isLoading: false, + nextCursor: page.nextCursor, + error: null, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + } + } + + Future refresh() async { + await _loadInitial(); + } + + Future loadNextPage() async { + if (state.isLoadingMore) return; + if (state.nextCursor == null || state.nextCursor!.isEmpty) return; + + state = state.copyWith(isLoadingMore: true); + + try { + final page = await _repository.searchTweets( + query: _params.query, + tab: _params.tab, + cursor: state.nextCursor, + ); + + final updatedTweets = List.from(state.tweets) + ..addAll(page.tweets); + + state = state.copyWith( + tweets: updatedTweets, + isLoadingMore: false, + nextCursor: page.nextCursor, + error: null, + ); + } catch (e) { + state = state.copyWith( + isLoadingMore: false, + error: e.toString(), + ); + } + } + + Future toggleLike(String tweetId) async { + final index = state.tweets.indexWhere((t) => t.id == tweetId); + if (index == -1) return; + + final current = state.tweets[index]; + final currentlyLiked = current.isLiked; + final updatedTweet = current.copyWith( + isLiked: !currentlyLiked, + likes: currentlyLiked ? current.likes - 1 : current.likes + 1, + ); + + final updatedTweets = List.from(state.tweets) + ..[index] = updatedTweet; + state = state.copyWith(tweets: updatedTweets); + + try { + final serverTweet = + await _repository.toggleLike(tweetId, currentlyLiked); + final serverIndex = + state.tweets.indexWhere((element) => element.id == serverTweet.id); + if (serverIndex != -1) { + final copy = List.from(state.tweets) + ..[serverIndex] = serverTweet; + state = state.copyWith(tweets: copy); + } + } catch (_) {} + } +} + +final searchResultsProvider = StateNotifierProvider.family< + SearchResultsNotifier, SearchResultsState, SearchParams>((ref, params) { + final repository = ref.watch(searchRepositoryProvider); + return SearchResultsNotifier(repository, params); +}); + +class SearchHistoryNotifier extends StateNotifier> { + SearchHistoryNotifier() : super(const []); + + void add(String query) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return; + + final lower = trimmed.toLowerCase(); + final filtered = state.where((q) => q.toLowerCase() != lower).toList(); + state = [trimmed, ...filtered].take(20).toList(); + } + + void remove(String query) { + final lower = query.toLowerCase(); + state = state.where((q) => q.toLowerCase() != lower).toList(); + } + + void clear() { + state = const []; + } +} + +final searchHistoryProvider = + StateNotifierProvider>((ref) { + return SearchHistoryNotifier(); +}); + +final suggestionsProvider = + FutureProvider.family, String>((ref, query) async { + final repository = ref.watch(searchRepositoryProvider); + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final page = await repository.searchUsers(trimmed, limit: 20); + return page.users; +}); diff --git a/lib/features/search/repositories/local_search_repository.dart b/lib/features/search/repositories/local_search_repository.dart deleted file mode 100644 index add7f6c..0000000 --- a/lib/features/search/repositories/local_search_repository.dart +++ /dev/null @@ -1,19 +0,0 @@ -import '../models/local_search_data_source.dart'; -import '../models/search_result_model.dart'; - -class LocalSearchRepository { - final LocalSearchDataSource _localDataSource; - - LocalSearchRepository(this._localDataSource); - - Future> readHistory() => - _localDataSource.readHistory(); - - Future saveToHistory(SearchResultModel item) => - _localDataSource.saveToHistory(item); - - Future deleteFromHistory(String id) => - _localDataSource.deleteFromHistory(id); - - Future clearHistory() => _localDataSource.clearHistory(); -} diff --git a/lib/features/search/repositories/remote_search_repository.dart b/lib/features/search/repositories/remote_search_repository.dart deleted file mode 100644 index f5b8857..0000000 --- a/lib/features/search/repositories/remote_search_repository.dart +++ /dev/null @@ -1,34 +0,0 @@ -import '../models/remote_search_data_source.dart'; -import '../models/search_result_model.dart'; - -/// Repository that exposes remote search operations -class RemoteSearchRepository { - final RemoteSearchDataSource _remoteDataSource; - - RemoteSearchRepository(this._remoteDataSource); - - /// Search users by query - Future> search(String query) { - return _remoteDataSource.search(query); - } - - /// Save an item to the remote "history" (simulated) - Future saveItem(SearchResultModel item) { - return _remoteDataSource.saveItem(item); - } - - /// Read saved items from remote (simulated backend cache) - Future> readSavedItems() { - return _remoteDataSource.readSavedItems(); - } - - /// Delete a saved item by ID - Future deleteItem(String id) { - return _remoteDataSource.deleteItem(id); - } - - /// Clear all saved items - Future clearSaved() { - return _remoteDataSource.clearSaved(); - } -} diff --git a/lib/features/search/view/search_results_screen.dart b/lib/features/search/view/search_results_screen.dart new file mode 100644 index 0000000..309f8d0 --- /dev/null +++ b/lib/features/search/view/search_results_screen.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lite_x/core/routes/Route_Constants.dart'; +import 'package:lite_x/features/search/data/search_repository.dart'; +import 'package:lite_x/features/search/providers/search_providers.dart'; +import 'package:lite_x/features/search/view/widgets/error_retry.dart'; +import 'package:lite_x/features/search/view/widgets/people_card.dart'; +import 'package:lite_x/features/search/view/widgets/search_bar.dart'; +import 'package:lite_x/features/search/view/widgets/tweet_card.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; + +class SearchResultsScreen extends ConsumerStatefulWidget { + final String initialQuery; + const SearchResultsScreen({super.key, required this.initialQuery}); + + @override + ConsumerState createState() => _SearchResultsScreenState(); +} + +class _SearchResultsScreenState extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _controller; + final _tabs = ['Top', 'Latest', 'People', 'Media']; + + @override + void initState() { + super.initState(); + _controller = TabController(length: _tabs.length, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + SearchTab _tabForIndex(int idx) { + switch (idx) { + case 1: + return SearchTab.LATEST; + case 2: + return SearchTab.PEOPLE; + case 3: + return SearchTab.MEDIA; + case 0: + default: + return SearchTab.TOP; + } + } + + @override + Widget build(BuildContext context) { + final query = widget.initialQuery; + return Scaffold( + appBar: AppSearchBar( + initialText: query, + onSubmitted: (q) { + // Only add to history on submit; tapping the field handles navigation. + ref.read(searchHistoryProvider.notifier).add(q); + }, + onTap: () { + context.pushNamed( + RouteConstants.SearchScreen, + extra: { + 'query': query, + 'showResults': false, + }, + ); + }, + trailingIcon: Icons.more_horiz, + bottom: TabBar( + controller: _controller, + labelColor: Colors.white, + unselectedLabelColor: Colors.grey, + labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + unselectedLabelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + indicator: const UnderlineTabIndicator( + borderSide: BorderSide(width: 4, color: Colors.blue), + insets: EdgeInsets.symmetric(horizontal: 50), + ), + tabs: _tabs.map((t) => Tab(text: t)).toList(), + ), + ), + + body: TabBarView(controller: _controller, + children: List.generate(_tabs.length, (index) { + final tab = _tabForIndex(index); + return _TabContent(query: query, tab: tab); + })), + ); + } +} + +class _TabContent extends ConsumerWidget { + final String query; + final SearchTab tab; + const _TabContent({required this.query, required this.tab}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (tab == SearchTab.PEOPLE) { + final usersAsync = ref.watch(suggestionsProvider(query)); + return usersAsync.when( + data: (users) { + if (users.isEmpty) { + return _buildEmptyResultsMessage(query); + } + return ListView.builder( + itemCount: users.length, + itemBuilder: (c, i) { + final user = users[i]; + return PeopleCard( + user: user, + onTap: () { + ref + .read(searchHistoryProvider.notifier) + .add('@${user.userName}'); + context.push('/profilescreen/${user.userName}'); + }, + onFollowTap: () async { + if (user.isFollowing) { + final unfollow = ref.read(unFollowControllerProvider); + await unfollow(user.userName); + } else { + final follow = ref.read(followControllerProvider); + await follow(user.userName); + } + // ignore: unused_result + ref.refresh(suggestionsProvider(query)); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => ErrorRetry( + message: 'Failed to load users — try reloading', + onRetry: () => ref.refresh(suggestionsProvider(query)), + ), + ); + } + + final notifier = ref.watch( + searchResultsProvider(SearchParams(query: query, tab: tab)), + ); + + if (notifier.isLoading && notifier.tweets.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (notifier.error != null && notifier.tweets.isEmpty) { + return ErrorRetry( + message: 'Something went wrong — try reloading', + onRetry: () => ref.read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier).refresh(), + ); + } + + final isTopTab = tab == SearchTab.TOP; + + return NotificationListener( + onNotification: (n) { + if (n.metrics.pixels >= n.metrics.maxScrollExtent - 200) { + ref.read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier).loadNextPage(); + } + return false; + }, + child: Builder( + builder: (context) { + if (!isTopTab) { + if (notifier.tweets.isEmpty) { + return _buildEmptyResultsMessage(query); + } + final int itemCount = + notifier.tweets.length + (notifier.isLoadingMore ? 1 : 0); + return ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + if (index >= notifier.tweets.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + final tw = notifier.tweets[index]; + return TweetCardWidget( + tweet: tw, + onLike: (id) => ref + .read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier) + .toggleLike(id), + ); + }, + ); + } + + final usersAsync = ref.watch(suggestionsProvider(query)); + + return usersAsync.when( + data: (users) { + final topUsers = users.length > 4 ? users.sublist(0, 4) : users; + + if (topUsers.isEmpty && notifier.tweets.isEmpty) { + return _buildEmptyResultsMessage(query); + } + + final int itemCount = topUsers.length + + notifier.tweets.length + + (notifier.isLoadingMore ? 1 : 0); + + return ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + if (index < topUsers.length) { + final user = topUsers[index]; + return PeopleCard( + user: user, + onTap: () { + ref + .read(searchHistoryProvider.notifier) + .add('@${user.userName}'); + context.push('/profilescreen/${user.userName}'); + }, + onFollowTap: () async { + if (user.isFollowing) { + final unfollow = ref.read(unFollowControllerProvider); + await unfollow(user.userName); + } else { + final follow = ref.read(followControllerProvider); + await follow(user.userName); + } + // ignore: unused_result + ref.refresh(suggestionsProvider(query)); + }, + ); + } + + final int tweetIndex = index - topUsers.length; + if (tweetIndex >= notifier.tweets.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + + final tw = notifier.tweets[tweetIndex]; + return TweetCardWidget( + tweet: tw, + onLike: (id) => ref + .read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier) + .toggleLike(id), + ); + }, + ); + }, + loading: () { + // While users load, just show tweets list + final int itemCount = + notifier.tweets.length + (notifier.isLoadingMore ? 1 : 0); + return ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + if (index >= notifier.tweets.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + final tw = notifier.tweets[index]; + return TweetCardWidget( + tweet: tw, + onLike: (id) => ref + .read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier) + .toggleLike(id), + ); + }, + ); + }, + error: (_, __) { + // On error fetching users, still show tweets list + final int itemCount = + notifier.tweets.length + (notifier.isLoadingMore ? 1 : 0); + return ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) { + if (index >= notifier.tweets.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + final tw = notifier.tweets[index]; + return TweetCardWidget( + tweet: tw, + onLike: (id) => ref + .read(searchResultsProvider(SearchParams(query: query, tab: tab)).notifier) + .toggleLike(id), + ); + }, + ); + }, + ); + }, + ), + ); + } + +Widget _buildEmptyResultsMessage(String query) { + return Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Align( + alignment: Alignment.topCenter, // box is horizontally centered + child: SizedBox( + width: 336, + // optional height; use mainAxisSize.min if you want auto height + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, // text centered inside box + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No results for "$query"', + style: const TextStyle( + fontSize: 31, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + const Text( + 'Try searching for something else, or check your search settings to see if they’re protecting you from potentially sensitive content.', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Colors.grey, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); +} + + + + + + + + + +} diff --git a/lib/features/search/view/search_screen.dart b/lib/features/search/view/search_screen.dart index d9d23b4..b2ec06e 100644 --- a/lib/features/search/view/search_screen.dart +++ b/lib/features/search/view/search_screen.dart @@ -1,37 +1,315 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../widgets/search_bar.dart' as sb; -import '../widgets/search_results_list.dart'; -import '../widgets/search_history_list.dart'; -import '../view_model/search_view_model.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lite_x/core/routes/Route_Constants.dart'; import 'package:lite_x/core/theme/palette.dart'; -class SearchScreen extends ConsumerWidget { - const SearchScreen({super.key}); +import 'package:lite_x/features/search/providers/search_providers.dart'; +import 'package:lite_x/features/search/view/search_results_screen.dart'; +import 'package:lite_x/features/search/view/widgets/search_bar.dart'; +import 'package:lite_x/features/search/view/widgets/people_card.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; + + +class SearchScreen extends ConsumerStatefulWidget { + final Map? extra; + + const SearchScreen({super.key, this.extra}); + + @override + ConsumerState createState() => _SearchScreenState(); +} + +class _SearchScreenState extends ConsumerState { + late final TextEditingController _controller; + late final FocusNode _focusNode; + Timer? _debounce; + String _query = ''; + bool _showResults = false; + + @override + void initState() { + super.initState(); + final extra = widget.extra; + _query = (extra?['query'] as String?) ?? ''; + _showResults = (extra?['showResults'] as bool?) ?? false; + _controller = TextEditingController(text: _query); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onQueryChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + setState(() { + _query = value; + }); + }); + } + + void _onSubmitted(String q) { + final trimmed = q.trim(); + if (trimmed.isEmpty) return; + ref.read(searchHistoryProvider.notifier).add(trimmed); + context.pushNamed( + RouteConstants.SearchScreen, + extra: { + 'query': trimmed, + 'showResults': true, + }, + ); + } @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(searchViewModelProvider); + Widget build(BuildContext context) { + if (_showResults && _query.trim().isNotEmpty) { + return SearchResultsScreen(initialQuery: _query); + } + + final history = ref.watch(searchHistoryProvider); + final trimmedQuery = _query.trim(); + final hasQuery = trimmedQuery.isNotEmpty; return Scaffold( - backgroundColor: Palette.background, + appBar: AppSearchBar( + initialText: _query, + onSubmitted: _onSubmitted, + onChanged: _onQueryChanged, + ), body: Column( - children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16,vertical: 8), - child: const sb.SearchBar(), - ), - const SizedBox(height: 14), - Expanded( - child: state.isLoading - ? const Center(child: CircularProgressIndicator()) - : state.results.isNotEmpty - ? SearchResultsList(results: state.results) - : state.history.isNotEmpty - ? SearchHistoryList(history: state.history) - : const Text('Try searching for people, lists, or keywords',style: TextStyle(color: Palette.textSecondary,fontSize: 16)), - ), - ], - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, color: Palette.divider), + if (!hasQuery) ...[ + if (history.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: Text( + 'Try searching for people, lists, or keywords', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ), + ) + else ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Recent searches', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () => + ref.read(searchHistoryProvider.notifier).clear(), + child: const Text('Clear all'), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + final isUser = item.startsWith('@') && item.length > 1; + + if (!isUser) { + // Keyword/text recent — keep existing simple style + return ListTile( + leading: + const Icon(Icons.history, color: Palette.icons), + title: Text(item), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchHistoryProvider.notifier) + .remove(item), + ), + onTap: () { + _onSubmitted(item); + }, + ); + } + + // User recent: show avatar, name, and @username like search results + final username = item.substring(1); + final usersAsync = + ref.watch(suggestionsProvider(username)); + + return usersAsync.when( + data: (users) { + if (users.isEmpty) { + // Fallback to simple tile if no user found + return ListTile( + leading: const Icon(Icons.history, + color: Palette.icons), + title: Text(item), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchHistoryProvider.notifier) + .remove(item), + ), + onTap: () { + _onSubmitted(item); + }, + ); + } + + // Prefer an exact username match if available + final lower = username.toLowerCase(); + final user = users.firstWhere( + (u) => u.userName.toLowerCase() == lower, + orElse: () => users.first, + ); + + return ListTile( + leading: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: user.avatarUrl, + radius: 20, + ), + ), + ), + title: Text( + user.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Palette.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + subtitle: Text( + '@${user.userName}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Palette.textSecondary, + fontSize: 13, + ), + ), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchHistoryProvider.notifier) + .remove(item), + ), + onTap: () { + context.push( + '/profilescreen/${user.userName}'); + }, + ); + }, + loading: () => ListTile( + leading: const Icon(Icons.history, color: Palette.icons), + title: Text(item), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchHistoryProvider.notifier) + .remove(item), + ), + onTap: () { + _onSubmitted(item); + }, + ), + error: (e, s) => ListTile( + leading: const Icon(Icons.history, color: Palette.icons), + title: Text(item), + trailing: IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => ref + .read(searchHistoryProvider.notifier) + .remove(item), + ), + onTap: () { + _onSubmitted(item); + }, + ), + ); + }, + ), + ), + ], + ] + else ...[ + Expanded( + child: ref.watch(suggestionsProvider(trimmedQuery)).when( + data: (users) { + if (users.isEmpty) { + return ListTile( + title: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, // removes extra padding + alignment: Alignment.centerLeft, // aligns like normal list item + ), + onPressed: () { + _onSubmitted(trimmedQuery); + }, + child: Text( + 'Search for "$trimmedQuery"', + style: const TextStyle( + fontSize: 16, + color: Colors.white, + ), + ), + ), + ); + } + + return ListView.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return PeopleCard( + user: user, + onTap: () { + ref + .read(searchHistoryProvider.notifier) + .add('@${user.userName}'); + context.push('/profilescreen/${user.userName}'); + }, + showFollowButton: false, + ); + }, + ); + }, + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (e, s) => const Center( + child: Text( + 'Something went wrong. Please try again.', + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + ], + ], + ), ); } } diff --git a/lib/features/search/view/widgets/error_retry.dart b/lib/features/search/view/widgets/error_retry.dart new file mode 100644 index 0000000..6feafa9 --- /dev/null +++ b/lib/features/search/view/widgets/error_retry.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ErrorRetry extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const ErrorRetry({super.key, required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + TextButton( + onPressed: onRetry, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/search/view/widgets/people_card.dart b/lib/features/search/view/widgets/people_card.dart new file mode 100644 index 0000000..075eb10 --- /dev/null +++ b/lib/features/search/view/widgets/people_card.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/search/data/search_repository.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; + +class PeopleCard extends StatelessWidget { + final SearchSuggestionUser user; + final VoidCallback? onTap; + final VoidCallback? onFollowTap; + final bool showFollowButton; + + const PeopleCard({ + super.key, + required this.user, + this.onTap, + this.onFollowTap, + this.showFollowButton = true, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: user.avatarUrl, + radius: 20, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + user.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Palette.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + if (user.verified) + const Padding( + padding: EdgeInsets.only(left: 4.0), + child: Icon( + Icons.verified, + color: Palette.verified, + size: 16, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '@${user.userName}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Palette.textSecondary, + fontSize: 14, + ), + ), + if (user.bio != null && user.bio!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + user.bio!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Palette.textTertiary, + fontSize: 13, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + showFollowButton + ? OutlinedButton( + onPressed: onFollowTap, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.black, + backgroundColor: Colors.white, + ), + child: Text(style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + + user.isFollowing + ? 'Following' + : user.isFollower + ? 'Follow back' + : 'Follow' + ), + ) + : const SizedBox.shrink(), + ], + ), + ), + ); + } +} diff --git a/lib/features/search/view/widgets/search_bar.dart b/lib/features/search/view/widgets/search_bar.dart new file mode 100644 index 0000000..d4dade8 --- /dev/null +++ b/lib/features/search/view/widgets/search_bar.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; + + +class AppSearchBar extends StatefulWidget implements PreferredSizeWidget { + final String initialText; + final ValueChanged onSubmitted; + final ValueChanged? onChanged; + final PreferredSizeWidget? bottom; + final VoidCallback? onTap; + final IconData trailingIcon; + + const AppSearchBar({ + super.key, + required this.initialText, + required this.onSubmitted, + this.onChanged, + this.bottom, + this.onTap, + this.trailingIcon = Icons.settings_outlined, + }); + + @override + Size get preferredSize { + final base = const Size.fromHeight(kToolbarHeight); + if (bottom == null) return base; + return Size(base.width, base.height + bottom!.preferredSize.height); + } + + @override + State createState() => _AppSearchBarState(); +} + +class _AppSearchBarState extends State { + late final TextEditingController _controller; + late final FocusNode _focusNode; + Timer? _submitCooldown; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _submitCooldown?.cancel(); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onChanged(String value) { + if (widget.onChanged != null) { + widget.onChanged!(value); + } + } + + @override + Widget build(BuildContext context) { + final isFocused = _focusNode.hasFocus; + + return AppBar( + titleSpacing: 0, + bottom: widget.bottom, + automaticallyImplyLeading: false, + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + ), + title: SizedBox( + height: 40, + child: Row( + children: [ + Expanded( + child: TextField( + focusNode: _focusNode, + controller: _controller, + onTap: widget.onTap, + onChanged: _onChanged, + onSubmitted: (s) { + if (_submitCooldown?.isActive ?? false) return; + widget.onSubmitted(s); + _submitCooldown = + Timer(const Duration(milliseconds: 800), () {}); + }, + textInputAction: TextInputAction.search, + style: const TextStyle( + color: Palette.textPrimary, // your primary text color + fontSize: 15, + ), + decoration: InputDecoration( + filled: true, + fillColor: Palette.inputBackground, // background color + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + prefixIcon: const Icon( + Icons.search, + size: 20, + color: Palette.textSecondary, + ), + hintText: 'Search', + hintStyle: const TextStyle( + color: Palette.textSecondary, + fontSize: 15, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide( + color: isFocused ? Colors.blue : Colors.transparent, + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ), + + const SizedBox(width: 16), + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: IconButton( + icon: Icon(widget.trailingIcon), + onPressed: () {}, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/search/view/widgets/tweet_card.dart b/lib/features/search/view/widgets/tweet_card.dart new file mode 100644 index 0000000..55c03f5 --- /dev/null +++ b/lib/features/search/view/widgets/tweet_card.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/home/models/tweet_model.dart'; + +class TweetCardWidget extends StatelessWidget { + final TweetModel tweet; + final ValueChanged? onLike; + + const TweetCardWidget({ + super.key, + required this.tweet, + this.onLike, + }); + + String _formatTimestamp(DateTime createdAt) { + final now = DateTime.now(); + final diff = now.difference(createdAt); + if (diff.inDays > 7) { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } else if (diff.inDays > 0) { + return '${diff.inDays}d'; + } else if (diff.inHours > 0) { + return '${diff.inHours}h'; + } else if (diff.inMinutes > 0) { + return '${diff.inMinutes}m'; + } + return 'now'; + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Palette.cardBackground, + backgroundImage: tweet.authorAvatar.isNotEmpty + ? NetworkImage(tweet.authorAvatar) + : null, + child: tweet.authorAvatar.isEmpty + ? const Icon(Icons.person, color: Palette.textPrimary, size: 20) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + tweet.authorName, + style: const TextStyle( + color: Palette.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + Text( + '@${tweet.authorUsername}', + style: const TextStyle( + color: Palette.textSecondary, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 8), + Text( + _formatTimestamp(tweet.createdAt), + style: const TextStyle( + color: Palette.textTertiary, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 4), + if (tweet.content.isNotEmpty) + Text( + tweet.content, + style: const TextStyle( + color: Palette.textPrimary, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + IconButton( + icon: Icon( + tweet.isLiked + ? Icons.favorite + : Icons.favorite_border, + color: + tweet.isLiked ? Palette.like : Palette.textTertiary, + size: 18, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => onLike?.call(tweet.id), + ), + const SizedBox(width: 4), + Text( + tweet.likes.toString(), + style: const TextStyle( + color: Palette.textTertiary, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/search/view_model/search_state.dart b/lib/features/search/view_model/search_state.dart deleted file mode 100644 index 5de4233..0000000 --- a/lib/features/search/view_model/search_state.dart +++ /dev/null @@ -1,52 +0,0 @@ -import '../models/search_result_model.dart'; - -class SearchState { - final bool isLoading; - final List results; - final List history; - final String? error; - - const SearchState({ - this.isLoading = false, - this.results = const [], - this.history = const [], - this.error, - }); - - /// Creates a new instance with updated fields - SearchState copyWith({ - bool? isLoading, - List? results, - List? history, - String? error, - }) { - return SearchState( - isLoading: isLoading ?? this.isLoading, - results: results ?? this.results, - history: history ?? this.history, - error: error, - ); - } - - /// Initial state factory - factory SearchState.initial() => const SearchState(); - - /// Loading state factory - SearchState loading() => copyWith(isLoading: true, error: null); - - /// Error state factory - SearchState failure(String message) => copyWith(isLoading: false, error: message); - - /// Success state factory - SearchState success({ - List? results, - List? history, - }) { - return copyWith( - isLoading: false, - results: results ?? this.results, - history: history ?? this.history, - error: null, - ); - } -} diff --git a/lib/features/search/view_model/search_view_model.dart b/lib/features/search/view_model/search_view_model.dart deleted file mode 100644 index 26d04cd..0000000 --- a/lib/features/search/view_model/search_view_model.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../models/search_result_model.dart'; -import '../repositories/remote_search_repository.dart'; -import '../repositories/local_search_repository.dart'; -import 'search_state.dart'; -import '../providers.dart'; - -part 'search_view_model.g.dart'; -@Riverpod(keepAlive: true) -class SearchViewModel extends _$SearchViewModel { - late final RemoteSearchRepository _remoteRepo; - late final LocalSearchRepository _localRepo; - - @override - SearchState build() { - // Read repositories from Riverpod - _remoteRepo = ref.read(remoteSearchRepositoryProvider); - _localRepo = ref.read(localSearchRepositoryProvider); - - _loadHistory(); - return SearchState.initial(); - } - - Future _loadHistory() async { - final history = await _localRepo.readHistory(); - state = state.copyWith(history: history); - } - - Future search(String query) async { - if (query.trim().isEmpty) { - state = state.copyWith(results: []); - return; - } - - state = state.copyWith(isLoading: true, error: null); - - try { - final results = await _remoteRepo.search(query); - state = state.copyWith(isLoading: false, results: results); - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); - } - } - - Future saveToHistory(SearchResultModel item) async { - await _localRepo.saveToHistory(item); - final history = await _localRepo.readHistory(); - state = state.copyWith(history: history); - } - - Future deleteFromHistory(String id) async { - await _localRepo.deleteFromHistory(id); - final history = await _localRepo.readHistory(); - state = state.copyWith(history: history); - } - - Future clearHistory() async { - await _localRepo.clearHistory(); - state = state.copyWith(history: []); - } -} diff --git a/lib/features/search/widgets/search_bar.dart b/lib/features/search/widgets/search_bar.dart deleted file mode 100644 index 3b86f2e..0000000 --- a/lib/features/search/widgets/search_bar.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../view_model/search_view_model.dart'; -import 'package:lite_x/core/theme/palette.dart'; - -class SearchBar extends ConsumerWidget { - const SearchBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Row( - children: [ - // Back arrow button - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - Navigator.pop(context); - }, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - - const SizedBox(width: 8), - - // Search input field - Expanded( - child: SizedBox( - height: 48, - child: TextField( - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - hintText: 'Search', - hintStyle: const TextStyle(color: Palette.textSecondary), - filled: true, // 👈 enables background color - fillColor: Palette.background, // 👈 sets the background color - // 🟢 Capsule shape with 1px border - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: const BorderSide( - color: Palette.textSecondary, - width: 0.5, // 👈 set border width here - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: const BorderSide( - color: Palette.textSecondary, - width: 0.5, // 👈 set width for enabled state - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: const BorderSide( - color: Palette.primary, - width: 2, // 👈 set width for focused state - ), - ), - - isDense: true, - contentPadding: - const EdgeInsets.symmetric(vertical: 0, horizontal: 12), - ), - onChanged: (value) { - ref.read(searchViewModelProvider.notifier).search(value); - }, - ), - ), - ), - - const SizedBox(width: 8), - - // Settings button - IconButton( - icon: const Icon(Icons.settings), - onPressed: () {}, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ], - ); - } -} diff --git a/lib/features/search/widgets/search_history_list.dart b/lib/features/search/widgets/search_history_list.dart deleted file mode 100644 index 57c09b3..0000000 --- a/lib/features/search/widgets/search_history_list.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/search_result_model.dart'; - -class SearchHistoryList extends StatelessWidget { - final List history; - const SearchHistoryList({super.key, required this.history}); - - @override - Widget build(BuildContext context) { - if (history.isEmpty) { - return const Center( - child: Text('Try searching for people, lists, or keywords'), - ); - } - - return ListView.separated( - padding: const EdgeInsets.all(8), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: true, - itemCount: history.length, - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (context, index) { - final user = history[index]; - return ListTile( - leading: CircleAvatar( - backgroundImage: NetworkImage(user.avatarUrl ?? ''), - ), - title: Row( - children: [ - Text(user.name), - if (user.isVerified) ...[ - const SizedBox(width: 4), - const Icon(Icons.check_circle, size: 16, color: Colors.blue), - ], - ], - ), - subtitle: Text(user.username), - trailing: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - // Call ViewModel to delete from history - }, - ), - onTap: () {}, - ); - }, - ); - } -} diff --git a/lib/features/search/widgets/search_results_list.dart b/lib/features/search/widgets/search_results_list.dart deleted file mode 100644 index 9d06c4c..0000000 --- a/lib/features/search/widgets/search_results_list.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/search_result_model.dart'; -import 'package:lite_x/core/theme/palette.dart'; - -class SearchResultsList extends StatelessWidget { - final List results; - - const SearchResultsList({ - super.key, - required this.results, - }); - - @override - Widget build(BuildContext context) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - cacheExtent: 500, - addAutomaticKeepAlives: false, - addRepaintBoundaries: true, - itemCount: results.length, - itemBuilder: (context, index) { - final user = results[index]; - - return GestureDetector( - onTap: () {}, - behavior: HitTestBehavior.opaque, // still responds to taps - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - CircleAvatar( - backgroundImage: NetworkImage(user.avatarUrl ?? ''), - radius: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - children: [ - Flexible( - child: Text( - user.name, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - color: Palette.textWhite, - fontWeight: FontWeight.bold, - ), - ), - ), - if (user.isVerified) ...[ - const SizedBox(width: 4), - const Icon(Icons.check_circle, - size: 16, color: Colors.blue), - ], - ], - ), - Text( - user.username, - style: const TextStyle(fontSize: 15, color: Colors.grey), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), -); - - }, - ); - } -} diff --git a/lib/features/shared/widgets/bottom_navigation.dart b/lib/features/shared/widgets/bottom_navigation.dart index 2a6bf22..db808cb 100644 --- a/lib/features/shared/widgets/bottom_navigation.dart +++ b/lib/features/shared/widgets/bottom_navigation.dart @@ -1,7 +1,9 @@ // lib/features/shared/widgets/bottom_navigation.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/core/providers/unseenChatsCountProvider.dart'; import 'package:lite_x/core/view/screen/app_shell.dart'; +import 'package:lite_x/features/chat/repositories/socket_repository.dart'; class XBottomNavigation extends ConsumerWidget { const XBottomNavigation({super.key}); @@ -9,6 +11,7 @@ class XBottomNavigation extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedIndex = ref.watch(shellNavigationProvider); + final unseen = ref.watch(unseenChatsCountProvider); return Container( height: 60, @@ -39,10 +42,46 @@ class XBottomNavigation extends ConsumerWidget { isSelected: selectedIndex == 3, onTap: () => _onTabTapped(ref, 3), ), - _NavItem( - icon: Icons.mail_outline, - isSelected: selectedIndex == 4, - onTap: () => _onTabTapped(ref, 4), + Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () => _onTabTapped(ref, 4), + child: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.mail_outline, + color: selectedIndex == 4 ? Colors.white : Colors.grey[600], + size: 26, + ), + ), + ), + if (unseen > 0) + Positioned( + right: 8, + top: 10, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 12, + minHeight: 12, + ), + child: Text( + unseen > 99 ? '99+' : unseen.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], ), ], ), @@ -51,6 +90,11 @@ class XBottomNavigation extends ConsumerWidget { void _onTabTapped(WidgetRef ref, int index) { ref.read(shellNavigationProvider.notifier).state = index; + + if (index == 4) { + ref.read(unseenChatsCountProvider.notifier).state = 0; + ref.read(socketRepositoryProvider).sendOpenMessageTab(); + } } } diff --git a/lib/features/trends/models/for_you_response_model.dart b/lib/features/trends/models/for_you_response_model.dart new file mode 100644 index 0000000..c9bd903 --- /dev/null +++ b/lib/features/trends/models/for_you_response_model.dart @@ -0,0 +1,12 @@ +import 'package:lite_x/features/profile/models/user_model.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; + +class ForYouResponseModel { + final List categories; + final List suggestedUsers; + + ForYouResponseModel({required this.categories, required this.suggestedUsers}); +} + + + diff --git a/lib/features/trends/models/trend_category.dart b/lib/features/trends/models/trend_category.dart new file mode 100644 index 0000000..e7764af --- /dev/null +++ b/lib/features/trends/models/trend_category.dart @@ -0,0 +1,31 @@ +import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; + + +class TrendCategory { + final String categoryName; + final List viralTweets; + final List trends; + + TrendCategory({ + required this.categoryName, + required this.viralTweets, + required this.trends, + }); + + factory TrendCategory.fromJson(Map json) { + final viralTweets = convertJsonListToTweetList(json["viralTweets"] ?? []); + + final trendsJson = json["trends"] ?? []; + final trends = trendsJson + .map((t) => TrendModel.fromJson(t)) + .toList() + .cast(); + return TrendCategory( + categoryName: json['category'] ?? "", + viralTweets: viralTweets, + trends: trends, + ); + } +} diff --git a/lib/features/trends/models/trend_model.dart b/lib/features/trends/models/trend_model.dart new file mode 100644 index 0000000..948ad36 --- /dev/null +++ b/lib/features/trends/models/trend_model.dart @@ -0,0 +1,26 @@ +// Model representing a single trend item in the Trends screen +class TrendModel { + final String id; + final String title; + final num postCount; + final num rank; + final num likesCount; + + const TrendModel({ + required this.id, + required this.title, + required this.postCount, + required this.rank, + required this.likesCount, + }); + + factory TrendModel.fromJson(Map json) { + return TrendModel( + id: json["id"] ?? "", + title: json["hashtag"], + postCount: json["tweetCount"], + rank: json["rank"], + likesCount: json["likesCount"], + ); + } +} diff --git a/lib/features/trends/repositories/trends_repository.dart b/lib/features/trends/repositories/trends_repository.dart new file mode 100644 index 0000000..faaf892 --- /dev/null +++ b/lib/features/trends/repositories/trends_repository.dart @@ -0,0 +1,5 @@ +// Placeholder repository for Trends feature +// Implement local/remote methods similar to profile repositories +class TrendsRepository { + TrendsRepository(); +} diff --git a/lib/features/trends/view/screens/trends_screen.dart b/lib/features/trends/view/screens/trends_screen.dart new file mode 100644 index 0000000..b810745 --- /dev/null +++ b/lib/features/trends/view/screens/trends_screen.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import '../../models/trend_model.dart'; +import '../widgets/trend_tile.dart'; + +class TrendsScreen extends StatelessWidget { + const TrendsScreen({super.key}); + + List _sampleTrends() { + return const [ + TrendModel( + id: "1", + title: "#AI", + postCount: 1234567, + rank: 1, + likesCount: 987654, + ), + TrendModel( + id: "2", + title: "#TechNews", + postCount: 892345, + rank: 2, + likesCount: 756432, + ), + TrendModel( + id: "3", + title: "#CyberMonday", + postCount: 678912, + rank: 3, + likesCount: 543210, + ), + TrendModel( + id: "4", + title: "#Flutter", + postCount: 567890, + rank: 4, + likesCount: 456789, + ), + TrendModel( + id: "5", + title: "#ChatGPT", + postCount: 489234, + rank: 5, + likesCount: 398765, + ), + TrendModel( + id: "6", + title: "#Gaming", + postCount: 423567, + rank: 6, + likesCount: 345678, + ), + TrendModel( + id: "7", + title: "#ClimateAction", + postCount: 398765, + rank: 7, + likesCount: 312456, + ), + TrendModel( + id: "8", + title: "#CryptoNews", + postCount: 376543, + rank: 8, + likesCount: 289543, + ), + TrendModel( + id: "9", + title: "#NFT", + postCount: 345678, + rank: 9, + likesCount: 267890, + ), + TrendModel( + id: "10", + title: "#Metaverse", + postCount: 323456, + rank: 10, + likesCount: 245678, + ), + TrendModel( + id: "11", + title: "#WebDev", + postCount: 298765, + rank: 11, + likesCount: 223456, + ), + TrendModel( + id: "12", + title: "#MachineLearning", + postCount: 276543, + rank: 12, + likesCount: 201234, + ), + TrendModel( + id: "13", + title: "#SpaceX", + postCount: 254321, + rank: 13, + likesCount: 189876, + ), + TrendModel( + id: "14", + title: "#ElectricVehicles", + postCount: 232109, + rank: 14, + likesCount: 176543, + ), + TrendModel( + id: "15", + title: "#FitnessGoals", + postCount: 209876, + rank: 15, + likesCount: 163210, + ), + TrendModel( + id: "16", + title: "#TravelTuesday", + postCount: 187654, + rank: 16, + likesCount: 149876, + ), + TrendModel( + id: "17", + title: "#Photography", + postCount: 165432, + rank: 17, + likesCount: 136543, + ), + TrendModel( + id: "18", + title: "#Foodie", + postCount: 143210, + rank: 18, + likesCount: 123210, + ), + TrendModel( + id: "19", + title: "#SustainableLiving", + postCount: 120987, + rank: 19, + likesCount: 109876, + ), + TrendModel( + id: "20", + title: "#MentalHealthMatters", + postCount: 98765, + rank: 20, + likesCount: 96543, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final trends = _sampleTrends(); + return Scaffold( + appBar: AppBar(title: const Text('Trends')), + body: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + itemCount: trends.length, + itemBuilder: (context, index) => TrendTile( + trend: trends[index], + trendCategory: "Sports", + showRank: true, + ), + separatorBuilder: (context, index) => Divider( + height: 1, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.1), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/features/trends/view/widgets/category_profile_trend_tab.dart b/lib/features/trends/view/widgets/category_profile_trend_tab.dart new file mode 100644 index 0000000..dad4de4 --- /dev/null +++ b/lib/features/trends/view/widgets/category_profile_trend_tab.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/features/profile/models/profile_model.dart'; +import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/profile/models/user_model.dart'; +import 'package:lite_x/features/profile/view/widgets/following_followers/follower_card.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart' + hide Padding; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; +import 'package:lite_x/features/trends/view/widgets/trend_tile.dart'; + +class CategoryProfileTrendTab extends ConsumerWidget { + const CategoryProfileTrendTab({ + Key? key, + required this.pm, + required this.categoryName, + }) : super(key: key); + final ProfileModel pm; + final String categoryName; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(trendCategoryProvider(this.categoryName)); + return asyncData.when( + data: (res) { + return res.fold( + (l) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center(child: Text(l.message)), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(trendCategoryProvider(this.categoryName)); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + (data) { + return RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + await ref.refresh(trendCategoryProvider(this.categoryName)); + }, + child: ListView( + children: data.trends.isEmpty && data.viralTweets.isEmpty + ? [ + Padding( + padding: const EdgeInsets.all(24), + child: Text( + "Nothing to see here -- yet.", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 35, + ), + ), + ), + ] + : [ + SizedBox(height: 20), + _buildTweetsSection(data.viralTweets, pm), + Container( + width: double.infinity, + height: 0.5, + color: Colors.grey, + ), + SizedBox(height: 20), + _buildTredsSection(data, 30), + ], + ), + ); + }, + ); + }, + error: (err, _) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center( + child: Text("Can't get Trends at this time, Try again Later..."), + ), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(trendCategoryProvider(this.categoryName)); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + loading: () { + return SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + } + + Widget _buildTredsSection(TrendCategory category, int limit) { + return ListView.builder( + padding: EdgeInsets.only(left: 16), + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + return TrendTile( + trend: category.trends[index], + trendCategory: category.categoryName.length >= 2 + ? "${category.categoryName[0].toUpperCase()}${category.categoryName.substring(1)}" + : "", + showRank: false, + ); + }, + itemCount: category.trends.length <= limit + ? category.trends.length + : limit, + ); + } + + Widget _buildTweetsSection(List tweets, ProfileModel pm) { + if (tweets.isEmpty) { + return SizedBox.shrink(); + } + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListView.separated( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + TweetType type = tweets[index].type; + if (type == TweetType.ReTweet) + return ProfileRetweetWidget( + profileModel: pm, + tweetModel: tweets[index], + ); + + if (type == TweetType.Quote) + return ProfileQuoteWidget( + tweetModel: tweets[index], + profileModel: pm, + ); + + return ProfileNormalTweetWidget( + profileModel: pm, + profilePostModel: tweets[index], + ); + }, + itemCount: tweets.length <= 5 ? tweets.length : 5, + separatorBuilder: (context, index) { + return Container( + width: double.infinity, + height: 0.5, + color: Colors.grey, + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/trends/view/widgets/for_you_profile_tab.dart b/lib/features/trends/view/widgets/for_you_profile_tab.dart new file mode 100644 index 0000000..eeb4a2a --- /dev/null +++ b/lib/features/trends/view/widgets/for_you_profile_tab.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lite_x/features/profile/models/profile_model.dart'; +import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import 'package:lite_x/features/profile/models/user_model.dart'; +import 'package:lite_x/features/profile/view/widgets/following_followers/follower_card.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_normal_tweet_widget.dart' + hide Padding; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_quote_widget.dart'; +import 'package:lite_x/features/profile/view/widgets/profile_tweets/profile_retweet_widget.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:lite_x/features/trends/models/trend_category.dart'; +import 'package:lite_x/features/trends/models/trend_model.dart'; +import 'package:lite_x/features/trends/view/widgets/trend_tile.dart'; + +class ForYouProfileTab extends ConsumerWidget { + const ForYouProfileTab({Key? key, required this.pm}) : super(key: key); + final ProfileModel pm; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(forYouTrendsProvider); + final asyncTrends = ref.watch(profileTrendsProvider); + return asyncData.when( + data: (res) { + return res.fold( + (l) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center(child: Text(l.message)), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(forYouTrendsProvider); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + (data) { + return RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + ref.invalidate(forYouTrendsProvider); + // Optionally wait for the new data + await ref.read(forYouTrendsProvider.future); + }, + child: ListView( + children: [ + asyncTrends.when( + data: (res) => res.fold( + (l) => Text(l.message), + (r) => _buildTredsSection(r), + ), + error: (err, _) => SizedBox.shrink(), + loading: () => SizedBox.shrink(), + ), + _buildWhoToFollowSection(data.suggestedUsers, context), + ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => + _buildCategorySection(data.categories[index], pm), + itemCount: data.categories.length, + ), + ], + ), + ); + }, + ); + }, + error: (err, _) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center( + child: Text("Can't get Trends at this time, Try again Later..."), + ), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(forYouTrendsProvider); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + loading: () { + return SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + } + + Widget _buildTredsSection(List trends) { + return ListView.builder( + padding: EdgeInsets.only(left: 16), + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + return TrendTile( + trend: trends[index], + trendCategory: "ُEgypt", + showRank: false, + ); + }, + itemCount: trends.length <= 6 ? trends.length : 6, + ); + } + + Widget _buildWhoToFollowSection(List users, BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 0.2, color: Colors.grey), + Padding( + padding: EdgeInsets.only(left: 16, top: 5), + child: Text( + "Who to Follow", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => + FollowerCard(user: users[index], isMe: true), + itemCount: users.length <= 5 ? users.length : 5, + ), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 16), + child: GestureDetector( + onTap: () { + // TODO: go to who to follow screen + // context.push(); + }, + child: Text("Show more", style: TextStyle(color: Colors.blue)), + ), + ), + ], + ), + ], + ); + } + + Widget _buildCategorySection(TrendCategory category, ProfileModel pm) { + List filteredData = category.viralTweets; + + if (filteredData.isEmpty) { + return SizedBox.shrink(); + } + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(width: double.infinity, height: 0.25, color: Colors.grey), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + category.categoryName.length >= 2 + ? "${category.categoryName[0].toUpperCase()}${category.categoryName.substring(1)} Trends" + : "", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900), + ), + ), + ListView.separated( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + TweetType type = filteredData[index].type; + if (type == TweetType.ReTweet) + return ProfileRetweetWidget( + profileModel: pm, + tweetModel: filteredData[index], + ); + + if (type == TweetType.Quote) + return ProfileQuoteWidget( + tweetModel: filteredData[index], + profileModel: pm, + ); + + return ProfileNormalTweetWidget( + profileModel: pm, + profilePostModel: filteredData[index], + ); + }, + itemCount: filteredData.length <= 5 ? filteredData.length : 5, + separatorBuilder: (context, index) { + return Container( + width: double.infinity, + height: 0.5, + color: Colors.grey, + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/trends/view/widgets/trend_tile.dart b/lib/features/trends/view/widgets/trend_tile.dart new file mode 100644 index 0000000..48c9852 --- /dev/null +++ b/lib/features/trends/view/widgets/trend_tile.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import '../../models/trend_model.dart'; + +class TrendTile extends StatelessWidget { + final TrendModel trend; + final String trendCategory; + final bool showRank; + const TrendTile({ + super.key, + required this.trend, + required this.trendCategory, + required this.showRank, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return InkWell( + onTap: () { + // TODO: go to trend tweets screen + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Context label (e.g., Trending in Egypt) + Text( + "${showRank ? trend.rank : ""}${showRank ? "." : ""}Trending in ${this.trendCategory}", + style: textTheme.labelSmall?.copyWith( + color: const Color.fromARGB(255, 95, 101, 104), + fontWeight: FontWeight.w900, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + // Title + Text( + "#" + trend.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + const SizedBox(height: 4), + // Posts count + if (trend.postCount != 0) + Text( + "${Shared.formatCount(trend.postCount.toInt())} posts", + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + fontSize: 13, + ), + ), + ], + ), + ), + // Kebab menu icon + IconButton( + icon: const Icon(Icons.more_vert), + color: colorScheme.onSurface.withOpacity(0.7), + iconSize: 18, + onPressed: () {}, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/trends/view/widgets/trending_profile_tab.dart b/lib/features/trends/view/widgets/trending_profile_tab.dart new file mode 100644 index 0000000..e52be42 --- /dev/null +++ b/lib/features/trends/view/widgets/trending_profile_tab.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lite_x/features/profile/view_model/providers.dart'; +import 'package:lite_x/features/trends/view/widgets/trend_tile.dart'; + +class TrendingProfileTab extends ConsumerWidget { + const TrendingProfileTab({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncTrends = ref.watch(profileTrendsProvider); + return asyncTrends.when( + data: (res) { + return res.fold( + (l) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center(child: Text(l.message)), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(profileTrendsProvider); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + (data) { + return RefreshIndicator( + onRefresh: () async { + // ignore: unused_result + await ref.refresh(profileTrendsProvider); + }, + child: ListView.builder( + padding: EdgeInsets.only(left: 16), + itemBuilder: (context, index) => TrendTile( + trend: data[index], + trendCategory: "Egypt", + showRank: true, + ), + itemCount: data.length, + ), + ); + }, + ); + }, + error: (err, _) { + return ListView( + padding: EdgeInsets.only(top: 50), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + Center( + child: Text("Can't get Trends at this time, Try again Later..."), + ), + Center( + child: IconButton( + onPressed: () async { + // ignore: unused_result + ref.refresh(profileTrendsProvider); + }, + icon: Icon(Icons.refresh), + ), + ), + ], + ); + }, + loading: () { + return SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + } +} diff --git a/lib/features/trends/view_model/trends_view_model.dart b/lib/features/trends/view_model/trends_view_model.dart new file mode 100644 index 0000000..917c2a2 --- /dev/null +++ b/lib/features/trends/view_model/trends_view_model.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +class TrendsViewModel extends ChangeNotifier { + // Placeholder state and methods mirroring profile view model + List trends = []; + + void loadTrends() { + // TODO: implement loading logic + trends = []; + notifyListeners(); + } +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index e5678ab..fcef8c6 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -41,52 +41,50 @@ class DefaultFirebaseOptions { } static const FirebaseOptions web = FirebaseOptions( - apiKey: 'AIzaSyC6-I0IOtAmC9jnyIxf9WruRMUuc_VJqFo', - appId: '1:123824690535:web:5c24b3d2f16411d1960bc2', - messagingSenderId: '123824690535', - projectId: 'litex-3c6f1', - authDomain: 'litex-3c6f1.firebaseapp.com', - storageBucket: 'litex-3c6f1.firebasestorage.app', - measurementId: 'G-V5P21GBW6S', + apiKey: 'AIzaSyD5HN4nEV7xxsqLPufVptBwf2j-oXYVYCs', + appId: '1:112144721859:web:cd55e199eb8ef04e813f76', + messagingSenderId: '112144721859', + projectId: 'psychic-fin-474008-h8', + authDomain: 'psychic-fin-474008-h8.firebaseapp.com', + storageBucket: 'psychic-fin-474008-h8.firebasestorage.app', + measurementId: 'G-6XVXJKTZLW', ); static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyDYiU34-5-Rr2nP_SHphvLSIiOyr4RuC8I', - appId: '1:123824690535:android:fc6ea2d45764d44a960bc2', - messagingSenderId: '123824690535', - projectId: 'litex-3c6f1', - storageBucket: 'litex-3c6f1.firebasestorage.app', + apiKey: 'AIzaSyAPqcctNBpWkQ-BKvYsHUEjvc_iwPBwsZ0', + appId: '1:112144721859:android:227c69fccfe2ec4c813f76', + messagingSenderId: '112144721859', + projectId: 'psychic-fin-474008-h8', + storageBucket: 'psychic-fin-474008-h8.firebasestorage.app', ); static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyANjGmgYBumwUsVg_BIBYsD0SN_BdEZfFg', - appId: '1:123824690535:ios:7d4e4fe266a006fe960bc2', - messagingSenderId: '123824690535', - projectId: 'litex-3c6f1', - storageBucket: 'litex-3c6f1.firebasestorage.app', - iosClientId: - '123824690535-5pta8j3mu07g0bb21n43n8q2vuulrstr.apps.googleusercontent.com', + apiKey: 'AIzaSyDwLkRHFcciSTzy8dGANfbWzmUQ4kEKhCM', + appId: '1:112144721859:ios:990931c2c90bbd22813f76', + messagingSenderId: '112144721859', + projectId: 'psychic-fin-474008-h8', + storageBucket: 'psychic-fin-474008-h8.firebasestorage.app', + iosClientId: '112144721859-1i3tqk7isj7tf6733s8eje2fnj357k80.apps.googleusercontent.com', iosBundleId: 'com.example.litex', ); static const FirebaseOptions macos = FirebaseOptions( - apiKey: 'AIzaSyANjGmgYBumwUsVg_BIBYsD0SN_BdEZfFg', - appId: '1:123824690535:ios:7d4e4fe266a006fe960bc2', - messagingSenderId: '123824690535', - projectId: 'litex-3c6f1', - storageBucket: 'litex-3c6f1.firebasestorage.app', - iosClientId: - '123824690535-5pta8j3mu07g0bb21n43n8q2vuulrstr.apps.googleusercontent.com', + apiKey: 'AIzaSyDwLkRHFcciSTzy8dGANfbWzmUQ4kEKhCM', + appId: '1:112144721859:ios:990931c2c90bbd22813f76', + messagingSenderId: '112144721859', + projectId: 'psychic-fin-474008-h8', + storageBucket: 'psychic-fin-474008-h8.firebasestorage.app', + iosClientId: '112144721859-1i3tqk7isj7tf6733s8eje2fnj357k80.apps.googleusercontent.com', iosBundleId: 'com.example.litex', ); static const FirebaseOptions windows = FirebaseOptions( - apiKey: 'AIzaSyC6-I0IOtAmC9jnyIxf9WruRMUuc_VJqFo', - appId: '1:123824690535:web:70728cc3cfda8de2960bc2', - messagingSenderId: '123824690535', - projectId: 'litex-3c6f1', - authDomain: 'litex-3c6f1.firebaseapp.com', - storageBucket: 'litex-3c6f1.firebasestorage.app', - measurementId: 'G-ML4Y8SWB2Y', + apiKey: 'AIzaSyD5HN4nEV7xxsqLPufVptBwf2j-oXYVYCs', + appId: '1:112144721859:web:01d0e76c4bc65596813f76', + messagingSenderId: '112144721859', + projectId: 'psychic-fin-474008-h8', + authDomain: 'psychic-fin-474008-h8.firebaseapp.com', + storageBucket: 'psychic-fin-474008-h8.firebasestorage.app', + measurementId: 'G-7R40WH2L4T', ); } diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb new file mode 100644 index 0000000..bdb9be6 --- /dev/null +++ b/lib/l10n/app_ar.arb @@ -0,0 +1,6 @@ +{ + "@locale": "ar", + "appTitle": "اكس لايت", + "trendsTitle": "المتصدر", + "postsCount": "{count} منشور" +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..231ea4d --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,5 @@ +{ + "appTitle": "X Lite", + "trendsTitle": "Trends", + "postsCount": "{count} posts" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..06793ef --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_ar.dart'; +import 'app_localizations_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('ar'), + Locale('en'), + ]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'X Lite'** + String get appTitle; + + /// No description provided for @trendsTitle. + /// + /// In en, this message translates to: + /// **'Trends'** + String get trendsTitle; + + /// No description provided for @postsCount. + /// + /// In en, this message translates to: + /// **'{count} posts'** + String postsCount(Object count); +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['ar', 'en'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'ar': + return AppLocalizationsAr(); + case 'en': + return AppLocalizationsEn(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart new file mode 100644 index 0000000..16f80a2 --- /dev/null +++ b/lib/l10n/app_localizations_ar.dart @@ -0,0 +1,21 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Arabic (`ar`). +class AppLocalizationsAr extends AppLocalizations { + AppLocalizationsAr([String locale = 'ar']) : super(locale); + + @override + String get appTitle => 'اكس لايت'; + + @override + String get trendsTitle => 'المتصدر'; + + @override + String postsCount(Object count) { + return '$count منشور'; + } +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..db43e12 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,21 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'X Lite'; + + @override + String get trendsTitle => 'Trends'; + + @override + String postsCount(Object count) { + return '$count posts'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 16a75be..31a99a7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; @@ -20,7 +21,15 @@ void main() async { Future init() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + try { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } on FirebaseException catch (e) { + if (e.code != 'duplicate-app') { + rethrow; + } + } DeepLinkService.init(); await Hive.initFlutter(); Hive.registerAdapter(UserModelAdapter()); @@ -37,7 +46,6 @@ Future init() async { await Hive.openBox('conversationsBox'); await Hive.openBox('messagesBox'); await dotenv.load(fileName: ".env"); - await Hive.openBox('search_history'); } @@ -50,6 +58,12 @@ class MyApp extends StatelessWidget { debugShowCheckedModeBanner: false, title: 'X Lite', theme: appTheme, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en'), Locale('ar')], routerConfig: Approuter.router, ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e070368..a8cdaa9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -23,6 +23,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos +import video_player_avfoundation import webview_flutter_wkwebview import window_to_front @@ -45,6 +46,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 7c1bc15..dfc7a59 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -735,7 +735,7 @@ packages: source: hosted version: "0.14.4" flutter_localizations: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -1986,6 +1986,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: d74b66f283afff135d5be0ceccca2ca74dff7df1e9b1eaca6bd4699875d3ae60 + url: "https://pub.dev" + source: hosted + version: "2.8.22" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: e4d33b79a064498c6eb3a6a492b6a5012573d4943c28d566caf1a6c0840fe78d + url: "https://pub.dev" + source: hosted + version: "2.8.8" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + visibility_detector: + dependency: "direct main" + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78f3670..a7345d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 flutter_riverpod: ^3.0.0 @@ -28,6 +30,7 @@ dependencies: encrypt: ^5.0.3 fpdart: ^1.1.1 image: ^4.5.4 + visibility_detector: ^0.4.0+2 url_launcher: ^6.3.2 audio_waveforms: ^1.3.0 emoji_picker_flutter: ^4.3.0 @@ -72,9 +75,11 @@ dependencies: share_plus: ^12.0.1 fluttertoast: ^9.0.0 + # video_player: ^2.9.2 http: any collection: any + video_player: ^2.10.1 dev_dependencies: build_runner: ^2.7.1 flutter_test: @@ -88,6 +93,7 @@ dev_dependencies: # The following section is specific to Flutter packages. riverpod: any flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/test/features/auth/repositories/auth_remote_repository_test.dart b/test/features/auth/repositories/auth_remote_repository_test.dart deleted file mode 100644 index a7f436a..0000000 --- a/test/features/auth/repositories/auth_remote_repository_test.dart +++ /dev/null @@ -1,1548 +0,0 @@ -// auth_remote_repository_test.dart - -// ignore_for_file: unused_local_variable - -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/core/classes/PickedImage.dart'; -import 'package:lite_x/features/auth/repositories/auth_remote_repository.dart'; -import 'package:mockito/annotations.dart'; -import 'package:lite_x/core/classes/AppFailure.dart'; -import 'package:lite_x/core/models/TokensModel.dart'; -import 'package:lite_x/core/models/usermodel.dart'; -import 'package:mockito/mockito.dart'; -import 'auth_remote_repository_test.mocks.dart'; - -@GenerateMocks([Dio]) -void main() { - late MockDio mockDio; - late AuthRemoteRepository authRepository; - setUp(() { - mockDio = MockDio(); - authRepository = AuthRemoteRepository(dio: mockDio); - }); - group('create (signup)', () { - const testName = 'AserMohamed'; - const testEmail = 'asermohamed@gmail.com'; - const testDateOfBirth = '2004-11-11'; - - test('should return success message on successful signup', () async { - final apiResponse = {'message': 'Verification email sent'}; - - when( - mockDio.post( - 'api/auth/signup', - data: { - 'name': testName, - 'email': testEmail, - 'dateOfBirth': testDateOfBirth, - }, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/signup'), - data: apiResponse, - statusCode: 201, - ), - ); - - final result = await authRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail( - 'Test failed: Should have returned Right, but got Left($failure)', - ), - (message) { - expect(message, 'Verification email sent'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post( - 'api/auth/signup', - data: { - 'name': testName, - 'email': testEmail, - 'dateOfBirth': testDateOfBirth, - }, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/signup'), - data: apiResponse, - statusCode: 201, - ), - ); - - final result = await authRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Verification email sent'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/signup', - data: { - 'name': testName, - 'email': testEmail, - 'dateOfBirth': testDateOfBirth, - }, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/signup'), - message: 'Network error', - ), - ); - - final result = await authRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Signup failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/signup', - data: { - 'name': testName, - 'email': testEmail, - 'dateOfBirth': testDateOfBirth, - }, - ), - ).thenThrow(Exception('Unexpected error')); - - final result = await authRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('verifySignupEmail', () { - const testEmail = 'test@example.com'; - const testCode = '123456'; - - test('should return success message on successful verification', () async { - final apiResponse = {'message': 'Verified successfully'}; - - when( - mockDio.post( - 'api/auth/verify-signup', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-signup'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Verified successfully'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post( - 'api/auth/verify-signup', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-signup'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Verified successfully'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/verify-signup', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/verify-signup'), - message: 'Invalid code', - ), - ); - - final result = await authRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Email verification failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/verify-signup', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenThrow(Exception('Network timeout')); - - final result = await authRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('signup (finalize)', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'ASERMOHAMED123***aaa'; - - final userMap = { - 'id': '1', - 'name': 'Test User', - 'email': testEmail, - 'username': 'testuser', - 'dateOfBirth': '1990-01-01', - 'isEmailVerified': true, - 'isVerified': false, - }; - - final tokensMap = { - 'accessToken': 'fake_access_token_123456789', - 'refreshToken': 'fake_refresh_token_123456789', - }; - - final apiResponse = {'user': userMap, 'tokens': tokensMap}; - - test( - 'should return (UserModel, TokensModel) on successful finalization', - () async { - when( - mockDio.post( - 'api/auth/finalize_signup', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/finalize_signup'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.signup( - email: testEmail, - password: testPassword, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (successData) { - final (user, tokens) = successData; - expect(user, isA()); - expect(user.email, testEmail); - expect(user.name, 'Test User'); - expect(tokens, isA()); - - expect(tokens.accessToken, 'fake_access_token_123456789'); - expect(tokens.refreshToken, 'fake_refresh_token_123456789'); - }, - ); - }, - ); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/finalize_signup', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/finalize_signup'), - message: 'Server error', - ), - ); - - final result = await authRepository.signup( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Signup failed'); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/finalize_signup', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow(Exception('Parse error')); - - final result = await authRepository.signup( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - - test('should handle malformed response data', () async { - final malformedResponse = { - 'user': {'id': '1'}, - 'tokens': {}, - }; - - when( - mockDio.post( - 'api/auth/finalize_signup', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/finalize_signup'), - data: malformedResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.signup( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - }); - group('login', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'ASERMOHAMED123***aaa'; - final userMap = { - 'id': '1', - 'name': 'Test User', - 'email': testEmail, - 'username': 'testuser', - }; - final tokensMap = { - 'Token': 'fake_access_token_123456789', - 'Refresh_token': 'fake_refresh_token_123456789', - }; - - final apiResponse = {'user': userMap, ...tokensMap}; - - final userModel = UserModel.fromMap(userMap); - final tokensModel = TokensModel.fromMap_login(apiResponse); - - test( - 'should return (UserModel, TokensModel) on successful login', - () async { - when( - mockDio.post( - 'api/auth/login', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/login'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.login( - email: testEmail, - password: testPassword, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail( - 'Test failed: Should have returned Right, but got Left($failure)', - ), - (successData) { - final (user, tokens) = successData; - expect(user, userModel); - expect(tokens.accessToken, tokensModel.accessToken); - expect(tokens.refreshToken, tokensModel.refreshToken); - }, - ); - }, - ); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/login', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/login'), - message: 'Connection error', - ), - ); - - final result = await authRepository.login( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - - result.fold( - (failure) { - expect(failure, isA()); - expect(failure.message, 'Login failed'); - }, - (successData) => - fail('Test failed: Should have returned Left, but got Right'), - ); - }); - - test( - 'should return AppFailure with "Wrong Password" on generic exception', - () async { - when( - mockDio.post( - 'api/auth/login', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow(Exception(' error')); - - final result = await authRepository.login( - email: testEmail, - password: testPassword, - ); - - result.fold((failure) { - expect(failure.message, 'Wrong Password'); - }, (successData) => fail('Test failed: Should have returned Left')); - }, - ); - }); - - group('updateUsername', () { - const testUsername = 'Aser_Mohamed_2025'; - final currentUser = UserModel( - id: '1', - name: 'Test User', - email: 'test@example.com', - username: 'oldusername', - dob: '1990-01-01', - isEmailVerified: false, - isVerified: false, - ); - - final apiResponse = { - 'user': {'username': testUsername}, - 'tokens': {'access': 'new_access_token', 'refresh': 'new_refresh_token'}, - }; - - test('should return updated UserModel and new tokens', () async { - when( - mockDio.put( - 'api/auth/update_username', - data: {'username': testUsername}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/update_username'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.updateUsername( - currentUser: currentUser, - Username: testUsername, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (successData) { - final (user, tokens) = successData; - expect(user.username, testUsername); - expect(user.id, currentUser.id); - expect(user.email, currentUser.email); - expect(tokens.accessToken, 'new_access_token'); - expect(tokens.refreshToken, 'new_refresh_token'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.put( - 'api/auth/update_username', - data: {'username': testUsername}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/update_username'), - message: 'Username already taken', - ), - ); - - final result = await authRepository.updateUsername( - currentUser: currentUser, - Username: testUsername, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Failed to update username'); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.put( - 'api/auth/update_username', - data: {'username': testUsername}, - ), - ).thenThrow(Exception('Parse error')); - - final result = await authRepository.updateUsername( - currentUser: currentUser, - Username: testUsername, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - }); - - group('forget_password', () { - const testEmail = 'asermohamed@gmail.com'; - - test('should return success message', () async { - final apiResponse = {'message': 'Reset code sent'}; - - when( - mockDio.post('api/auth/forget-password', data: {'email': testEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/forget-password'), - data: apiResponse, - ), - ); - - final result = await authRepository.forget_password(email: testEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Reset code sent'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post('api/auth/forget-password', data: {'email': testEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/forget-password'), - data: apiResponse, - ), - ); - - final result = await authRepository.forget_password(email: testEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Reset code sent'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post('api/auth/forget-password', data: {'email': testEmail}), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/forget-password'), - message: 'Email not found', - ), - ); - - final result = await authRepository.forget_password(email: testEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Forget password failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post('api/auth/forget-password', data: {'email': testEmail}), - ).thenThrow(Exception('Network error')); - - final result = await authRepository.forget_password(email: testEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('verify_reset_code', () { - const testEmail = 'asermohamed@gmail.com'; - const testCode = '123456'; - - test('should return success message', () async { - final apiResponse = {'message': 'Reset code verified'}; - - when( - mockDio.post( - 'api/auth/verify-reset-code', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-reset-code'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.verify_reset_code( - email: testEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Reset code verified'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post( - 'api/auth/verify-reset-code', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-reset-code'), - data: apiResponse, - ), - ); - - final result = await authRepository.verify_reset_code( - email: testEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Reset code verified'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/verify-reset-code', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/verify-reset-code'), - message: 'Invalid code', - ), - ); - - final result = await authRepository.verify_reset_code( - email: testEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Verify reset code failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/verify-reset-code', - data: {'email': testEmail, 'code': testCode}, - ), - ).thenThrow(Exception('Timeout')); - - final result = await authRepository.verify_reset_code( - email: testEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('reset_password', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'ASERMOHAMED123***aaa'; - - final userMap = { - 'id': '1', - 'name': 'Test User', - 'email': testEmail, - 'username': 'testuser', - 'dateOfBirth': '2004-11-11', - 'isEmailVerified': false, - 'isVerified': false, - }; - - final apiResponse = { - 'user': userMap, - 'accesstoken': 'reset_access_token', - 'refresh_token': 'reset_refresh_token', - }; - - test('should return UserModel and TokensModel on success', () async { - when( - mockDio.post( - 'api/auth/reset-password', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/reset-password'), - data: apiResponse, - ), - ); - - final result = await authRepository.reset_password( - email: testEmail, - password: testPassword, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (successData) { - final (user, tokens) = successData; - expect(user, isA()); - expect(user.email, testEmail); - expect(tokens, isA()); - expect(tokens.accessToken, 'reset_access_token'); - expect(tokens.refreshToken, 'reset_refresh_token'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/reset-password', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/reset-password'), - message: 'Server error', - ), - ); - - final result = await authRepository.reset_password( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Reset password failed'); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/reset-password', - data: {'email': testEmail, 'password': testPassword}, - ), - ).thenThrow(Exception('Parse error')); - - final result = await authRepository.reset_password( - email: testEmail, - password: testPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (successData) => fail('Test failed: Should have returned Left')); - }); - }); - - group('uploadProfilePhoto', () { - test('should return AppFailure when no file is selected', () async { - final pickedImage = PickedImage(file: null, name: 'test.jpg'); - - final result = await authRepository.uploadProfilePhoto( - pickedImage: pickedImage, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'No file selected'); - }, (data) => fail('Test failed: Should have returned Left')); - }); - - test('should return mediaId and keyName on successful upload', () async { - final mockFile = File('test.jpg'); - final pickedImage = PickedImage(file: mockFile, name: 'test.jpg'); - - final uploadRequestResponse = { - 'url': 'https://presigned-url.com', - 'keyName': 'media/test-key-123', - }; - - final confirmResponse = { - 'newMedia': {'id': 'media-id-123', 'keyName': 'media/test-key-123'}, - }; - - when( - mockDio.post( - 'api/media/upload-request', - data: {'fileName': 'test.jpg', 'contentType': 'image/jpeg'}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/media/upload-request'), - data: uploadRequestResponse, - statusCode: 200, - ), - ); - - when( - mockDio.post('api/media/confirm-upload/media/test-key-123'), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions( - path: 'api/media/confirm-upload/media/test-key-123', - ), - data: confirmResponse, - statusCode: 200, - ), - ); - }); - - test( - 'should return AppFailure on DioException during upload request', - () async { - final mockFile = File('test.jpg'); - final pickedImage = PickedImage(file: mockFile, name: 'test.jpg'); - - when( - mockDio.post( - 'api/media/upload-request', - data: {'fileName': 'test.jpg', 'contentType': 'image/jpeg'}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/media/upload-request'), - message: 'Server error', - ), - ); - }, - ); - }); - - group('downloadMedia', () { - const testMediaId = 'media-123'; - - test('should return AppFailure on exception', () async { - when(mockDio.get('api/media/download-request/$testMediaId')).thenThrow( - DioException( - requestOptions: RequestOptions( - path: 'api/media/download-request/$testMediaId', - ), - message: 'Not found', - ), - ); - - final result = await authRepository.downloadMedia(mediaId: testMediaId); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Download failed')); - }, (file) => fail('Test failed: Should have returned Left')); - }); - }); - group('check_email', () { - const testEmail = 'asermohamed@gmail.com'; - - test('should return true when email exists', () async { - final apiResponse = {'exists': true}; - - when( - mockDio.post('api/auth/getUser', data: {'email': testEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/getUser'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.check_email(email: testEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (exists) { - expect(exists, true); - }, - ); - }); - - test('should return false when email does not exist', () async { - final apiResponse = {'exists': false}; - - when( - mockDio.post('api/auth/getUser', data: {'email': testEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/getUser'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.check_email(email: testEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (exists) { - expect(exists, false); - }, - ); - }); - - test('should return false when exists field is missing', () async { - final apiResponse = {}; - - when( - mockDio.post('api/auth/getUser', data: {'email': testEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/getUser'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.check_email(email: testEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (exists) { - expect(exists, false); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post('api/auth/getUser', data: {'email': testEmail}), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/getUser'), - message: 'Network error', - ), - ); - - final result = await authRepository.check_email(email: testEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Email check failed'); - }, (exists) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post('api/auth/getUser', data: {'email': testEmail}), - ).thenThrow(Exception('Unexpected error')); - - final result = await authRepository.check_email(email: testEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (exists) => fail('Test failed: Should have returned Left')); - }); - }); - - group('update_password', () { - const testPassword = 'OldPassword123***'; - const testNewPassword = 'NewPassword123***'; - const testConfirmPassword = 'NewPassword123***'; - - test( - 'should return success message on successful password update', - () async { - final apiResponse = {'message': 'Password updated successfully'}; - - when( - mockDio.post( - 'api/auth/change-password', - data: { - 'password': testPassword, - 'newpassword': testNewPassword, - 'confirmPassword': testConfirmPassword, - }, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/change-password'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Password updated successfully'); - }, - ); - }, - ); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post( - 'api/auth/change-password', - data: { - 'password': testPassword, - 'newpassword': testNewPassword, - 'confirmPassword': testConfirmPassword, - }, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/change-password'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Password updated successfully'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/change-password', - data: { - 'password': testPassword, - 'newpassword': testNewPassword, - 'confirmPassword': testConfirmPassword, - }, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/change-password'), - message: 'Wrong password', - ), - ); - - final result = await authRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'update password failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/change-password', - data: { - 'password': testPassword, - 'newpassword': testNewPassword, - 'confirmPassword': testConfirmPassword, - }, - ), - ).thenThrow(Exception('Network timeout')); - - final result = await authRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('update_email', () { - const testNewEmail = 'asermohamed123@gmail.com'; - - test('should return success message on successful email update', () async { - final apiResponse = {'message': 'Email updated successfully'}; - - when( - mockDio.post('api/auth/change-email', data: {'newemail': testNewEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/change-email'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.update_email(newemail: testNewEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Email updated successfully'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post('api/auth/change-email', data: {'newemail': testNewEmail}), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/change-email'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.update_email(newemail: testNewEmail); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'Email updated successfully'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post('api/auth/change-email', data: {'newemail': testNewEmail}), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/change-email'), - message: 'Email already in use', - ), - ); - - final result = await authRepository.update_email(newemail: testNewEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'update email failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post('api/auth/change-email', data: {'newemail': testNewEmail}), - ).thenThrow(Exception('Server error')); - - final result = await authRepository.update_email(newemail: testNewEmail); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('verify_new_email', () { - const testNewEmail = 'asermohamed123@gmail.com'; - const testCode = '123456'; - - test('should return success message on successful verification', () async { - final apiResponse = {'message': 'updated email successfully'}; - - when( - mockDio.post( - 'api/auth/verify-new-email', - data: {'email': testNewEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-new-email'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'updated email successfully'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post( - 'api/auth/verify-new-email', - data: {'email': testNewEmail, 'code': testCode}, - ), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/auth/verify-new-email'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'updated email successfully'); - }, - ); - }); - - test('should return AppFailure on DioException', () async { - when( - mockDio.post( - 'api/auth/verify-new-email', - data: {'email': testNewEmail, 'code': testCode}, - ), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/auth/verify-new-email'), - message: 'Invalid verification code', - ), - ); - - final result = await authRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'Email update failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post( - 'api/auth/verify-new-email', - data: {'email': testNewEmail, 'code': testCode}, - ), - ).thenThrow(Exception('Network error')); - - final result = await authRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); - - group('registerFcmToken', () { - const testFcmToken = 'fcm_token_123456789'; - - test('should return success message on successful registration', () async { - final apiResponse = {'message': 'FCM registered successfully'}; - - when( - mockDio.post('api/users/fcm-token', data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/users/fcm-token'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.registerFcmToken( - fcmToken: testFcmToken, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'FCM registered successfully'); - }, - ); - }); - - test('should return default message when message is missing', () async { - final apiResponse = {}; - - when( - mockDio.post('api/users/fcm-token', data: anyNamed('data')), - ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: 'api/users/fcm-token'), - data: apiResponse, - statusCode: 200, - ), - ); - - final result = await authRepository.registerFcmToken( - fcmToken: testFcmToken, - ); - - expect(result.isRight(), true); - result.fold( - (failure) => fail('Test failed: Should have returned Right'), - (message) { - expect(message, 'FCM registered successfully'); - }, - ); - }); - - test('should return AppFailure on DioException with response', () async { - when( - mockDio.post('api/users/fcm-token', data: anyNamed('data')), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/users/fcm-token'), - response: Response( - requestOptions: RequestOptions(path: 'api/users/fcm-token'), - data: {'message': 'Token registration failed'}, - statusCode: 400, - ), - message: 'Bad request', - ), - ); - - final result = await authRepository.registerFcmToken( - fcmToken: testFcmToken, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('FCM registration failed')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on DioException without response', () async { - when( - mockDio.post('api/users/fcm-token', data: anyNamed('data')), - ).thenThrow( - DioException( - requestOptions: RequestOptions(path: 'api/users/fcm-token'), - message: 'Connection timeout', - ), - ); - - final result = await authRepository.registerFcmToken( - fcmToken: testFcmToken, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, 'FCM registration failed'); - }, (message) => fail('Test failed: Should have returned Left')); - }); - - test('should return AppFailure on generic exception', () async { - when( - mockDio.post('api/users/fcm-token', data: anyNamed('data')), - ).thenThrow(Exception('Unexpected error')); - - final result = await authRepository.registerFcmToken( - fcmToken: testFcmToken, - ); - - expect(result.isLeft(), true); - result.fold((failure) { - expect(failure, isA()); - expect(failure.message, contains('Exception')); - }, (message) => fail('Test failed: Should have returned Left')); - }); - }); -} diff --git a/test/features/auth/repositories/auth_remote_repository_test.mocks.dart b/test/features/auth/repositories/auth_remote_repository_test.mocks.dart deleted file mode 100644 index dae153b..0000000 --- a/test/features/auth/repositories/auth_remote_repository_test.mocks.dart +++ /dev/null @@ -1,806 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/features/auth/repositories/auth_remote_repository_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i8; - -import 'package:dio/src/adapter.dart' as _i3; -import 'package:dio/src/cancel_token.dart' as _i9; -import 'package:dio/src/dio.dart' as _i7; -import 'package:dio/src/dio_mixin.dart' as _i5; -import 'package:dio/src/options.dart' as _i2; -import 'package:dio/src/response.dart' as _i6; -import 'package:dio/src/transformer.dart' as _i4; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions { - _FakeBaseOptions_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeHttpClientAdapter_1 extends _i1.SmartFake - implements _i3.HttpClientAdapter { - _FakeHttpClientAdapter_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeTransformer_2 extends _i1.SmartFake implements _i4.Transformer { - _FakeTransformer_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeInterceptors_3 extends _i1.SmartFake implements _i5.Interceptors { - _FakeInterceptors_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { - _FakeResponse_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { - _FakeDio_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [Dio]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDio extends _i1.Mock implements _i7.Dio { - MockDio() { - _i1.throwOnMissingStub(this); - } - - @override - _i2.BaseOptions get options => - (super.noSuchMethod( - Invocation.getter(#options), - returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)), - ) - as _i2.BaseOptions); - - @override - _i3.HttpClientAdapter get httpClientAdapter => - (super.noSuchMethod( - Invocation.getter(#httpClientAdapter), - returnValue: _FakeHttpClientAdapter_1( - this, - Invocation.getter(#httpClientAdapter), - ), - ) - as _i3.HttpClientAdapter); - - @override - _i4.Transformer get transformer => - (super.noSuchMethod( - Invocation.getter(#transformer), - returnValue: _FakeTransformer_2( - this, - Invocation.getter(#transformer), - ), - ) - as _i4.Transformer); - - @override - _i5.Interceptors get interceptors => - (super.noSuchMethod( - Invocation.getter(#interceptors), - returnValue: _FakeInterceptors_3( - this, - Invocation.getter(#interceptors), - ), - ) - as _i5.Interceptors); - - @override - set options(_i2.BaseOptions? _options) => super.noSuchMethod( - Invocation.setter(#options, _options), - returnValueForMissingStub: null, - ); - - @override - set httpClientAdapter(_i3.HttpClientAdapter? _httpClientAdapter) => - super.noSuchMethod( - Invocation.setter(#httpClientAdapter, _httpClientAdapter), - returnValueForMissingStub: null, - ); - - @override - set transformer(_i4.Transformer? _transformer) => super.noSuchMethod( - Invocation.setter(#transformer, _transformer), - returnValueForMissingStub: null, - ); - - @override - void close({bool? force = false}) => super.noSuchMethod( - Invocation.method(#close, [], {#force: force}), - returnValueForMissingStub: null, - ); - - @override - _i8.Future<_i6.Response> head( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - }) => - (super.noSuchMethod( - Invocation.method( - #head, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #head, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> headUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - }) => - (super.noSuchMethod( - Invocation.method( - #headUri, - [uri], - {#data: data, #options: options, #cancelToken: cancelToken}, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #headUri, - [uri], - {#data: data, #options: options, #cancelToken: cancelToken}, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> get( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #get, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #get, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> getUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #getUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #getUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> post( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #post, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #post, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> postUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #postUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #postUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> put( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #put, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #put, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> putUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #putUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #putUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> patch( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #patch, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #patch, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> patchUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #patchUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #patchUri, - [uri], - { - #data: data, - #options: options, - #cancelToken: cancelToken, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> delete( - String? path, { - Object? data, - Map? queryParameters, - _i2.Options? options, - _i9.CancelToken? cancelToken, - }) => - (super.noSuchMethod( - Invocation.method( - #delete, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #delete, - [path], - { - #data: data, - #queryParameters: queryParameters, - #options: options, - #cancelToken: cancelToken, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> deleteUri( - Uri? uri, { - Object? data, - _i2.Options? options, - _i9.CancelToken? cancelToken, - }) => - (super.noSuchMethod( - Invocation.method( - #deleteUri, - [uri], - {#data: data, #options: options, #cancelToken: cancelToken}, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #deleteUri, - [uri], - {#data: data, #options: options, #cancelToken: cancelToken}, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> download( - String? urlPath, - dynamic savePath, { - _i2.ProgressCallback? onReceiveProgress, - Map? queryParameters, - _i9.CancelToken? cancelToken, - bool? deleteOnError = true, - _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, - String? lengthHeader = 'content-length', - Object? data, - _i2.Options? options, - }) => - (super.noSuchMethod( - Invocation.method( - #download, - [urlPath, savePath], - { - #onReceiveProgress: onReceiveProgress, - #queryParameters: queryParameters, - #cancelToken: cancelToken, - #deleteOnError: deleteOnError, - #fileAccessMode: fileAccessMode, - #lengthHeader: lengthHeader, - #data: data, - #options: options, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #download, - [urlPath, savePath], - { - #onReceiveProgress: onReceiveProgress, - #queryParameters: queryParameters, - #cancelToken: cancelToken, - #deleteOnError: deleteOnError, - #fileAccessMode: fileAccessMode, - #lengthHeader: lengthHeader, - #data: data, - #options: options, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> downloadUri( - Uri? uri, - dynamic savePath, { - _i2.ProgressCallback? onReceiveProgress, - _i9.CancelToken? cancelToken, - bool? deleteOnError = true, - _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, - String? lengthHeader = 'content-length', - Object? data, - _i2.Options? options, - }) => - (super.noSuchMethod( - Invocation.method( - #downloadUri, - [uri, savePath], - { - #onReceiveProgress: onReceiveProgress, - #cancelToken: cancelToken, - #deleteOnError: deleteOnError, - #fileAccessMode: fileAccessMode, - #lengthHeader: lengthHeader, - #data: data, - #options: options, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #downloadUri, - [uri, savePath], - { - #onReceiveProgress: onReceiveProgress, - #cancelToken: cancelToken, - #deleteOnError: deleteOnError, - #fileAccessMode: fileAccessMode, - #lengthHeader: lengthHeader, - #data: data, - #options: options, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> request( - String? url, { - Object? data, - Map? queryParameters, - _i9.CancelToken? cancelToken, - _i2.Options? options, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #request, - [url], - { - #data: data, - #queryParameters: queryParameters, - #cancelToken: cancelToken, - #options: options, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #request, - [url], - { - #data: data, - #queryParameters: queryParameters, - #cancelToken: cancelToken, - #options: options, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> requestUri( - Uri? uri, { - Object? data, - _i9.CancelToken? cancelToken, - _i2.Options? options, - _i2.ProgressCallback? onSendProgress, - _i2.ProgressCallback? onReceiveProgress, - }) => - (super.noSuchMethod( - Invocation.method( - #requestUri, - [uri], - { - #data: data, - #cancelToken: cancelToken, - #options: options, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method( - #requestUri, - [uri], - { - #data: data, - #cancelToken: cancelToken, - #options: options, - #onSendProgress: onSendProgress, - #onReceiveProgress: onReceiveProgress, - }, - ), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i8.Future<_i6.Response> fetch(_i2.RequestOptions? requestOptions) => - (super.noSuchMethod( - Invocation.method(#fetch, [requestOptions]), - returnValue: _i8.Future<_i6.Response>.value( - _FakeResponse_4( - this, - Invocation.method(#fetch, [requestOptions]), - ), - ), - ) - as _i8.Future<_i6.Response>); - - @override - _i7.Dio clone({ - _i2.BaseOptions? options, - _i5.Interceptors? interceptors, - _i3.HttpClientAdapter? httpClientAdapter, - _i4.Transformer? transformer, - }) => - (super.noSuchMethod( - Invocation.method(#clone, [], { - #options: options, - #interceptors: interceptors, - #httpClientAdapter: httpClientAdapter, - #transformer: transformer, - }), - returnValue: _FakeDio_5( - this, - Invocation.method(#clone, [], { - #options: options, - #interceptors: interceptors, - #httpClientAdapter: httpClientAdapter, - #transformer: transformer, - }), - ), - ) - as _i7.Dio); -} diff --git a/test/features/auth/view_model/auth_view_model_test.dart b/test/features/auth/view_model/auth_view_model_test.dart deleted file mode 100644 index 9a2684c..0000000 --- a/test/features/auth/view_model/auth_view_model_test.dart +++ /dev/null @@ -1,901 +0,0 @@ -// auth_view_model_test.dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/core/classes/AppFailure.dart'; -import 'package:lite_x/core/models/TokensModel.dart'; -import 'package:lite_x/core/models/usermodel.dart'; -import 'package:lite_x/core/providers/current_user_provider.dart'; -import 'package:lite_x/features/auth/repositories/auth_local_repository.dart'; -import 'package:lite_x/features/auth/repositories/auth_remote_repository.dart'; -import 'package:lite_x/features/auth/view_model/auth_state.dart'; -import 'package:lite_x/features/auth/view_model/auth_view_model.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:riverpod/riverpod.dart'; -import 'package:fpdart/fpdart.dart'; - -import 'auth_view_model_test.mocks.dart'; - -@GenerateMocks([AuthRemoteRepository, AuthLocalRepository]) -void main() { - late MockAuthRemoteRepository mockRemoteRepository; - late MockAuthLocalRepository mockLocalRepository; - late ProviderContainer container; - - setUpAll(() async { - provideDummy>(right('dummy string')); - provideDummy>(right(true)); - provideDummy>( - right(( - UserModel( - id: '1', - name: 'aser', - email: 'aser@test.com', - username: 'aser', - dob: '2000-01-01', - isEmailVerified: false, - isVerified: false, - ), - TokensModel( - accessToken: 'access_token_123', - refreshToken: 'refresh_token_123', - accessTokenExpiry: DateTime.now(), - refreshTokenExpiry: DateTime.now(), - ), - )), - ); - }); - - setUp(() { - mockRemoteRepository = MockAuthRemoteRepository(); - mockLocalRepository = MockAuthLocalRepository(); - when(mockLocalRepository.getUser()).thenReturn(null); - when(mockLocalRepository.getTokens()).thenReturn(null); - - container = ProviderContainer( - overrides: [ - authRemoteRepositoryProvider.overrideWithValue(mockRemoteRepository), - authLocalRepositoryProvider.overrideWithValue(mockLocalRepository), - ], - ); - }); - - tearDown(() { - container.dispose(); - }); - - group('createAccount', () { - const testName = 'AserMohamed'; - const testEmail = 'asermohamed@gmail.com'; - const testDateOfBirth = '2004-11-11'; - - test( - 'should update state to success on successful account creation', - () async { - when( - mockRemoteRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ), - ).thenAnswer((_) async => right('Verification email sent')); - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.createAccount( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Verification email sent'); - - verify( - mockRemoteRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ), - ).called(1); - }, - ); - - test('should update state to error on failure', () async { - when( - mockRemoteRepository.create( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ), - ).thenAnswer((_) async => left(AppFailure(message: 'Signup failed'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.createAccount( - name: testName, - email: testEmail, - dateOfBirth: testDateOfBirth, - ); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Signup failed'); - }); - }); - - group('verifySignupEmail', () { - const testEmail = 'asermohamed@gmail.com'; - const testCode = '123456'; - - test( - 'should update state to verified on successful verification', - () async { - when( - mockRemoteRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ), - ).thenAnswer((_) async => right('Verified successfully')); - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifySignupEmail(email: testEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.verified); - expect(state.message, 'Verified successfully'); - - verify( - mockRemoteRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ), - ).called(1); - }, - ); - - test('should update state to error on verification failure', () async { - when( - mockRemoteRepository.verifySignupEmail( - email: testEmail, - code: testCode, - ), - ).thenAnswer( - (_) async => left(AppFailure(message: 'Invalid verification code')), - ); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifySignupEmail(email: testEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Invalid verification code'); - }); - }); - - group('finalizeSignup', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'ASERMOHAMED123***aaa'; - - final testUser = UserModel( - id: '1', - name: 'Test User', - email: testEmail, - username: 'testuser', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - final testTokens = TokensModel( - accessToken: 'access_token_123', - refreshToken: 'refresh_token_123', - accessTokenExpiry: DateTime.now().add(const Duration(hours: 1)), - refreshTokenExpiry: DateTime.now().add(const Duration(days: 30)), - ); - - test('should save user and tokens and update state on success', () async { - when( - mockRemoteRepository.signup(email: testEmail, password: testPassword), - ).thenAnswer((_) async => right((testUser, testTokens))); - - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - when( - mockLocalRepository.saveTokens(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.finalizeSignup(email: testEmail, password: testPassword); - - await Future.delayed(const Duration(milliseconds: 100)); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.authenticated); - expect(state.message, 'Signup successful'); - - verify(mockLocalRepository.saveUser(testUser)).called(1); - verify(mockLocalRepository.saveTokens(testTokens)).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser, testUser); - }); - - test('should update state to error on signup failure', () async { - when( - mockRemoteRepository.signup(email: testEmail, password: testPassword), - ).thenAnswer((_) async => left(AppFailure(message: 'Signup failed'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.finalizeSignup(email: testEmail, password: testPassword); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Signup failed'); - - verifyNever(mockLocalRepository.saveUser(any)); - verifyNever(mockLocalRepository.saveTokens(any)); - }); - }); - group('login', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'ASERMOHAMED123***aaa'; - - final testUser = UserModel( - id: '1', - name: 'Test User', - email: testEmail, - username: 'testuser', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - final testTokens = TokensModel( - accessToken: 'access_token_123', - refreshToken: 'refresh_token_123', - accessTokenExpiry: DateTime.now().add(const Duration(hours: 1)), - refreshTokenExpiry: DateTime.now().add(const Duration(days: 30)), - ); - - test( - 'should save user and tokens and update state on successful login', - () async { - when( - mockRemoteRepository.login(email: testEmail, password: testPassword), - ).thenAnswer((_) async => right((testUser, testTokens))); - - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - when( - mockLocalRepository.saveTokens(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.login(email: testEmail, password: testPassword); - await Future.delayed(const Duration(milliseconds: 50)); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.authenticated); - expect(state.message, 'Login successful'); - - verify(mockLocalRepository.saveUser(testUser)).called(1); - verify(mockLocalRepository.saveTokens(testTokens)).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser, testUser); - }, - ); - - test('should update state to error on login failure', () async { - when( - mockRemoteRepository.login(email: testEmail, password: testPassword), - ).thenAnswer( - (_) async => left(AppFailure(message: 'Invalid credentials')), - ); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.login(email: testEmail, password: testPassword); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Invalid credentials'); - - verifyNever(mockLocalRepository.saveUser(any)); - verifyNever(mockLocalRepository.saveTokens(any)); - }); - }); - - group('logout', () { - test('should clear user and tokens on logout', () async { - when( - mockLocalRepository.clearUser(), - ).thenAnswer((_) async => Future.value()); - when( - mockLocalRepository.clearTokens(), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.logout(); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.unauthenticated); - - verify(mockLocalRepository.clearUser()).called(1); - verify(mockLocalRepository.clearTokens()).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser, null); - }); - }); - - group('checkEmail', () { - const testEmail = 'asermohamed@gmail.com'; - - test('should update state to success when email exists', () async { - when( - mockRemoteRepository.check_email(email: testEmail), - ).thenAnswer((_) async => right(true)); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.checkEmail(email: testEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Email found'); - - verify(mockRemoteRepository.check_email(email: testEmail)).called(1); - }); - - test('should update state to error when email does not exist', () async { - when( - mockRemoteRepository.check_email(email: testEmail), - ).thenAnswer((_) async => right(false)); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.checkEmail(email: testEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Email not found. Please create an account.'); - }); - - test('should update state to error on email check failure', () async { - when( - mockRemoteRepository.check_email(email: testEmail), - ).thenAnswer((_) async => left(AppFailure(message: 'Network error'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.checkEmail(email: testEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Failed to check email. Please try again.'); - }); - }); - - group('forgetPassword', () { - const testEmail = 'asermohamed@gmail.com'; - - test( - 'should update state to success on successful password reset request', - () async { - when( - mockRemoteRepository.forget_password(email: testEmail), - ).thenAnswer((_) async => right('Reset code sent')); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.forgetPassword(email: testEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Reset code sent'); - - verify( - mockRemoteRepository.forget_password(email: testEmail), - ).called(1); - }, - ); - - test('should update state to error on password reset failure', () async { - when( - mockRemoteRepository.forget_password(email: testEmail), - ).thenAnswer((_) async => left(AppFailure(message: 'Email not found'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.forgetPassword(email: testEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Email not found'); - }); - }); - - group('verifyResetCode', () { - const testEmail = 'asermohamed@gmail.com'; - const testCode = '123456'; - - test( - 'should update state to awaitingPassword on successful verification', - () async { - when( - mockRemoteRepository.verify_reset_code( - email: testEmail, - code: testCode, - ), - ).thenAnswer((_) async => right('Code verified')); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifyResetCode(email: testEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.awaitingPassword); - expect(state.message, 'Code verified'); - - verify( - mockRemoteRepository.verify_reset_code( - email: testEmail, - code: testCode, - ), - ).called(1); - }, - ); - - test('should update state to error on verification failure', () async { - when( - mockRemoteRepository.verify_reset_code( - email: testEmail, - code: testCode, - ), - ).thenAnswer((_) async => left(AppFailure(message: 'Invalid code'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifyResetCode(email: testEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Invalid code'); - }); - }); - - group('resetPassword', () { - const testEmail = 'asermohamed@gmail.com'; - const testPassword = 'NewPassword123***'; - - final testUser = UserModel( - id: '1', - name: 'Test User', - email: testEmail, - username: 'testuser', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - final testTokens = TokensModel( - accessToken: 'access_token_123', - refreshToken: 'refresh_token_123', - accessTokenExpiry: DateTime.now().add(const Duration(hours: 1)), - refreshTokenExpiry: DateTime.now().add(const Duration(days: 30)), - ); - - test('should save user and tokens on successful password reset', () async { - when( - mockRemoteRepository.reset_password( - email: testEmail, - password: testPassword, - ), - ).thenAnswer((_) async => right((testUser, testTokens))); - - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - when( - mockLocalRepository.saveTokens(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.resetPassword(email: testEmail, password: testPassword); - await Future.delayed(const Duration(milliseconds: 50)); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Reset_Password successful'); - - verify(mockLocalRepository.saveUser(testUser)).called(1); - verify(mockLocalRepository.saveTokens(testTokens)).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser, testUser); - }); - - test('should update state to error on password reset failure', () async { - when( - mockRemoteRepository.reset_password( - email: testEmail, - password: testPassword, - ), - ).thenAnswer((_) async => left(AppFailure(message: 'Reset failed'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.resetPassword(email: testEmail, password: testPassword); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Reset failed'); - - verifyNever(mockLocalRepository.saveUser(any)); - verifyNever(mockLocalRepository.saveTokens(any)); - }); - }); - - group('updatePassword', () { - const testPassword = 'OldPassword123***'; - const testNewPassword = 'NewPassword123***'; - const testConfirmPassword = 'NewPassword123***'; - - test( - 'should update state to success on successful password update', - () async { - when( - mockRemoteRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ), - ).thenAnswer((_) async => right('Password updated successfully')); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updatePassword( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Password updated successfully'); - - verify( - mockRemoteRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ), - ).called(1); - }, - ); - - test('should update state to error on password update failure', () async { - when( - mockRemoteRepository.update_password( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ), - ).thenAnswer( - (_) async => left(AppFailure(message: 'Current password is incorrect')), - ); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updatePassword( - password: testPassword, - newpassword: testNewPassword, - confirmPassword: testConfirmPassword, - ); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Current password is incorrect'); - }); - }); - - group('updateEmail', () { - const testNewEmail = 'oliver_1@gmail.com'; - - test( - 'should update state to awaitingVerification on successful email update request', - () async { - when( - mockRemoteRepository.update_email(newemail: testNewEmail), - ).thenAnswer((_) async => right('Verification code sent')); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updateEmail(newEmail: testNewEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.awaitingVerification); - expect(state.message, 'Verification code sent'); - - verify( - mockRemoteRepository.update_email(newemail: testNewEmail), - ).called(1); - }, - ); - - test('should update state to error on email update failure', () async { - when( - mockRemoteRepository.update_email(newemail: testNewEmail), - ).thenAnswer( - (_) async => left(AppFailure(message: 'Email already exists')), - ); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updateEmail(newEmail: testNewEmail); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Email already exists'); - }); - }); - - group('verifyNewEmail', () { - const testNewEmail = 'aser_123_mohamed@gmail.com'; - const testCode = '123456'; - - final testUser = UserModel( - id: '1', - name: 'aser mohamed', - email: 'aser@gmail.com', - username: 'aser_1', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - test('should update user email on successful verification', () async { - when( - mockRemoteRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ), - ).thenAnswer((_) async => right('Email verified successfully')); - - when(mockLocalRepository.getUser()).thenReturn(testUser); - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - container.read(currentUserProvider.notifier).adduser(testUser); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifyNewEmail(newEmail: testNewEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Email verified successfully'); - - final currentUser = container.read(currentUserProvider); - expect(currentUser?.email, testNewEmail); - - verify(mockLocalRepository.saveUser(any)).called(1); - }); - - test('should update state to error on verification failure', () async { - when( - mockRemoteRepository.verify_new_email( - newemail: testNewEmail, - code: testCode, - ), - ).thenAnswer((_) async => left(AppFailure(message: 'Invalid code'))); - - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.verifyNewEmail(newEmail: testNewEmail, code: testCode); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Invalid code'); - }); - }); - - group('updateUsername', () { - const testUsername = 'oliver_1'; - - final testUser = UserModel( - id: '1', - name: 'aser mohamed', - email: 'aser@gmail.com', - username: 'oliver', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - final updatedUser = testUser.copyWith(username: testUsername); - - final testTokens = TokensModel( - accessToken: 'new_access_token', - refreshToken: 'new_refresh_token', - accessTokenExpiry: DateTime.now().add(const Duration(hours: 1)), - refreshTokenExpiry: DateTime.now().add(const Duration(days: 30)), - ); - - test('should update username and save user on success', () async { - when( - mockRemoteRepository.updateUsername( - currentUser: testUser, - Username: testUsername, - ), - ).thenAnswer((_) async => right((updatedUser, testTokens))); - - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - when( - mockLocalRepository.saveTokens(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - container.read(currentUserProvider.notifier).adduser(testUser); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updateUsername(username: testUsername); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Username updated successfully'); - - verify(mockLocalRepository.saveUser(updatedUser)).called(1); - verify(mockLocalRepository.saveTokens(testTokens)).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser?.username, testUsername); - }); - - test('should update state to error on username update failure', () async { - when( - mockRemoteRepository.updateUsername( - currentUser: testUser, - Username: testUsername, - ), - ).thenAnswer( - (_) async => left(AppFailure(message: 'Username already taken')), - ); - - await Future.delayed(const Duration(milliseconds: 500)); - - container.read(currentUserProvider.notifier).adduser(testUser); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updateUsername(username: testUsername); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'Username already taken'); - }); - - test('should update state to error when user not found', () async { - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.updateUsername(username: testUsername); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'User not found'); - }); - }); - - group('saveInterests', () { - final testInterests = {'coding', 'gaming', 'reading'}; - - final testUser = UserModel( - id: '1', - name: 'aser mohamed', - email: 'aser@gmail.com', - username: 'aser_1', - dob: '2004-11-11', - isEmailVerified: true, - isVerified: false, - ); - - test('should save interests and update user on success', () async { - when( - mockLocalRepository.saveUser(any), - ).thenAnswer((_) async => Future.value()); - - await Future.delayed(const Duration(milliseconds: 500)); - - container.read(currentUserProvider.notifier).adduser(testUser); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.saveInterests(testInterests); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.success); - expect(state.message, 'Interests saved successfully'); - - verify(mockLocalRepository.saveUser(any)).called(1); - - final currentUser = container.read(currentUserProvider); - expect(currentUser?.interests, testInterests); - }); - - test('should update state to error when user not found', () async { - await Future.delayed(const Duration(milliseconds: 500)); - - final viewModel = container.read(authViewModelProvider.notifier); - - await viewModel.saveInterests(testInterests); - - final state = container.read(authViewModelProvider); - expect(state.type, AuthStateType.error); - expect(state.message, 'User not found!'); - }); - }); -} diff --git a/test/features/auth/view_model/auth_view_model_test.mocks.dart b/test/features/auth/view_model/auth_view_model_test.mocks.dart deleted file mode 100644 index 333f1cb..0000000 --- a/test/features/auth/view_model/auth_view_model_test.mocks.dart +++ /dev/null @@ -1,456 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/features/auth/view_model/auth_view_model_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; -import 'dart:io' as _i10; - -import 'package:fpdart/fpdart.dart' as _i4; -import 'package:lite_x/core/classes/AppFailure.dart' as _i5; -import 'package:lite_x/core/classes/PickedImage.dart' as _i9; -import 'package:lite_x/core/models/TokensModel.dart' as _i7; -import 'package:lite_x/core/models/usermodel.dart' as _i6; -import 'package:lite_x/features/auth/repositories/auth_local_repository.dart' - as _i11; -import 'package:lite_x/features/auth/repositories/auth_remote_repository.dart' - as _i2; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i8; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -/// A class which mocks [AuthRemoteRepository]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAuthRemoteRepository extends _i1.Mock - implements _i2.AuthRemoteRepository { - MockAuthRemoteRepository() { - _i1.throwOnMissingStub(this); - } - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - loginWithGithub() => - (super.noSuchMethod( - Invocation.method(#loginWithGithub, []), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >(this, Invocation.method(#loginWithGithub, [])), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - signInWithGoogleAndroid() => - (super.noSuchMethod( - Invocation.method(#signInWithGoogleAndroid, []), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >(this, Invocation.method(#signInWithGoogleAndroid, [])), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> create({ - required String? name, - required String? email, - required String? dateOfBirth, - }) => - (super.noSuchMethod( - Invocation.method(#create, [], { - #name: name, - #email: email, - #dateOfBirth: dateOfBirth, - }), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#create, [], { - #name: name, - #email: email, - #dateOfBirth: dateOfBirth, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> verifySignupEmail({ - required String? email, - required String? code, - }) => - (super.noSuchMethod( - Invocation.method(#verifySignupEmail, [], { - #email: email, - #code: code, - }), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#verifySignupEmail, [], { - #email: email, - #code: code, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - signup({required String? email, required String? password}) => - (super.noSuchMethod( - Invocation.method(#signup, [], { - #email: email, - #password: password, - }), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >( - this, - Invocation.method(#signup, [], { - #email: email, - #password: password, - }), - ), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, Map>> - uploadProfilePhoto({required _i9.PickedImage? pickedImage}) => - (super.noSuchMethod( - Invocation.method(#uploadProfilePhoto, [], { - #pickedImage: pickedImage, - }), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, Map> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, Map> - >( - this, - Invocation.method(#uploadProfilePhoto, [], { - #pickedImage: pickedImage, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, Map>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, _i10.File>> downloadMedia({ - required String? mediaId, - }) => - (super.noSuchMethod( - Invocation.method(#downloadMedia, [], {#mediaId: mediaId}), - returnValue: - _i3.Future<_i4.Either<_i5.AppFailure, _i10.File>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, _i10.File>>( - this, - Invocation.method(#downloadMedia, [], {#mediaId: mediaId}), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, _i10.File>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - updateUsername({ - required _i6.UserModel? currentUser, - required String? Username, - }) => - (super.noSuchMethod( - Invocation.method(#updateUsername, [], { - #currentUser: currentUser, - #Username: Username, - }), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >( - this, - Invocation.method(#updateUsername, [], { - #currentUser: currentUser, - #Username: Username, - }), - ), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> registerFcmToken({ - required String? fcmToken, - }) => - (super.noSuchMethod( - Invocation.method(#registerFcmToken, [], {#fcmToken: fcmToken}), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#registerFcmToken, [], {#fcmToken: fcmToken}), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - login({required String? email, required String? password}) => - (super.noSuchMethod( - Invocation.method(#login, [], {#email: email, #password: password}), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >( - this, - Invocation.method(#login, [], { - #email: email, - #password: password, - }), - ), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, bool>> check_email({ - required String? email, - }) => - (super.noSuchMethod( - Invocation.method(#check_email, [], {#email: email}), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, bool>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, bool>>( - this, - Invocation.method(#check_email, [], {#email: email}), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, bool>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> forget_password({ - required String? email, - }) => - (super.noSuchMethod( - Invocation.method(#forget_password, [], {#email: email}), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#forget_password, [], {#email: email}), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> verify_reset_code({ - required String? email, - required String? code, - }) => - (super.noSuchMethod( - Invocation.method(#verify_reset_code, [], { - #email: email, - #code: code, - }), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#verify_reset_code, [], { - #email: email, - #code: code, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)>> - reset_password({required String? email, required String? password}) => - (super.noSuchMethod( - Invocation.method(#reset_password, [], { - #email: email, - #password: password, - }), - returnValue: - _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >.value( - _i8.dummyValue< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >( - this, - Invocation.method(#reset_password, [], { - #email: email, - #password: password, - }), - ), - ), - ) - as _i3.Future< - _i4.Either<_i5.AppFailure, (_i6.UserModel, _i7.TokensModel)> - >); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> update_password({ - required String? password, - required String? newpassword, - required String? confirmPassword, - }) => - (super.noSuchMethod( - Invocation.method(#update_password, [], { - #password: password, - #newpassword: newpassword, - #confirmPassword: confirmPassword, - }), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#update_password, [], { - #password: password, - #newpassword: newpassword, - #confirmPassword: confirmPassword, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> update_email({ - required String? newemail, - }) => - (super.noSuchMethod( - Invocation.method(#update_email, [], {#newemail: newemail}), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#update_email, [], {#newemail: newemail}), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); - - @override - _i3.Future<_i4.Either<_i5.AppFailure, String>> verify_new_email({ - required String? newemail, - required String? code, - }) => - (super.noSuchMethod( - Invocation.method(#verify_new_email, [], { - #newemail: newemail, - #code: code, - }), - returnValue: _i3.Future<_i4.Either<_i5.AppFailure, String>>.value( - _i8.dummyValue<_i4.Either<_i5.AppFailure, String>>( - this, - Invocation.method(#verify_new_email, [], { - #newemail: newemail, - #code: code, - }), - ), - ), - ) - as _i3.Future<_i4.Either<_i5.AppFailure, String>>); -} - -/// A class which mocks [AuthLocalRepository]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAuthLocalRepository extends _i1.Mock - implements _i11.AuthLocalRepository { - MockAuthLocalRepository() { - _i1.throwOnMissingStub(this); - } - - @override - _i3.Stream<_i7.TokensModel?> get tokenStream => - (super.noSuchMethod( - Invocation.getter(#tokenStream), - returnValue: _i3.Stream<_i7.TokensModel?>.empty(), - ) - as _i3.Stream<_i7.TokensModel?>); - - @override - _i3.Future saveUser(_i6.UserModel? user) => - (super.noSuchMethod( - Invocation.method(#saveUser, [user]), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future clearUser() => - (super.noSuchMethod( - Invocation.method(#clearUser, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future saveTokens(_i7.TokensModel? tokens) => - (super.noSuchMethod( - Invocation.method(#saveTokens, [tokens]), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); - - @override - _i3.Future clearTokens() => - (super.noSuchMethod( - Invocation.method(#clearTokens, []), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) - as _i3.Future); -} diff --git a/test/features/home/README.md b/test/features/home/README.md deleted file mode 100644 index 7953ecc..0000000 --- a/test/features/home/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# Home Feature Unit Tests - -This directory contains comprehensive unit tests for the Home feature of the LiteX application. - -## Test Coverage - -### Models Tests - -#### 1. `tweet_model_test.dart` - -Tests for the `TweetModel` class: - -- ✅ Creation with required fields -- ✅ Default values -- ✅ `copyWith` functionality -- ✅ Quoted tweet handling -- ✅ Reply relationships -- ✅ Interaction counts (likes, retweets, quotes, bookmarks) -- ✅ Boolean state toggles (isLiked, isRetweeted, isBookmarked) -- ✅ Multiple images handling -- ✅ Different tweet types (TWEET, RETWEET, REPLY, QUOTE) -- ✅ Null optional fields - -#### 2. `user_profile_model_test.dart` - -Tests for `UserProfileModel` and `MediaModel` classes: - -- ✅ MediaModel creation and JSON serialization -- ✅ UserProfileModel creation with all fields -- ✅ Profile and cover media handling -- ✅ Followers/following counts -- ✅ Verified status -- ✅ Protected account status -- ✅ Optional fields as null -- ✅ Join date handling - -#### 3. `tweet_summary_test.dart` - -Tests for the `TweetSummary` class: - -- ✅ Creation with default values -- ✅ Creation with custom values -- ✅ JSON deserialization with standard keys -- ✅ Alternative key name handling (viewCount, likesCount, etc.) -- ✅ Key coalescing logic (multiple key variations) -- ✅ Type conversions (int, double, string to int) -- ✅ Invalid value handling -- ✅ Missing and null key handling -- ✅ Mixed data types -- ✅ Large numbers -- ✅ Zero and negative values - -### View Model Tests - -#### 3. `home_state_test.dart` - -Tests for the `HomeState` class: - -- ✅ FeedType enum values (forYou, following) -- ✅ Default state values -- ✅ Custom state values -- ✅ State copying with modifications -- ✅ Loading and error states -- ✅ Refreshing state -- ✅ Feed type switching -- ✅ Separate tweet lists (tweets, forYouTweets, followingTweets) -- ✅ Empty lists handling -- ✅ Feed switching scenarios - -#### 4. `home_view_model_test.dart` - -Tests for the `HomeViewModel` state management: - -- ✅ Tweet like status updates -- ✅ Tweet retweet status updates -- ✅ Tweet bookmark status updates -- ✅ Maintaining separate feed lists -- ✅ Feed switching logic -- ✅ Adding new tweets to feed -- ✅ Error state handling -- ✅ Loading and refreshing states -- ✅ Updating tweets in feed -- ✅ Reply relationships - -### Providers Tests - -#### 5. `user_profile_provider_test.dart` - -Tests for the `UserProfileProvider` and `UserProfileController`: - -- ✅ Provider initialization -- ✅ Controller instantiation -- 📝 Note: Full integration tests require mocking Ref and dependencies - -### Repositories Tests - -#### 6. `home_repository_test.dart` - -Tests for the `HomeRepository`: - -- ✅ Repository instantiation -- 📝 Note: Comprehensive tests require mocked HTTP responses and would be better suited as integration tests - -## Running the Tests - -### Run all home feature tests: - -```bash -flutter test test/features/home/ -``` - -### Run specific test file: - -```bash -flutter test test/features/home/models/tweet_model_test.dart -flutter test test/features/home/models/user_profile_model_test.dart -flutter test test/features/home/models/tweet_summary_test.dart -flutter test test/features/home/view_model/home_state_test.dart -flutter test test/features/home/view_model/home_view_model_test.dart -flutter test test/features/home/providers/user_profile_provider_test.dart -flutter test test/features/home/repositories/home_repository_test.dart -``` - -### Run with coverage: - -```bash -flutter test --coverage test/features/home/ -``` - -### Run in watch mode: - -```bash -flutter test --watch test/features/home/ -``` - -## Test Structure - -Each test file follows this structure: - -1. **Setup**: Helper functions and test data creation -2. **Group Tests**: Organized by functionality -3. **Individual Tests**: Specific behavior validation - -## Key Testing Patterns - -### Model Testing - -- Tests object creation and initialization -- Validates immutability with `copyWith` -- Checks field defaults and nullability -- Verifies serialization/deserialization - -### State Testing - -- Tests state transitions -- Validates immutability -- Checks default values -- Tests copy operations - -### View Model Testing - -- Tests business logic -- Validates state updates -- Checks interaction handling -- Tests error scenarios - -## Dependencies - -Tests use the following packages: - -- `flutter_test`: Core testing framework -- `mockito`: For mocking dependencies (future enhancement) - -## Future Enhancements - -Potential additions to test coverage: - -- [ ] Integration tests with repository -- [ ] Widget tests for UI components -- [ ] End-to-end tests for user flows -- [ ] Performance tests for feed loading -- [ ] Mock HTTP responses for repository tests - -## Contributing - -When adding new tests: - -1. Follow the existing naming conventions -2. Group related tests together -3. Add clear test descriptions -4. Include both positive and negative test cases -5. Update this README with new test coverage diff --git a/test/features/home/models/tweet_model_test.dart b/test/features/home/models/tweet_model_test.dart deleted file mode 100644 index 43dc60a..0000000 --- a/test/features/home/models/tweet_model_test.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/models/tweet_model.dart'; - -void main() { - group('TweetModel', () { - late TweetModel testTweet; - - setUp(() { - testTweet = TweetModel( - id: '1', - content: 'Test tweet content', - authorName: 'John Doe', - authorUsername: 'johndoe', - authorAvatar: 'https://example.com/avatar.jpg', - createdAt: DateTime(2024, 1, 1), - likes: 10, - retweets: 5, - replies: 3, - images: ['image1.jpg', 'image2.jpg'], - isLiked: false, - isRetweeted: false, - isBookmarked: false, - quotes: 2, - bookmarks: 1, - userId: 'user123', - tweetType: 'TWEET', - ); - }); - - test('should create TweetModel with required fields', () { - expect(testTweet.id, '1'); - expect(testTweet.content, 'Test tweet content'); - expect(testTweet.authorName, 'John Doe'); - expect(testTweet.authorUsername, 'johndoe'); - expect(testTweet.authorAvatar, 'https://example.com/avatar.jpg'); - expect(testTweet.createdAt, DateTime(2024, 1, 1)); - }); - - test('should have correct default values', () { - final defaultTweet = TweetModel( - id: '2', - content: 'Default tweet', - authorName: 'Jane Doe', - authorUsername: 'janedoe', - authorAvatar: 'avatar.jpg', - createdAt: DateTime.now(), - ); - - expect(defaultTweet.likes, 0); - expect(defaultTweet.retweets, 0); - expect(defaultTweet.replies, 0); - expect(defaultTweet.images, const []); - expect(defaultTweet.isLiked, false); - expect(defaultTweet.isRetweeted, false); - expect(defaultTweet.isBookmarked, false); - expect(defaultTweet.replyIds, const []); - expect(defaultTweet.quotes, 0); - expect(defaultTweet.bookmarks, 0); - expect(defaultTweet.tweetType, 'TWEET'); - }); - - test('should create copy with modified fields', () { - final copiedTweet = testTweet.copyWith( - likes: 20, - isLiked: true, - content: 'Updated content', - ); - - expect(copiedTweet.likes, 20); - expect(copiedTweet.isLiked, true); - expect(copiedTweet.content, 'Updated content'); - expect(copiedTweet.id, testTweet.id); - expect(copiedTweet.authorName, testTweet.authorName); - }); - - test('should preserve unmodified fields in copyWith', () { - final copiedTweet = testTweet.copyWith(likes: 15); - - expect(copiedTweet.likes, 15); - expect(copiedTweet.content, testTweet.content); - expect(copiedTweet.authorUsername, testTweet.authorUsername); - expect(copiedTweet.isRetweeted, testTweet.isRetweeted); - }); - - test('should handle quoted tweet', () { - final quotedTweet = TweetModel( - id: 'quoted1', - content: 'Original tweet', - authorName: 'Original Author', - authorUsername: 'original', - authorAvatar: 'avatar.jpg', - createdAt: DateTime.now(), - ); - - final tweetWithQuote = testTweet.copyWith( - quotedTweetId: 'quoted1', - quotedTweet: quotedTweet, - ); - - expect(tweetWithQuote.quotedTweetId, 'quoted1'); - expect(tweetWithQuote.quotedTweet, quotedTweet); - expect(tweetWithQuote.quotedTweet?.content, 'Original tweet'); - }); - - test('should handle reply relationships', () { - final replyTweet = testTweet.copyWith( - replyToId: 'parent123', - replyIds: ['reply1', 'reply2', 'reply3'], - ); - - expect(replyTweet.replyToId, 'parent123'); - expect(replyTweet.replyIds.length, 3); - expect(replyTweet.replyIds, ['reply1', 'reply2', 'reply3']); - }); - - test('should update interaction counts', () { - expect(testTweet.likes, 10); - testTweet.likes = 15; - expect(testTweet.likes, 15); - - expect(testTweet.retweets, 5); - testTweet.retweets = 8; - expect(testTweet.retweets, 8); - }); - - test('should toggle boolean states', () { - expect(testTweet.isLiked, false); - testTweet.isLiked = true; - expect(testTweet.isLiked, true); - - expect(testTweet.isRetweeted, false); - testTweet.isRetweeted = true; - expect(testTweet.isRetweeted, true); - - expect(testTweet.isBookmarked, false); - testTweet.isBookmarked = true; - expect(testTweet.isBookmarked, true); - }); - - test('should handle multiple images', () { - expect(testTweet.images.length, 2); - expect(testTweet.images, ['image1.jpg', 'image2.jpg']); - - final tweetWithMoreImages = testTweet.copyWith( - images: ['img1.jpg', 'img2.jpg', 'img3.jpg', 'img4.jpg'], - ); - expect(tweetWithMoreImages.images.length, 4); - }); - - test('should handle empty images list', () { - final tweetNoImages = testTweet.copyWith(images: []); - expect(tweetNoImages.images, isEmpty); - }); - - test('should handle different tweet types', () { - final retweet = testTweet.copyWith(tweetType: 'RETWEET'); - expect(retweet.tweetType, 'RETWEET'); - - final reply = testTweet.copyWith(tweetType: 'REPLY'); - expect(reply.tweetType, 'REPLY'); - - final quote = testTweet.copyWith(tweetType: 'QUOTE'); - expect(quote.tweetType, 'QUOTE'); - }); - - test('should handle null optional fields', () { - final simpleTweet = TweetModel( - id: '3', - content: 'Simple tweet', - authorName: 'Simple User', - authorUsername: 'simple', - authorAvatar: 'avatar.jpg', - createdAt: DateTime.now(), - ); - - expect(simpleTweet.replyToId, isNull); - expect(simpleTweet.quotedTweetId, isNull); - expect(simpleTweet.quotedTweet, isNull); - expect(simpleTweet.userId, isNull); - }); - }); -} diff --git a/test/features/home/models/tweet_summary_test.dart b/test/features/home/models/tweet_summary_test.dart deleted file mode 100644 index b12da8a..0000000 --- a/test/features/home/models/tweet_summary_test.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/models/tweet_summary.dart'; - -void main() { - group('TweetSummary', () { - test('should create TweetSummary with default values', () { - const summary = TweetSummary(); - - expect(summary.views, 0); - expect(summary.likes, 0); - expect(summary.replies, 0); - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 0); - expect(summary.summary, isNull); - }); - - test('should create TweetSummary with custom values', () { - const summary = TweetSummary( - views: 1000, - likes: 50, - replies: 10, - retweets: 20, - quotes: 5, - bookmarks: 15, - summary: 'Test summary', - ); - - expect(summary.views, 1000); - expect(summary.likes, 50); - expect(summary.replies, 10); - expect(summary.retweets, 20); - expect(summary.quotes, 5); - expect(summary.bookmarks, 15); - expect(summary.summary, 'Test summary'); - }); - - test('should create TweetSummary from JSON with standard keys', () { - final json = { - 'views': 500, - 'likes': 25, - 'replies': 8, - 'retweets': 12, - 'quotes': 3, - 'bookmarks': 7, - 'summary': 'JSON summary', - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 500); - expect(summary.likes, 25); - expect(summary.replies, 8); - expect(summary.retweets, 12); - expect(summary.quotes, 3); - expect(summary.bookmarks, 7); - expect(summary.summary, 'JSON summary'); - }); - - test('should handle alternative key names with Count suffix', () { - final json = { - 'viewCount': 300, - 'likesCount': 15, - 'repliesCount': 5, - 'retweetCount': 8, - 'quotesCount': 2, - 'bookmarksCount': 4, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 300); - expect(summary.likes, 15); - expect(summary.replies, 5); - expect(summary.retweets, 8); - expect(summary.quotes, 2); - expect(summary.bookmarks, 4); - }); - - test('should handle alternative key name viewsCount', () { - final json = { - 'viewsCount': 250, - 'likes': 10, - 'replies': 3, - 'retweets': 5, - 'quotes': 1, - 'bookmarks': 2, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 250); - }); - - test('should coalesce multiple key variations and use first non-zero', () { - // Test that it uses the first non-zero value - final json = { - 'views': 0, - 'viewCount': 100, - 'viewsCount': 200, // Should use 100, first non-zero - 'likes': 5, - 'replies': 2, - 'retweets': 3, - 'quotes': 1, - 'bookmarks': 1, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 100); // First non-zero value - }); - - test('should handle int values correctly', () { - final json = { - 'views': 100, - 'likes': 20, - 'replies': 5, - 'retweets': 10, - 'quotes': 2, - 'bookmarks': 3, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 100); - expect(summary.likes, 20); - }); - - test('should handle double values and round them', () { - final json = { - 'views': 100.7, - 'likes': 20.3, - 'replies': 5.9, - 'retweets': 10.1, - 'quotes': 2.5, - 'bookmarks': 3.8, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 101); // Rounded - expect(summary.likes, 20); // Rounded - expect(summary.replies, 6); // Rounded - expect(summary.retweets, 10); // Rounded - expect(summary.quotes, 3); // Rounded - expect(summary.bookmarks, 4); // Rounded - }); - - test('should handle string values and parse them', () { - final json = { - 'views': '150', - 'likes': '30', - 'replies': '8', - 'retweets': '12', - 'quotes': '4', - 'bookmarks': '6', - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 150); - expect(summary.likes, 30); - expect(summary.replies, 8); - expect(summary.retweets, 12); - expect(summary.quotes, 4); - expect(summary.bookmarks, 6); - }); - - test('should handle invalid string values and default to 0', () { - final json = { - 'views': 'invalid', - 'likes': 'not-a-number', - 'replies': '', - 'retweets': 'abc', - 'quotes': null, - 'bookmarks': {}, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 0); - expect(summary.likes, 0); - expect(summary.replies, 0); - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 0); - }); - - test('should handle missing keys and default to 0', () { - final json = {}; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 0); - expect(summary.likes, 0); - expect(summary.replies, 0); - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 0); - expect(summary.summary, isNull); - }); - - test('should handle null values and default to 0', () { - final json = { - 'views': null, - 'likes': null, - 'replies': null, - 'retweets': null, - 'quotes': null, - 'bookmarks': null, - 'summary': null, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 0); - expect(summary.likes, 0); - expect(summary.replies, 0); - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 0); - expect(summary.summary, isNull); - }); - - test('should handle mixed data types in JSON', () { - final json = { - 'views': 100, - 'likes': '50', - 'replies': 10.5, - 'retweets': null, - 'quotes': 'invalid', - 'bookmarks': 5, - 'summary': 'Mixed types summary', - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 100); - expect(summary.likes, 50); - expect(summary.replies, 11); // Rounded from 10.5 - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 5); - expect(summary.summary, 'Mixed types summary'); - }); - - test('should convert summary field to string', () { - final json = { - 'views': 10, - 'likes': 5, - 'replies': 2, - 'retweets': 1, - 'quotes': 0, - 'bookmarks': 1, - 'summary': 123, // Non-string value - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.summary, '123'); - }); - - test('should handle large numbers', () { - final json = { - 'views': 1000000, - 'likes': 50000, - 'replies': 10000, - 'retweets': 25000, - 'quotes': 5000, - 'bookmarks': 15000, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 1000000); - expect(summary.likes, 50000); - expect(summary.replies, 10000); - expect(summary.retweets, 25000); - expect(summary.quotes, 5000); - expect(summary.bookmarks, 15000); - }); - - test('should handle zero values explicitly', () { - final json = { - 'views': 0, - 'likes': 0, - 'replies': 0, - 'retweets': 0, - 'quotes': 0, - 'bookmarks': 0, - }; - - final summary = TweetSummary.fromJson(json); - - expect(summary.views, 0); - expect(summary.likes, 0); - expect(summary.replies, 0); - expect(summary.retweets, 0); - expect(summary.quotes, 0); - expect(summary.bookmarks, 0); - }); - - test('should handle negative numbers and convert to positive', () { - // Assuming the implementation doesn't specifically handle negatives, - // they should be parsed as-is - final json = { - 'views': -10, - 'likes': -5, - 'replies': -2, - 'retweets': -3, - 'quotes': -1, - 'bookmarks': -4, - }; - - final summary = TweetSummary.fromJson(json); - - // Just verify they are parsed (behavior depends on implementation) - expect(summary.views, -10); - expect(summary.likes, -5); - }); - }); -} diff --git a/test/features/home/models/user_profile_model_test.dart b/test/features/home/models/user_profile_model_test.dart deleted file mode 100644 index 247a400..0000000 --- a/test/features/home/models/user_profile_model_test.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/models/user_profile_model.dart'; - -void main() { - group('MediaModel', () { - test('should create MediaModel with all fields', () { - final media = MediaModel( - id: 'media1', - name: 'profile.jpg', - keyName: 'profile_key', - type: 'image/jpeg', - ); - - expect(media.id, 'media1'); - expect(media.name, 'profile.jpg'); - expect(media.keyName, 'profile_key'); - expect(media.type, 'image/jpeg'); - }); - - test('should create MediaModel from JSON', () { - final json = { - 'id': 'media1', - 'name': 'avatar.png', - 'keyName': 'avatar_key', - 'type': 'image/png', - }; - - final media = MediaModel.fromJson(json); - - expect(media.id, 'media1'); - expect(media.name, 'avatar.png'); - expect(media.keyName, 'avatar_key'); - expect(media.type, 'image/png'); - }); - - test('should convert MediaModel to JSON', () { - final media = MediaModel( - id: 'media1', - name: 'banner.jpg', - keyName: 'banner_key', - type: 'image/jpeg', - ); - - final json = media.toJson(); - - expect(json['id'], 'media1'); - expect(json['name'], 'banner.jpg'); - expect(json['keyName'], 'banner_key'); - expect(json['type'], 'image/jpeg'); - }); - - test('should handle empty strings in fromJson', () { - final json = {'id': null, 'name': null, 'keyName': null, 'type': null}; - - final media = MediaModel.fromJson(json); - - expect(media.id, ''); - expect(media.name, ''); - expect(media.keyName, ''); - expect(media.type, ''); - }); - }); - - group('UserProfileModel', () { - late UserProfileModel testProfile; - late MediaModel profileMedia; - late MediaModel coverMedia; - - setUp(() { - profileMedia = MediaModel( - id: 'profile1', - name: 'profile.jpg', - keyName: 'profile_key', - type: 'image/jpeg', - ); - - coverMedia = MediaModel( - id: 'cover1', - name: 'cover.jpg', - keyName: 'cover_key', - type: 'image/jpeg', - ); - - testProfile = UserProfileModel( - id: 'user1', - name: 'John Doe', - username: 'johndoe', - email: 'john@example.com', - bio: 'Software developer', - website: 'https://johndoe.com', - verified: true, - address: 'New York, USA', - protectedAccount: false, - joinDate: '2024-01-01', - profileMediaId: 'profile1', - profileMedia: profileMedia, - resolvedProfilePhotoUrl: 'https://example.com/profile.jpg', - coverMediaId: 'cover1', - coverMedia: coverMedia, - followersCount: 100, - followingCount: 50, - ); - }); - - test('should create UserProfileModel with all fields', () { - expect(testProfile.id, 'user1'); - expect(testProfile.name, 'John Doe'); - expect(testProfile.username, 'johndoe'); - expect(testProfile.email, 'john@example.com'); - expect(testProfile.bio, 'Software developer'); - expect(testProfile.website, 'https://johndoe.com'); - expect(testProfile.verified, true); - expect(testProfile.address, 'New York, USA'); - expect(testProfile.protectedAccount, false); - expect(testProfile.joinDate, '2024-01-01'); - }); - - test('should handle profile media', () { - expect(testProfile.profileMediaId, 'profile1'); - expect(testProfile.profileMedia, profileMedia); - expect(testProfile.profileMedia?.name, 'profile.jpg'); - expect( - testProfile.resolvedProfilePhotoUrl, - 'https://example.com/profile.jpg', - ); - }); - - test('should handle cover media', () { - expect(testProfile.coverMediaId, 'cover1'); - expect(testProfile.coverMedia, coverMedia); - expect(testProfile.coverMedia?.name, 'cover.jpg'); - }); - - test('should track followers and following counts', () { - expect(testProfile.followersCount, 100); - expect(testProfile.followingCount, 50); - }); - - test('should handle verified status', () { - expect(testProfile.verified, true); - - final unverifiedProfile = UserProfileModel( - id: 'user2', - name: 'Jane Doe', - username: 'janedoe', - email: 'jane@example.com', - verified: false, - protectedAccount: false, - joinDate: '2024-01-01', - followersCount: 10, - followingCount: 20, - ); - - expect(unverifiedProfile.verified, false); - }); - - test('should handle protected account', () { - expect(testProfile.protectedAccount, false); - - final protectedProfile = UserProfileModel( - id: 'user3', - name: 'Private User', - username: 'private', - email: 'private@example.com', - verified: false, - protectedAccount: true, - joinDate: '2024-01-01', - followersCount: 5, - followingCount: 3, - ); - - expect(protectedProfile.protectedAccount, true); - }); - - test('should handle optional fields as null', () { - final minimalProfile = UserProfileModel( - id: 'user4', - name: 'Minimal User', - username: 'minimal', - email: 'minimal@example.com', - verified: false, - protectedAccount: false, - joinDate: '2024-01-01', - followersCount: 0, - followingCount: 0, - ); - - expect(minimalProfile.bio, isNull); - expect(minimalProfile.website, isNull); - expect(minimalProfile.address, isNull); - expect(minimalProfile.profileMediaId, isNull); - expect(minimalProfile.profileMedia, isNull); - expect(minimalProfile.resolvedProfilePhotoUrl, isNull); - expect(minimalProfile.coverMediaId, isNull); - expect(minimalProfile.coverMedia, isNull); - }); - - test('should handle zero followers and following', () { - final newProfile = UserProfileModel( - id: 'user5', - name: 'New User', - username: 'newuser', - email: 'new@example.com', - verified: false, - protectedAccount: false, - joinDate: '2024-06-01', - followersCount: 0, - followingCount: 0, - ); - - expect(newProfile.followersCount, 0); - expect(newProfile.followingCount, 0); - }); - - test('should handle different join dates', () { - expect(testProfile.joinDate, '2024-01-01'); - - final recentProfile = UserProfileModel( - id: 'user6', - name: 'Recent User', - username: 'recent', - email: 'recent@example.com', - verified: false, - protectedAccount: false, - joinDate: '2024-12-01', - followersCount: 1, - followingCount: 1, - ); - - expect(recentProfile.joinDate, '2024-12-01'); - }); - }); -} diff --git a/test/features/home/providers/user_profile_provider_test.dart b/test/features/home/providers/user_profile_provider_test.dart deleted file mode 100644 index 167e48a..0000000 --- a/test/features/home/providers/user_profile_provider_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/providers/user_profile_provider.dart'; - -void main() { - group('UserProfileProvider', () { - test('should have initial state as AsyncValue.data(null)', () { - // This test verifies the provider initialization - // In a real scenario, you would use ProviderContainer to test this - expect(userProfileProvider, isNotNull); - }); - }); - - group('UserProfileController', () { - test('should be instantiable', () { - // Basic test to ensure the class structure is correct - // Full integration tests would require mocking Ref and dependencies - expect(UserProfileController, isNotNull); - }); - - // Note: Full integration tests for UserProfileController would require: - // - Mocking the Ref object - // - Mocking homeRepositoryProvider - // - Mocking currentUserProvider - // - Mocking getMediaUrls function - // These would be better suited as integration tests rather than unit tests - }); -} diff --git a/test/features/home/repositories/home_repository_test.dart b/test/features/home/repositories/home_repository_test.dart deleted file mode 100644 index 6e6adba..0000000 --- a/test/features/home/repositories/home_repository_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/repositories/home_repository.dart'; - -void main() { - group('HomeRepository', () { - test('should be instantiable with Ref', () { - // Basic structural test - // Full integration tests would require: - // - Mocking Dio client - // - Mocking HTTP responses - // - Testing error handling - // - Testing data transformation - expect(HomeRepository, isNotNull); - }); - - // Note: Comprehensive tests for HomeRepository would require: - // - // 1. Mock Dio responses for: - // - fetchForYouTweets - // - fetchFollowingTweets - // - fetchTweets - // - createPost - // - createReply - // - likeTweet / unlikeTweet - // - retweet / undoRetweet - // - bookmarkTweet / unbookmarkTweet - // - deleteTweet - // - getTweetById - // - getUserProfile - // - // 2. Test error scenarios: - // - Network errors - // - Invalid responses - // - Authentication errors - // - Server errors (400, 401, 403, 404, 500) - // - // 3. Test data transformation: - // - Tweet deserialization - // - Media attachment - // - Error message formatting - // - // These tests are best implemented as integration tests with mocked - // HTTP clients using packages like mockito or http_mock_adapter. - // - // Example test structure: - // - // group('fetchFollowingTweets', () { - // test('should return list of tweets on success', () async { - // // Setup mock Dio to return test data - // // Call repository method - // // Verify result - // }); - // - // test('should throw error on network failure', () async { - // // Setup mock Dio to throw DioException - // // Verify exception is thrown with correct message - // }); - // }); - }); -} diff --git a/test/features/home/view_model/home_state_test.dart b/test/features/home/view_model/home_state_test.dart deleted file mode 100644 index bef221b..0000000 --- a/test/features/home/view_model/home_state_test.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/models/tweet_model.dart'; -import 'package:lite_x/features/home/view_model/home_state.dart'; - -void main() { - group('FeedType', () { - test('should have forYou value', () { - expect(FeedType.forYou, isNotNull); - expect(FeedType.forYou.toString(), 'FeedType.forYou'); - }); - - test('should have following value', () { - expect(FeedType.following, isNotNull); - expect(FeedType.following.toString(), 'FeedType.following'); - }); - - test('should be comparable', () { - expect(FeedType.forYou == FeedType.forYou, true); - expect(FeedType.following == FeedType.following, true); - expect(FeedType.forYou == FeedType.following, false); - }); - }); - - group('HomeState', () { - late TweetModel testTweet1; - late TweetModel testTweet2; - late TweetModel testTweet3; - - setUp(() { - testTweet1 = TweetModel( - id: '1', - content: 'Tweet 1', - authorName: 'User 1', - authorUsername: 'user1', - authorAvatar: 'avatar1.jpg', - createdAt: DateTime(2024, 1, 1), - ); - - testTweet2 = TweetModel( - id: '2', - content: 'Tweet 2', - authorName: 'User 2', - authorUsername: 'user2', - authorAvatar: 'avatar2.jpg', - createdAt: DateTime(2024, 1, 2), - ); - - testTweet3 = TweetModel( - id: '3', - content: 'Tweet 3', - authorName: 'User 3', - authorUsername: 'user3', - authorAvatar: 'avatar3.jpg', - createdAt: DateTime(2024, 1, 3), - ); - }); - - test('should create HomeState with default values', () { - const state = HomeState(); - - expect(state.tweets, isEmpty); - expect(state.forYouTweets, isEmpty); - expect(state.followingTweets, isEmpty); - expect(state.isLoading, false); - expect(state.error, isNull); - expect(state.isRefreshing, false); - expect(state.currentFeed, FeedType.following); - }); - - test('should create HomeState with custom values', () { - final state = HomeState( - tweets: [testTweet1, testTweet2], - forYouTweets: [testTweet1], - followingTweets: [testTweet2, testTweet3], - isLoading: true, - error: 'Test error', - isRefreshing: true, - currentFeed: FeedType.forYou, - ); - - expect(state.tweets.length, 2); - expect(state.forYouTweets.length, 1); - expect(state.followingTweets.length, 2); - expect(state.isLoading, true); - expect(state.error, 'Test error'); - expect(state.isRefreshing, true); - expect(state.currentFeed, FeedType.forYou); - }); - - test('should copy state with modified tweets', () { - const initialState = HomeState(); - final newState = initialState.copyWith(tweets: [testTweet1, testTweet2]); - - expect(newState.tweets.length, 2); - expect(newState.tweets[0], testTweet1); - expect(newState.tweets[1], testTweet2); - expect(newState.isLoading, initialState.isLoading); - expect(newState.error, initialState.error); - }); - - test('should copy state with loading flag', () { - const initialState = HomeState(isLoading: false); - final newState = initialState.copyWith(isLoading: true); - - expect(newState.isLoading, true); - expect(newState.tweets, initialState.tweets); - expect(newState.error, initialState.error); - }); - - test('should copy state with error message', () { - const initialState = HomeState(); - final newState = initialState.copyWith(error: 'Network error'); - - expect(newState.error, 'Network error'); - expect(newState.tweets, initialState.tweets); - expect(newState.isLoading, initialState.isLoading); - }); - - test('should copy state with refreshing flag', () { - const initialState = HomeState(isRefreshing: false); - final newState = initialState.copyWith(isRefreshing: true); - - expect(newState.isRefreshing, true); - expect(newState.tweets, initialState.tweets); - expect(newState.isLoading, initialState.isLoading); - }); - - test('should copy state with different feed type', () { - const initialState = HomeState(currentFeed: FeedType.following); - final newState = initialState.copyWith(currentFeed: FeedType.forYou); - - expect(newState.currentFeed, FeedType.forYou); - expect(newState.tweets, initialState.tweets); - }); - - test('should copy state with forYou tweets', () { - const initialState = HomeState(); - final newState = initialState.copyWith( - forYouTweets: [testTweet1, testTweet2, testTweet3], - ); - - expect(newState.forYouTweets.length, 3); - expect(newState.forYouTweets[0], testTweet1); - expect(newState.forYouTweets[2], testTweet3); - }); - - test('should copy state with following tweets', () { - const initialState = HomeState(); - final newState = initialState.copyWith( - followingTweets: [testTweet2, testTweet3], - ); - - expect(newState.followingTweets.length, 2); - expect(newState.followingTweets[0], testTweet2); - expect(newState.followingTweets[1], testTweet3); - }); - - test('should copy state with multiple fields', () { - const initialState = HomeState(); - final newState = initialState.copyWith( - tweets: [testTweet1], - isLoading: true, - error: 'Loading error', - currentFeed: FeedType.forYou, - ); - - expect(newState.tweets.length, 1); - expect(newState.isLoading, true); - expect(newState.error, 'Loading error'); - expect(newState.currentFeed, FeedType.forYou); - }); - - test('should preserve all three tweet lists independently', () { - final state = HomeState( - tweets: [testTweet1], - forYouTweets: [testTweet2], - followingTweets: [testTweet3], - ); - - expect(state.tweets.length, 1); - expect(state.forYouTweets.length, 1); - expect(state.followingTweets.length, 1); - expect(state.tweets[0].id, '1'); - expect(state.forYouTweets[0].id, '2'); - expect(state.followingTweets[0].id, '3'); - }); - - test('should handle empty tweet lists', () { - final state = HomeState( - tweets: const [], - forYouTweets: const [], - followingTweets: const [], - ); - - expect(state.tweets, isEmpty); - expect(state.forYouTweets, isEmpty); - expect(state.followingTweets, isEmpty); - }); - - test('should clear error on copyWith without error parameter', () { - const initialState = HomeState(error: 'Some error'); - final newState = initialState.copyWith(isLoading: true); - - expect( - newState.error, - 'Some error', - ); // Error persists if not explicitly cleared - }); - - test('should handle null error', () { - const state = HomeState(error: null); - expect(state.error, isNull); - }); - - test('should support feed switching scenario', () { - // Simulate switching from following to forYou - final followingState = HomeState( - tweets: [testTweet1, testTweet2], - followingTweets: [testTweet1, testTweet2], - forYouTweets: const [], - currentFeed: FeedType.following, - ); - - final forYouState = followingState.copyWith( - tweets: [testTweet3], - forYouTweets: [testTweet3], - currentFeed: FeedType.forYou, - ); - - expect(forYouState.currentFeed, FeedType.forYou); - expect(forYouState.tweets[0], testTweet3); - expect(forYouState.forYouTweets[0], testTweet3); - expect( - forYouState.followingTweets.length, - 2, - ); // Previous following tweets preserved - }); - - test('should handle loading and refreshing states independently', () { - const state = HomeState(isLoading: true, isRefreshing: true); - - expect(state.isLoading, true); - expect(state.isRefreshing, true); - - final loadedState = state.copyWith(isLoading: false); - expect(loadedState.isLoading, false); - expect(loadedState.isRefreshing, true); - - final refreshedState = loadedState.copyWith(isRefreshing: false); - expect(refreshedState.isLoading, false); - expect(refreshedState.isRefreshing, false); - }); - }); -} diff --git a/test/features/home/view_model/home_view_model_test.dart b/test/features/home/view_model/home_view_model_test.dart deleted file mode 100644 index 00081b7..0000000 --- a/test/features/home/view_model/home_view_model_test.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/home/models/tweet_model.dart'; -import 'package:lite_x/features/home/view_model/home_state.dart'; - -void main() { - TweetModel createTestTweet(String id, String content) { - return TweetModel( - id: id, - content: content, - authorName: 'Test User', - authorUsername: 'testuser', - authorAvatar: 'avatar.jpg', - createdAt: DateTime.now(), - ); - } - - group('HomeViewModel State Management', () { - test('should update tweet like status correctly', () { - final tweet = createTestTweet('1', 'Test Tweet'); - tweet.isLiked = false; - tweet.likes = 5; - - // Simulate like - tweet.isLiked = true; - tweet.likes = 6; - - expect(tweet.isLiked, true); - expect(tweet.likes, 6); - }); - - test('should update tweet retweet status correctly', () { - final tweet = createTestTweet('1', 'Test Tweet'); - tweet.isRetweeted = false; - tweet.retweets = 3; - - // Simulate retweet - tweet.isRetweeted = true; - tweet.retweets = 4; - - expect(tweet.isRetweeted, true); - expect(tweet.retweets, 4); - }); - - test('should update tweet bookmark status correctly', () { - final tweet = createTestTweet('1', 'Test Tweet'); - tweet.isBookmarked = false; - - // Simulate bookmark - tweet.isBookmarked = true; - - expect(tweet.isBookmarked, true); - }); - - test('should maintain separate feed lists', () { - final followingTweets = [ - createTestTweet('1', 'Following Tweet 1'), - createTestTweet('2', 'Following Tweet 2'), - ]; - - final forYouTweets = [ - createTestTweet('3', 'For You Tweet 1'), - createTestTweet('4', 'For You Tweet 2'), - ]; - - final state = HomeState( - tweets: followingTweets, - followingTweets: followingTweets, - forYouTweets: forYouTweets, - currentFeed: FeedType.following, - ); - - expect(state.tweets.length, 2); - expect(state.followingTweets.length, 2); - expect(state.forYouTweets.length, 2); - expect(state.currentFeed, FeedType.following); - }); - - test('should handle feed switching', () { - final followingTweets = [createTestTweet('1', 'Following')]; - final forYouTweets = [createTestTweet('2', 'For You')]; - - final initialState = HomeState( - tweets: followingTweets, - followingTweets: followingTweets, - forYouTweets: forYouTweets, - currentFeed: FeedType.following, - ); - - // Switch to forYou feed - final newState = initialState.copyWith( - tweets: forYouTweets, - currentFeed: FeedType.forYou, - ); - - expect(newState.currentFeed, FeedType.forYou); - expect(newState.tweets[0].id, '2'); - expect(newState.followingTweets[0].id, '1'); // Preserved - }); - - test('should add new tweet to feed', () { - final existingTweets = [createTestTweet('1', 'Existing Tweet')]; - final newTweet = createTestTweet('2', 'New Tweet'); - - final state = HomeState( - tweets: existingTweets, - followingTweets: existingTweets, - ); - - // Add new tweet - final updatedTweets = [newTweet, ...state.followingTweets]; - final newState = state.copyWith( - tweets: updatedTweets, - followingTweets: updatedTweets, - ); - - expect(newState.tweets.length, 2); - expect(newState.tweets[0].id, '2'); // New tweet at top - expect(newState.tweets[1].id, '1'); // Existing tweet - }); - - test('should handle error states', () { - const state = HomeState( - isLoading: false, - error: 'Network error occurred', - ); - - expect(state.error, 'Network error occurred'); - expect(state.isLoading, false); - }); - - test('should handle loading and refreshing independently', () { - const loadingState = HomeState(isLoading: true, isRefreshing: false); - expect(loadingState.isLoading, true); - expect(loadingState.isRefreshing, false); - - const refreshingState = HomeState(isLoading: false, isRefreshing: true); - expect(refreshingState.isLoading, false); - expect(refreshingState.isRefreshing, true); - }); - - test('should update tweet in feed list', () { - final tweet1 = createTestTweet('1', 'Tweet 1'); - tweet1.likes = 5; - final tweet2 = createTestTweet('2', 'Tweet 2'); - - final state = HomeState(tweets: [tweet1, tweet2]); - - // Update tweet1 likes - tweet1.likes = 10; - - expect(state.tweets[0].likes, 10); - expect(state.tweets[1].id, '2'); - }); - - test('should handle reply relationships in feed', () { - final parentTweet = createTestTweet('1', 'Parent tweet'); - final replyTweet = createTestTweet('2', 'Reply tweet'); - - final updatedParent = parentTweet.copyWith(replyIds: ['2']); - - final updatedReply = replyTweet.copyWith(replyToId: '1'); - - expect(updatedParent.replyIds, contains('2')); - expect(updatedReply.replyToId, '1'); - }); - }); -} diff --git a/test/profile/TEST_SUMMARY.md b/test/profile/TEST_SUMMARY.md deleted file mode 100644 index c7a2ae3..0000000 --- a/test/profile/TEST_SUMMARY.md +++ /dev/null @@ -1,88 +0,0 @@ -# Profile Tests - -Added 83 new tests across 8 files for better coverage. - -## Test Files - -### follow_button_widget_test.dart (11 tests) -- Button text changes based on following state -- Optimistic updates on follow/unfollow -- Error rollback when actions fail -- Confirmation dialog for unfollow -- Cancel and confirm behavior - -### block_mute_widget_test.dart (9 tests) -- Block button display and styling -- Unblock confirmation dialog -- Profile refresh after actions -- Error handling and messages -- Mute/unmute functionality - -### like_tweet_optimistic_test.dart (9 tests) -- Like/unlike provider calls -- Error handling with rollback -- Save/unsave tweets -- Rapid interactions -- Multiple simultaneous likes - -### follower_card_widget_test.dart (11 tests) -- User information display -- Follows you badge -- Follow button states -- Error rollback -- Navigation to profile -- Default avatar handling - -### error_handling_retry_test.dart (11 tests) -- Connection timeout errors -- Generic failure handling -- Provider error scenarios -- Multiple failures independently -- Retry after failure -- Error message consistency - -### refresh_indicator_test.dart (17 tests) -- Profile data refresh -- Followers/following refresh -- Posts refresh -- Multiple providers independently -- Refresh after errors -- Loading states - -### edit_profile_controller_test.dart (5 tests) -- Controller instantiation -- Image picking from camera/gallery -- Crop operations -- Error handling -- Sequential picks - -### profile_header_widget_test.dart (10 tests) -- Avatar and banner URLs -- Verified badge display -- Follower/following counts -- Bio display -- Location and website -- Join date -- Protected account icon - -## Running Tests - -Run all profile tests: -``` -flutter test test/profile/ -``` - -Run specific file: -``` -flutter test test/profile/follow_button_widget_test.dart -``` - -With verbose output: -``` -flutter test test/profile/ -r expanded -``` - -## Notes - -Total: 83 new tests + 45 existing = 128 tests -All tests passing on last run diff --git a/test/profile/block_mute_widget_test.dart b/test/profile/block_mute_widget_test.dart deleted file mode 100644 index ec125ca..0000000 --- a/test/profile/block_mute_widget_test.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/view/widgets/profile/block_button.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: true, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('Block Button Widget Tests', () { - testWidgets('displays "Blocked" when user is blocked', (tester) async { - bool showDataCalled = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - expect(find.text('Blocked'), findsOneWidget); - }); - - testWidgets('unblock shows confirmation dialog', (tester) async { - bool showDataCalled = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - // Tap blocked button - await tester.tap(find.text('Blocked')); - await tester.pumpAndSettle(); - - // Should show confirmation dialog - expect(find.text('Unblock @testuser?'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - expect(find.text('UnBlock'), findsOneWidget); - }); - - testWidgets('unblock dialog cancel does not call unblock', (tester) async { - bool showDataCalled = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - // Tap blocked button - await tester.tap(find.text('Blocked')); - await tester.pumpAndSettle(); - - // Tap cancel - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - // Should not call unblock - verifyNever(mockRepo.unBlockUser(any)); - expect(showDataCalled, isFalse); - }); - - testWidgets('unblock success refreshes profile data', (tester) async { - bool showDataCalled = false; - when(mockRepo.unBlockUser('testuser')) - .thenAnswer((_) async => const Right(())); - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile.copyWith(isBlockedByMe: false))); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - // Tap blocked button - await tester.tap(find.text('Blocked')); - await tester.pumpAndSettle(); - - // Confirm unblock - await tester.tap(find.text('UnBlock')); - await tester.pumpAndSettle(); - - // Should call unblock and refresh - verify(mockRepo.unBlockUser('testuser')).called(1); - expect(showDataCalled, isTrue); - }); - - testWidgets('unblock failure shows error message', (tester) async { - bool showDataCalled = false; - when(mockRepo.unBlockUser('testuser')) - .thenAnswer((_) async => Left(Failure("Failed to unblock user"))); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - // Tap blocked button - await tester.tap(find.text('Blocked')); - await tester.pumpAndSettle(); - - // Confirm unblock - await tester.tap(find.text('UnBlock')); - await tester.pumpAndSettle(); - - // Should show error (check for error icon or message) - expect(find.byIcon(Icons.error), findsOneWidget); - expect(showDataCalled, isFalse); - - verify(mockRepo.unBlockUser('testuser')).called(1); - }); - - testWidgets('button has red styling for blocked state', (tester) async { - bool showDataCalled = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: BlockButton( - profileData: testProfile, - showDataFunc: () { - showDataCalled = true; - }, - ), - ), - ), - ), - ); - - // Find button and check styling - final button = tester.widget(find.byType(OutlinedButton)); - final style = button.style!; - - // Should have red foreground color - expect(style.foregroundColor?.resolve({}), Colors.red); - }); - }); - - group('Mute/Unmute Provider Tests', () { - testWidgets('mute user calls repo and returns Right', (tester) async { - when(mockRepo.muteUser('testuser')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final mute = ref.read(muteUserProvider); - await mute('testuser'); - }, - child: Text('Mute'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Mute')); - await tester.pumpAndSettle(); - - verify(mockRepo.muteUser('testuser')).called(1); - }); - - testWidgets('unmute user calls repo and returns Right', (tester) async { - when(mockRepo.unMuteUser('testuser')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final unmute = ref.read(unMuteUserProvider); - await unmute('testuser'); - }, - child: Text('Unmute'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Unmute')); - await tester.pumpAndSettle(); - - verify(mockRepo.unMuteUser('testuser')).called(1); - }); - - testWidgets('mute returns Left on failure', (tester) async { - when(mockRepo.muteUser('testuser')) - .thenAnswer((_) async => Left(Failure("Failed to mute"))); - - String? errorMessage; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final mute = ref.read(muteUserProvider); - final result = await mute('testuser'); - result.fold( - (fail) => errorMessage = fail.message, - (r) => errorMessage = null, - ); - }, - child: Text('Mute'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Mute')); - await tester.pumpAndSettle(); - - expect(errorMessage, 'Failed to mute'); - verify(mockRepo.muteUser('testuser')).called(1); - }); - }); - - group('Block User Provider Tests', () { - testWidgets('block user calls repo and returns Right', (tester) async { - when(mockRepo.blockUser('testuser')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final block = ref.read(blockUserProvider); - await block('testuser'); - }, - child: Text('Block'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Block')); - await tester.pumpAndSettle(); - - verify(mockRepo.blockUser('testuser')).called(1); - }); - - testWidgets('block returns Left on failure', (tester) async { - when(mockRepo.blockUser('testuser')) - .thenAnswer((_) async => Left(Failure("Failed to block"))); - - String? errorMessage; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final block = ref.read(blockUserProvider); - final result = await block('testuser'); - result.fold( - (fail) => errorMessage = fail.message, - (r) => errorMessage = null, - ); - }, - child: Text('Block'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Block')); - await tester.pumpAndSettle(); - - expect(errorMessage, 'Failed to block'); - verify(mockRepo.blockUser('testuser')).called(1); - }); - }); -} diff --git a/test/profile/edit_profile_controller_test.dart b/test/profile/edit_profile_controller_test.dart deleted file mode 100644 index 12ca2ad..0000000 --- a/test/profile/edit_profile_controller_test.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:image_cropper/image_cropper.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:lite_x/features/profile/view/widgets/edit_profile/controller/edit_profile_controller.dart'; - -void main() { - late EditProfileController controller; - - setUp(() { - controller = EditProfileController(); - }); - - group('EditProfileController Tests', () { - test('controller can be instantiated', () { - expect(controller, isNotNull); - expect(controller, isA()); - }); - - test('pickImage returns XFile on successful pick', () async { - // This test would require mocking ImagePicker - // For now, we test that the method exists and returns correct type - expect( - controller.pickImage(ImageSource.gallery), - isA>(), - ); - }); - - test('pickImage handles null return gracefully', () async { - // When user cancels image selection, should return null - final result = await controller.pickImage(ImageSource.gallery); - // Result can be null if user cancels - expect(result, anyOf([isNull, isA()])); - }); - - test('cropImageCircle returns CroppedFile type', () async { - // This test would require mocking ImageCropper and File - // Testing method signature exists - expect( - controller.cropImageCircle, - isA(), - ); - }); - - - - test('controller handles image source camera', () { - expect( - controller.pickImage(ImageSource.camera), - isA>(), - ); - }); - - test('controller handles image source gallery', () { - expect( - controller.pickImage(ImageSource.gallery), - isA>(), - ); - }); - }); - - group('Image Quality Tests', () { - test('pickImage uses quality 100', () async { - // The controller is configured to use imageQuality: 100 - // This ensures maximum quality for profile images - // Test verifies the configuration exists - expect(controller, isNotNull); - }); - - test('crop operations maintain aspect ratio options', () { - // The controller provides circle crop option - // This test verifies the method exists - expect(controller.cropImageCircle, isA()); - }); - }); - - group('Error Handling Tests', () { - test('pickImage returns null on error', () async { - // When an error occurs (e.g., permission denied), should return null - final result = await controller.pickImage(ImageSource.gallery); - expect(result, anyOf([isNull, isA()])); - }); - - test('controller handles multiple sequential picks', () async { - // Test that controller can handle multiple image selections - await controller.pickImage(ImageSource.gallery); - await controller.pickImage(ImageSource.gallery); - - // Should not throw exception - expect(controller, isNotNull); - }); - }); - - group('Image Picker Configuration Tests', () { - test('controller supports both camera and gallery sources', () { - // Verify both image sources are supported - expect(ImageSource.camera, isNotNull); - expect(ImageSource.gallery, isNotNull); - }); - - test('controller maintains instance state', () { - final controller1 = EditProfileController(); - final controller2 = EditProfileController(); - - // Each instance should be independent - expect(controller1, isNot(same(controller2))); - }); - }); - - group('Crop Style Tests', () { - test('circle crop style is supported', () { - // Controller provides circle crop for avatars - expect(CropStyle.circle, isNotNull); - }); - }); -} diff --git a/test/profile/edit_profile_integration_test.dart b/test/profile/edit_profile_integration_test.dart deleted file mode 100644 index 6353cd1..0000000 --- a/test/profile/edit_profile_integration_test.dart +++ /dev/null @@ -1,446 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/view/screens/edit_profile_screen.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('EditProfileScreen Integration Tests', () { - testWidgets('displays all form fields correctly', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify form fields are displayed - expect(find.text('Edit profile'), findsOneWidget); - expect(find.text('Save'), findsOneWidget); - - // Check for form labels - expect(find.text('Name'), findsOneWidget); - expect(find.text('Bio'), findsOneWidget); - expect(find.text('Location'), findsOneWidget); - expect(find.text('Website'), findsOneWidget); - expect(find.text('Birth date'), findsOneWidget); - }); - - testWidgets('name field validation - empty name disables save', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Find the name field and clear it - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - expect(nameField, findsOneWidget); - - await tester.enterText(nameField, ''); - await tester.pumpAndSettle(); - - // Verify Save button is disabled (grey) - final saveText = tester.widget(find.text('Save')); - expect(saveText.style?.color, equals(Colors.grey)); - }); - - testWidgets('name field validation - non-empty name enables save', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Find the name field and enter text - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(nameField, 'New Name'); - await tester.pumpAndSettle(); - - // Verify Save button is enabled (white) - final saveText = tester.widget(find.text('Save')); - expect(saveText.style?.color, equals(Colors.white)); - }); - - testWidgets('form fields update correctly', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Update bio field - final bioField = find.ancestor( - of: find.text('Bio'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(bioField, 'Updated bio text'); - await tester.pumpAndSettle(); - - // Verify the text was entered - expect(find.text('Updated bio text'), findsOneWidget); - }); - - testWidgets('shows loading indicator when saving', (tester) async { - // Setup mock for successful save - when(mockRepo.updateProfile(newModel: anyNamed('newModel'))) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Change name to trigger save - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(nameField, 'Updated Name'); - await tester.pumpAndSettle(); - - // Tap save button - await tester.tap(find.text('Save')); - await tester.pump(); - - // Verify loading indicator appears - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.text('Update Profile...'), findsOneWidget); - }); - - testWidgets('back button pops screen', (tester) async { - bool popped = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Builder( - builder: (context) { - return Scaffold( - body: ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditProfileScreen( - profileData: testProfile, - ), - ), - ).then((_) => popped = true); - }, - child: Text('Open Edit'), - ), - ); - }, - ), - ), - ), - ); - - await tester.tap(find.text('Open Edit')); - await tester.pumpAndSettle(); - - // Find and tap back button - final backButton = find.byType(BackButton); - expect(backButton, findsOneWidget); - - await tester.tap(backButton); - await tester.pumpAndSettle(); - - expect(popped, isTrue); - }); - }); - - group('Form Field Validation Tests', () { - testWidgets('name field accepts valid input', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - // Test various valid inputs - await tester.enterText(nameField, 'John Doe'); - await tester.pumpAndSettle(); - expect(find.text('John Doe'), findsOneWidget); - - await tester.enterText(nameField, 'Alice'); - await tester.pumpAndSettle(); - expect(find.text('Alice'), findsOneWidget); - - await tester.enterText(nameField, 'User 123'); - await tester.pumpAndSettle(); - expect(find.text('User 123'), findsOneWidget); - }); - - testWidgets('bio field accepts multiline input', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - final bioField = find.ancestor( - of: find.text('Bio'), - matching: find.byType(TextFormField), - ); - - const multilineBio = 'Line 1\nLine 2\nLine 3'; - await tester.enterText(bioField, multilineBio); - await tester.pumpAndSettle(); - - expect(find.text(multilineBio), findsOneWidget); - }); - - testWidgets('website field accepts URL input', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - final websiteField = find.ancestor( - of: find.text('Website'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(websiteField, 'https://example.com'); - await tester.pumpAndSettle(); - - expect(find.text('https://example.com'), findsOneWidget); - }); - }); - - group('User Interaction Tests', () { - testWidgets('save button calls update when fields changed', (tester) async { - final updatedProfile = testProfile.copyWith(displayName: 'New Name'); - - when(mockRepo.updateProfile(newModel: anyNamed('newModel'))) - .thenAnswer((_) async => Right(updatedProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Change the name - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(nameField, 'New Name'); - await tester.pumpAndSettle(); - - // Tap save - await tester.tap(find.text('Save')); - await tester.pump(); - await tester.pump(Duration(seconds: 1)); - - // Verify update was called - verify(mockRepo.updateProfile(newModel: anyNamed('newModel'))).called(1); - }); - - testWidgets('save button does not call update when no changes', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Tap save without making changes - await tester.tap(find.text('Save')); - await tester.pump(); - await tester.pump(Duration(milliseconds: 500)); - - // Verify update was not called - verifyNever(mockRepo.updateProfile(newModel: anyNamed('newModel'))); - }); - - testWidgets('tapping Switch to Professional button', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Scroll to find the button - await tester.drag(find.byType(CustomScrollView), Offset(0, -500)); - await tester.pumpAndSettle(); - - // Find and verify button exists - expect(find.text('Switch to Professional'), findsOneWidget); - }); - }); - - group('Error Handling Tests', () { - testWidgets('displays error when save fails', (tester) async { - final failure = Failure('Failed to update profile'); - - when(mockRepo.updateProfile(newModel: anyNamed('newModel'))) - .thenAnswer((_) async => Left(failure)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Change name - final nameField = find.ancestor( - of: find.text('Name'), - matching: find.byType(TextFormField), - ); - - await tester.enterText(nameField, 'New Name'); - await tester.pumpAndSettle(); - - // Tap save - await tester.tap(find.text('Save')); - await tester.pump(); - await tester.pump(Duration(seconds: 1)); - - // Verify error handling was triggered - verify(mockRepo.updateProfile(newModel: anyNamed('newModel'))).called(1); - }); - }); -} diff --git a/test/profile/error_handling_retry_test.dart b/test/profile/error_handling_retry_test.dart deleted file mode 100644 index 541b57c..0000000 --- a/test/profile/error_handling_retry_test.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('Error Handling & Retry Tests', () { - test('getProfileData returns Left with timeout message on connection timeout', () async { - when(mockRepo.getProfileData('testuser')).thenAnswer( - (_) async => Left(Failure('connection timeout, please try agin...')), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final result = await container.read(profileDataProvider('testuser').future); - - result.fold( - (failure) { - expect(failure.message, contains('timeout')); - }, - (profile) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.getProfileData('testuser')).called(1); - container.dispose(); - }); - - test('getProfileData handles generic failure gracefully', () async { - when(mockRepo.getProfileData('testuser')).thenAnswer( - (_) async => Left(Failure('Failed to load profile data, try agian later...')), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final result = await container.read(profileDataProvider('testuser').future); - - result.fold( - (failure) { - expect(failure.message, 'Failed to load profile data, try agian later...'); - }, - (profile) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.getProfileData('testuser')).called(1); - container.dispose(); - }); - - test('followUser returns Left on network failure', () async { - when(mockRepo.followUser('testuser')).thenAnswer( - (_) async => Left(Failure("couldn't follow user")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final follow = container.read(followControllerProvider); - final result = await follow('testuser'); - - result.fold( - (failure) { - expect(failure.message, "couldn't follow user"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.followUser('testuser')).called(1); - container.dispose(); - }); - - test('unFollowUser returns Left on network failure', () async { - when(mockRepo.unFollowUser('testuser')).thenAnswer( - (_) async => Left(Failure("couldn't unfollow user")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final unfollow = container.read(unFollowControllerProvider); - final result = await unfollow('testuser'); - - result.fold( - (failure) { - expect(failure.message, "couldn't unfollow user"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.unFollowUser('testuser')).called(1); - container.dispose(); - }); - - test('blockUser returns Left on failure', () async { - when(mockRepo.blockUser('testuser')).thenAnswer( - (_) async => Left(Failure("couldn't block user")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final block = container.read(blockUserProvider); - final result = await block('testuser'); - - result.fold( - (failure) { - expect(failure.message, "couldn't block user"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.blockUser('testuser')).called(1); - container.dispose(); - }); - - test('muteUser returns Left on failure', () async { - when(mockRepo.muteUser('testuser')).thenAnswer( - (_) async => Left(Failure("couldn't mute user")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final mute = container.read(muteUserProvider); - final result = await mute('testuser'); - - result.fold( - (failure) { - expect(failure.message, "couldn't mute user"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.muteUser('testuser')).called(1); - container.dispose(); - }); - - test('updateProfile returns Left on failure', () async { - when(mockRepo.updateProfile(newModel: testProfile)).thenAnswer( - (_) async => Left(Failure("Failed to update profile")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final editProfile = container.read(editProfileProvider); - final result = await editProfile(testProfile); - - result.fold( - (failure) { - expect(failure.message, "Failed to update profile"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.updateProfile(newModel: testProfile)).called(1); - container.dispose(); - }); - - test('deleteTweet returns Left on failure', () async { - when(mockRepo.deleteTweet('tweet123')).thenAnswer( - (_) async => Left(Failure("Failed to delete tweet")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final deleteTweet = container.read(deleteTweetProvider); - final result = await deleteTweet('tweet123'); - - result.fold( - (failure) { - expect(failure.message, "Failed to delete tweet"); - }, - (_) => fail('Expected Left but got Right'), - ); - - verify(mockRepo.deleteTweet('tweet123')).called(1); - container.dispose(); - }); - - test('multiple failures are handled independently', () async { - when(mockRepo.followUser('user1')).thenAnswer( - (_) async => Left(Failure("Error 1")), - ); - when(mockRepo.followUser('user2')).thenAnswer( - (_) async => Left(Failure("Error 2")), - ); - when(mockRepo.followUser('user3')).thenAnswer( - (_) async => const Right(()), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final follow = container.read(followControllerProvider); - - final result1 = await follow('user1'); - final result2 = await follow('user2'); - final result3 = await follow('user3'); - - expect(result1.isLeft(), true); - expect(result2.isLeft(), true); - expect(result3.isRight(), true); - - result1.fold((f) => expect(f.message, "Error 1"), (_) {}); - result2.fold((f) => expect(f.message, "Error 2"), (_) {}); - - container.dispose(); - }); - - test('consecutive retry after failure succeeds', () async { - int callCount = 0; - when(mockRepo.getProfileData('testuser')).thenAnswer((_) async { - callCount++; - if (callCount == 1) { - return Left(Failure('Network error')); - } else { - return Right(testProfile); - } - }); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // First call fails - final result1 = await container.read(profileDataProvider('testuser').future); - expect(result1.isLeft(), true); - - // Refresh and try again - container.refresh(profileDataProvider('testuser')); - final result2 = await container.read(profileDataProvider('testuser').future); - expect(result2.isRight(), true); - - verify(mockRepo.getProfileData('testuser')).called(2); - container.dispose(); - }); - - test('error message formats are consistent across providers', () async { - when(mockRepo.followUser('user')).thenAnswer( - (_) async => Left(Failure("couldn't follow user")), - ); - when(mockRepo.blockUser('user')).thenAnswer( - (_) async => Left(Failure("couldn't block user")), - ); - when(mockRepo.muteUser('user')).thenAnswer( - (_) async => Left(Failure("couldn't mute user")), - ); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final follow = await container.read(followControllerProvider)('user'); - final block = await container.read(blockUserProvider)('user'); - final mute = await container.read(muteUserProvider)('user'); - - follow.fold((f) => expect(f.message, contains("couldn't")), (_) {}); - block.fold((f) => expect(f.message, contains("couldn't")), (_) {}); - mute.fold((f) => expect(f.message, contains("couldn't")), (_) {}); - - container.dispose(); - }); - }); -} diff --git a/test/profile/follow_button_widget_test.dart b/test/profile/follow_button_widget_test.dart deleted file mode 100644 index 0b132aa..0000000 --- a/test/profile/follow_button_widget_test.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/view/widgets/profile/follow_following_button.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('Follow/Following Button Widget Tests', () { - testWidgets('displays "Follow" when user is not following', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: testProfile), - ), - ), - ), - ); - - expect(find.text('Follow'), findsOneWidget); - expect(find.text('Following'), findsNothing); - }); - - testWidgets('displays "Follow Back" when user is follower but not following', (tester) async { - final followerProfile = testProfile.copyWith(isFollower: true, isFollowing: false); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followerProfile), - ), - ), - ), - ); - - expect(find.text('Follow Back'), findsOneWidget); - }); - - testWidgets('displays "Following" when user is following', (tester) async { - final followingProfile = testProfile.copyWith(isFollowing: true); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followingProfile), - ), - ), - ), - ); - - expect(find.text('Following'), findsOneWidget); - }); - - testWidgets('follow action updates state optimistically on success', (tester) async { - when(mockRepo.followUser('testuser')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: testProfile), - ), - ), - ), - ); - - // Initial state - expect(find.text('Follow'), findsOneWidget); - - // Tap follow button - await tester.tap(find.text('Follow')); - await tester.pump(); - - // Should update optimistically to "Following" - expect(find.text('Following'), findsOneWidget); - - verify(mockRepo.followUser('testuser')).called(1); - }); - - testWidgets('follow action rolls back state on failure', (tester) async { - when(mockRepo.followUser('testuser')) - .thenAnswer((_) async => Left(Failure("Network error"))); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: testProfile), - ), - ), - ), - ); - - // Tap follow button - await tester.tap(find.text('Follow')); - await tester.pump(); - - // Wait for async operation - await tester.pump(const Duration(milliseconds: 100)); - - // Should roll back to "Follow" after error - expect(find.text('Follow'), findsOneWidget); - - verify(mockRepo.followUser('testuser')).called(1); - }); - - testWidgets('unfollow shows confirmation dialog', (tester) async { - final followingProfile = testProfile.copyWith(isFollowing: true); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followingProfile), - ), - ), - ), - ); - - // Tap following button - await tester.tap(find.text('Following')); - await tester.pumpAndSettle(); - - // Should show confirmation dialog - expect(find.text('Unfollow Test User'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - expect(find.text('Unfollow'), findsAtLeastNWidgets(1)); - }); - - testWidgets('unfollow dialog cancel keeps following state', (tester) async { - final followingProfile = testProfile.copyWith(isFollowing: true); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followingProfile), - ), - ), - ), - ); - - // Tap following button - await tester.tap(find.text('Following')); - await tester.pumpAndSettle(); - - // Tap cancel - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - - // Should still be following - expect(find.text('Following'), findsOneWidget); - - verifyNever(mockRepo.unFollowUser(any)); - }); - - testWidgets('unfollow dialog confirm updates state', (tester) async { - final followingProfile = testProfile.copyWith(isFollowing: true); - when(mockRepo.unFollowUser('testuser')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followingProfile), - ), - ), - ), - ); - - // Tap following button - await tester.tap(find.text('Following')); - await tester.pumpAndSettle(); - - // Tap unfollow in dialog - final unfollowButton = find.text('Unfollow').last; - await tester.tap(unfollowButton); - await tester.pump(); - - // Should update to "Follow" - expect(find.text('Follow'), findsOneWidget); - - verify(mockRepo.unFollowUser('testuser')).called(1); - }); - - testWidgets('unfollow rolls back on failure', (tester) async { - final followingProfile = testProfile.copyWith(isFollowing: true); - when(mockRepo.unFollowUser('testuser')) - .thenAnswer((_) async => Left(Failure("Network error"))); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: followingProfile), - ), - ), - ), - ); - - // Tap following button - await tester.tap(find.text('Following')); - await tester.pumpAndSettle(); - - // Confirm unfollow - final unfollowButton = find.text('Unfollow').last; - await tester.tap(unfollowButton); - await tester.pump(); - - // Wait for async error - await tester.pump(const Duration(milliseconds: 100)); - - // Should roll back to "Following" - expect(find.text('Following'), findsOneWidget); - - verify(mockRepo.unFollowUser('testuser')).called(1); - }); - - testWidgets('button styling changes based on following state', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Follow_Following_Button(profileData: testProfile), - ), - ), - ), - ); - - // Find button widget - final button = tester.widget(find.byType(OutlinedButton)); - final style = button.style!; - - // Check initial styling for "Follow" state (white background) - expect(style.backgroundColor?.resolve({}), Colors.white); - expect(style.foregroundColor?.resolve({}), Colors.white); - }); - }); -} diff --git a/test/profile/follower_card_widget_test.dart b/test/profile/follower_card_widget_test.dart deleted file mode 100644 index 45af6a4..0000000 --- a/test/profile/follower_card_widget_test.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/user_model.dart'; -import 'package:lite_x/features/profile/view/widgets/following_followers/follower_card.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testUser = UserModel( - displayName: 'Test User', - userName: 'testuser', - image: 'https://example.com/avatar.jpg', - bio: 'Test bio', - isFollowing: false, - isFollower: false, - isVerified: false, - ); - - group('Follower Card Widget Tests', () { - testWidgets('displays user information correctly', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - GoRoute( - path: '/profilescreen/:username', - builder: (context, state) => Scaffold( - body: Text('Profile'), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - expect(find.text('Test User'), findsOneWidget); - expect(find.text('@testuser'), findsOneWidget); - }); - - testWidgets('shows "Follows you" badge when isFollower is true', (tester) async { - final followerUser = testUser.copyWith(isFollower: true); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: followerUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - expect(find.text('Follows you'), findsOneWidget); - }); - - testWidgets('does not show "Follows you" badge when isFollower is false', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - expect(find.text('Follows you'), findsNothing); - }); - - testWidgets('displays Follow button when not following', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - expect(find.text('Follow'), findsOneWidget); - }); - - testWidgets('displays Following button when already following', (tester) async { - final followingUser = testUser.copyWith(isFollowing: true); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: followingUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - expect(find.text('Following'), findsOneWidget); - }); - - testWidgets('follow button triggers followUser action', (tester) async { - when(mockRepo.followUser('testuser')) - .thenAnswer((_) async => const Right(())); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - await tester.tap(find.text('Follow')); - await tester.pump(); - - // Optimistically updates to "Following" - expect(find.text('Following'), findsOneWidget); - - verify(mockRepo.followUser('testuser')).called(1); - }); - - testWidgets('follow error rolls back to Follow state', (tester) async { - when(mockRepo.followUser('testuser')) - .thenAnswer((_) async => Left(Failure("Network error"))); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - await tester.tap(find.text('Follow')); - await tester.pump(); - - // Wait for error - await tester.pump(const Duration(milliseconds: 100)); - - // Should roll back to "Follow" - expect(find.text('Follow'), findsOneWidget); - - verify(mockRepo.followUser('testuser')).called(1); - }); - - testWidgets('unfollow shows confirmation dialog', (tester) async { - final followingUser = testUser.copyWith(isFollowing: true); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: followingUser, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - await tester.pumpAndSettle(); - - // Test verifies widget can be built with following user - expect(find.text('Test User'), findsOneWidget); - }); - - testWidgets('navigates to profile on tap', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: testUser, isMe: false), - ), - ), - GoRoute( - path: '/profilescreen/:username', - builder: (context, state) { - final username = state.pathParameters['username']; - return Scaffold( - body: Text('Profile: $username'), - ); - }, - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - // Tap on the card (not the button) - await tester.tap(find.text('Test User')); - await tester.pumpAndSettle(); - - // Should navigate to profile - expect(find.text('Profile: testuser'), findsOneWidget); - }); - - testWidgets('displays default avatar when image is empty', (tester) async { - final userWithoutImage = testUser.copyWith(image: ''); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: FollowerCard(user: userWithoutImage, isMe: false), - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp.router( - routerConfig: router, - ), - ), - ); - - // Should show default person icon - expect(find.byIcon(Icons.person), findsWidgets); - }); - }); -} diff --git a/test/profile/like_tweet_optimistic_test.dart b/test/profile/like_tweet_optimistic_test.dart deleted file mode 100644 index c5fe7a7..0000000 --- a/test/profile/like_tweet_optimistic_test.dart +++ /dev/null @@ -1,371 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - group('Like/Unlike Tweet Optimistic Update Tests', () { - testWidgets('like tweet provider calls repo and returns Right on success', (tester) async { - when(mockRepo.likeTweet('tweet123')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final like = ref.read(likeTweetProvider); - await like('tweet123'); - }, - child: Text('Like'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Like')); - await tester.pumpAndSettle(); - - verify(mockRepo.likeTweet('tweet123')).called(1); - }); - - testWidgets('unlike tweet provider calls repo and returns Right on success', (tester) async { - when(mockRepo.unLikeTweet('tweet123')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final unlike = ref.read(unlikeTweetProvider); - await unlike('tweet123'); - }, - child: Text('Unlike'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Unlike')); - await tester.pumpAndSettle(); - - verify(mockRepo.unLikeTweet('tweet123')).called(1); - }); - - testWidgets('like tweet returns Left on failure', (tester) async { - when(mockRepo.likeTweet('tweet123')) - .thenAnswer((_) async => Left(Failure("Network error"))); - - String? errorMessage; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final like = ref.read(likeTweetProvider); - final result = await like('tweet123'); - result.fold( - (fail) => errorMessage = fail.message, - (r) => errorMessage = null, - ); - }, - child: Text('Like'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Like')); - await tester.pumpAndSettle(); - - expect(errorMessage, 'Network error'); - verify(mockRepo.likeTweet('tweet123')).called(1); - }); - - testWidgets('unlike tweet returns Left on failure', (tester) async { - when(mockRepo.unLikeTweet('tweet123')) - .thenAnswer((_) async => Left(Failure("Network error"))); - - String? errorMessage; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final unlike = ref.read(unlikeTweetProvider); - final result = await unlike('tweet123'); - result.fold( - (fail) => errorMessage = fail.message, - (r) => errorMessage = null, - ); - }, - child: Text('Unlike'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Unlike')); - await tester.pumpAndSettle(); - - expect(errorMessage, 'Network error'); - verify(mockRepo.unLikeTweet('tweet123')).called(1); - }); - - testWidgets('multiple like calls are handled correctly', (tester) async { - when(mockRepo.likeTweet('tweet123')) - .thenAnswer((_) async => const Right(())); - when(mockRepo.likeTweet('tweet456')) - .thenAnswer((_) async => const Right(())); - - int likeCount = 0; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return Column( - children: [ - ElevatedButton( - onPressed: () async { - final like = ref.read(likeTweetProvider); - await like('tweet123'); - likeCount++; - }, - child: Text('Like 1'), - ), - ElevatedButton( - onPressed: () async { - final like = ref.read(likeTweetProvider); - await like('tweet456'); - likeCount++; - }, - child: Text('Like 2'), - ), - ], - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Like 1')); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Like 2')); - await tester.pumpAndSettle(); - - expect(likeCount, 2); - verify(mockRepo.likeTweet('tweet123')).called(1); - verify(mockRepo.likeTweet('tweet456')).called(1); - }); - }); - - group('Save/Unsave Tweet Tests', () { - testWidgets('save tweet provider calls repo and returns Right', (tester) async { - when(mockRepo.saveTweet('tweet123')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final save = ref.read(saveTweetProvider); - await save('tweet123'); - }, - child: Text('Save'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - verify(mockRepo.saveTweet('tweet123')).called(1); - }); - - testWidgets('unsave tweet provider calls repo and returns Right', (tester) async { - when(mockRepo.unSaveTweet('tweet123')) - .thenAnswer((_) async => const Right(())); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final unsave = ref.read(unSaveTweetProvider); - await unsave('tweet123'); - }, - child: Text('Unsave'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Unsave')); - await tester.pumpAndSettle(); - - verify(mockRepo.unSaveTweet('tweet123')).called(1); - }); - - testWidgets('save tweet returns Left on failure', (tester) async { - when(mockRepo.saveTweet('tweet123')) - .thenAnswer((_) async => Left(Failure("Save failed"))); - - String? errorMessage; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final save = ref.read(saveTweetProvider); - final result = await save('tweet123'); - result.fold( - (fail) => errorMessage = fail.message, - (r) => errorMessage = null, - ); - }, - child: Text('Save'), - ); - }, - ), - ), - ), - ), - ); - - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(errorMessage, 'Save failed'); - verify(mockRepo.saveTweet('tweet123')).called(1); - }); - }); - - group('Tweet Interaction Performance Tests', () { - testWidgets('rapid like/unlike calls are handled correctly', (tester) async { - int callCount = 0; - when(mockRepo.likeTweet('tweet123')).thenAnswer((_) async { - callCount++; - return const Right(()); - }); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - return ElevatedButton( - onPressed: () async { - final like = ref.read(likeTweetProvider); - await like('tweet123'); - }, - child: Text('Like'), - ); - }, - ), - ), - ), - ), - ); - - // Rapidly tap like button - await tester.tap(find.text('Like')); - await tester.pump(const Duration(milliseconds: 10)); - await tester.tap(find.text('Like')); - await tester.pump(const Duration(milliseconds: 10)); - await tester.tap(find.text('Like')); - await tester.pumpAndSettle(); - - expect(callCount, 3); - }); - }); -} diff --git a/test/profile/navigation_flow_test.dart b/test/profile/navigation_flow_test.dart deleted file mode 100644 index fc7b674..0000000 --- a/test/profile/navigation_flow_test.dart +++ /dev/null @@ -1,508 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/search_user_model.dart'; -import 'package:lite_x/features/profile/models/user_model.dart'; -import 'package:lite_x/features/profile/view/screens/edit_profile_screen.dart'; -import 'package:lite_x/features/profile/view/screens/profile_search_screen.dart'; -import 'package:lite_x/features/profile/view/screens/following_followers_screen.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - final testSearchUser = SearchUserModel( - profileMediaId: "123", - id: '123', - username: 'testuser', - name: 'Test User', - verified: false, - bio: 'Test bio', - profileMedia: '', - followers: 100, - score: 0.8, - isFollowing: false, - isFollower: false, - ); - - final testUserModel = UserModel( - displayName: 'Test User', - userName: 'testuser', - image: '', - bio: 'Test bio', - isFollowing: false, - isFollower: false, - isVerified: false, - ); - - group('Profile Navigation Flow Tests', () { - testWidgets('search screen to profile screen navigation', (tester) async { - when( - mockRepo.profileCurrentSearch('testuser'), - ).thenAnswer((_) async => Right([testSearchUser])); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => ProfileSearchScreen(), - ), - GoRoute( - path: '/profilescreen/:username', - builder: (context, state) { - final username = state.pathParameters['username']!; - return Scaffold( - appBar: AppBar(title: Text('Profile: $username')), - body: Center(child: Text('Profile Screen')), - ); - }, - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Enter search query - final searchField = find.byType(TextFormField); - await tester.enterText(searchField, 'testuser'); - await tester.pumpAndSettle(); - - // Verify search results appear - expect(find.text('Test User'), findsOneWidget); - expect(find.text('@testuser'), findsOneWidget); - - // Tap on search result - await tester.tap(find.text('Test User')); - await tester.pumpAndSettle(); - - // Verify navigation to profile screen - expect(find.text('Profile Screen'), findsOneWidget); - expect(find.text('Profile: testuser'), findsOneWidget); - }); - - testWidgets('profile to edit profile navigation', (tester) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - appBar: AppBar(title: Text('Profile')), - body: Center( - child: ElevatedButton( - onPressed: () { - context.push('/edit-profile'); - }, - child: Text('Edit Profile'), - ), - ), - ), - ), - GoRoute( - path: '/edit-profile', - builder: (context, state) => - EditProfileScreen(profileData: testProfile), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Tap edit profile button - await tester.tap(find.text('Edit Profile')); - await tester.pumpAndSettle(); - - // Verify navigation to edit screen - expect(find.text('Edit profile'), findsOneWidget); - expect(find.text('Name'), findsOneWidget); - expect(find.text('Bio'), findsOneWidget); - }); - - testWidgets('profile to followers/following navigation', (tester) async { - when( - mockRepo.getFollowers('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - when( - mockRepo.getFollowings('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - // Mock additional tab providers to prevent errors - when( - mockRepo.getVerifiedFollowers('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - when( - mockRepo.getFollowersYouKnow('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - appBar: AppBar(title: Text('Profile')), - body: Center( - child: Column( - children: [ - ElevatedButton( - onPressed: () { - context.push('/followers'); - }, - child: Text('View Followers'), - ), - ElevatedButton( - onPressed: () { - context.push('/following'); - }, - child: Text('View Following'), - ), - ], - ), - ), - ), - ), - GoRoute( - path: '/followers', - builder: (context, state) => FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 2, - ), - ), - GoRoute( - path: '/following', - builder: (context, state) => FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 3, - ), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Navigate to followers - await tester.tap(find.text('View Followers')); - await tester.pumpAndSettle(); - - expect(find.byType(FollowingFollowersScreen), findsOneWidget); - - // Navigate to following (don't test back navigation in this complex widget) - router.go('/'); - await tester.pumpAndSettle(); - - await tester.tap(find.text('View Following')); - await tester.pumpAndSettle(); - - expect(find.byType(FollowingFollowersScreen), findsOneWidget); - }); - }); - - group('Back Navigation Tests', () { - testWidgets('back button from edit profile returns to profile', ( - tester, - ) async { - bool returnedToProfile = false; - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - appBar: AppBar(title: Text('Profile')), - body: Center( - child: ElevatedButton( - onPressed: () async { - await context.push('/edit-profile'); - returnedToProfile = true; - }, - child: Text('Edit Profile'), - ), - ), - ), - ), - GoRoute( - path: '/edit-profile', - builder: (context, state) => - EditProfileScreen(profileData: testProfile), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Navigate to edit profile - await tester.tap(find.text('Edit Profile')); - await tester.pumpAndSettle(); - - // Tap back button - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - - // Verify returned to profile - expect(returnedToProfile, isTrue); - expect(find.text('Profile'), findsOneWidget); - }); - - testWidgets('back button from search screen', (tester) async { - when( - mockRepo.profileCurrentSearch(''), - ).thenAnswer((_) async => Right([])); - - bool canPopCalled = false; - - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - appBar: AppBar(title: Text('Home')), - body: Center( - child: ElevatedButton( - onPressed: () async { - await context.push('/search'); - canPopCalled = true; - }, - child: Text('Search'), - ), - ), - ), - ), - GoRoute( - path: '/search', - builder: (context, state) => ProfileSearchScreen(), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Navigate to search - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - - // Find and tap back button - final backButton = find.byType(BackButton); - await tester.tap(backButton); - await tester.pumpAndSettle(); - - // Verify returned to home - expect(canPopCalled, isTrue); - expect(find.text('Home'), findsOneWidget); - }); - }); - - group('Deep Linking Navigation Tests', () { - testWidgets('navigate directly to profile with username', (tester) async { - when( - mockRepo.getProfileData('testuser'), - ).thenAnswer((_) async => Right(testProfile)); - - final router = GoRouter( - initialLocation: '/profile/testuser', - routes: [ - GoRoute( - path: '/', - builder: (context, state) => - Scaffold(appBar: AppBar(title: Text('Home'))), - ), - GoRoute( - path: '/profile/:username', - builder: (context, state) { - final username = state.pathParameters['username']!; - return Scaffold( - appBar: AppBar(title: Text('Profile: $username')), - body: Center(child: Text('Viewing $username')), - ); - }, - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Verify deep link worked - expect(find.text('Profile: testuser'), findsOneWidget); - expect(find.text('Viewing testuser'), findsOneWidget); - }); - - testWidgets('navigate to followers with initial tab index', (tester) async { - when( - mockRepo.getFollowers('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - when( - mockRepo.getFollowings('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - // Mock additional tab providers to prevent errors - when( - mockRepo.getVerifiedFollowers('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - when( - mockRepo.getFollowersYouKnow('testuser'), - ).thenAnswer((_) async => Right([testUserModel])); - - final router = GoRouter( - initialLocation: '/followers?tab=1', - routes: [ - GoRoute( - path: '/', - builder: (context, state) => - Scaffold(appBar: AppBar(title: Text('Home'))), - ), - GoRoute( - path: '/followers', - builder: (context, state) { - final tab = state.uri.queryParameters['tab']; - final initialIndex = int.tryParse(tab ?? '0') ?? 0; - return FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: initialIndex, - ); - }, - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester - .pump(); // Just one pump, not pumpAndSettle to avoid tab loading issues - - // Verify correct screen is loaded - expect(find.byType(FollowingFollowersScreen), findsOneWidget); - }); - }); - - group('Navigation State Preservation Tests', () { - testWidgets('edit profile preserves form state on interruption', ( - tester, - ) async { - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - appBar: AppBar(title: Text('Home')), - body: Center( - child: ElevatedButton( - onPressed: () { - context.push('/edit-profile'); - }, - child: Text('Edit Profile'), - ), - ), - ), - ), - GoRoute( - path: '/edit-profile', - builder: (context, state) => - EditProfileScreen(profileData: testProfile), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - - // Navigate to edit profile - await tester.tap(find.text('Edit Profile')); - await tester.pumpAndSettle(); - - // Find the TextFormField with label 'Name' - final nameField = find.widgetWithText(TextFormField, 'Test User').first; - - await tester.enterText(nameField, 'Modified Name'); - await tester.pumpAndSettle(); - - // Verify text persists - expect(find.text('Modified Name'), findsOneWidget); - }); - }); -} diff --git a/test/profile/profile_additional_providers_test.dart b/test/profile/profile_additional_providers_test.dart deleted file mode 100644 index 4a23f3b..0000000 --- a/test/profile/profile_additional_providers_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dartz/dartz.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:lite_x/features/profile/repositories/profile_repo.dart'; -import 'package:lite_x/features/profile/models/search_user_model.dart'; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) -void main() { - setUpAll(() { - provideDummy>(Left(Failure('dummy'))); - provideDummy>>( - Left(Failure('dummy')), - ); - provideDummy>>( - Left(Failure('dummy')), - ); - }); - - group('Profile additional providers (mockito)', () { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - test( - 'updateProfileBannerProvider calls repo.updateProfileBanner and returns Right', - () async { - when( - mockRepo.updateProfileBanner('user1', 'media1'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(updateProfileBannerProvider); - final res = await fn('user1', 'media1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.updateProfileBanner('user1', 'media1')).called(1); - }, - ); - - test( - 'updateProfilePhotoProvider calls repo.updateProfilePhoto and returns Right', - () async { - when( - mockRepo.updateProfilePhoto('user1', 'media1'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(updateProfilePhotoProvider); - final res = await fn('user1', 'media1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.updateProfilePhoto('user1', 'media1')).called(1); - }, - ); - - test( - 'deleteTweetProvider calls repo.deleteTweet and returns Right', - () async { - when( - mockRepo.deleteTweet('tweet1'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(deleteTweetProvider); - final res = await fn('tweet1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.deleteTweet('tweet1')).called(1); - }, - ); - - test( - 'profileCurrentSearchProvider returns search results on success', - () async { - final mockSearchResults = [ - SearchUserModel( - profileMediaId: "123", - id: '1', - username: 'user1', - name: 'User One', - verified: false, - bio: 'Bio', - profileMedia: '', - followers: 100, - score: 10, - isFollowing: false, - isFollower: false, - ), - ]; - when( - mockRepo.profileCurrentSearch('query'), - ).thenAnswer((_) async => Right(mockSearchResults)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final either = await container.read( - profileCurrentSearchProvider('query').future, - ); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }, - ); - - test( - 'changeEmailProfileProvider calls repo.changeEmailProfile and returns Right', - () async { - when( - mockRepo.changeEmailProfile('newemail@test.com'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(changeEmailProfileProvider); - final res = await fn('newemail@test.com'); - - expect(res.isRight(), isTrue); - verify(mockRepo.changeEmailProfile('newemail@test.com')).called(1); - }, - ); - - test( - 'verifyChangeEmailProfileProvider calls repo.verifyChangeEmailProfile and returns Right', - () async { - when( - mockRepo.verifyChangeEmailProfile('newemail@test.com', '123456'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(verifyChangeEmailProfileProvider); - final res = await fn('newemail@test.com', '123456'); - - expect(res.isRight(), isTrue); - verify( - mockRepo.verifyChangeEmailProfile('newemail@test.com', '123456'), - ).called(1); - }, - ); - - test( - 'changePasswordProfileProvider calls repo.changePasswordProfile and returns Right', - () async { - when( - mockRepo.changePasswordProfile('oldPass', 'newPass', 'newPass'), - ).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final fn = container.read(changePasswordProfileProvider); - final res = await fn( - oldPassword: 'oldPass', - newPassword: 'newPass', - confirmNewPassword: 'newPass', - ); - - expect(res.isRight(), isTrue); - verify( - mockRepo.changePasswordProfile('oldPass', 'newPass', 'newPass'), - ).called(1); - }, - ); - - test('tweetRepliesProvider returns replies on success', () async { - final mockReplies = [ - TweetReplyModel( - id: '1', - userId: 'user1', - content: 'reply content', - createdAt: '2024-01-01', - lastActivityAt: '2024-01-01', - likesCount: 0, - retweetCount: 0, - repliesCount: 0, - quotesCount: 0, - replyControl: 'everyone', - parentId: 'parent1', - tweetType: 'reply', - user: UserReplyModel( - id: 'user1', - name: 'User', - username: 'user1', - profileMedia: '', - protectedAccount: false, - verified: false, - ), - ), - ]; - when( - mockRepo.getTweetReplies('tweet1'), - ).thenAnswer((_) async => Right(mockReplies)); - - final container = ProviderContainer( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - ); - - final either = await container.read( - tweetRepliesProvider('tweet1').future, - ); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - }); -} diff --git a/test/profile/profile_additional_providers_test.mocks.dart b/test/profile/profile_additional_providers_test.mocks.dart deleted file mode 100644 index 1397743..0000000 --- a/test/profile/profile_additional_providers_test.mocks.dart +++ /dev/null @@ -1,829 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_additional_providers_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getMediaPosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getMediaPosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>> getProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileTweet, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> retweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#retweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteRetweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/profile_basic_data_notifier_test.mocks.dart b/test/profile/profile_basic_data_notifier_test.mocks.dart deleted file mode 100644 index b5e0b25..0000000 --- a/test/profile/profile_basic_data_notifier_test.mocks.dart +++ /dev/null @@ -1,732 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_basic_data_notifier_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/profile_header_widget_test.dart b/test/profile/profile_header_widget_test.dart deleted file mode 100644 index e8a4953..0000000 --- a/test/profile/profile_header_widget_test.dart +++ /dev/null @@ -1,383 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: 'https://example.com/avatar.jpg', - bannerUrl: 'https://example.com/banner.jpg', - isVerified: true, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: 'avatar123', - ); - - group('Profile Header Tests', () { - test('profile has avatar URL', () { - expect(testProfile.avatarUrl, isNotEmpty); - expect(testProfile.avatarUrl, contains('https://')); - }); - - test('profile has banner URL', () { - expect(testProfile.bannerUrl, isNotEmpty); - expect(testProfile.bannerUrl, contains('https://')); - }); - - testWidgets('shows verified badge for verified users', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Text(profile.displayName), - if (profile.isVerified) - Icon(Icons.verified, color: Colors.blue), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.verified), findsOneWidget); - }); - - testWidgets('does not show verified badge for non-verified users', (tester) async { - final unverifiedProfile = testProfile.copyWith(isVerified: false); - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(unverifiedProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Text(profile.displayName), - if (profile.isVerified) - Icon(Icons.verified, color: Colors.blue), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.verified), findsNothing); - }); - - testWidgets('displays follower count correctly', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Column( - children: [ - Text('${profile.followersCount} Followers'), - Text('${profile.followingCount} Following'), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text('100 Followers'), findsOneWidget); - expect(find.text('50 Following'), findsOneWidget); - }); - - testWidgets('displays bio correctly', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Column( - children: [ - Text(profile.displayName), - Text(profile.bio), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text('Test bio'), findsOneWidget); - }); - - testWidgets('displays location when available', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Icon(Icons.location_on), - Text(profile.location), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text('Test Location'), findsOneWidget); - expect(find.byIcon(Icons.location_on), findsOneWidget); - }); - - testWidgets('displays website when available', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Icon(Icons.link), - Text(profile.website), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.text('https://test.com'), findsOneWidget); - expect(find.byIcon(Icons.link), findsOneWidget); - }); - - testWidgets('displays join date correctly', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Icon(Icons.calendar_today), - Text('Joined ${profile.joinedDate}'), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.textContaining('Joined'), findsOneWidget); - expect(find.byIcon(Icons.calendar_today), findsOneWidget); - }); - - testWidgets('shows protected account icon when applicable', (tester) async { - final protectedProfile = testProfile.copyWith(protectedAccount: true); - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(protectedProfile)); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return profileData.when( - data: (either) => either.fold( - (l) => Text('Error'), - (profile) => Row( - children: [ - Text(profile.displayName), - if (profile.protectedAccount) - Icon(Icons.lock), - ], - ), - ), - loading: () => CircularProgressIndicator(), - error: (e, s) => Text('Error'), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.lock), findsOneWidget); - }); - }); -} diff --git a/test/profile/profile_notifier_test.dart b/test/profile/profile_notifier_test.dart deleted file mode 100644 index e9fb4a8..0000000 --- a/test/profile/profile_notifier_test.dart +++ /dev/null @@ -1,227 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/repositories/profile_repo.dart'; -import 'package:lite_x/features/profile/view_model/profile_basic_data_notifier.dart'; -import 'package:lite_x/features/profile/view_model/profile_basic_data_states.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('ProfileBasicDataNotifier', () { - test('initial state should be loading with null data', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - // Initial state before async call completes - expect(notifier.state.isLoading, isTrue); - expect(notifier.state.profileData, isNull); - expect(notifier.state.errorMessage, isNull); - - await Future.delayed(Duration(milliseconds: 100)); - notifier.dispose(); - }); - - test('loadProfileData should update state with profile on success', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - // Wait for the async operation to complete - await Future.delayed(Duration(milliseconds: 100)); - - expect(notifier.state.isLoading, isFalse); - expect(notifier.state.profileData, equals(testProfile)); - expect(notifier.state.errorMessage, isNull); - verify(mockRepo.getProfileData('testuser')).called(1); - - notifier.dispose(); - }); - - test('loadProfileData should update state with error on failure', () async { - final failure = Failure('Profile not found'); - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Left(failure)); - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - // Wait for the async operation to complete - await Future.delayed(Duration(milliseconds: 100)); - - expect(notifier.state.isLoading, isFalse); - expect(notifier.state.profileData, isNull); - expect(notifier.state.errorMessage, equals('Profile not found')); - verify(mockRepo.getProfileData('testuser')).called(1); - - notifier.dispose(); - }); - - test('loadProfileData can be called again to refresh data', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - await Future.delayed(Duration(milliseconds: 100)); - - // Call loadProfileData again - final updatedProfile = testProfile.copyWith(displayName: 'Updated Name'); - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(updatedProfile)); - - notifier.loadProfileData('testuser'); - await Future.delayed(Duration(milliseconds: 100)); - - expect(notifier.state.profileData?.displayName, equals('Updated Name')); - verify(mockRepo.getProfileData('testuser')).called(2); - - notifier.dispose(); - }); - - test('loadProfileData can fetch different user profile', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - await Future.delayed(Duration(milliseconds: 100)); - - // Load a different user's profile - final anotherProfile = testProfile.copyWith(username: 'anotheruser'); - when(mockRepo.getProfileData('anotheruser')) - .thenAnswer((_) async => Right(anotherProfile)); - - notifier.loadProfileData('anotheruser'); - await Future.delayed(Duration(milliseconds: 100)); - - expect(notifier.state.profileData?.username, equals('anotheruser')); - verify(mockRepo.getProfileData('anotheruser')).called(1); - - notifier.dispose(); - }); - - test('state transitions from loading to loaded correctly', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async { - await Future.delayed(Duration(milliseconds: 50)); - return Right(testProfile); - }); - - final states = []; - - final notifier = ProfileBasicDataNotifier( - profileRepo: mockRepo, - userName: 'testuser', - ); - - // Listen to state changes - notifier.addListener((state) { - states.add(state); - }); - - await Future.delayed(Duration(milliseconds: 150)); - - // Should have at least loading state and loaded state - expect(states.length, greaterThanOrEqualTo(2)); - expect(states.first.isLoading, isTrue); - expect(states.last.isLoading, isFalse); - expect(states.last.profileData, isNotNull); - - notifier.dispose(); - }); - }); - - group('ProfileBasicDataStates', () { - test('initial factory creates correct initial state', () { - final state = ProfileBasicDataStates.initial(); - - expect(state.isLoading, isFalse); - expect(state.errorMessage, isNull); - expect(state.profileData, isNull); - }); - - test('copyWith updates only specified fields', () { - final initialState = ProfileBasicDataStates.initial(); - - final loadingState = initialState.copyWith(isLoading: true); - expect(loadingState.isLoading, isTrue); - expect(loadingState.errorMessage, isNull); - expect(loadingState.profileData, isNull); - - final errorState = loadingState.copyWith( - isLoading: false, - errorMessage: 'Error occurred', - ); - expect(errorState.isLoading, isFalse); - expect(errorState.errorMessage, equals('Error occurred')); - expect(errorState.profileData, isNull); - }); - - test('copyWith preserves existing values when not specified', () { - final state = ProfileBasicDataStates( - isLoading: true, - errorMessage: 'Initial error', - profileData: testProfile, - ); - - final updatedState = state.copyWith(isLoading: false); - - expect(updatedState.isLoading, isFalse); - expect(updatedState.errorMessage, equals('Initial error')); - expect(updatedState.profileData, equals(testProfile)); - }); - }); -} diff --git a/test/profile/profile_notifier_test.mocks.dart b/test/profile/profile_notifier_test.mocks.dart deleted file mode 100644 index 80cc836..0000000 --- a/test/profile/profile_notifier_test.mocks.dart +++ /dev/null @@ -1,829 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_notifier_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getMediaPosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getMediaPosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>> getProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileTweet, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> retweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#retweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteRetweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/profile_providers_mockito_test.dart b/test/profile/profile_providers_mockito_test.dart deleted file mode 100644 index aea45c0..0000000 --- a/test/profile/profile_providers_mockito_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dartz/dartz.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:lite_x/features/profile/repositories/profile_repo.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/user_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; - -import 'profile_providers_mockito_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) -void main() { - setUpAll(() { - // register dummy values for Mockito where generic Either are used in generated mocks - provideDummy>(Left(Failure('dummy'))); - provideDummy>(Left(Failure('dummy'))); - provideDummy>>(Left(Failure('dummy'))); - }); - - group('Profile providers (mockito)', () { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - test('profileDataProvider returns profile on success', () async { - final mockProfile = ProfileModel( - id: '1', - username: 'testuser', - displayName: 'Test User', - bio: '', - avatarUrl: '', - bannerUrl: '', - followersCount: 0, - followingCount: 0, - tweetsCount: 0, - isVerified: false, - joinedDate: '', - website: '', - location: '', - postCount: 0, - birthDate: '', - isFollowing: false, - isFollower: false, - protectedAccount: false, - isBlockedByMe: false, - isMutedByMe: false, - email: '', - avatarId: '', - ); - - when(mockRepo.getProfileData('testuser')).thenAnswer((_) async => Right(mockProfile)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(profileDataProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.username, 'testuser')); - }); - - test('profileDataProvider returns failure on error', () async { - when(mockRepo.getProfileData('baduser')).thenAnswer((_) async => Left(Failure('not found'))); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(profileDataProvider('baduser').future); - expect(either.isLeft(), isTrue); - either.fold((l) => expect(l.message, 'not found'), (r) => null); - }); - - test('followControllerProvider calls repo.followUser and returns Right', () async { - when(mockRepo.followUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(followControllerProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.followUser('other')).called(1); - }); - - test('updateProfileProvider calls repo.updateProfile and returns updated model', () async { - final input = ProfileModel( - id: '2', - username: 'u2', - displayName: 'U2', - bio: '', - avatarUrl: '', - bannerUrl: '', - followersCount: 0, - followingCount: 0, - tweetsCount: 0, - isVerified: false, - joinedDate: '', - website: '', - location: '', - postCount: 0, - birthDate: '', - isFollowing: false, - isFollower: false, - protectedAccount: false, - isBlockedByMe: false, - isMutedByMe: false, - email: '', - avatarId: '', - ); - when(mockRepo.updateProfile(newModel: anyNamed('newModel'))).thenAnswer((_) async => Right(input)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(editProfileProvider); - final res = await fn(input); - - expect(res.isRight(), isTrue); - res.fold((l) => fail('expected Right'), (r) => expect(r.username, 'u2')); - verify(mockRepo.updateProfile(newModel: input)).called(1); - }); - }); -} diff --git a/test/profile/profile_providers_mockito_test.mocks.dart b/test/profile/profile_providers_mockito_test.mocks.dart deleted file mode 100644 index 1423c2e..0000000 --- a/test/profile/profile_providers_mockito_test.mocks.dart +++ /dev/null @@ -1,829 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_providers_mockito_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getMediaPosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getMediaPosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>> getProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileTweet, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> retweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#retweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteRetweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/profile_repo_extended_test.dart b/test/profile/profile_repo_extended_test.dart deleted file mode 100644 index 40afe5f..0000000 --- a/test/profile/profile_repo_extended_test.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dartz/dartz.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:lite_x/features/profile/repositories/profile_repo.dart'; -import 'package:lite_x/features/profile/models/user_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart'; - -import 'profile_providers_mockito_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) -void main() { - setUpAll(() { - provideDummy>(Left(Failure('dummy'))); - provideDummy>>(Left(Failure('dummy'))); - }); - - group('Profile providers (extended mockito)', () { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - test('unFollowControllerProvider calls repo.unFollowUser and returns Right', () async { - when(mockRepo.unFollowUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(unFollowControllerProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.unFollowUser('other')).called(1); - }); - - test('followersProvider returns followers on success', () async { - final mockFollowers = [ - UserModel(userName: 'follower1', displayName: 'Follower One', image: '', bio: '', isVerified: false, isFollowing: false, isFollower: false), - UserModel(userName: 'follower2', displayName: 'Follower Two', image: '', bio: '', isVerified: false, isFollowing: false, isFollower: false), - ]; - when(mockRepo.getFollowers('testuser')).thenAnswer((_) async => Right(mockFollowers)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(followersProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 2)); - }); - - test('followingsProvider returns followings on success', () async { - final mockFollowings = [ - UserModel(userName: 'following1', displayName: 'Following One', image: '', bio: '', isVerified: false, isFollowing: false, isFollower: false), - ]; - when(mockRepo.getFollowings('testuser')).thenAnswer((_) async => Right(mockFollowings)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(followingsProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - - test('blockUserProvider calls repo.blockUser and returns Right', () async { - when(mockRepo.blockUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(blockUserProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.blockUser('other')).called(1); - }); - - test('unBlockUserProvider calls repo.unBlockUser and returns Right', () async { - when(mockRepo.unBlockUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(unBlockUserProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.unBlockUser('other')).called(1); - }); - - test('muteUserProvider calls repo.muteUser and returns Right', () async { - when(mockRepo.muteUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(muteUserProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.muteUser('other')).called(1); - }); - - test('unMuteUserProvider calls repo.unMuteUser and returns Right', () async { - when(mockRepo.unMuteUser('other')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(unMuteUserProvider); - final res = await fn('other'); - - expect(res.isRight(), isTrue); - verify(mockRepo.unMuteUser('other')).called(1); - }); - test('getMutedUsersProvider returns muted list on success', () async { - final mockMuted = [ - UserModel(userName: 'muted1', displayName: 'Muted One', image: '', bio: '', isVerified: false, isFollowing: false, isFollower: false), - ]; - when(mockRepo.getMutedList('testuser')).thenAnswer((_) async => Right(mockMuted)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(getMutedUsersProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - - test('getBlockedUsersProvider returns blocked list on success', () async { - final mockBlocked = [ - UserModel(userName: 'blocked1', displayName: 'Blocked One', image: '', bio: '', isVerified: false, isFollowing: false, isFollower: false), - ]; - when(mockRepo.getBlockedList('testuser')).thenAnswer((_) async => Right(mockBlocked)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(getBlockedUsersProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - test('profilePostsProvider returns posts on success', () async { - final mockPosts = [ - ProfileTweetModel( - id: '1', - text: 'test post', - timeAgo: '1h', - likes: 0, - retweets: 0, - replies: 0, - activityNumber: 0, - isLikedByMe: false, - isSavedByMe: false, - isRepostedWithMe: false, - quotesCount: 0, - userId: 'user1', - userUserName: 'testuser', - userDisplayName: 'Test User', - profileMediaId: '', - verified: false, - protectedAccount: false, - type: TweetType.Tweet, - mediaIds: [], - parentId: "123" - ), - ]; - when(mockRepo.getProfilePosts('testuser')).thenAnswer((_) async => Right(mockPosts)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(profilePostsProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - - test('profileLikesProvider returns liked posts on success', () async { - final mockLikes = [ - ProfileTweetModel( - id: '2', - text: 'liked post', - timeAgo: '2h', - likes: 1, - retweets: 0, - replies: 0, - activityNumber: 0, - isLikedByMe: true, - isSavedByMe: false, - isRepostedWithMe: false, - quotesCount: 0, - userId: 'user2', - userUserName: 'anotheruser', - userDisplayName: 'Another User', - profileMediaId: '', - verified: false, - protectedAccount: false, - type: TweetType.Tweet, - mediaIds: [], - parentId: "123" - ), - ]; - when(mockRepo.getProfileLikes('testuser')).thenAnswer((_) async => Right(mockLikes)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(profileLikesProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - test('likeTweetProvider calls repo.likeTweet and returns Right', () async { - when(mockRepo.likeTweet('tweet1')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(likeTweetProvider); - final res = await fn('tweet1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.likeTweet('tweet1')).called(1); - }); - - test('unlikeTweetProvider calls repo.unLikeTweet and returns Right', () async { - when(mockRepo.unLikeTweet('tweet1')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(unlikeTweetProvider); - final res = await fn('tweet1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.unLikeTweet('tweet1')).called(1); - }); - - test('saveTweetProvider calls repo.saveTweet and returns Right', () async { - when(mockRepo.saveTweet('tweet1')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(saveTweetProvider); - final res = await fn('tweet1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.saveTweet('tweet1')).called(1); - }); - - test('unSaveTweetProvider calls repo.unSaveTweet and returns Right', () async { - when(mockRepo.unSaveTweet('tweet1')).thenAnswer((_) async => const Right(null)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final fn = container.read(unSaveTweetProvider); - final res = await fn('tweet1'); - - expect(res.isRight(), isTrue); - verify(mockRepo.unSaveTweet('tweet1')).called(1); - }); - test('verifiedFollowersProvider returns verified followers on success', () async { - final mockUsers = [ - UserModel(userName: 'verified1', displayName: 'Verified One', image: '', bio: '', isVerified: true), - ]; - when(mockRepo.getVerifiedFollowers('testuser')).thenAnswer((_) async => Right(mockUsers)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(verifiedFollowersProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - - test('followersYouKnowProvider returns followers you know on success', () async { - final mockUsers = [ - UserModel(userName: 'known1', displayName: 'Known One', image: '', bio: '', isFollower: true), - ]; - when(mockRepo.getFollowersYouKnow('testuser')).thenAnswer((_) async => Right(mockUsers)); - - final container = ProviderContainer(overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ]); - - final either = await container.read(followersYouKnowProvider('testuser').future); - expect(either.isRight(), isTrue); - either.fold((l) => fail('expected Right'), (r) => expect(r.length, 1)); - }); - }); -} diff --git a/test/profile/profile_repo_extended_test.mocks.dart b/test/profile/profile_repo_extended_test.mocks.dart deleted file mode 100644 index 1c41604..0000000 --- a/test/profile/profile_repo_extended_test.mocks.dart +++ /dev/null @@ -1,829 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_repo_extended_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getMediaPosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getMediaPosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>> getProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileTweet, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> retweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#retweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteRetweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/profile_screens_widget_test.dart b/test/profile/profile_screens_widget_test.dart deleted file mode 100644 index 330a2dd..0000000 --- a/test/profile/profile_screens_widget_test.dart +++ /dev/null @@ -1,339 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/view/screens/edit_profile_screen.dart'; -import 'package:lite_x/features/profile/view/screens/following_followers_screen.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - group('EditProfileScreen Widget Tests', () { - testWidgets('EditProfileScreen displays profile form', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify screen structure - expect(find.byType(EditProfileScreen), findsOneWidget); - expect(find.byType(AppBar), findsOneWidget); - }); - - testWidgets('EditProfileScreen has save button', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Look for save button or text - at least one should exist - final hasSaveButton = find.descendant( - of: find.byType(AppBar), - matching: find.byType(TextButton), - ).evaluate().isNotEmpty; - final hasSaveText = find.text('Save').evaluate().isNotEmpty; - expect(hasSaveButton || hasSaveText, isTrue); - }); - }); - - group('FollowingFollowersScreen Widget Tests', () { - testWidgets('FollowingFollowersScreen displays tabs', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 0, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify screen exists - expect(find.byType(FollowingFollowersScreen), findsOneWidget); - expect(find.byType(AppBar), findsOneWidget); - expect(find.byType(TabBar), findsOneWidget); - }); - - testWidgets('FollowingFollowersScreen displays user name in AppBar', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 0, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify display name appears in AppBar - expect(find.text(testProfile.displayName), findsOneWidget); - }); - - testWidgets('FollowingFollowersScreen back button pops navigation', (tester) async { - bool popped = false; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Builder( - builder: (context) { - return Scaffold( - body: ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 0, - ), - ), - ).then((_) => popped = true); - }, - child: Text('Navigate'), - ), - ); - }, - ), - ), - ), - ); - - // Navigate to FollowingFollowersScreen - await tester.tap(find.text('Navigate')); - await tester.pumpAndSettle(); - - // Find and tap back button - final backButton = find.byIcon(Icons.arrow_back); - expect(backButton, findsOneWidget); - - await tester.tap(backButton); - await tester.pumpAndSettle(); - - expect(popped, isTrue); - }); - - testWidgets('FollowingFollowersScreen shows correct number of tabs for own profile', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: FollowingFollowersScreen( - isMe: 'me', // User's own profile - profileModel: testProfile, - initialIndex: 0, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // For own profile, should have 6 tabs - final tabBar = tester.widget(find.byType(TabBar)); - expect(tabBar.tabs.length, equals(6)); - }); - - testWidgets('FollowingFollowersScreen shows correct number of tabs for other profile', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: FollowingFollowersScreen( - isMe: 'notme', // Someone else's profile - profileModel: testProfile, - initialIndex: 0, - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // For other's profile, should have 4 tabs - final tabBar = tester.widget(find.byType(TabBar)); - expect(tabBar.tabs.length, equals(4)); - }); - - testWidgets('FollowingFollowersScreen respects initial index', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - initialIndex: 1, // Start at second tab - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify the initial tab index is set to 1 - final tabController = DefaultTabController.of( - tester.element(find.byType(TabBarView)), - ); - expect(tabController.index, equals(1)); - }); - }); - - group('Profile Screen Integration Tests', () { - testWidgets('EditProfileScreen form validation', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: EditProfileScreen(profileData: testProfile), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify form exists - expect(find.byType(Form), findsOneWidget); - }); - - testWidgets('Profile screens can be dismissed', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Column( - children: [ - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EditProfileScreen( - profileData: testProfile, - ), - ), - ); - }, - child: Text('Open Edit'), - ), - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => FollowingFollowersScreen( - isMe: 'me', - profileModel: testProfile, - ), - ), - ); - }, - child: Text('Open Following'), - ), - ], - ); - }, - ), - ), - ), - ), - ); - - // Test EditProfileScreen navigation - await tester.tap(find.text('Open Edit')); - await tester.pumpAndSettle(); - expect(find.byType(EditProfileScreen), findsOneWidget); - - await tester.pageBack(); - await tester.pumpAndSettle(); - expect(find.byType(EditProfileScreen), findsNothing); - - // Test FollowingFollowersScreen navigation - await tester.tap(find.text('Open Following')); - await tester.pumpAndSettle(); - expect(find.byType(FollowingFollowersScreen), findsOneWidget); - - await tester.pageBack(); - await tester.pumpAndSettle(); - expect(find.byType(FollowingFollowersScreen), findsNothing); - }); - }); -} diff --git a/test/profile/profile_widget_tests.dart b/test/profile/profile_widget_tests.dart deleted file mode 100644 index d5799af..0000000 --- a/test/profile/profile_widget_tests.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:dartz/dartz.dart'; - -import 'package:lite_x/features/profile/repositories/profile_repo.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/search_user_model.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/view/screens/profile_search_screen.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -@GenerateNiceMocks([MockSpec()]) -void main() { - setUpAll(() { - provideDummy>>( - Left(Failure('dummy')), - ); - }); - - group('ProfileSearchScreen Widget Tests', () { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - testWidgets('ProfileSearchScreen displays search bar', ( - WidgetTester tester, - ) async { - when( - mockRepo.profileCurrentSearch(any), - ).thenAnswer((_) async => Right([])); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp(home: ProfileSearchScreen()), - ), - ); - - await tester.pumpAndSettle(); - - // Verify search bar is displayed - expect(find.byType(TextField), findsOneWidget); - }); - - testWidgets('ProfileSearchScreen triggers search on text input', ( - WidgetTester tester, - ) async { - final mockResults = [ - SearchUserModel( - profileMediaId: "123", - id: '1', - username: 'testuser', - name: 'Test User', - verified: false, - bio: 'Test bio', - profileMedia: '', - followers: 100, - score: 10, - isFollowing: false, - isFollower: false, - ), - ]; - - when( - mockRepo.profileCurrentSearch('test'), - ).thenAnswer((_) async => Right(mockResults)); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp(home: ProfileSearchScreen()), - ), - ); - - await tester.pumpAndSettle(); - - // Enter search text - await tester.enterText(find.byType(TextField), 'test'); - await tester.pump(const Duration(milliseconds: 500)); - await tester.pumpAndSettle(); - - // Verify search was called - verify(mockRepo.profileCurrentSearch('test')).called(greaterThan(0)); - }); - }); - - group('Profile Navigation Tests', () { - late MockProfileRepo mockRepo; - final testProfile = ProfileModel( - id: '1', - username: 'testuser', - displayName: 'Test User', - bio: 'Test bio', - avatarUrl: '', - bannerUrl: '', - followersCount: 100, - followingCount: 50, - tweetsCount: 20, - isVerified: false, - joinedDate: '2024-01-01', - website: '', - location: '', - postCount: 20, - birthDate: '', - isFollowing: false, - isFollower: false, - protectedAccount: false, - isBlockedByMe: false, - isMutedByMe: false, - email: 'test@test.com', - avatarId: '', - ); - - setUp(() { - mockRepo = MockProfileRepo(); - when( - mockRepo.getProfileData(any), - ).thenAnswer((_) async => Right(testProfile)); - when(mockRepo.getFollowers(any)).thenAnswer((_) async => Right([])); - when(mockRepo.getFollowings(any)).thenAnswer((_) async => Right([])); - }); - - testWidgets('Can navigate to followers screen from profile', ( - WidgetTester tester, - ) async { - final goRouter = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => Scaffold( - body: ElevatedButton( - onPressed: () => context.push('/followers'), - child: Text('Go to Followers'), - ), - ), - ), - GoRoute( - path: '/followers', - builder: (context, state) => - Scaffold(body: Text('Followers Screen')), - ), - ], - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [profileRepoProvider.overrideWithValue(mockRepo)], - child: MaterialApp.router(routerConfig: goRouter), - ), - ); - - await tester.pumpAndSettle(); - - // Tap button to navigate - await tester.tap(find.text('Go to Followers')); - await tester.pumpAndSettle(); - - // Verify navigation occurred - expect(find.text('Followers Screen'), findsOneWidget); - }); - }); -} diff --git a/test/profile/profile_widget_tests.mocks.dart b/test/profile/profile_widget_tests.mocks.dart deleted file mode 100644 index 2193412..0000000 --- a/test/profile/profile_widget_tests.mocks.dart +++ /dev/null @@ -1,829 +0,0 @@ -// Mocks generated by Mockito 5.4.6 from annotations -// in lite_x/test/profile/profile_widget_tests.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; - -import 'package:dartz/dartz.dart' as _i2; -import 'package:lite_x/features/profile/models/create_reply_model.dart' as _i12; -import 'package:lite_x/features/profile/models/create_tweet_model.dart' as _i9; -import 'package:lite_x/features/profile/models/profile_model.dart' as _i6; -import 'package:lite_x/features/profile/models/profile_tweet_model.dart' as _i8; -import 'package:lite_x/features/profile/models/search_user_model.dart' as _i11; -import 'package:lite_x/features/profile/models/shared.dart' as _i5; -import 'package:lite_x/features/profile/models/tweet_reply_model.dart' as _i10; -import 'package:lite_x/features/profile/models/user_model.dart' as _i7; -import 'package:lite_x/features/profile/repositories/profile_repo.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: must_be_immutable -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeEither_0 extends _i1.SmartFake implements _i2.Either { - _FakeEither_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); -} - -/// A class which mocks [ProfileRepo]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockProfileRepo extends _i1.Mock implements _i3.ProfileRepo { - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> getProfileData( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileData, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#getProfileData, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowings( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowings, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowings, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getVerifiedFollowers( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getVerifiedFollowers, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getVerifiedFollowers, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getFollowersYouKnow( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getFollowersYouKnow, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getFollowersYouKnow, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> followUser(String? username) => - (super.noSuchMethod( - Invocation.method(#followUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#followUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unFollowUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unFollowUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unFollowUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>> updateProfile({ - required _i6.ProfileModel? newModel, - }) => - (super.noSuchMethod( - Invocation.method(#updateProfile, [], {#newModel: newModel}), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>.value( - _FakeEither_0<_i5.Failure, _i6.ProfileModel>( - this, - Invocation.method(#updateProfile, [], { - #newModel: newModel, - }), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i6.ProfileModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> blockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#blockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#blockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unBlockUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unBlockUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unBlockUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> muteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#muteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#muteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unMuteUser(String? username) => - (super.noSuchMethod( - Invocation.method(#unMuteUser, [username]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unMuteUser, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getMutedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getMutedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getMutedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>> getBlockedList( - String? userName, - ) => - (super.noSuchMethod( - Invocation.method(#getBlockedList, [userName]), - returnValue: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>.value( - _FakeEither_0<_i5.Failure, List<_i7.UserModel>>( - this, - Invocation.method(#getBlockedList, [userName]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i7.UserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfileBanner( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfileBanner, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfileBanner, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> updateProfilePhoto( - String? userId, - String? mediaId, - ) => - (super.noSuchMethod( - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#updateProfilePhoto, [userId, mediaId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfilePosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfilePosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfilePosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getMediaPosts(String? username) => - (super.noSuchMethod( - Invocation.method(#getMediaPosts, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getMediaPosts, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>> - getProfileLikes(String? username) => - (super.noSuchMethod( - Invocation.method(#getProfileLikes, [username]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i8.ProfileTweetModel>>( - this, - Invocation.method(#getProfileLikes, [username]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i8.ProfileTweetModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>> getProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#getProfileTweet, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, _i8.ProfileTweetModel> - >.value( - _FakeEither_0<_i5.Failure, _i8.ProfileTweetModel>( - this, - Invocation.method(#getProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, _i8.ProfileTweetModel>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> createTweet( - _i9.CreateTweetModel? createTweetModel, - ) => - (super.noSuchMethod( - Invocation.method(#createTweet, [createTweetModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#createTweet, [createTweetModel]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>> - getTweetReplies(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#getTweetReplies, [tweetId]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i10.TweetReplyModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i10.TweetReplyModel>>( - this, - Invocation.method(#getTweetReplies, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i10.TweetReplyModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#deleteTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>> - profileCurrentSearch(String? query) => - (super.noSuchMethod( - Invocation.method(#profileCurrentSearch, [query]), - returnValue: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - returnValueForMissingStub: - _i4.Future< - _i2.Either<_i5.Failure, List<_i11.SearchUserModel>> - >.value( - _FakeEither_0<_i5.Failure, List<_i11.SearchUserModel>>( - this, - Invocation.method(#profileCurrentSearch, [query]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, List<_i11.SearchUserModel>>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> likeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#likeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#likeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unLikeTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unLikeTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unLikeTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> saveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#saveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#saveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> unSaveTweet(String? tweetId) => - (super.noSuchMethod( - Invocation.method(#unSaveTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#unSaveTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> replyOnTweet( - String? tweetId, - _i12.CreateReplyModel? createreplyModel, - ) => - (super.noSuchMethod( - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [tweetId, createreplyModel]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#replyOnTweet, [ - tweetId, - createreplyModel, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> retweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#retweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#retweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> deleteRetweetProfileTweet( - String? tweetId, - ) => - (super.noSuchMethod( - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#deleteRetweetProfileTweet, [tweetId]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changeEmailProfile( - String? newEmail, - ) => - (super.noSuchMethod( - Invocation.method(#changeEmailProfile, [newEmail]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changeEmailProfile, [newEmail]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> verifyChangeEmailProfile( - String? newEmail, - String? code, - ) => - (super.noSuchMethod( - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [newEmail, code]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#verifyChangeEmailProfile, [ - newEmail, - code, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); - - @override - _i4.Future<_i2.Either<_i5.Failure, void>> changePasswordProfile( - String? oldPassword, - String? newPassword, - String? confirmNewPassword, - ) => - (super.noSuchMethod( - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - returnValue: _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - returnValueForMissingStub: - _i4.Future<_i2.Either<_i5.Failure, void>>.value( - _FakeEither_0<_i5.Failure, void>( - this, - Invocation.method(#changePasswordProfile, [ - oldPassword, - newPassword, - confirmNewPassword, - ]), - ), - ), - ) - as _i4.Future<_i2.Either<_i5.Failure, void>>); -} diff --git a/test/profile/refresh_indicator_test.dart b/test/profile/refresh_indicator_test.dart deleted file mode 100644 index c62d800..0000000 --- a/test/profile/refresh_indicator_test.dart +++ /dev/null @@ -1,301 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lite_x/features/profile/models/shared.dart'; -import 'package:lite_x/features/profile/models/profile_model.dart'; -import 'package:lite_x/features/profile/models/user_model.dart'; -import 'package:lite_x/features/profile/view_model/providers.dart'; -import 'package:mockito/mockito.dart'; - -import 'profile_repo_extended_test.mocks.dart'; - -void main() { - late MockProfileRepo mockRepo; - - setUp(() { - mockRepo = MockProfileRepo(); - }); - - final testProfile = ProfileModel( - id: '123', - username: 'testuser', - displayName: 'Test User', - email: 'test@example.com', - bio: 'Test bio', - birthDate: '1990-01-01', - location: 'Test Location', - website: 'https://test.com', - avatarUrl: '', - bannerUrl: '', - isVerified: false, - isFollowing: false, - isFollower: false, - isBlockedByMe: false, - isMutedByMe: false, - followersCount: 100, - followingCount: 50, - tweetsCount: 10, - postCount: 10, - joinedDate: '2020-01-01', - protectedAccount: false, - avatarId: '', - ); - - final testUser = UserModel( - displayName: 'Test User', - userName: 'testuser', - image: '', - bio: 'Test bio', - isFollowing: false, - isFollower: false, - isVerified: false, - ); - - group('RefreshIndicator Tests', () { - testWidgets('profile data refresh calls provider refresh', (tester) async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - int refreshCount = 0; - - await tester.pumpWidget( - ProviderScope( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - child: MaterialApp( - home: Scaffold( - body: Consumer( - builder: (context, ref, child) { - final profileData = ref.watch(profileDataProvider('testuser')); - - return RefreshIndicator( - onRefresh: () async { - refreshCount++; - // ignore: unused_result - ref.refresh(profileDataProvider('testuser')); - }, - child: profileData.when( - data: (either) => either.fold( - (l) => ListView(children: [Text('Error')]), - (profile) => ListView( - children: [Text(profile.displayName)], - ), - ), - loading: () => ListView(children: [CircularProgressIndicator()]), - error: (e, s) => ListView(children: [Text('Error: $e')]), - ), - ); - }, - ), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Trigger refresh by dragging down - await tester.drag(find.text('Test User'), Offset(0, 300)); - await tester.pumpAndSettle(); - - expect(refreshCount, 1); - verify(mockRepo.getProfileData('testuser')).called(greaterThan(1)); - }); - - test('followers provider refresh reloads data', () async { - when(mockRepo.getFollowers('testuser')) - .thenAnswer((_) async => Right([testUser])); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Initial load - await container.read(followersProvider('testuser').future); - verify(mockRepo.getFollowers('testuser')).called(1); - - // Refresh - container.refresh(followersProvider('testuser')); - await container.read(followersProvider('testuser').future); - - verify(mockRepo.getFollowers('testuser')).called(greaterThanOrEqualTo(1)); - container.dispose(); - }); - - test('followings provider refresh reloads data', () async { - when(mockRepo.getFollowings('testuser')) - .thenAnswer((_) async => Right([testUser])); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Initial load - await container.read(followingsProvider('testuser').future); - verify(mockRepo.getFollowings('testuser')).called(1); - - // Refresh - container.refresh(followingsProvider('testuser')); - await container.read(followingsProvider('testuser').future); - - verify(mockRepo.getFollowings('testuser')).called(greaterThanOrEqualTo(1)); - container.dispose(); - }); - - test('profile posts refresh reloads posts', () async { - when(mockRepo.getProfilePosts('123')) - .thenAnswer((_) async => Right([])); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Initial load - await container.read(profilePostsProvider('123').future); - verify(mockRepo.getProfilePosts('123')).called(1); - - // Refresh - container.refresh(profilePostsProvider('123')); - await container.read(profilePostsProvider('123').future); - - verify(mockRepo.getProfilePosts('123')).called(greaterThanOrEqualTo(1)); - container.dispose(); - }); - - test('multiple providers can be refreshed independently', () async { - when(mockRepo.getProfileData('user1')) - .thenAnswer((_) async => Right(testProfile)); - when(mockRepo.getProfileData('user2')) - .thenAnswer((_) async => Right(testProfile.copyWith(username: 'user2'))); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Load both - await container.read(profileDataProvider('user1').future); - await container.read(profileDataProvider('user2').future); - - // Refresh only user1 - container.refresh(profileDataProvider('user1')); - await container.read(profileDataProvider('user1').future); - - // user1 called twice, user2 called once - verify(mockRepo.getProfileData('user1')).called(2); - verify(mockRepo.getProfileData('user2')).called(1); - - container.dispose(); - }); - - test('refresh after error retries successfully', () async { - int callCount = 0; - when(mockRepo.getProfileData('testuser')).thenAnswer((_) async { - callCount++; - if (callCount == 1) { - return Left(Failure('Error')); - } - return Right(testProfile); - }); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // First call fails - final result1 = await container.read(profileDataProvider('testuser').future); - expect(result1.isLeft(), true); - - // Refresh - container.refresh(profileDataProvider('testuser')); - final result2 = await container.read(profileDataProvider('testuser').future); - expect(result2.isRight(), true); - - container.dispose(); - }); - - test('refresh preserves other provider states', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - when(mockRepo.getFollowers('testuser')) - .thenAnswer((_) async => Right([testUser])); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Load both - await container.read(profileDataProvider('testuser').future); - await container.read(followersProvider('testuser').future); - - // Refresh only profile data - container.refresh(profileDataProvider('testuser')); - await container.read(profileDataProvider('testuser').future); - - // Profile called twice, followers once - verify(mockRepo.getProfileData('testuser')).called(2); - verify(mockRepo.getFollowers('testuser')).called(1); - - container.dispose(); - }); - }); - - group('Loading State Tests', () { - test('profile data shows loading state initially', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - final provider = container.read(profileDataProvider('testuser')); - - expect(provider.isLoading, true); - expect(provider.hasValue, false); - - await container.read(profileDataProvider('testuser').future); - - final loadedProvider = container.read(profileDataProvider('testuser')); - expect(loadedProvider.hasValue, true); - - container.dispose(); - }); - - test('refresh maintains existing data during reload', () async { - when(mockRepo.getProfileData('testuser')) - .thenAnswer((_) async => Right(testProfile)); - - final container = ProviderContainer( - overrides: [ - profileRepoProvider.overrideWithValue(mockRepo), - ], - ); - - // Initial load - await container.read(profileDataProvider('testuser').future); - final initialData = container.read(profileDataProvider('testuser')); - expect(initialData.hasValue, true); - - // Refresh - data should still be available - container.refresh(profileDataProvider('testuser')); - - container.dispose(); - }); - }); -}