diff --git a/.env b/.env
index a376873..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/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..7581985
--- /dev/null
+++ b/lib/features/notifications/view/widgets/card/all_tweet_card.dart
@@ -0,0 +1,822 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lite_x/core/providers/dio_interceptor.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import 'package:lite_x/features/home/repositories/home_repository.dart';
+import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart';
+import 'package:lite_x/features/home/view/screens/tweet_screen.dart';
+import 'package:lite_x/features/profile/models/shared.dart';
+
+import '../../../notification_model.dart';
+import '../../../notification_view_model.dart';
+
+class AllTweetCardWidget extends ConsumerStatefulWidget {
+ final NotificationItem notification;
+
+ const AllTweetCardWidget({super.key, required this.notification});
+
+ @override
+ ConsumerState createState() => _AllTweetCardWidgetState();
+}
+
+class _AllTweetCardWidgetState extends ConsumerState {
+ late bool _liked;
+ late bool _retweeted;
+ late int _likesCount;
+ late int _repostsCount;
+ bool _processingLike = false;
+ bool _processingRetweet = false;
+ late bool _bookmarked;
+ bool _processingBookmark = false;
+ bool _handlingQuote = false;
+
+ NotificationItem get notification => widget.notification;
+
+ bool get _isSystemAlert => notification.title == 'LOGIN';
+ bool get _isRetweet =>
+ notification.title == 'RETWEET' || notification.title == 'REPOST';
+ bool get _isLike => notification.title == 'LIKE';
+ bool get _isFollow => notification.title == 'FOLLOW';
+ bool get _hasTweetLink => _tweetId != null;
+
+ TextStyle get _nameStyle => const TextStyle(
+ fontFamily: 'SF Pro Text',
+ fontWeight: FontWeight.w600,
+ color: Palette.textPrimary,
+ );
+
+ TextStyle get _secondaryStyle => const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textSecondary,
+ fontSize: 14,
+ );
+
+ TextStyle get _bodyStyle => const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textPrimary,
+ fontSize: 14,
+ height: 1.4,
+ );
+
+ String? get _tweetId {
+ final id = notification.tweetId;
+ if (id == null || id.isEmpty) return null;
+ return id;
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _hydrateCounts();
+ }
+
+ @override
+ void didUpdateWidget(AllTweetCardWidget oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.notification.id != widget.notification.id ||
+ oldWidget.notification.likesCount != widget.notification.likesCount ||
+ oldWidget.notification.repostsCount !=
+ widget.notification.repostsCount ||
+ oldWidget.notification.isLiked != widget.notification.isLiked ||
+ oldWidget.notification.isRetweeted != widget.notification.isRetweeted) {
+ _hydrateCounts();
+ }
+ }
+
+ void _hydrateCounts() {
+ _liked = notification.isLiked;
+ _retweeted = notification.isRetweeted;
+ _likesCount = notification.likesCount;
+ _repostsCount = notification.repostsCount;
+ _bookmarked = notification.isBookmarked;
+ }
+
+ void _showSnack(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(message)));
+ }
+
+ void _openTweetDetail() {
+ final tweetId = _tweetId;
+ if (tweetId == null) {
+ _showSnack('Tweet is no longer available.');
+ return;
+ }
+
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => TweetDetailScreen(tweetId: tweetId)),
+ );
+ }
+
+ void _openUserProfile() {
+ final username = notification.actor.username;
+ if (username.isEmpty) {
+ _showSnack('User profile not available');
+ return;
+ }
+ Navigator.of(context).pushNamed('/profile', arguments: {'username': username});
+ }
+
+ Future _toggleLike() async {
+ if (_processingLike) return;
+ final tweetId = _tweetId;
+ if (tweetId == null) {
+ _showSnack('Tweet is no longer available.');
+ return;
+ }
+
+ _processingLike = true;
+ final previousLiked = _liked;
+ final previousCount = _likesCount;
+ final newState = !previousLiked;
+
+ setState(() {
+ _liked = newState;
+ _likesCount = newState
+ ? previousCount + 1
+ : (previousCount - 1 < 0 ? 0 : previousCount - 1);
+ });
+
+ final vm = ref.read(notificationViewModelProvider.notifier);
+ final currentTweetId = tweetId;
+ if (currentTweetId != null) {
+ vm.updateTweetInteractions(
+ currentTweetId,
+ likesCount: _likesCount,
+ isLiked: _liked,
+ );
+ }
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousLiked) {
+ await dio.delete('api/tweets/$tweetId/likes');
+ } else {
+ await dio.post('api/tweets/$tweetId/likes');
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _liked = previousLiked;
+ _likesCount = previousCount;
+ });
+ final currentTweetId = tweetId;
+ if (currentTweetId != null) {
+ ref.read(notificationViewModelProvider.notifier).updateTweetInteractions(
+ currentTweetId,
+ likesCount: previousCount,
+ isLiked: previousLiked,
+ );
+ }
+ }
+ _showSnack('Unable to ${previousLiked ? 'unlike' : 'like'} right now.');
+ } finally {
+ _processingLike = false;
+ }
+ }
+
+ Future _toggleRetweet() async {
+ if (_processingRetweet) return;
+ final tweetId = _tweetId;
+ if (tweetId == null) {
+ _showSnack('Tweet is no longer available.');
+ return;
+ }
+
+ _processingRetweet = true;
+ final previousState = _retweeted;
+ final previousCount = _repostsCount;
+ final newState = !previousState;
+
+ setState(() {
+ _retweeted = newState;
+ _repostsCount = newState
+ ? previousCount + 1
+ : (previousCount - 1 < 0 ? 0 : previousCount - 1);
+ });
+
+ final vm = ref.read(notificationViewModelProvider.notifier);
+ final currentTweetId = tweetId;
+ if (currentTweetId != null) {
+ vm.updateTweetInteractions(
+ currentTweetId,
+ repostsCount: _repostsCount,
+ isRetweeted: _retweeted,
+ );
+ }
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousState) {
+ await dio.delete('api/tweets/$tweetId/retweets');
+ } else {
+ await dio.post('api/tweets/$tweetId/retweets');
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _retweeted = previousState;
+ _repostsCount = previousCount;
+ });
+ final currentTweetId = tweetId;
+ if (currentTweetId != null) {
+ ref.read(notificationViewModelProvider.notifier).updateTweetInteractions(
+ currentTweetId,
+ repostsCount: previousCount,
+ isRetweeted: previousState,
+ );
+ }
+ }
+ _showSnack('Unable to ${previousState ? 'undo' : 'send'} repost.');
+ } finally {
+ _processingRetweet = false;
+ }
+ }
+
+ Future _toggleBookmark() async {
+ if (_processingBookmark) return;
+ final tweetId = _tweetId;
+ if (tweetId == null) {
+ _showSnack('Tweet is no longer available.');
+ return;
+ }
+
+ _processingBookmark = true;
+ final previousBookmarked = _bookmarked;
+ final newState = !previousBookmarked;
+
+ setState(() {
+ _bookmarked = newState;
+ });
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousBookmarked) {
+ await dio.delete('api/tweets/$tweetId/bookmark');
+ } else {
+ await dio.post('api/tweets/$tweetId/bookmark');
+ }
+
+ final vm = ref.read(notificationViewModelProvider.notifier);
+ vm.updateTweetInteractions(
+ tweetId,
+ isBookmarked: _bookmarked,
+ );
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _bookmarked = previousBookmarked;
+ });
+ ref.read(notificationViewModelProvider.notifier).updateTweetInteractions(
+ tweetId,
+ isBookmarked: previousBookmarked,
+ );
+ }
+ _showSnack('Unable to update bookmark right now.');
+ } finally {
+ _processingBookmark = false;
+ }
+ }
+
+ Future _openQuoteComposer() async {
+ if (_handlingQuote) return;
+ final tweetId = _tweetId;
+ if (tweetId == null) {
+ _showSnack('Tweet is no longer available.');
+ return;
+ }
+
+ _handlingQuote = true;
+ try {
+ final repository = ref.read(homeRepositoryProvider);
+ final tweet = await repository.getTweetById(tweetId);
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => QuoteComposerScreen(quotedTweet: tweet),
+ ),
+ );
+ } catch (_) {
+ _showSnack('Unable to open quote composer.');
+ } finally {
+ _handlingQuote = false;
+ }
+ }
+
+ void _showRetweetMenu() {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: const Color(0xFF1E1E1E),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (BuildContext modalContext) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(height: 12),
+ Container(
+ width: 40,
+ height: 4,
+ decoration: BoxDecoration(
+ color: Colors.grey[600],
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(height: 20),
+ ListTile(
+ leading: Icon(
+ Icons.repeat,
+ color: _retweeted ? Colors.green : Colors.grey[300],
+ ),
+ title: Text(
+ _retweeted ? 'Undo Repost' : 'Repost',
+ style: TextStyle(
+ color: _retweeted ? Colors.green : Colors.grey[300],
+ fontSize: 16,
+ ),
+ ),
+ onTap: () {
+ Navigator.pop(modalContext);
+ _toggleRetweet();
+ },
+ ),
+ ListTile(
+ leading: Icon(Icons.edit_outlined, color: Colors.grey[300]),
+ title: const Text(
+ 'Quote',
+ style: TextStyle(color: Colors.grey, fontSize: 16),
+ ),
+ onTap: () {
+ Navigator.pop(modalContext);
+ _openQuoteComposer();
+ },
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _cardShell({
+ required Widget child,
+ EdgeInsetsGeometry? padding,
+ VoidCallback? onTap,
+ }) {
+ final content = Container(
+ margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
+ padding: padding ?? const EdgeInsets.symmetric(vertical: 12.0),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: Colors.white, width: 0.5),
+ ),
+ ),
+ child: child,
+ );
+
+ if (onTap == null) {
+ return content;
+ }
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: content,
+ );
+ }
+
+ Widget _buildBadgeIcon({required IconData icon, required Color color}) {
+ return Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: Colors.black,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(icon, color: color, size: 30),
+ );
+ }
+
+ Widget _buildBrandBadge() {
+ return Container(
+ width: 36,
+ height: 36,
+ alignment: Alignment.center,
+ child: const Text(
+ 'X',
+ style: TextStyle(
+ fontFamily: 'SF Pro Display',
+ fontWeight: FontWeight.w700,
+ color: Palette.textPrimary,
+ fontSize: 24,
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTimestampText() {
+ return Text(
+ _formatTimestamp(notification.createdAt),
+ style: const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textTertiary,
+ fontSize: 12,
+ ),
+ );
+ }
+
+ String _formatTimestamp(String createdAt) {
+ try {
+ final dateTime = DateTime.parse(createdAt);
+ final now = DateTime.now();
+ final difference = now.difference(dateTime);
+ if (difference.inDays > 7) {
+ return '${dateTime.day}/${dateTime.month}/${dateTime.year}';
+ } else if (difference.inDays > 0) {
+ return '${difference.inDays}d';
+ } else if (difference.inHours > 0) {
+ return '${difference.inHours}h';
+ } else if (difference.inMinutes > 0) {
+ return '${difference.inMinutes}m';
+ } else {
+ return 'now';
+ }
+ } catch (_) {
+ return createdAt;
+ }
+ }
+
+ String _formatHandle(String value) {
+ if (value.isEmpty) return '@you';
+ return value.startsWith('@') ? value : '@$value';
+ }
+
+ String _actorHandle() {
+ if (notification.actor.username.isNotEmpty) {
+ return _formatHandle(notification.actor.username);
+ }
+ final sanitized = notification.actor.name.toLowerCase().replaceAll(
+ RegExp(r'[^a-z0-9_]'),
+ '_',
+ );
+ return _formatHandle(sanitized);
+ }
+
+ String _getActionText() {
+ final target = _formatHandle(
+ notification.targetUsername ?? notification.actor.username,
+ );
+
+ switch (notification.title) {
+ case 'RETWEET':
+ case 'REPOST':
+ final reposts = _repostsCount.clamp(1, 99).toInt();
+ return 'retweeted $reposts of your posts';
+ case 'LIKE':
+ return 'liked your post';
+ case 'FOLLOW':
+ return 'followed you';
+ case 'REPLY':
+ return 'replied to your post';
+ case 'QUOTE':
+ return 'quoted your post';
+ default:
+ return 'Replying to $target';
+ }
+ }
+
+ bool _hasQuotedTweet() {
+ return (notification.quotedAuthor?.isNotEmpty ?? false) &&
+ (notification.quotedContent?.isNotEmpty ?? false);
+ }
+
+
+ Widget _metricButton({
+ required IconData icon,
+ int? count,
+ Color color = Palette.textTertiary,
+ VoidCallback? onTap,
+ }) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Row(
+ children: [
+ Icon(icon, size: 16, color: color),
+ if (count != null && count > 0) ...[
+ const SizedBox(width: 4),
+ Text(
+ '$count',
+ style: TextStyle(
+ fontSize: 12,
+ color: color,
+ fontFamily: 'SF Pro Text',
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildMetricsRow() {
+ return Padding(
+ padding: const EdgeInsets.only(top: 8.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _metricButton(
+ icon: Icons.mode_comment_outlined,
+ count: notification.repliesCount,
+ onTap: _openTweetDetail,
+ ),
+ _metricButton(
+ icon: Icons.repeat,
+ count: _repostsCount,
+ color: _retweeted ? Palette.retweet : Palette.textTertiary,
+ onTap: _showRetweetMenu,
+ ),
+ _metricButton(
+ icon: Icons.favorite,
+ count: _likesCount,
+ color: _liked ? Palette.like : Palette.textTertiary,
+ onTap: _toggleLike,
+ ),
+ _metricButton(
+ icon:
+ _bookmarked ? Icons.bookmark : Icons.bookmark_border,
+ color:
+ _bookmarked ? Palette.primary : Palette.textTertiary,
+ onTap: _toggleBookmark,
+ ),
+ _metricButton(
+ icon: Icons.ios_share_outlined,
+ onTap: _openQuoteComposer,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildQuotedTweet() {
+ return Container(
+ width: double.infinity,
+ margin: const EdgeInsets.only(top: 8.0),
+ padding: const EdgeInsets.all(12.0),
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Palette.textTertiary.withOpacity(0.2)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(notification.quotedAuthor ?? '', style: _nameStyle),
+ const SizedBox(height: 4),
+ Text(notification.quotedContent ?? '', style: _secondaryStyle),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildAlertCard() {
+ return _cardShell(
+ padding: const EdgeInsets.symmetric(vertical: 16.0),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildBrandBadge(),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ notification.body,
+ style: _bodyStyle,
+ ),
+ ),
+ const SizedBox(width: 12),
+ _buildTimestampText(),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildActivityCard({required IconData icon, required Color color}) {
+ final description = _getActionText();
+
+ // Prefer tweet content as snapshot; fall back to notification body
+ final String snapshotText;
+ if (notification.tweet != null &&
+ notification.tweet!.content.isNotEmpty) {
+ snapshotText = notification.tweet!.content;
+ } else {
+ snapshotText = notification.body;
+ }
+
+ return _cardShell(
+ onTap: _hasTweetLink ? _openTweetDetail : null,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildBadgeIcon(icon: icon, color: color),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ text: '${notification.actor.name} ',
+ style: _nameStyle,
+ children: [
+ TextSpan(
+ text: description,
+ style: _secondaryStyle,
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ _buildTimestampText(),
+ ],
+ ),
+ if (snapshotText.isNotEmpty) ...[
+ const SizedBox(height: 6),
+ Text(
+ snapshotText,
+ style: _secondaryStyle,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildFollowCard() {
+ final description = _getActionText();
+
+ return _cardShell(
+ onTap: _hasTweetLink ? _openTweetDetail : null,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ BuildSmallProfileImage(
+ mediaId: notification.mediaUrl,
+ radius: 20,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ text: '${notification.actor.name} ',
+ style: _nameStyle,
+ children: [
+ TextSpan(
+ text: description,
+ style: _secondaryStyle,
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ _buildTimestampText(),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildConversationCard() {
+ final String bodyText;
+ if (notification.tweet != null && notification.tweet!.content.isNotEmpty) {
+ bodyText = notification.tweet!.content;
+ } else {
+ bodyText = notification.body;
+ }
+
+ return _cardShell(
+ onTap: _hasTweetLink ? _openTweetDetail : null,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ GestureDetector(
+ onTap: _openUserProfile,
+ child: ClipOval(
+ child: SizedBox(
+ width: 40,
+ height: 40,
+ child: BuildSmallProfileImage(
+ mediaId: notification.mediaUrl,
+ radius: 20,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Flexible(
+ child: GestureDetector(
+ onTap: _openUserProfile,
+ child: Text(
+ notification.actor.name,
+ style: _nameStyle,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ const SizedBox(width: 6),
+ GestureDetector(
+ onTap: _openUserProfile,
+ child: Text(
+ '${_actorHandle()} Β· ${_formatTimestamp(notification.createdAt)}',
+ style: const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textTertiary,
+ fontSize: 13,
+ ),
+ ),
+ ),
+ const Spacer(),
+ ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ _getActionText(),
+ style: const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textSecondary,
+ fontSize: 13,
+ ),
+ ),
+ if (bodyText.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(bodyText, style: _bodyStyle),
+ ],
+ if (_hasQuotedTweet()) _buildQuotedTweet(),
+ _buildMetricsRow(),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_isSystemAlert) {
+ return _buildAlertCard();
+ }
+ if (_isRetweet) {
+ return _buildActivityCard(icon: Icons.repeat, color: Palette.retweet);
+ }
+ if (_isLike) {
+ return _buildActivityCard(icon: Icons.favorite, color: Palette.like);
+ }
+ if (_isFollow) {
+ return _buildFollowCard();
+ }
+ return _buildConversationCard();
+ }
+}
\ No newline at end of file
diff --git a/lib/features/notifications/view/widgets/card/interaction_bar.dart b/lib/features/notifications/view/widgets/card/interaction_bar.dart
new file mode 100644
index 0000000..9f2b53d
--- /dev/null
+++ b/lib/features/notifications/view/widgets/card/interaction_bar.dart
@@ -0,0 +1,278 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:dio/dio.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import 'package:lite_x/core/providers/dio_interceptor.dart';
+import 'package:lite_x/features/home/repositories/home_repository.dart';
+import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart';
+
+class InteractionBar extends ConsumerStatefulWidget {
+ final String tweetId;
+ final int repliesCount;
+ final int retweetCount;
+ final int likesCount;
+ final int quotesCount;
+ final bool isLiked;
+ final bool isRetweeted;
+ final bool isBookmarked;
+ final VoidCallback? onUpdate;
+
+ const InteractionBar({
+ super.key,
+ required this.tweetId,
+ this.repliesCount = 0,
+ this.retweetCount = 0,
+ this.likesCount = 0,
+ this.quotesCount = 0,
+ this.isLiked = false,
+ this.isRetweeted = false,
+ this.isBookmarked = false,
+ this.onUpdate,
+ });
+
+ @override
+ ConsumerState createState() => _InteractionBarState();
+}
+
+class _InteractionBarState extends ConsumerState {
+ bool _liked = false;
+ bool _retweeted = false;
+ bool _bookmarked = false;
+ int _likesCount = 0;
+ int _retweetsCount = 0;
+ bool _handlingQuote = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _liked = widget.isLiked;
+ _retweeted = widget.isRetweeted;
+ _bookmarked = widget.isBookmarked;
+ _likesCount = widget.likesCount;
+ _retweetsCount = widget.retweetCount;
+ }
+
+ @override
+ void didUpdateWidget(InteractionBar oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.isLiked != widget.isLiked) {
+ _liked = widget.isLiked;
+ }
+ if (oldWidget.isRetweeted != widget.isRetweeted) {
+ _retweeted = widget.isRetweeted;
+ }
+ if (oldWidget.isBookmarked != widget.isBookmarked) {
+ _bookmarked = widget.isBookmarked;
+ }
+ if (oldWidget.likesCount != widget.likesCount) {
+ _likesCount = widget.likesCount;
+ }
+ if (oldWidget.retweetCount != widget.retweetCount) {
+ _retweetsCount = widget.retweetCount;
+ }
+ }
+
+ Future _toggleLike() async {
+ final dio = ref.read(dioProvider);
+ final wasLiked = _liked;
+ final oldCount = _likesCount;
+
+ // Optimistic update
+ setState(() {
+ _liked = !_liked;
+ _likesCount += _liked ? 1 : -1;
+ });
+
+ try {
+ if (wasLiked) {
+ await dio.delete('/api/tweets/${widget.tweetId}/likes');
+ } else {
+ await dio.post('/api/tweets/${widget.tweetId}/likes');
+ }
+ // Callback to refresh parent if provided
+ widget.onUpdate?.call();
+ } catch (e) {
+ // Revert on error
+ if (mounted) {
+ setState(() {
+ _liked = wasLiked;
+ _likesCount = oldCount;
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Failed to ${wasLiked ? 'unlike' : 'like'} tweet'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ Future _toggleRetweet() async {
+ final dio = ref.read(dioProvider);
+ final wasRetweeted = _retweeted;
+ final oldCount = _retweetsCount;
+
+ // Optimistic update
+ setState(() {
+ _retweeted = !_retweeted;
+ _retweetsCount += _retweeted ? 1 : -1;
+ });
+
+ try {
+ if (wasRetweeted) {
+ await dio.delete('/api/tweets/${widget.tweetId}/retweets');
+ } else {
+ await dio.post('/api/tweets/${widget.tweetId}/retweets');
+ }
+ // Callback to refresh parent if provided
+ widget.onUpdate?.call();
+ } catch (e) {
+ // Revert on error
+ if (mounted) {
+ setState(() {
+ _retweeted = wasRetweeted;
+ _retweetsCount = oldCount;
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Failed to ${wasRetweeted ? 'unretweet' : 'retweet'}',
+ ),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ Future _toggleBookmark() async {
+ final dio = ref.read(dioProvider);
+ final wasBookmarked = _bookmarked;
+
+ // Optimistic update
+ setState(() {
+ _bookmarked = !_bookmarked;
+ });
+
+ try {
+ if (wasBookmarked) {
+ await dio.delete('/api/tweets/${widget.tweetId}/bookmark');
+ } else {
+ await dio.post('/api/tweets/${widget.tweetId}/bookmark');
+ }
+ // Callback to refresh parent if provided
+ widget.onUpdate?.call();
+ } catch (e) {
+ // Revert on error
+ if (mounted) {
+ setState(() {
+ _bookmarked = wasBookmarked;
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Failed to update bookmark'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ }
+ }
+
+ void _handleReply() {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Reply functionality coming soon')),
+ );
+ }
+
+ Future _handleQuote() async {
+ if (_handlingQuote) return;
+ _handlingQuote = true;
+
+ try {
+ final repository = ref.read(homeRepositoryProvider);
+ final tweet = await repository.getTweetById(widget.tweetId);
+
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => QuoteComposerScreen(quotedTweet: tweet),
+ ),
+ );
+ widget.onUpdate?.call();
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Failed to open quote composer'),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ _handlingQuote = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _buildButton(
+ Icons.reply,
+ widget.repliesCount,
+ Palette.reply,
+ _handleReply,
+ ),
+ _buildButton(
+ Icons.repeat,
+ _retweetsCount,
+ _retweeted ? Palette.retweet : Palette.reply,
+ _toggleRetweet,
+ ),
+ _buildButton(
+ Icons.favorite,
+ _likesCount,
+ _liked ? Palette.like : Palette.reply,
+ _toggleLike,
+ ),
+ GestureDetector(
+ onTap: _toggleBookmark,
+ child: Icon(
+ _bookmarked ? Icons.bookmark : Icons.bookmark_border,
+ color: _bookmarked ? Palette.primary : Palette.reply,
+ size: 18,
+ ),
+ ),
+ _buildButton(
+ Icons.ios_share_outlined,
+ 0,
+ Palette.reply,
+ _handleQuote,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildButton(
+ IconData icon,
+ int count,
+ Color color,
+ VoidCallback onTap,
+ ) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Row(
+ children: [
+ Icon(icon, color: color, size: 18),
+ const SizedBox(width: 4),
+ Text(
+ count > 0 ? count.toString() : '',
+ style: TextStyle(color: color, fontSize: 13),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart
new file mode 100644
index 0000000..463ab61
--- /dev/null
+++ b/lib/features/notifications/view/widgets/card/mentions_tweet_card.dart
@@ -0,0 +1,490 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import 'package:lite_x/core/providers/dio_interceptor.dart';
+import 'package:lite_x/features/profile/models/shared.dart';
+import 'package:lite_x/features/home/repositories/home_repository.dart';
+import 'package:lite_x/features/home/view/screens/quote_composer_screen.dart';
+import 'package:lite_x/features/home/view/screens/tweet_screen.dart';
+import '../../../mentions_model.dart';
+
+class MentionTweetCard extends ConsumerStatefulWidget {
+ final MentionItem mention;
+
+ const MentionTweetCard({super.key, required this.mention});
+
+ @override
+ ConsumerState createState() => _MentionTweetCardState();
+}
+
+class _MentionTweetCardState extends ConsumerState {
+ late bool _liked;
+ late bool _retweeted;
+ late int _likesCount;
+ late int _repostsCount;
+ bool _processingLike = false;
+ bool _processingRetweet = false;
+ late bool _bookmarked;
+ bool _processingBookmark = false;
+ bool _handlingQuote = false;
+
+ MentionItem get mention => widget.mention;
+
+ @override
+ void initState() {
+ super.initState();
+ _hydrateCounts();
+ }
+
+ @override
+ void didUpdateWidget(MentionTweetCard oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.mention.id != widget.mention.id ||
+ oldWidget.mention.likesCount != widget.mention.likesCount ||
+ oldWidget.mention.retweetCount != widget.mention.retweetCount ||
+ oldWidget.mention.isLiked != widget.mention.isLiked ||
+ oldWidget.mention.isRetweeted != widget.mention.isRetweeted) {
+ _hydrateCounts();
+ }
+ }
+
+ void _hydrateCounts() {
+ _liked = mention.isLiked;
+ _retweeted = mention.isRetweeted;
+ _likesCount = mention.likesCount;
+ _repostsCount = mention.retweetCount;
+ _bookmarked = mention.isBookmarked ?? false;
+ }
+
+ void _showSnack(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(message)),
+ );
+ }
+
+ void _openTweetDetail() {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => TweetDetailScreen(tweetId: mention.id)),
+ );
+ }
+
+ void _openUserProfile() {
+ final username = mention.user.username;
+ if (username.isEmpty) {
+ _showSnack('User profile not available');
+ return;
+ }
+ Navigator.of(context).pushNamed('/profile', arguments: {'username': username});
+ }
+
+ Future _openQuoteComposer() async {
+ if (_handlingQuote) return;
+
+ _handlingQuote = true;
+ try {
+ final repository = ref.read(homeRepositoryProvider);
+ final tweet = await repository.getTweetById(mention.id);
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => QuoteComposerScreen(quotedTweet: tweet),
+ ),
+ );
+ } catch (_) {
+ _showSnack('Unable to open quote composer.');
+ } finally {
+ _handlingQuote = false;
+ }
+ }
+
+ void _showRetweetMenu() {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: const Color(0xFF1E1E1E),
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ builder: (BuildContext modalContext) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(height: 12),
+ Container(
+ width: 40,
+ height: 4,
+ decoration: BoxDecoration(
+ color: Colors.grey[600],
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(height: 20),
+ ListTile(
+ leading: Icon(
+ Icons.repeat,
+ color: _retweeted ? Colors.green : Colors.grey[300],
+ ),
+ title: Text(
+ _retweeted ? 'Undo Repost' : 'Repost',
+ style: TextStyle(
+ color: _retweeted ? Colors.green : Colors.grey[300],
+ fontSize: 16,
+ ),
+ ),
+ onTap: () {
+ Navigator.pop(modalContext);
+ _toggleRetweet();
+ },
+ ),
+ ListTile(
+ leading: Icon(Icons.edit_outlined, color: Colors.grey[300]),
+ title: const Text(
+ 'Quote',
+ style: TextStyle(color: Colors.grey, fontSize: 16),
+ ),
+ onTap: () {
+ Navigator.pop(modalContext);
+ _openQuoteComposer();
+ },
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Future _toggleLike() async {
+ if (_processingLike) return;
+
+ _processingLike = true;
+ final previousLiked = _liked;
+ final previousCount = _likesCount;
+ final newState = !previousLiked;
+
+ setState(() {
+ _liked = newState;
+ _likesCount = newState
+ ? (previousCount + 1)
+ : (previousCount - 1 < 0 ? 0 : previousCount - 1);
+ });
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousLiked) {
+ await dio.delete('api/tweets/${mention.id}/likes');
+ } else {
+ await dio.post('api/tweets/${mention.id}/likes');
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _liked = previousLiked;
+ _likesCount = previousCount;
+ });
+ }
+ _showSnack('Unable to ${previousLiked ? 'unlike' : 'like'} right now.');
+ } finally {
+ _processingLike = false;
+ }
+ }
+
+ Future _toggleRetweet() async {
+ if (_processingRetweet) return;
+
+ _processingRetweet = true;
+ final previousState = _retweeted;
+ final previousCount = _repostsCount;
+ final newState = !previousState;
+
+ setState(() {
+ _retweeted = newState;
+ _repostsCount = newState
+ ? (previousCount + 1)
+ : (previousCount - 1 < 0 ? 0 : previousCount - 1);
+ });
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousState) {
+ await dio.delete('api/tweets/${mention.id}/retweets');
+ } else {
+ await dio.post('api/tweets/${mention.id}/retweets');
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _retweeted = previousState;
+ _repostsCount = previousCount;
+ });
+ }
+ _showSnack('Unable to ${previousState ? 'undo' : 'send'} repost.');
+ } finally {
+ _processingRetweet = false;
+ }
+ }
+
+ Future _toggleBookmark() async {
+ if (_processingBookmark) return;
+
+ _processingBookmark = true;
+ final previousBookmarked = _bookmarked;
+ final newState = !previousBookmarked;
+
+ setState(() {
+ _bookmarked = newState;
+ });
+
+ try {
+ final dio = ref.read(dioProvider);
+ if (previousBookmarked) {
+ await dio.delete('api/tweets/${mention.id}/bookmark');
+ } else {
+ await dio.post('api/tweets/${mention.id}/bookmark');
+ }
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _bookmarked = previousBookmarked;
+ });
+ }
+ _showSnack('Unable to update bookmark right now.');
+ } finally {
+ _processingBookmark = false;
+ }
+ }
+
+ String _formatTimestamp(String createdAt) {
+ try {
+ final dateTime = DateTime.parse(createdAt);
+ final now = DateTime.now();
+ final difference = now.difference(dateTime);
+ if (difference.inDays > 7) {
+ return '${dateTime.day}/${dateTime.month}/${dateTime.year}';
+ } else if (difference.inDays > 0) {
+ return '${difference.inDays}d';
+ } else if (difference.inHours > 0) {
+ return '${difference.inHours}h';
+ } else if (difference.inMinutes > 0) {
+ return '${difference.inMinutes}m';
+ } else {
+ return 'now';
+ }
+ } catch (e) {
+ return createdAt;
+ }
+ }
+
+ String _formatHandle(String value) {
+ if (value.isEmpty) return '@you';
+ return value.startsWith('@') ? value : '@$value';
+ }
+
+ TextStyle get _nameStyle => const TextStyle(
+ fontFamily: 'SF Pro Text',
+ fontWeight: FontWeight.w600,
+ color: Palette.textPrimary,
+ );
+
+ TextStyle get _bodyStyle => const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textPrimary,
+ fontSize: 14,
+ height: 1.4,
+ );
+
+ Widget _cardShell({
+ required Widget child,
+ EdgeInsetsGeometry? padding,
+ VoidCallback? onTap,
+ }) {
+ final content = Container(
+ margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
+ padding: padding ?? const EdgeInsets.symmetric(vertical: 12.0),
+ decoration: const BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: Colors.white, width: 0.5),
+ ),
+ ),
+ child: child,
+ );
+
+ if (onTap == null) {
+ return content;
+ }
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: content,
+ );
+ }
+
+ Widget _metricButton({
+ required IconData icon,
+ int? count,
+ Color color = Palette.textTertiary,
+ VoidCallback? onTap,
+ }) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 16, color: color),
+ if (count != null) ...[
+ const SizedBox(width: 4),
+ Text(
+ count.toString(),
+ style: TextStyle(
+ fontSize: 12,
+ color: color,
+ fontFamily: 'SF Pro Text',
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildMetricsRow() {
+ return Padding(
+ padding: const EdgeInsets.only(top: 8.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _metricButton(
+ icon: Icons.mode_comment_outlined,
+ count: mention.repliesCount,
+ onTap: _openTweetDetail,
+ ),
+ _metricButton(
+ icon: Icons.repeat,
+ count: _repostsCount,
+ color: _retweeted ? Palette.retweet : Palette.textTertiary,
+ onTap: _showRetweetMenu,
+ ),
+ _metricButton(
+ icon: Icons.favorite,
+ count: _likesCount,
+ color: _liked ? Palette.like : Palette.textTertiary,
+ onTap: _toggleLike,
+ ),
+ _metricButton(
+ icon: _bookmarked ? Icons.bookmark : Icons.bookmark_border,
+ color: _bookmarked ? Palette.primary : Palette.textTertiary,
+ onTap: _toggleBookmark,
+ ),
+ _metricButton(
+ icon: Icons.ios_share_outlined,
+ onTap: _openQuoteComposer,
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final profileImageUrl = mention.user.profileMedia?.id;
+ final String bodyText = mention.content.isNotEmpty ? mention.content : '';
+
+ return _cardShell(
+ onTap: _openTweetDetail,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ GestureDetector(
+ onTap: _openUserProfile,
+ child: ClipOval(
+ child: SizedBox(
+ width: 40,
+ height: 40,
+ child: BuildSmallProfileImage(
+ mediaId: profileImageUrl,
+ radius: 20,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Flexible(
+ child: GestureDetector(
+ onTap: _openUserProfile,
+ child: Text(
+ mention.user.name,
+ style: _nameStyle,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ const SizedBox(width: 6),
+ GestureDetector(
+ onTap: _openUserProfile,
+ child: Text(
+ '${_formatHandle(mention.user.username)} Β· ${_formatTimestamp(mention.createdAt)}',
+ style: const TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textTertiary,
+ fontSize: 13,
+ ),
+ ),
+ ),
+ const Spacer(),
+ ],
+ ),
+ const SizedBox(height: 2),
+ const Text(
+ 'mentioned you',
+ style: TextStyle(
+ fontFamily: 'SF Pro Text',
+ color: Palette.textSecondary,
+ fontSize: 13,
+ ),
+ ),
+ if (bodyText.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(bodyText, style: _bodyStyle),
+ ],
+ if (mention.mediaUrls.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 200,
+ child: ListView.builder(
+ scrollDirection: Axis.horizontal,
+ itemCount: mention.mediaUrls.length,
+ itemBuilder: (context, index) {
+ return Container(
+ margin: const EdgeInsets.only(right: 8.0),
+ width: 200,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ image: DecorationImage(
+ image: NetworkImage(mention.mediaUrls[index].url),
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ _buildMetricsRow(),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/notifications/view/widgets/empty/all_empty.dart b/lib/features/notifications/view/widgets/empty/all_empty.dart
new file mode 100644
index 0000000..3cb180b
--- /dev/null
+++ b/lib/features/notifications/view/widgets/empty/all_empty.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:lite_x/core/theme/palette.dart';
+
+class AllEmptyStateWidget extends StatelessWidget {
+ const AllEmptyStateWidget({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 28.0),
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: SizedBox(
+ width: 336,
+ height: 148,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ Text(
+ "Nothing to see here β yet",
+ style: TextStyle(
+ fontSize: 31,
+ fontWeight: FontWeight.w800,
+ color: Palette.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ "From likes to reposts and a whole lot more, this is where all the action happens.",
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ color: Palette.textSecondary,
+ height: 1.4,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/notifications/view/widgets/empty/mention_empty.dart b/lib/features/notifications/view/widgets/empty/mention_empty.dart
new file mode 100644
index 0000000..699fd23
--- /dev/null
+++ b/lib/features/notifications/view/widgets/empty/mention_empty.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+import 'package:lite_x/core/theme/palette.dart';
+
+class MentionsEmptyStateWidget extends StatelessWidget {
+ const MentionsEmptyStateWidget({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 28.0),
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: SizedBox(
+ width: 336,
+ height: 148,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ Text(
+ "Nothing to see here β yet",
+ style: TextStyle(
+ fontSize: 31,
+ fontWeight: FontWeight.w800,
+ color: Palette.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ "When someone mentions you, you'll find it here.",
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ color: Palette.textSecondary,
+ height: 1.4,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/notifications/view/widgets/empty/verified_empty.dart b/lib/features/notifications/view/widgets/empty/verified_empty.dart
new file mode 100644
index 0000000..c9c7a69
--- /dev/null
+++ b/lib/features/notifications/view/widgets/empty/verified_empty.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+import 'package:lite_x/core/theme/palette.dart';
+
+class VerifiedEmptyStateWidget extends StatelessWidget {
+ const VerifiedEmptyStateWidget({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 16.0),
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: SizedBox(
+ width: 336,
+ height: 388,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(16.0),
+ child: Image.network(
+ 'https://abs.twimg.com/responsive-web/client-web/verification-check-800x400.v1.52677a99.png',
+ width: 336,
+ height: 168,
+ fit: BoxFit.cover,
+ ),
+ ),
+ const SizedBox(height: 12),
+
+ Text(
+ "Nothing to see here β yet",
+ style: TextStyle(
+ fontSize: 31,
+ fontWeight: FontWeight.w800,
+ color: Palette.textPrimary,
+ ),
+ ),
+ const SizedBox(height: 6),
+
+ Text(
+ "Likes, mentions, reposts, and a whole lot more β when it comes from a verified account, you'll find it here.",
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ color: Palette.textSecondary,
+ height: 1.4,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/notifications/view/widgets/notification_tabs.dart b/lib/features/notifications/view/widgets/notification_tabs.dart
new file mode 100644
index 0000000..dab9261
--- /dev/null
+++ b/lib/features/notifications/view/widgets/notification_tabs.dart
@@ -0,0 +1,144 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import 'package:lite_x/features/notifications/notification_fcm_service.dart';
+import 'tabs/all_notifications.dart';
+import 'tabs/verified_notifications.dart';
+import 'tabs/mentions_notifications.dart';
+import '../../notification_view_model.dart';
+import '../../mentions_view_model.dart';
+
+class NotificationTabs extends ConsumerStatefulWidget {
+ const NotificationTabs({super.key});
+
+ @override
+ ConsumerState createState() => _NotificationTabsState();
+}
+
+class _NotificationTabsState extends ConsumerState
+ with SingleTickerProviderStateMixin {
+ late TabController _tabController;
+ int selectedIndex = 0;
+ final tabs = ['All', 'Verified', 'Mentions'];
+
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(length: 3, vsync: this);
+
+ final fcmService = NotificationFcmService();
+ fcmService.notificationsRefreshCallback = () {
+ ref.read(notificationViewModelProvider.notifier).refresh();
+ };
+ fcmService.mentionsRefreshCallback = () {
+ ref.read(mentionsViewModelProvider.notifier).refresh();
+ };
+ _tabController.addListener(() {
+ if (selectedIndex != _tabController.index) {
+ setState(() {
+ selectedIndex = _tabController.index;
+ });
+ // Trigger refresh for the newly selected tab
+ switch (_tabController.index) {
+ case 0:
+ ref.read(notificationViewModelProvider.notifier).refresh();
+ break;
+ case 1:
+ // Verified tab currently static; no provider to refresh
+ break;
+ case 2:
+ ref.read(mentionsViewModelProvider.notifier).refresh();
+ break;
+ }
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ final fcmService = NotificationFcmService();
+ fcmService.notificationsRefreshCallback = null;
+ fcmService.mentionsRefreshCallback = null;
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ children: [
+ Container(
+ decoration: BoxDecoration(
+ color: Palette.background,
+ border: Border(
+ bottom: BorderSide(
+ color: Palette.border,
+ width: 1,
+ ),
+ ),
+ ),
+ height: 53,
+ child: Row(
+ children: List.generate(
+ tabs.length,
+ (index) => Expanded(
+ child: GestureDetector(
+ onTap: () {
+ _tabController.animateTo(index);
+ },
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ Container(
+ color: Colors.transparent,
+ height: 53,
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ child: Center(
+ child: Text(
+ tabs[index],
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontWeight: selectedIndex == index
+ ? FontWeight.w700
+ : FontWeight.w500,
+ fontSize: 15,
+ color: selectedIndex == index
+ ? Palette.textPrimary
+ : Palette.textSecondary,
+ ),
+ ),
+ ),
+ ),
+ if (selectedIndex == index)
+ Container(
+ width: 60,
+ height: 3,
+ decoration: BoxDecoration(
+ color: Palette.primary,
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(1),
+ topRight: Radius.circular(1),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: TabBarView(
+ controller: _tabController,
+ children: const [
+ AllTab(key: PageStorageKey('allTab')),
+ VerifiedTab(key: PageStorageKey('verifiedTab')),
+ MentionsTab(key: PageStorageKey('mentionsTab')),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/notifications/view/widgets/status_bar.dart b/lib/features/notifications/view/widgets/status_bar.dart
new file mode 100644
index 0000000..97455ce
--- /dev/null
+++ b/lib/features/notifications/view/widgets/status_bar.dart
@@ -0,0 +1,68 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:lite_x/core/models/usermodel.dart';
+import 'package:lite_x/core/providers/current_user_provider.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import 'package:lite_x/features/profile/models/shared.dart';
+
+class Statusbar extends ConsumerWidget {
+ final GlobalKey scaffoldKey;
+
+ const Statusbar({super.key, required this.scaffoldKey});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final UserModel? currentUser = ref.watch(currentUserProvider);
+ final avatarUrl = currentUser?.photo;
+
+ return Container(
+ height: 53,
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ color: Palette.background,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ GestureDetector(
+ onTap: () => scaffoldKey.currentState?.openDrawer(),
+ child: ClipOval(
+ child: SizedBox(
+ width: 40,
+ height: 40,
+ child: BuildSmallProfileImage(
+ mediaId: avatarUrl,
+ radius: 20,
+ ),
+ ),
+ ),
+ ),
+ SizedBox(width: 12),
+ Text(
+ 'Notifications',
+ style: TextStyle(
+ fontFamily: 'Inter',
+ fontWeight: FontWeight.w700,
+ fontSize: 17,
+ color: Palette.textWhite,
+ letterSpacing: -0.3,
+ ),
+ ),
+ ],
+ ),
+ GestureDetector(
+ onTap: () {
+ context.push("/settingandprivacyscreen");
+ },
+ child: Icon(
+ Icons.settings_outlined,
+ size: 20,
+ color: Palette.icons,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/features/notifications/view/widgets/tabs/all_notifications.dart b/lib/features/notifications/view/widgets/tabs/all_notifications.dart
new file mode 100644
index 0000000..061ca3b
--- /dev/null
+++ b/lib/features/notifications/view/widgets/tabs/all_notifications.dart
@@ -0,0 +1,99 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lite_x/core/theme/palette.dart';
+
+import '../empty/all_empty.dart';
+import '../card/all_tweet_card.dart';
+import '../../../notification_model.dart';
+import '../../../notification_view_model.dart';
+
+class AllTab extends ConsumerStatefulWidget {
+ const AllTab({super.key});
+
+ @override
+ ConsumerState createState() => _AllTabState();
+}
+
+class _AllTabState extends ConsumerState
+ with AutomaticKeepAliveClientMixin {
+ final GlobalKey _listKey = GlobalKey();
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(notificationViewModelProvider.notifier).refresh();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+
+ final state = ref.watch(notificationViewModelProvider);
+
+ return Container(
+ color: Palette.background,
+ child: state.when(
+ data: (items) {
+ return RefreshIndicator(
+ onRefresh: () async {
+ await ref
+ .read(notificationViewModelProvider.notifier)
+ .refresh();
+ },
+ child: items.isEmpty
+ ? ListView(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ children: const [AllEmptyStateWidget()],
+ )
+ : AnimatedList(
+ key: _listKey,
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ initialItemCount: items.length,
+ itemBuilder: (context, index, animation) {
+ return _buildItem(items[index], animation);
+ },
+ ),
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (e, st) => Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text(
+ 'Failed to load notifications',
+ style: TextStyle(color: Colors.red),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ ref.read(notificationViewModelProvider.notifier).refresh();
+ },
+ child: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildItem(NotificationItem notification, Animation animation) {
+ return FadeTransition(
+ opacity: animation,
+ child: SlideTransition(
+ position: Tween(begin: const Offset(0, 0.2), end: Offset.zero)
+ .animate(animation),
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 16.0),
+ child: AllTweetCardWidget(notification: notification),
+ ),
+ ),
+ );
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+}
diff --git a/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart
new file mode 100644
index 0000000..2729186
--- /dev/null
+++ b/lib/features/notifications/view/widgets/tabs/mentions_notifications.dart
@@ -0,0 +1,78 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import '../empty/mention_empty.dart';
+import '../card/mentions_tweet_card.dart';
+import '../../../mentions_view_model.dart';
+
+class MentionsTab extends ConsumerStatefulWidget {
+ const MentionsTab({super.key});
+
+ @override
+ ConsumerState createState() => _MentionsTabState();
+}
+
+class _MentionsTabState extends ConsumerState
+ with AutomaticKeepAliveClientMixin {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref.read(mentionsViewModelProvider.notifier).refresh();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+
+ final state = ref.watch(mentionsViewModelProvider);
+
+ return Container(
+ color: Palette.background,
+ child: state.when(
+ data: (items) {
+ return RefreshIndicator(
+ onRefresh: () async {
+ await ref.read(mentionsViewModelProvider.notifier).refresh();
+ },
+ child: items.isEmpty
+ ? ListView(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ children: const [MentionsEmptyStateWidget()],
+ )
+ : ListView.builder(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ return MentionTweetCard(mention: items[index]);
+ },
+ ),
+ );
+ },
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (e, st) => Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text(
+ 'Failed to load mentions',
+ style: TextStyle(color: Colors.red),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ ref.read(mentionsViewModelProvider.notifier).refresh();
+ },
+ child: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+}
diff --git a/lib/features/notifications/view/widgets/tabs/verified_notifications.dart b/lib/features/notifications/view/widgets/tabs/verified_notifications.dart
new file mode 100644
index 0000000..d512a32
--- /dev/null
+++ b/lib/features/notifications/view/widgets/tabs/verified_notifications.dart
@@ -0,0 +1,159 @@
+import 'package:flutter/material.dart';
+import '../../../notification_model.dart';
+import 'package:lite_x/core/theme/palette.dart';
+import '../empty/verified_empty.dart';
+
+class VerifiedTab extends StatefulWidget {
+ const VerifiedTab({super.key});
+
+ @override
+ State createState() => _VerifiedTabState();
+}
+
+class _VerifiedTabState extends State
+ with AutomaticKeepAliveClientMixin {
+ final List _notifications = []; // Set to empty for demo
+
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ return Container(
+ color: Palette.background,
+ child: const VerifiedEmptyStateWidget(),
+ );
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+}
+
+class _VerifiedNotificationCard extends StatefulWidget {
+ final NotificationItem notification;
+
+ const _VerifiedNotificationCard({required this.notification});
+
+ @override
+ State<_VerifiedNotificationCard> createState() =>
+ _VerifiedNotificationCardState();
+}
+
+class _VerifiedNotificationCardState extends State<_VerifiedNotificationCard>
+ with SingleTickerProviderStateMixin {
+ late AnimationController _controller;
+ late Animation _animation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 500),
+ );
+ _animation = CurvedAnimation(parent: _controller, curve: Curves.easeIn);
+ _controller.forward();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return FadeTransition(
+ opacity: _animation,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8.0),
+ child: Material(
+ color: Palette.cardBackground,
+ borderRadius: BorderRadius.circular(16.0),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(16.0),
+ onTap: () {},
+ splashColor: Palette.primaryHover.withOpacity(0.2),
+ highlightColor: Palette.primaryHover.withOpacity(0.1),
+ child: Container(
+ padding: const EdgeInsets.all(16.0),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16.0),
+ border: Border(
+ left: BorderSide(color: Palette.primary, width: 4.0),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: Palette.primary.withOpacity(0.5),
+ blurRadius: 8.0,
+ spreadRadius: 1.0,
+ ),
+ ],
+ ),
+ child: CircleAvatar(
+ radius: 24,
+ backgroundImage:
+ widget.notification.mediaUrl.isNotEmpty
+ ? NetworkImage(widget.notification.mediaUrl)
+ : null,
+ backgroundColor: Palette.cardBackground,
+ child: widget.notification.mediaUrl.isEmpty
+ ? Icon(Icons.person, color: Palette.textPrimary)
+ : null,
+ ),
+ ),
+ const SizedBox(width: 12.0),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ widget.notification.mediaUrl,
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 16,
+ color: Palette.textWhite,
+ ),
+ ),
+ const SizedBox(width: 4.0),
+ Icon(
+ Icons.verified,
+ color: Palette.verified,
+ size: 16,
+ ),
+ ],
+ ),
+ Text(
+ widget.notification.body,
+ style: TextStyle(color: Palette.textSecondary),
+ ),
+ ],
+ ),
+ ),
+ Text(
+ widget.notification.mediaUrl,
+ style: TextStyle(
+ color: Palette.textTertiary,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/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