From 06bae4f683491926c5a3c8fb13c5202634cc23af Mon Sep 17 00:00:00 2001 From: Abd El-Rhman Zakaria Date: Fri, 12 Dec 2025 00:14:10 +0200 Subject: [PATCH 1/2] latest working notification and searching --- .env | 4 +- lib/core/routes/AppRouter.dart | 11 +- lib/core/routes/Route_Constants.dart | 3 +- lib/core/view/screen/app_shell.dart | 3 +- .../notifications/mentions_model.dart | 346 ++++++++ .../notifications/mentions_provider.dart | 30 + .../notifications/mentions_view_model.dart | 33 + .../notifications/mentions_view_model.g.dart | 56 ++ .../notification_fcm_service.dart | 89 ++ .../notifications/notification_model.dart | 319 ++++++++ .../notifications/notification_provider.dart | 29 + .../notification_view_model.dart | 81 ++ .../notification_view_model.g.dart | 63 ++ .../repositories/mentions_repository.dart | 139 ++++ .../repositories/notification_repository.dart | 156 ++++ .../view/screens/Notification_Screen.dart | 35 + .../view/widgets/card/all_tweet_card.dart | 757 ++++++++++++++++++ .../view/widgets/card/interaction_bar.dart | 231 ++++++ .../widgets/card/mentions_tweet_card.dart | 190 +++++ .../view/widgets/empty/all_empty.dart | 44 + .../view/widgets/empty/mention_empty.dart | 44 + .../view/widgets/empty/verified_empty.dart | 56 ++ .../view/widgets/notification_tabs.dart | 144 ++++ .../view/widgets/status_bar.dart | 66 ++ .../view/widgets/tabs/all_notifications.dart | 86 ++ .../widgets/tabs/mentions_notifications.dart | 65 ++ .../widgets/tabs/verified_notifications.dart | 159 ++++ .../view/screens/explore_profile_screen.dart | 2 +- .../search/data/search_repository.dart | 225 ++++++ .../models/local_search_data_source.dart | 49 -- .../models/remote_search_data_source.dart | 98 --- .../models/search_history_hive_model.dart | 92 +-- .../models/search_history_hive_model.g.dart | 49 +- .../search/models/search_result_model.dart | 51 -- lib/features/search/providers.dart | 28 - .../search/providers/search_providers.dart | 206 +++++ .../repositories/local_search_repository.dart | 19 - .../remote_search_repository.dart | 34 - .../search/view/search_results_screen.dart | 352 ++++++++ lib/features/search/view/search_screen.dart | 334 +++++++- .../search/view/widgets/error_retry.dart | 31 + .../search/view/widgets/people_card.dart | 111 +++ .../search/view/widgets/search_bar.dart | 148 ++++ .../search/view/widgets/tweet_card.dart | 129 +++ .../search/view_model/search_state.dart | 52 -- .../search/view_model/search_view_model.dart | 61 -- .../view_model/search_view_model.g.dart | 63 -- lib/features/search/widgets/search_bar.dart | 82 -- .../search/widgets/search_history_list.dart | 50 -- .../search/widgets/search_results_list.dart | 77 -- 50 files changed, 4765 insertions(+), 817 deletions(-) create mode 100644 lib/features/notifications/mentions_model.dart create mode 100644 lib/features/notifications/mentions_provider.dart create mode 100644 lib/features/notifications/mentions_view_model.dart create mode 100644 lib/features/notifications/mentions_view_model.g.dart create mode 100644 lib/features/notifications/notification_fcm_service.dart create mode 100644 lib/features/notifications/notification_model.dart create mode 100644 lib/features/notifications/notification_provider.dart create mode 100644 lib/features/notifications/notification_view_model.dart create mode 100644 lib/features/notifications/notification_view_model.g.dart create mode 100644 lib/features/notifications/repositories/mentions_repository.dart create mode 100644 lib/features/notifications/repositories/notification_repository.dart create mode 100644 lib/features/notifications/view/screens/Notification_Screen.dart create mode 100644 lib/features/notifications/view/widgets/card/all_tweet_card.dart create mode 100644 lib/features/notifications/view/widgets/card/interaction_bar.dart create mode 100644 lib/features/notifications/view/widgets/card/mentions_tweet_card.dart create mode 100644 lib/features/notifications/view/widgets/empty/all_empty.dart create mode 100644 lib/features/notifications/view/widgets/empty/mention_empty.dart create mode 100644 lib/features/notifications/view/widgets/empty/verified_empty.dart create mode 100644 lib/features/notifications/view/widgets/notification_tabs.dart create mode 100644 lib/features/notifications/view/widgets/status_bar.dart create mode 100644 lib/features/notifications/view/widgets/tabs/all_notifications.dart create mode 100644 lib/features/notifications/view/widgets/tabs/mentions_notifications.dart create mode 100644 lib/features/notifications/view/widgets/tabs/verified_notifications.dart create mode 100644 lib/features/search/data/search_repository.dart delete mode 100644 lib/features/search/models/local_search_data_source.dart delete mode 100644 lib/features/search/models/remote_search_data_source.dart delete mode 100644 lib/features/search/models/search_result_model.dart delete mode 100644 lib/features/search/providers.dart create mode 100644 lib/features/search/providers/search_providers.dart delete mode 100644 lib/features/search/repositories/local_search_repository.dart delete mode 100644 lib/features/search/repositories/remote_search_repository.dart create mode 100644 lib/features/search/view/search_results_screen.dart create mode 100644 lib/features/search/view/widgets/error_retry.dart create mode 100644 lib/features/search/view/widgets/people_card.dart create mode 100644 lib/features/search/view/widgets/search_bar.dart create mode 100644 lib/features/search/view/widgets/tweet_card.dart delete mode 100644 lib/features/search/view_model/search_state.dart delete mode 100644 lib/features/search/view_model/search_view_model.dart delete mode 100644 lib/features/search/view_model/search_view_model.g.dart delete mode 100644 lib/features/search/widgets/search_bar.dart delete mode 100644 lib/features/search/widgets/search_history_list.dart delete mode 100644 lib/features/search/widgets/search_results_list.dart diff --git a/.env b/.env index a376873..c74dc18 100644 --- a/.env +++ b/.env @@ -4,6 +4,6 @@ API_URL=https://node.shoy.publicvm.com/ # API_URL=https://0ec88db618e2.ngrok-free.app/ # API_URL=https://67ee79b6365d.ngrok-free.app/ -# Socket_Url=https://app-dbef67eb-9a2e-44fa-abff-3e8b83204d9c.cleverapps.io -# serverClientId=1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com +Socket_Url=https://app-dbef67eb-9a2e-44fa-abff-3e8b83204d9c.cleverapps.io +serverClientId=1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com diff --git a/lib/core/routes/AppRouter.dart b/lib/core/routes/AppRouter.dart index c3bed02..e0e63d4 100644 --- a/lib/core/routes/AppRouter.dart +++ b/lib/core/routes/AppRouter.dart @@ -52,6 +52,7 @@ 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( @@ -363,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, ), ), @@ -439,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 602792f..da1c993 100644 --- a/lib/core/routes/Route_Constants.dart +++ b/lib/core/routes/Route_Constants.dart @@ -46,7 +46,7 @@ class RouteConstants { "VerifyChangeEmailProfileScreen"; // search feature - static String SearchScreen = "SearchScreen"; + static String SearchScreen = "searchScreen"; // explore feature static String ExploreScreen = "ExploreScreen"; @@ -58,4 +58,5 @@ class RouteConstants { static String TweetDetailsScreen = "TweetDetailsScreen"; + static String notifications = "notifications"; } diff --git a/lib/core/view/screen/app_shell.dart b/lib/core/view/screen/app_shell.dart index e730fd8..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,7 +31,7 @@ class AppShell extends ConsumerWidget { // _buildSearchScreen(), // Index 1 - Search ExploreProfileScreen(), _buildCommunitiesScreen(), // Index 2 - Communities - _buildNotificationsScreen(), // Index 3 - Notifications + NotificationScreen(), // Index 3 - Notifications ConversationsScreen(), ], 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..f5e3826 --- /dev/null +++ b/lib/features/notifications/view/widgets/card/all_tweet_card.dart @@ -0,0 +1,757 @@ +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)), + ); + } + + 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); + } + + bool get _hasMetrics => + notification.repliesCount > 0 || _repostsCount > 0 || _likesCount > 0; + + 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 _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: [ + BuildSmallProfileImage( + mediaId: notification.mediaUrl, + radius: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + notification.actor.name, + style: _nameStyle, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + 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(), + if (_hasMetrics) _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 _buildActivityCard( + icon: Icons.person, + color: Palette.textSecondary, + ); + } + 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..aa23889 --- /dev/null +++ b/lib/features/notifications/view/widgets/card/interaction_bar.dart @@ -0,0 +1,231 @@ +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 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.onUpdate, + }); + + @override + ConsumerState createState() => _InteractionBarState(); +} + +class _InteractionBarState extends ConsumerState { + bool _liked = false; + bool _retweeted = false; + int _likesCount = 0; + int _retweetsCount = 0; + bool _handlingQuote = false; + + @override + void initState() { + super.initState(); + _liked = widget.isLiked; + _retweeted = widget.isRetweeted; + _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.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, + ), + ); + } + } + } + + void _handleReply() { + // TODO: Navigate to reply screen or show reply dialog + 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, + ), + _buildButton( + Icons.format_quote, + widget.quotesCount, + 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..a4cece2 --- /dev/null +++ b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:lite_x/core/theme/palette.dart'; +import 'package:lite_x/features/profile/models/shared.dart'; +import '../../../mentions_model.dart'; +import 'interaction_bar.dart'; + +class MentionTweetCard extends StatelessWidget { + final MentionItem mention; + + const MentionTweetCard({super.key, required this.mention}); + + 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 formatShortDate(String date) { + final dt = DateTime.parse(date).toLocal(); + return "${dt.day.toString().padLeft(2, '0')}/" + "${dt.month.toString().padLeft(2, '0')}/" + "${dt.year.toString().substring(2)}"; + } + + String? _getProfileImageUrl() { + // Profile media URL is stored in TweetMedia.id (workaround) + return mention.user.profileMedia?.id; + } + + @override + Widget build(BuildContext context) { + final profileImageUrl = _getProfileImageUrl(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + padding: const EdgeInsets.all(12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + BuildSmallProfileImage( + mediaId: profileImageUrl, + radius: 20, + ), + + const SizedBox(width: 12), + + // Content Column + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Username + Verified + ShortDate + Timestamp + Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + '${mention.user.name} ', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Palette.textSecondary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Flexible( + child: Text( + '@${mention.user.username} ', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Palette.textWhite, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Text( + formatShortDate(mention.createdAt), + style: TextStyle( + color: Palette.textSecondary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + if (mention.user.verified) + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Icon( + Icons.verified, + color: Palette.verified, + size: 16, + ), + ), + ], + ), + ), + + // Right timestamp + Text( + _formatTimestamp(mention.createdAt), + style: TextStyle( + color: Palette.textTertiary, + fontSize: 12, + ), + ), + ], + ), + + const SizedBox(height: 6), + + // Tweet content + if (mention.content.isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8.0), + child: Text( + mention.content, + style: TextStyle( + color: Palette.textPrimary, + fontSize: 14, + ), + ), + ), + ], + + // Media images if available + 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, + ), + ), + ); + }, + ), + ), + ], + + const SizedBox(height: 8), + + // Interaction bar + InteractionBar( + tweetId: mention.id, + repliesCount: mention.repliesCount, + retweetCount: mention.retweetCount, + likesCount: mention.likesCount, + quotesCount: mention.quotesCount, + isLiked: mention.isLiked, + isRetweeted: mention.isRetweeted, + ), + ], + ), + ), + ], + ), + ); + } +} 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..7c6b2c3 --- /dev/null +++ b/lib/features/notifications/view/widgets/status_bar.dart @@ -0,0 +1,66 @@ +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: Container( + width: 56, + height: 53, + alignment: Alignment.centerLeft, + child: BuildSmallProfileImage( + mediaId: avatarUrl, + radius: 16, + ), + ), + ), + 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..de45395 --- /dev/null +++ b/lib/features/notifications/view/widgets/tabs/all_notifications.dart @@ -0,0 +1,86 @@ +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) { + if (items.isEmpty) { + return const AllEmptyStateWidget(); + } + + return RefreshIndicator( + onRefresh: () async { + await ref + .read(notificationViewModelProvider.notifier) + .refresh(); + }, + child: 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) => const Center( + child: Text( + 'Failed to load notifications', + style: TextStyle(color: Colors.red), + ), + ), + ), + ); + } + + 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..516d8f4 --- /dev/null +++ b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart @@ -0,0 +1,65 @@ +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) { + if (items.isEmpty) { + return const MentionsEmptyStateWidget(); + } + + return RefreshIndicator( + onRefresh: () async { + await ref.read(mentionsViewModelProvider.notifier).refresh(); + }, + child: 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) => const Center( + child: Text( + 'Failed to load mentions', + style: TextStyle(color: Colors.red), + ), + ), + ), + ); + } + + @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/view/screens/explore_profile_screen.dart b/lib/features/profile/view/screens/explore_profile_screen.dart index 5e127ed..5bc4f35 100644 --- a/lib/features/profile/view/screens/explore_profile_screen.dart +++ b/lib/features/profile/view/screens/explore_profile_screen.dart @@ -32,7 +32,7 @@ class _ExploreProfileScreenState extends ConsumerState { ), title: GestureDetector( onTap: () { - context.push("/profileSearchScreen"); + context.push("/searchScreen",extra: {'showResults': false}); }, child: Row( children: [ 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..66019f9 100644 --- a/lib/features/search/view/search_screen.dart +++ b/lib/features/search/view/search_screen.dart @@ -1,37 +1,319 @@ +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'; + +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 - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(searchViewModelProvider); + 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) { + 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: CircleAvatar( + radius: 18, + backgroundColor: Palette.cardBackground, + backgroundImage: (user.avatarUrl != null && + user.avatarUrl!.isNotEmpty) + ? NetworkImage(user.avatarUrl!) + : null, + child: (user.avatarUrl == null || + user.avatarUrl!.isEmpty) + ? const Icon( + Icons.person, + color: Palette.textPrimary, + size: 18, + ) + : null, + ), + 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..199163c --- /dev/null +++ b/lib/features/search/view/widgets/people_card.dart @@ -0,0 +1,111 @@ +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: [ + 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/view_model/search_view_model.g.dart b/lib/features/search/view_model/search_view_model.g.dart deleted file mode 100644 index 8f79ec9..0000000 --- a/lib/features/search/view_model/search_view_model.g.dart +++ /dev/null @@ -1,63 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search_view_model.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(SearchViewModel) -const searchViewModelProvider = SearchViewModelProvider._(); - -final class SearchViewModelProvider - extends $NotifierProvider { - const SearchViewModelProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'searchViewModelProvider', - isAutoDispose: false, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$searchViewModelHash(); - - @$internal - @override - SearchViewModel create() => SearchViewModel(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(SearchState value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$searchViewModelHash() => r'3b9f4cb58d93e52298e4e42513a2cac8bee5cf50'; - -abstract class _$SearchViewModel extends $Notifier { - SearchState build(); - @$mustCallSuper - @override - void runBuild() { - final created = build(); - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - SearchState, - Object?, - Object? - >; - element.handleValue(ref, created); - } -} 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, - ), - ], - ), - ), - ], - ), - ), -); - - }, - ); - } -} From 490491d1e4d6dbd1a9ca28022851e88ba02e23c7 Mon Sep 17 00:00:00 2001 From: Abd El-Rhman Zakaria Date: Fri, 12 Dec 2025 18:34:48 +0200 Subject: [PATCH 2/2] profile picture fixed --- .env | 11 +- android/app/build.gradle.kts | 49 +- android/app/google-services.json | 21 +- android/app/src/main/AndroidManifest.xml | 6 +- .../reports/problems/problems-report.html | 663 ++++++++++++++++++ .../view/widgets/card/all_tweet_card.dart | 105 ++- .../view/widgets/card/interaction_bar.dart | 53 +- .../widgets/card/mentions_tweet_card.dart | 506 ++++++++++--- .../view/widgets/status_bar.dart | 18 +- .../view/widgets/tabs/all_notifications.dart | 45 +- .../widgets/tabs/mentions_notifications.dart | 43 +- lib/features/search/view/search_screen.dart | 28 +- .../search/view/widgets/people_card.dart | 12 +- 13 files changed, 1321 insertions(+), 239 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html diff --git a/.env b/.env index c74dc18..0e02e14 100644 --- a/.env +++ b/.env @@ -1,9 +1,4 @@ - -# API_URL=https://app-dbef67eb-9a2e-44fa-abff-3e8b83204d9c.cleverapps.io/ -# API_URL=https://node.shoy.publicvm.com/ API_URL=https://node.shoy.publicvm.com/ -# API_URL=https://0ec88db618e2.ngrok-free.app/ -# API_URL=https://67ee79b6365d.ngrok-free.app/ -Socket_Url=https://app-dbef67eb-9a2e-44fa-abff-3e8b83204d9c.cleverapps.io -serverClientId=1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com - +giphyApiKey=Ahjpgfo4LVqCACHRcwj0eoMlY5s7u1Uq +Socket_Url=https://node.shoy.publicvm.com +serverClientId=1096363232606-2fducjadk56bt4nsreqkj2jna7oiomga.apps.googleusercontent.com \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aedeb3f..e792664 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,9 @@ -import java.util.Properties -import java.io.FileInputStream - plugins { id("com.android.application") id("kotlin-android") id("dev.flutter.flutter-gradle-plugin") id("com.google.gms.google-services") + } android { @@ -13,28 +11,6 @@ android { compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion - signingConfigs { - getByName("debug") { - storeFile = file("debug.keystore") - storePassword = "android" - keyAlias = "androiddebugkey" - keyPassword = "android" - } - - create("release") { - val keystoreProperties = Properties() - val keystorePropertiesFile = rootProject.file("key.properties") - if (keystorePropertiesFile.exists()) { - keystoreProperties.load(FileInputStream(keystorePropertiesFile)) - } - - storeFile = file(keystoreProperties["storeFile"] as String) - storePassword = keystoreProperties["storePassword"] as String - keyAlias = keystoreProperties["keyAlias"] as String - keyPassword = keystoreProperties["keyPassword"] as String - } - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -45,33 +21,32 @@ android { } defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.Artemsia.lite_x" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } -buildTypes { - getByName("debug") { - signingConfig = signingConfigs.getByName("debug") - } - getByName("release") { - signingConfig = signingConfigs.getByName("release") - isMinifyEnabled = false - isShrinkResources = false + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } } } - -} - dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation(platform("com.google.firebase:firebase-bom:33.5.1")) implementation("com.google.firebase:firebase-messaging") } + flutter { source = "../.." -} \ No newline at end of file +} diff --git a/android/app/google-services.json b/android/app/google-services.json index 926b671..82e92bf 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,34 +1,41 @@ { "project_info": { - "project_number": "112144721859", - "project_id": "psychic-fin-474008-h8", - "storage_bucket": "psychic-fin-474008-h8.firebasestorage.app" + "project_number": "123824690535", + "project_id": "litex-3c6f1", + "storage_bucket": "litex-3c6f1.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:112144721859:android:227c69fccfe2ec4c813f76", + "mobilesdk_app_id": "1:123824690535:android:f0760abf0029e802960bc2", "android_client_info": { "package_name": "com.Artemsia.lite_x" } }, "oauth_client": [ { - "client_id": "112144721859-3i16bpjr6jd704h3imdsp4ojodv7t64l.apps.googleusercontent.com", + "client_id": "123824690535-52cesp7okt1d8j63pn7su9rtmi0asq0b.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAPqcctNBpWkQ-BKvYsHUEjvc_iwPBwsZ0" + "current_key": "AIzaSyDYiU34-5-Rr2nP_SHphvLSIiOyr4RuC8I" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "112144721859-3i16bpjr6jd704h3imdsp4ojodv7t64l.apps.googleusercontent.com", + "client_id": "123824690535-52cesp7okt1d8j63pn7su9rtmi0asq0b.apps.googleusercontent.com", "client_type": 3 + }, + { + "client_id": "123824690535-5pta8j3mu07g0bb21n43n8q2vuulrstr.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.example.litex" + } } ] } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fe9021e..d445d4d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,9 +30,9 @@ - + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/features/notifications/view/widgets/card/all_tweet_card.dart b/lib/features/notifications/view/widgets/card/all_tweet_card.dart index f5e3826..7581985 100644 --- a/lib/features/notifications/view/widgets/card/all_tweet_card.dart +++ b/lib/features/notifications/view/widgets/card/all_tweet_card.dart @@ -110,6 +110,15 @@ class _AllTweetCardWidgetState extends ConsumerState { ); } + 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; @@ -486,8 +495,6 @@ class _AllTweetCardWidgetState extends ConsumerState { (notification.quotedContent?.isNotEmpty ?? false); } - bool get _hasMetrics => - notification.repliesCount > 0 || _repostsCount > 0 || _likesCount > 0; Widget _metricButton({ required IconData icon, @@ -669,6 +676,52 @@ class _AllTweetCardWidgetState extends ConsumerState { ); } + 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) { @@ -682,9 +735,18 @@ class _AllTweetCardWidgetState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - BuildSmallProfileImage( - mediaId: notification.mediaUrl, - radius: 20, + GestureDetector( + onTap: _openUserProfile, + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: notification.mediaUrl, + radius: 20, + ), + ), + ), ), const SizedBox(width: 12), Expanded( @@ -694,19 +756,25 @@ class _AllTweetCardWidgetState extends ConsumerState { Row( children: [ Flexible( - child: Text( - notification.actor.name, - style: _nameStyle, - overflow: TextOverflow.ellipsis, + child: GestureDetector( + onTap: _openUserProfile, + child: Text( + notification.actor.name, + style: _nameStyle, + overflow: TextOverflow.ellipsis, + ), ), ), const SizedBox(width: 6), - Text( - '${_actorHandle()} · ${_formatTimestamp(notification.createdAt)}', - style: const TextStyle( - fontFamily: 'SF Pro Text', - color: Palette.textTertiary, - fontSize: 13, + GestureDetector( + onTap: _openUserProfile, + child: Text( + '${_actorHandle()} · ${_formatTimestamp(notification.createdAt)}', + style: const TextStyle( + fontFamily: 'SF Pro Text', + color: Palette.textTertiary, + fontSize: 13, + ), ), ), const Spacer(), @@ -726,7 +794,7 @@ class _AllTweetCardWidgetState extends ConsumerState { Text(bodyText, style: _bodyStyle), ], if (_hasQuotedTweet()) _buildQuotedTweet(), - if (_hasMetrics) _buildMetricsRow(), + _buildMetricsRow(), ], ), ), @@ -747,10 +815,7 @@ class _AllTweetCardWidgetState extends ConsumerState { return _buildActivityCard(icon: Icons.favorite, color: Palette.like); } if (_isFollow) { - return _buildActivityCard( - icon: Icons.person, - color: Palette.textSecondary, - ); + return _buildFollowCard(); } return _buildConversationCard(); } diff --git a/lib/features/notifications/view/widgets/card/interaction_bar.dart b/lib/features/notifications/view/widgets/card/interaction_bar.dart index aa23889..9f2b53d 100644 --- a/lib/features/notifications/view/widgets/card/interaction_bar.dart +++ b/lib/features/notifications/view/widgets/card/interaction_bar.dart @@ -14,6 +14,7 @@ class InteractionBar extends ConsumerStatefulWidget { final int quotesCount; final bool isLiked; final bool isRetweeted; + final bool isBookmarked; final VoidCallback? onUpdate; const InteractionBar({ @@ -25,6 +26,7 @@ class InteractionBar extends ConsumerStatefulWidget { this.quotesCount = 0, this.isLiked = false, this.isRetweeted = false, + this.isBookmarked = false, this.onUpdate, }); @@ -35,6 +37,7 @@ class InteractionBar extends ConsumerStatefulWidget { class _InteractionBarState extends ConsumerState { bool _liked = false; bool _retweeted = false; + bool _bookmarked = false; int _likesCount = 0; int _retweetsCount = 0; bool _handlingQuote = false; @@ -44,6 +47,7 @@ class _InteractionBarState extends ConsumerState { super.initState(); _liked = widget.isLiked; _retweeted = widget.isRetweeted; + _bookmarked = widget.isBookmarked; _likesCount = widget.likesCount; _retweetsCount = widget.retweetCount; } @@ -57,6 +61,9 @@ class _InteractionBarState extends ConsumerState { if (oldWidget.isRetweeted != widget.isRetweeted) { _retweeted = widget.isRetweeted; } + if (oldWidget.isBookmarked != widget.isBookmarked) { + _bookmarked = widget.isBookmarked; + } if (oldWidget.likesCount != widget.likesCount) { _likesCount = widget.likesCount; } @@ -139,8 +146,40 @@ class _InteractionBarState extends ConsumerState { } } + 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() { - // TODO: Navigate to reply screen or show reply dialog ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Reply functionality coming soon')), ); @@ -198,9 +237,17 @@ class _InteractionBarState extends ConsumerState { _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.format_quote, - widget.quotesCount, + Icons.ios_share_outlined, + 0, Palette.reply, _handleQuote, ), diff --git a/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart index a4cece2..463ab61 100644 --- a/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart +++ b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart @@ -1,14 +1,261 @@ 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'; -import 'interaction_bar.dart'; -class MentionTweetCard extends StatelessWidget { +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); @@ -30,120 +277,185 @@ class MentionTweetCard extends StatelessWidget { } } - String formatShortDate(String date) { - final dt = DateTime.parse(date).toLocal(); - return "${dt.day.toString().padLeft(2, '0')}/" - "${dt.month.toString().padLeft(2, '0')}/" - "${dt.year.toString().substring(2)}"; + String _formatHandle(String value) { + if (value.isEmpty) return '@you'; + return value.startsWith('@') ? value : '@$value'; } - String? _getProfileImageUrl() { - // Profile media URL is stored in TweetMedia.id (workaround) - return mention.user.profileMedia?.id; + 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 = _getProfileImageUrl(); + final profileImageUrl = mention.user.profileMedia?.id; + final String bodyText = mention.content.isNotEmpty ? mention.content : ''; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), - padding: const EdgeInsets.all(12.0), + return _cardShell( + onTap: _openTweetDetail, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Avatar - BuildSmallProfileImage( - mediaId: profileImageUrl, - radius: 20, + GestureDetector( + onTap: _openUserProfile, + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: profileImageUrl, + radius: 20, + ), + ), + ), ), - const SizedBox(width: 12), - - // Content Column Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Username + Verified + ShortDate + Timestamp Row( children: [ - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - '${mention.user.name} ', - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Palette.textSecondary, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - Flexible( - child: Text( - '@${mention.user.username} ', - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Palette.textWhite, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - Text( - formatShortDate(mention.createdAt), - style: TextStyle( - color: Palette.textSecondary, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - if (mention.user.verified) - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Icon( - Icons.verified, - color: Palette.verified, - size: 16, - ), - ), - ], + Flexible( + child: GestureDetector( + onTap: _openUserProfile, + child: Text( + mention.user.name, + style: _nameStyle, + overflow: TextOverflow.ellipsis, + ), ), ), - - // Right timestamp - Text( - _formatTimestamp(mention.createdAt), - style: TextStyle( - color: Palette.textTertiary, - fontSize: 12, + 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: 6), - - // Tweet content - if (mention.content.isNotEmpty) ...[ - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(8.0), - child: Text( - mention.content, - style: TextStyle( - color: Palette.textPrimary, - fontSize: 14, - ), - ), + 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), ], - - // Media images if available if (mention.mediaUrls.isNotEmpty) ...[ const SizedBox(height: 8), SizedBox( @@ -167,19 +479,7 @@ class MentionTweetCard extends StatelessWidget { ), ), ], - - const SizedBox(height: 8), - - // Interaction bar - InteractionBar( - tweetId: mention.id, - repliesCount: mention.repliesCount, - retweetCount: mention.retweetCount, - likesCount: mention.likesCount, - quotesCount: mention.quotesCount, - isLiked: mention.isLiked, - isRetweeted: mention.isRetweeted, - ), + _buildMetricsRow(), ], ), ), diff --git a/lib/features/notifications/view/widgets/status_bar.dart b/lib/features/notifications/view/widgets/status_bar.dart index 7c6b2c3..97455ce 100644 --- a/lib/features/notifications/view/widgets/status_bar.dart +++ b/lib/features/notifications/view/widgets/status_bar.dart @@ -27,16 +27,18 @@ class Statusbar extends ConsumerWidget { children: [ GestureDetector( onTap: () => scaffoldKey.currentState?.openDrawer(), - child: Container( - width: 56, - height: 53, - alignment: Alignment.centerLeft, - child: BuildSmallProfileImage( - mediaId: avatarUrl, - radius: 16, - ), + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: avatarUrl, + radius: 20, ), ), + ), + ), + SizedBox(width: 12), Text( 'Notifications', style: TextStyle( diff --git a/lib/features/notifications/view/widgets/tabs/all_notifications.dart b/lib/features/notifications/view/widgets/tabs/all_notifications.dart index de45395..061ca3b 100644 --- a/lib/features/notifications/view/widgets/tabs/all_notifications.dart +++ b/lib/features/notifications/view/widgets/tabs/all_notifications.dart @@ -36,31 +36,44 @@ class _AllTabState extends ConsumerState color: Palette.background, child: state.when( data: (items) { - if (items.isEmpty) { - return const AllEmptyStateWidget(); - } - return RefreshIndicator( onRefresh: () async { await ref .read(notificationViewModelProvider.notifier) .refresh(); }, - child: AnimatedList( - key: _listKey, - padding: const EdgeInsets.symmetric(vertical: 8.0), - initialItemCount: items.length, - itemBuilder: (context, index, animation) { - return _buildItem(items[index], animation); - }, - ), + 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) => const Center( - child: Text( - 'Failed to load notifications', - style: TextStyle(color: Colors.red), + 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'), + ), + ], ), ), ), diff --git a/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart index 516d8f4..2729186 100644 --- a/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart +++ b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart @@ -32,28 +32,41 @@ class _MentionsTabState extends ConsumerState color: Palette.background, child: state.when( data: (items) { - if (items.isEmpty) { - return const MentionsEmptyStateWidget(); - } - return RefreshIndicator( onRefresh: () async { await ref.read(mentionsViewModelProvider.notifier).refresh(); }, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8.0), - itemCount: items.length, - itemBuilder: (context, index) { - return MentionTweetCard(mention: items[index]); - }, - ), + 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) => const Center( - child: Text( - 'Failed to load mentions', - style: TextStyle(color: Colors.red), + 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'), + ), + ], ), ), ), diff --git a/lib/features/search/view/search_screen.dart b/lib/features/search/view/search_screen.dart index 66019f9..b2ec06e 100644 --- a/lib/features/search/view/search_screen.dart +++ b/lib/features/search/view/search_screen.dart @@ -9,6 +9,8 @@ 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; @@ -181,22 +183,16 @@ class _SearchScreenState extends ConsumerState { ); return ListTile( - leading: CircleAvatar( - radius: 18, - backgroundColor: Palette.cardBackground, - backgroundImage: (user.avatarUrl != null && - user.avatarUrl!.isNotEmpty) - ? NetworkImage(user.avatarUrl!) - : null, - child: (user.avatarUrl == null || - user.avatarUrl!.isEmpty) - ? const Icon( - Icons.person, - color: Palette.textPrimary, - size: 18, - ) - : null, - ), + leading: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: user.avatarUrl, + radius: 20, + ), + ), + ), title: Text( user.name, overflow: TextOverflow.ellipsis, diff --git a/lib/features/search/view/widgets/people_card.dart b/lib/features/search/view/widgets/people_card.dart index 199163c..075eb10 100644 --- a/lib/features/search/view/widgets/people_card.dart +++ b/lib/features/search/view/widgets/people_card.dart @@ -25,9 +25,15 @@ class PeopleCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ - BuildSmallProfileImage( - mediaId: user.avatarUrl, - radius: 20, + ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: BuildSmallProfileImage( + mediaId: user.avatarUrl, + radius: 20, + ), + ), ), const SizedBox(width: 12), Expanded(