@@ -166,6 +166,21 @@ class TweetsApiServiceImpl implements TweetsApiService {
166166 if (raw is ! Map ) continue ;
167167 final json = raw as Map <String , dynamic >;
168168
169+ // If this is a repost whose originalPostData itself has an
170+ // originalPostData, we're dealing with a repost of a quote. Mark the
171+ // inner original as a quote so that TweetModel.fromJsonPosts will set
172+ // isQuote=true on the quoted tweet and the UI can render its parent
173+ // tweet nested inside.
174+ final bool topIsRepost = json['isRepost' ] == true ;
175+ final dynamic topOriginal = json['originalPostData' ];
176+ if (topIsRepost && topOriginal is Map <String , dynamic >) {
177+ final dynamic nestedOriginal = topOriginal['originalPostData' ];
178+ if (nestedOriginal is Map <String , dynamic > &&
179+ topOriginal['isQuote' ] == null ) {
180+ topOriginal['isQuote' ] = true ;
181+ }
182+ }
183+
169184 // Prefer the new transformed post shape from backend (with
170185 // originalPostData for replies / reposts / quotes). This ensures
171186 // replies and quotes carry their parent tweet tree via
@@ -795,72 +810,211 @@ class TweetsApiServiceImpl implements TweetsApiService {
795810 int page,
796811 String tweetsType,
797812 ) async {
798- String endpoint = ServerConstant .tweetsForYou;
799- Map <String , dynamic > response = await _apiService.get (
800- endpoint: "/posts/timeline/" + tweetsType,
801- queryParameters: {"limit" : limit, "page" : page},
802- );
813+ // Ensure flags are loaded before fetching tweets
814+ if (! _isInitialized) {
815+ await _loadStoredFlags ();
816+ }
803817
804- List <dynamic > postsJson = response['data' ]['posts' ];
818+ try {
819+ final response = await _apiService.get <Map <String , dynamic >>(
820+ endpoint: "/posts/timeline/$tweetsType " ,
821+ queryParameters: {"limit" : limit, "page" : page},
822+ );
805823
806- List <TweetModel > tweets = postsJson.map ((post) {
807- bool isRepost = post['isRepost' ] ?? false ;
808- bool isQuote = post['isQuote' ] ?? false ;
824+ // Backend returns: { status, message, data: { posts: [...] } }
825+ final dataWrapper = response['data' ];
826+ final postsJson = (dataWrapper is Map && dataWrapper['posts' ] is List )
827+ ? (dataWrapper['posts' ] as List )
828+ : < dynamic > [];
809829
810- Map <String , dynamic >? originalJson;
811- final rawOriginal = post['originalPostData' ];
812- if ((isRepost || isQuote) && rawOriginal is Map <String , dynamic >) {
813- originalJson = rawOriginal;
814- }
830+ final tweets = < TweetModel > [];
815831
816- return TweetModel (
817- id: post['postId' ].toString (),
818- body: post['text' ] ?? '' ,
819-
820- mediaImages: parseMedia (post['media' ]),
821- mediaVideos: const [],
822-
823- date: DateTime .parse (post['date' ]),
824- likes: post['likesCount' ] ?? 0 ,
825- qoutes: post['commentsCount' ] ?? 0 ,
826- repost: post['retweetsCount' ] ?? 0 ,
827- comments: post['commentsCount' ] ?? 0 ,
828- userId: post['userId' ].toString (),
829-
830- username: post['username' ],
831- authorName: post['name' ],
832- authorProfileImage: post['avatar' ],
833-
834- isRepost: isRepost,
835- isQuote: isQuote,
836-
837- originalTweet: originalJson != null
838- ? TweetModel (
839- id: originalJson['postId' ].toString (),
840- body: originalJson['text' ] ?? '' ,
841-
842- mediaImages: parseMedia (originalJson['media' ]),
843- mediaVideos: const [],
844-
845- date: DateTime .parse (originalJson['date' ]),
846- likes: originalJson['likesCount' ] ?? 0 ,
847- qoutes: originalJson['commentsCount' ] ?? 0 ,
848- repost: originalJson['retweetsCount' ] ?? 0 ,
849- comments: originalJson['commentsCount' ] ?? 0 ,
850- userId: originalJson['userId' ].toString (),
851-
852- username: originalJson['username' ],
853- authorName: originalJson['name' ],
854- authorProfileImage: originalJson['avatar' ],
855-
856- isRepost: false ,
857- isQuote: false ,
858- )
859- : null ,
860- );
861- }).toList ();
832+ for (final raw in postsJson) {
833+ if (raw is ! Map ) continue ;
834+ final json = raw as Map <String , dynamic >;
835+
836+ // If this is a repost whose originalPostData itself has an
837+ // originalPostData, we're dealing with a repost of a quote. Mark the
838+ // inner original as a quote so that TweetModel.fromJsonPosts will set
839+ // isQuote=true on the quoted tweet and the UI can render its parent
840+ // tweet nested inside.
841+ final bool topIsRepost = json['isRepost' ] == true ;
842+ final dynamic topOriginal = json['originalPostData' ];
843+ if (topIsRepost && topOriginal is Map <String , dynamic >) {
844+ final dynamic nestedOriginal = topOriginal['originalPostData' ];
845+ if (nestedOriginal is Map <String , dynamic > &&
846+ topOriginal['isQuote' ] == null ) {
847+ topOriginal['isQuote' ] = true ;
848+ }
849+ }
850+
851+ // Prefer the new transformed post shape from backend (with
852+ // originalPostData for replies / reposts / quotes). This ensures
853+ // replies and quotes carry their parent tweet tree via
854+ // TweetModel.originalTweet.
855+ try {
856+ if (json.containsKey ('postId' ) && json.containsKey ('date' )) {
857+ final tweet = TweetModel .fromJsonPosts (json);
858+
859+ final tweetId = tweet.id;
860+ final isLikedByMe = json['isLikedByMe' ] ?? false ;
861+ final isRepostedByMe = json['isRepostedByMe' ] ?? false ;
862+ final isRepost = json['isRepost' ] ?? false ;
863+ final isQuote = json['isQuote' ] ?? false ;
864+
865+ final existingFlags = _interactionFlags[tweetId] ?? < String , bool > {};
866+ _interactionFlags[tweetId] = {
867+ ...existingFlags,
868+ 'isLikedByMe' : isLikedByMe,
869+ 'isRepostedByMe' : isRepostedByMe,
870+ 'isRepost' : isRepost,
871+ 'isQuote' : isQuote,
872+ };
873+
874+ tweets.add (tweet);
875+ continue ;
876+ }
877+ } catch (e) {
878+ print (
879+ '⚠️ Failed to parse timeline post via fromJsonPosts, falling back: $e ' ,
880+ );
881+ }
882+
883+ // Some timeline entries for reposts are lightweight wrapper objects
884+ // that don't have a top-level postId/date; instead, the full
885+ // original post (possibly a quote/reply tree) lives under
886+ // originalPostData. Normalise those into the transformed post shape
887+ // so TweetModel.fromJsonPosts can build the hierarchical chain.
888+ final bool isRepostWrapper =
889+ (json['isRepost' ] == true ) && ! json.containsKey ('postId' );
890+ final originalWrapper = json['originalPostData' ];
891+ if (isRepostWrapper && originalWrapper is Map <String , dynamic >) {
892+ final original = Map <String , dynamic >.from (originalWrapper);
893+
894+ final merged = < String , dynamic > {
895+ ...original,
896+ };
897+
898+ // Override author fields with the reposting user's identity and
899+ // use the repost timestamp instead of the original.
900+ if (json['userId' ] != null ) merged['userId' ] = json['userId' ];
901+ if (json['username' ] != null ) merged['username' ] = json['username' ];
902+ if (json['name' ] != null ) merged['name' ] = json['name' ];
903+ if (json['avatar' ] != null ) merged['avatar' ] = json['avatar' ];
904+ if (json['date' ] != null ) merged['date' ] = json['date' ];
905+
906+ merged['isRepost' ] = true ;
907+ merged['originalPostData' ] = original;
908+
909+ try {
910+ final tweet = TweetModel .fromJsonPosts (merged);
911+ final tweetId = tweet.id;
912+
913+ final isLikedByMe = json['isLikedByMe' ] ?? false ;
914+ final isRepostedByMe = json['isRepostedByMe' ] ?? false ;
915+ final isQuote = merged['isQuote' ] ?? false ;
916+
917+ final existingFlags =
918+ _interactionFlags[tweetId] ?? < String , bool > {};
919+ _interactionFlags[tweetId] = {
920+ ...existingFlags,
921+ 'isLikedByMe' : isLikedByMe,
922+ 'isRepostedByMe' : isRepostedByMe,
923+ 'isRepost' : true ,
924+ 'isQuote' : isQuote is bool ? isQuote : (isQuote == true ),
925+ };
926+
927+ tweets.add (tweet);
928+ continue ;
929+ } catch (e) {
930+ print (
931+ '⚠️ Failed to parse timeline repost wrapper via fromJsonPosts, falling back: $e ' ,
932+ );
933+ }
934+ }
862935
863- return tweets;
936+ // Fallback: legacy mapping for older backend shapes.
937+ final tweetId =
938+ (json['postId' ] ??
939+ json['id' ] ??
940+ DateTime .now ().millisecondsSinceEpoch)
941+ .toString ();
942+
943+ final username = json['username' ]? .toString ();
944+ final authorName = json['name' ]? .toString ();
945+ final authorProfileImage = json['avatar' ]? .toString ();
946+
947+ int parseInt (dynamic v) =>
948+ v == null ? 0 : (v is int ? v : int .tryParse (v.toString ()) ?? 0 );
949+
950+ final likes = parseInt (json['likesCount' ]);
951+ final reposts = parseInt (json['retweetsCount' ]);
952+ final comments = parseInt (json['commentsCount' ]);
953+
954+ final isLikedByMe = json['isLikedByMe' ] ?? false ;
955+ final isRepostedByMe = json['isRepostedByMe' ] ?? false ;
956+ final isRepost = json['isRepost' ] ?? false ;
957+ final isQuote = json['isQuote' ] ?? false ;
958+
959+ final imageUrls = < String > [];
960+ final videoUrls = < String > [];
961+ if (json['media' ] is List ) {
962+ for (final m in (json['media' ] as List )) {
963+ if (m is ! Map ) continue ;
964+ final url = m['url' ]? .toString ();
965+ final type = m['type' ]? .toString ();
966+ if (url == null || url.isEmpty) continue ;
967+ if (type == 'VIDEO' ) {
968+ videoUrls.add (url);
969+ } else {
970+ imageUrls.add (url);
971+ }
972+ }
973+ }
974+
975+ final mappedJson = < String , dynamic > {
976+ 'id' : tweetId,
977+ 'userId' : (json['userId' ] ?? json['user_id' ] ?? '0' ).toString (),
978+ 'body' : (json['text' ] ?? json['content' ] ?? '' ).toString (),
979+ 'date' :
980+ (json['date' ] ??
981+ json['createdAt' ] ??
982+ DateTime .now ().toIso8601String ())
983+ .toString (),
984+ 'username' : username,
985+ 'authorName' : authorName,
986+ 'authorProfileImage' : authorProfileImage,
987+ 'likes' : likes,
988+ 'repost' : reposts,
989+ 'comments' : comments,
990+ 'views' : 0 ,
991+ 'qoutes' : 0 ,
992+ 'bookmarks' : 0 ,
993+ 'mediaImages' : imageUrls,
994+ 'mediaVideos' : videoUrls,
995+ 'isRepost' : isRepost,
996+ 'isQuote' : isQuote,
997+ };
998+
999+ final tweet = TweetModel .fromJson (mappedJson);
1000+
1001+ final existingFlags = _interactionFlags[tweetId] ?? < String , bool > {};
1002+ _interactionFlags[tweetId] = {
1003+ ...existingFlags,
1004+ 'isLikedByMe' : isLikedByMe,
1005+ 'isRepostedByMe' : isRepostedByMe,
1006+ 'isRepost' : isRepost,
1007+ 'isQuote' : isQuote,
1008+ };
1009+
1010+ tweets.add (tweet);
1011+ }
1012+
1013+ return tweets;
1014+ } catch (e) {
1015+ print ('❌ Error fetching timeline tweets ($tweetsType ): $e ' );
1016+ rethrow ;
1017+ }
8641018 }
8651019
8661020 @override
@@ -889,6 +1043,18 @@ class TweetsApiServiceImpl implements TweetsApiService {
8891043 if (data is ! List ) return [];
8901044
8911045 return data.map <TweetModel >((json) {
1046+ // Prefer the transformed hierarchical post shape when available so
1047+ // reposts/quotes keep their full parent chain via originalTweet.
1048+ if (json is Map <String , dynamic >) {
1049+ try {
1050+ if (json.containsKey ('postId' ) && json.containsKey ('date' )) {
1051+ return TweetModel .fromJsonPosts (json);
1052+ }
1053+ } catch (e) {
1054+ print ('⚠️ Failed to parse profile post via fromJsonPosts, falling back: $e ' );
1055+ }
1056+ }
1057+
8921058 final isRepost = json['isRepost' ] == true ;
8931059 final isQuote = json['isQuote' ] == true ;
8941060 final original = json['originalPostData' ];
@@ -897,6 +1063,34 @@ class TweetsApiServiceImpl implements TweetsApiService {
8971063 // CASE 1: REPOST → RETURN ONLY ORIGINAL TWEET
8981064 // ---------------------------------------------------------
8991065 if (isRepost && original is Map <String , dynamic >) {
1066+ // New profile shape returns a lightweight repost wrapper where the
1067+ // full post (possibly a quote/reply tree) lives under
1068+ // originalPostData. Merge that with the repost metadata and let
1069+ // TweetModel.fromJsonPosts build the hierarchical chain.
1070+ final merged = < String , dynamic > {
1071+ ...Map <String , dynamic >.from (original),
1072+ };
1073+
1074+ if (json is Map <String , dynamic >) {
1075+ if (json['userId' ] != null ) merged['userId' ] = json['userId' ];
1076+ if (json['username' ] != null ) merged['username' ] = json['username' ];
1077+ if (json['name' ] != null ) merged['name' ] = json['name' ];
1078+ if (json['avatar' ] != null ) merged['avatar' ] = json['avatar' ];
1079+ if (json['date' ] != null ) merged['date' ] = json['date' ];
1080+ }
1081+
1082+ merged['isRepost' ] = true ;
1083+ merged['originalPostData' ] = Map <String , dynamic >.from (original);
1084+
1085+ try {
1086+ return TweetModel .fromJsonPosts (merged);
1087+ } catch (e) {
1088+ print (
1089+ '⚠️ Failed to parse profile repost via fromJsonPosts, falling back: $e ' ,
1090+ );
1091+ }
1092+
1093+ // Fallback: flat mapping using only the original post data.
9001094 return TweetModel (
9011095 id: original['postId' ].toString (),
9021096 userId: original['userId' ].toString (),
@@ -986,6 +1180,22 @@ class TweetsApiServiceImpl implements TweetsApiService {
9861180 if (data is ! List ) return [];
9871181
9881182 return data.map <TweetModel >((json) {
1183+ // Prefer the transformed hierarchical post shape (TransformedPost)
1184+ // when available so replies carry their full parent chain via
1185+ // TweetModel.originalTweet, just like in the main timeline.
1186+ if (json is Map <String , dynamic >) {
1187+ try {
1188+ if (json.containsKey ('postId' ) && json.containsKey ('date' )) {
1189+ return TweetModel .fromJsonPosts (json);
1190+ }
1191+ } catch (e) {
1192+ print (
1193+ '⚠️ Failed to parse profile reply via fromJsonPosts, falling back: $e ' ,
1194+ );
1195+ }
1196+ }
1197+
1198+ // Legacy fallback: manual mapping using the flat shape.
9891199 final original = json['originalPostData' ];
9901200
9911201 // Parent tweet (what reply is replying to)
0 commit comments