[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: notifications

+1954 -155
+1
assets/icons/comment_filled.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 27 26"><path fill="#fff" d="M15.852 22.632c4.531-.301 8.14-3.962 8.438-8.558.058-.9.058-1.831 0-2.73-.297-4.596-3.907-8.257-8.438-8.558a36 36 0 0 0-4.703 0c-4.532.301-8.142 3.962-8.439 8.558-.058.899-.058 1.83 0 2.73.109 1.674.849 3.224 1.72 4.532.506.916.172 2.06-.355 3.059-.38.72-.57 1.08-.417 1.34s.493.268 1.175.285c1.348.033 2.257-.35 2.978-.881.41-.302.614-.453.755-.47.141-.018.418.097.973.325a5.3 5.3 0 0 0 1.61.368c1.543.102 3.157.103 4.703 0Z"/></svg>
+10
lib/src/core/design_system/components/atoms/icons.dart
··· 137 137 : null, 138 138 package: 'assets', 139 139 ); 140 + static Widget commentFilled({double size = 24, Color? color}) => 141 + SvgPicture.asset( 142 + '$_path/comment_filled.svg', 143 + width: size, 144 + height: size, 145 + colorFilter: color != null 146 + ? ColorFilter.mode(color, BlendMode.srcIn) 147 + : null, 148 + package: 'assets', 149 + ); 140 150 static Widget disk({double size = 24, Color? color}) => SvgPicture.asset( 141 151 '$_path/disk.svg', 142 152 width: size,
+83 -11
lib/src/core/design_system/components/organisms/bottom_nav_bar.dart
··· 1 1 import 'dart:ui'; 2 2 3 3 import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 5 import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 5 6 import 'package:spark/src/core/design_system/tokens/constants.dart'; 7 + import 'package:spark/src/core/ui/foundation/colors.dart'; 8 + import 'package:spark/src/features/notifications/providers/unread_count_provider.dart'; 6 9 7 - class SparkBottomNavBar extends StatelessWidget { 10 + class SparkBottomNavBar extends ConsumerWidget { 8 11 const SparkBottomNavBar({ 9 12 required this.currentIndex, 10 13 required this.onTap, ··· 12 15 super.key, 13 16 }); 14 17 15 - /// 0 = home, 1 = explore, 2 = create/post, 3 = messages, 4 = profile 18 + /// 0 = home, 1 = explore, 2 = messages, 3 = notifications, 4 = profile 16 19 final int currentIndex; 17 20 final ValueChanged<int> onTap; 18 21 final ImageProvider userAvatar; 19 22 20 23 @override 21 - Widget build(BuildContext context) { 24 + Widget build(BuildContext context, WidgetRef ref) { 22 25 final bottomPadding = MediaQuery.of(context).padding.bottom; 23 26 // Always use dark mode when on home tab (index 0) 24 27 final isDark = ··· 62 65 isSelected: currentIndex == 2, 63 66 onTap: () => onTap(2), 64 67 builder: (c, selected) => selected 65 - ? AppIcons.navbarPostFilled( 68 + ? AppIcons.messagesFilled( 66 69 color: isDark ? null : Colors.black, 67 70 ) 68 - : AppIcons.navbarPost( 69 - color: isDark ? null : Colors.black, 70 - ), 71 + : AppIcons.messages(color: isDark ? null : Colors.black), 71 72 ), 72 73 73 - _NavIcon( 74 + _NavIconWithBadge( 74 75 isSelected: currentIndex == 3, 75 76 onTap: () => onTap(3), 76 77 builder: (c, selected) => selected 77 - ? AppIcons.messagesFilled( 78 - color: isDark ? null : Colors.black, 78 + ? AppIcons.likeFilled( 79 + color: isDark ? Colors.white : Colors.black, 79 80 ) 80 - : AppIcons.messages(color: isDark ? null : Colors.black), 81 + : AppIcons.like(color: isDark ? null : Colors.black), 82 + badgeCount: ref.watch(unreadCountProvider()), 83 + isDark: isDark, 81 84 ), 82 85 83 86 _ProfileAvatar( ··· 143 146 duration: AppConstants.animationFast, 144 147 padding: const EdgeInsets.all(5), 145 148 child: builder(context, isSelected), 149 + ), 150 + ); 151 + } 152 + } 153 + 154 + class _NavIconWithBadge extends StatelessWidget { 155 + const _NavIconWithBadge({ 156 + required this.isSelected, 157 + required this.onTap, 158 + required this.builder, 159 + required this.badgeCount, 160 + required this.isDark, 161 + }); 162 + 163 + final bool isSelected; 164 + final VoidCallback onTap; 165 + final Widget Function(BuildContext, bool) builder; 166 + final AsyncValue<int> badgeCount; 167 + final bool isDark; 168 + 169 + @override 170 + Widget build(BuildContext context) { 171 + final count = badgeCount.value ?? 0; 172 + final showBadge = count > 0; 173 + 174 + return GestureDetector( 175 + behavior: HitTestBehavior.opaque, 176 + onTap: onTap, 177 + child: AnimatedContainer( 178 + duration: AppConstants.animationFast, 179 + padding: const EdgeInsets.all(5), 180 + child: Stack( 181 + clipBehavior: Clip.none, 182 + children: [ 183 + builder(context, isSelected), 184 + if (showBadge) 185 + Positioned( 186 + right: -7, 187 + top: -6, 188 + child: Container( 189 + padding: count < 10 190 + ? EdgeInsets.zero 191 + : const EdgeInsets.symmetric(horizontal: 6), 192 + constraints: BoxConstraints( 193 + minWidth: count < 10 ? 18 : 20, 194 + minHeight: 18, 195 + ), 196 + height: 18, 197 + decoration: BoxDecoration( 198 + color: AppColors.primary, 199 + shape: count < 10 ? BoxShape.circle : BoxShape.rectangle, 200 + borderRadius: count < 10 201 + ? null 202 + : const BorderRadius.all(Radius.circular(9)), 203 + ), 204 + alignment: Alignment.center, 205 + child: Text( 206 + count > 99 ? '99+' : count.toString(), 207 + style: const TextStyle( 208 + color: Colors.white, 209 + fontSize: 10, 210 + fontWeight: FontWeight.bold, 211 + ), 212 + textAlign: TextAlign.center, 213 + ), 214 + ), 215 + ), 216 + ], 217 + ), 146 218 ), 147 219 ); 148 220 }
+3 -1
lib/src/core/design_system/templates/profile_page_template.dart
··· 45 45 this.isLoading = false, 46 46 this.contentSlivers, 47 47 this.scrollController, 48 + this.leading, 48 49 }); 49 50 50 51 final String displayName; ··· 80 81 final Future<void> Function()? onRefresh; 81 82 final bool isLoading; 82 83 final ScrollController? scrollController; 84 + final Widget? leading; 83 85 84 86 @override 85 87 Widget build(BuildContext context) { ··· 94 96 : null, 95 97 elevation: 0, 96 98 actions: appBarActions, 97 - leading: const AppLeadingButton(), 99 + leading: leading ?? const AppLeadingButton(), 98 100 ), 99 101 body: RefreshIndicator( 100 102 onRefresh: onRefresh ?? () async {},
+1
lib/src/core/network/atproto/data/models/models.dart
··· 2 2 export 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 3 3 export 'package:spark/src/core/network/atproto/data/models/graph_models.dart'; 4 4 export 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; 5 + export 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 5 6 export 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; 6 7 export 'package:spark/src/core/network/atproto/data/models/record_models.dart'; 7 8 export 'package:spark/src/core/network/atproto/data/models/sound_models.dart';
+67
lib/src/core/network/atproto/data/models/notification_models.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 2 + import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:freezed_annotation/freezed_annotation.dart'; 4 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 5 + 6 + part 'notification_models.freezed.dart'; 7 + part 'notification_models.g.dart'; 8 + 9 + @freezed 10 + abstract class Notification with _$Notification { 11 + @JsonSerializable(explicitToJson: true) 12 + const factory Notification({ 13 + @AtUriConverter() required AtUri uri, 14 + required String cid, 15 + required ProfileViewBasic author, 16 + required String reason, 17 + required Map<String, dynamic> record, 18 + required bool isRead, 19 + required DateTime indexedAt, 20 + @JsonKey(name: r'$type') String? type, 21 + @AtUriConverter() AtUri? reasonSubject, 22 + @Default(null) List<Label>? labels, 23 + }) = _Notification; 24 + const Notification._(); 25 + 26 + factory Notification.fromJson(Map<String, dynamic> json) => 27 + _$NotificationFromJson(json); 28 + } 29 + 30 + @freezed 31 + abstract class ListNotificationsResponse with _$ListNotificationsResponse { 32 + @JsonSerializable(explicitToJson: true) 33 + const factory ListNotificationsResponse({ 34 + required List<Notification> notifications, 35 + String? cursor, 36 + bool? priority, 37 + DateTime? seenAt, 38 + }) = _ListNotificationsResponse; 39 + const ListNotificationsResponse._(); 40 + 41 + factory ListNotificationsResponse.fromJson(Map<String, dynamic> json) => 42 + _$ListNotificationsResponseFromJson(json); 43 + } 44 + 45 + @freezed 46 + abstract class UnreadCountResponse with _$UnreadCountResponse { 47 + @JsonSerializable(explicitToJson: true) 48 + const factory UnreadCountResponse({ 49 + required int count, 50 + }) = _UnreadCountResponse; 51 + const UnreadCountResponse._(); 52 + 53 + factory UnreadCountResponse.fromJson(Map<String, dynamic> json) => 54 + _$UnreadCountResponseFromJson(json); 55 + } 56 + 57 + @freezed 58 + abstract class UpdateSeenRequest with _$UpdateSeenRequest { 59 + @JsonSerializable(explicitToJson: true) 60 + const factory UpdateSeenRequest({ 61 + required DateTime seenAt, 62 + }) = _UpdateSeenRequest; 63 + const UpdateSeenRequest._(); 64 + 65 + factory UpdateSeenRequest.fromJson(Map<String, dynamic> json) => 66 + _$UpdateSeenRequestFromJson(json); 67 + }
+27
lib/src/core/network/atproto/data/repositories/notification_repository.dart
··· 1 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 2 + 3 + /// Interface for Notification-related API endpoints 4 + abstract class NotificationRepository { 5 + /// List notifications for the requesting account 6 + /// 7 + /// [limit] The number of notifications to return (default 50, max 100) 8 + /// [cursor] Pagination cursor for the next set of results 9 + /// [priority] Whether to return only priority notifications 10 + /// [reasons] Optional list of notification reasons to filter by 11 + Future<ListNotificationsResponse> listNotifications({ 12 + int limit = 50, 13 + String? cursor, 14 + bool? priority, 15 + List<String>? reasons, 16 + }); 17 + 18 + /// Get the count of unread notifications 19 + /// 20 + /// [priority] Whether to count only priority notifications 21 + Future<UnreadCountResponse> getUnreadCount({bool? priority}); 22 + 23 + /// Mark notifications as seen 24 + /// 25 + /// [seenAt] The timestamp to mark notifications as seen at 26 + Future<void> updateSeen(DateTime seenAt); 27 + }
+134
lib/src/core/network/atproto/data/repositories/notification_repository_impl.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto/core.dart'; 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 6 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository.dart'; 7 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 8 + import 'package:spark/src/core/utils/logging/log_service.dart'; 9 + import 'package:spark/src/core/utils/logging/logger.dart'; 10 + 11 + /// Notification-related API endpoints implementation 12 + class NotificationRepositoryImpl implements NotificationRepository { 13 + NotificationRepositoryImpl(this._client) { 14 + _logger.v('NotificationRepository initialized'); 15 + } 16 + final SprkRepository _client; 17 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 18 + 'NotificationRepository', 19 + ); 20 + 21 + @override 22 + Future<ListNotificationsResponse> listNotifications({ 23 + int limit = 50, 24 + String? cursor, 25 + bool? priority, 26 + List<String>? reasons, 27 + }) async { 28 + _logger.d( 29 + 'Listing notifications: limit=$limit, cursor=$cursor, ' 30 + 'priority=$priority, reasons=$reasons', 31 + ); 32 + return _client.executeWithRetry(() async { 33 + if (!_client.authRepository.isAuthenticated) { 34 + _logger.w('Not authenticated'); 35 + throw Exception('Not authenticated'); 36 + } 37 + 38 + final atproto = _client.authRepository.atproto; 39 + if (atproto == null) { 40 + _logger.e('AtProto not initialized'); 41 + throw Exception('AtProto not initialized'); 42 + } 43 + 44 + final parameters = <String, dynamic>{ 45 + 'limit': limit.toString(), 46 + }; 47 + if (cursor != null && cursor.isNotEmpty) { 48 + parameters['cursor'] = cursor; 49 + } 50 + if (priority != null) { 51 + parameters['priority'] = priority.toString(); 52 + } 53 + if (reasons != null && reasons.isNotEmpty) { 54 + parameters['reasons'] = reasons; 55 + } 56 + 57 + final result = await atproto.get( 58 + NSID.parse('so.sprk.notification.listNotifications'), 59 + parameters: parameters, 60 + headers: {'atproto-proxy': _client.sprkDid}, 61 + to: (jsonMap) => jsonMap, 62 + adaptor: (uint8) => 63 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 64 + ); 65 + 66 + final rawResponse = result.data as Map<String, dynamic>; 67 + _logger.d('Notifications retrieved successfully'); 68 + return ListNotificationsResponse.fromJson(rawResponse); 69 + }); 70 + } 71 + 72 + @override 73 + Future<UnreadCountResponse> getUnreadCount({bool? priority}) async { 74 + _logger.d('Getting unread count: priority=$priority'); 75 + return _client.executeWithRetry(() async { 76 + if (!_client.authRepository.isAuthenticated) { 77 + _logger.w('Not authenticated'); 78 + throw Exception('Not authenticated'); 79 + } 80 + 81 + final atproto = _client.authRepository.atproto; 82 + if (atproto == null) { 83 + _logger.e('AtProto not initialized'); 84 + throw Exception('AtProto not initialized'); 85 + } 86 + 87 + final parameters = <String, String>{}; 88 + if (priority != null) { 89 + parameters['priority'] = priority.toString(); 90 + } 91 + 92 + final result = await atproto.get( 93 + NSID.parse('so.sprk.notification.getUnreadCount'), 94 + parameters: parameters, 95 + headers: {'atproto-proxy': _client.sprkDid}, 96 + to: (jsonMap) => jsonMap, 97 + adaptor: (uint8) => 98 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 99 + ); 100 + 101 + final rawResponse = result.data as Map<String, dynamic>; 102 + _logger.d('Unread count retrieved successfully'); 103 + return UnreadCountResponse.fromJson(rawResponse); 104 + }); 105 + } 106 + 107 + @override 108 + Future<void> updateSeen(DateTime seenAt) async { 109 + _logger.d('Updating seen timestamp: $seenAt'); 110 + return _client.executeWithRetry(() async { 111 + if (!_client.authRepository.isAuthenticated) { 112 + _logger.w('Not authenticated'); 113 + throw Exception('Not authenticated'); 114 + } 115 + 116 + final atproto = _client.authRepository.atproto; 117 + if (atproto == null) { 118 + _logger.e('AtProto not initialized'); 119 + throw Exception('AtProto not initialized'); 120 + } 121 + 122 + // Convert DateTime to ISO8601 string for the API 123 + final body = {'seenAt': seenAt.toIso8601String()}; 124 + 125 + await atproto.post( 126 + NSID.parse('so.sprk.notification.updateSeen'), 127 + body: body, 128 + headers: {'atproto-proxy': _client.sprkDid}, 129 + ); 130 + 131 + _logger.d('Seen timestamp updated successfully'); 132 + }); 133 + } 134 + }
+2
lib/src/core/network/atproto/data/repositories/sprk_repository.dart
··· 2 2 import 'package:spark/src/core/network/atproto/data/repositories/feed_repository.dart'; 3 3 import 'package:spark/src/core/network/atproto/data/repositories/graph_repository.dart'; 4 4 import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; 5 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository.dart'; 5 6 import 'package:spark/src/core/network/atproto/data/repositories/repo_repository.dart'; 6 7 import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; 7 8 import 'package:spark/src/core/network/atproto/data/repositories/story_repository.dart'; ··· 31 32 GraphRepository get graph; 32 33 LabelerRepository get labeler; 33 34 SoundRepository get sound; 35 + NotificationRepository get notification; 34 36 }
+7
lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart
··· 10 10 import 'package:spark/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 11 11 import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository.dart'; 12 12 import 'package:spark/src/core/network/atproto/data/repositories/labeler_repository_impl.dart'; 13 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository.dart'; 14 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository_impl.dart'; 13 15 import 'package:spark/src/core/network/atproto/data/repositories/repo_repository.dart'; 14 16 import 'package:spark/src/core/network/atproto/data/repositories/repo_repository_impl.dart'; 15 17 import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; ··· 39 41 StoryRepository? _story; 40 42 LabelerRepository? _labeler; 41 43 SoundRepository? _sound; 44 + NotificationRepository? _notification; 42 45 43 46 /// Get the authentication service 44 47 @override ··· 127 130 128 131 @override 129 132 SoundRepository get sound => _sound ??= SoundRepositoryImpl(this); 133 + 134 + @override 135 + NotificationRepository get notification => 136 + _notification ??= NotificationRepositoryImpl(this); 130 137 }
+1 -1
lib/src/core/routing/app_router.dart
··· 73 73 children: [ 74 74 AutoRoute(page: FeedsRoute.page, path: 'feeds'), 75 75 AutoRoute(page: SearchRoute.page, path: 'search'), 76 - AutoRoute(page: EmptyRoute.page, path: 'create'), 77 76 AutoRoute(page: MessagesRoute.page, path: 'messages'), 77 + AutoRoute(page: NotificationsRoute.page, path: 'notifications'), 78 78 AutoRoute( 79 79 page: UserProfileRoute.page, 80 80 path: 'profile',
+1
lib/src/core/routing/pages.dart
··· 13 13 export 'package:spark/src/features/messages/ui/pages/chat_page.dart'; 14 14 export 'package:spark/src/features/messages/ui/pages/messages_page.dart'; 15 15 export 'package:spark/src/features/messages/ui/pages/new_chat_search_page.dart'; 16 + export 'package:spark/src/features/notifications/ui/pages/notifications_page.dart'; 16 17 export 'package:spark/src/features/posting/ui/pages/image_review_page.dart'; 17 18 export 'package:spark/src/features/posting/ui/pages/recording_page.dart'; 18 19 export 'package:spark/src/features/posting/ui/pages/video_review_page.dart';
+3 -2
lib/src/core/ui/foundation/colors.dart
··· 63 63 static const Color info = blue; 64 64 65 65 // Interaction colors 66 - static const Color likeColor = red; 67 - static const Color commentColor = green; 66 + static const Color likeColor = pink; 67 + static const Color commentColor = blue; 68 + static const Color repostColor = green; 68 69 static const Color followColor = blue; 69 70 static const Color unreadIndicator = pink; 70 71
+42
lib/src/features/comments/ui/pages/comments_page.dart
··· 16 16 required this.isSprk, 17 17 super.key, 18 18 this.post, 19 + this.highlightedReplyUri, 19 20 }); 20 21 final String postUri; 21 22 final bool isSprk; 22 23 final PostView? post; 24 + final String? highlightedReplyUri; 23 25 24 26 @override 25 27 ConsumerState<CommentsPage> createState() => _CommentsPageState(); ··· 100 102 late final String _postUri; 101 103 late final bool _isSprk; 102 104 PostView? _post; 105 + String? _highlightedReplyUri; 103 106 bool _initialized = false; 107 + bool _hasScrolledToHighlighted = false; 104 108 105 109 @override 106 110 void initState() { ··· 119 123 _postUri = parentArgs.postUri; 120 124 _isSprk = parentArgs.isSprk; 121 125 _post = parentArgs.post; 126 + _highlightedReplyUri = parentArgs.highlightedReplyUri; 122 127 _postAtUri = AtUri.parse(_postUri); 123 128 _initialized = true; 124 129 } ··· 156 161 } 157 162 } 158 163 164 + void _scrollToHighlightedReply(List<dynamic> replies) { 165 + if (_hasScrolledToHighlighted || _highlightedReplyUri == null) return; 166 + _hasScrolledToHighlighted = true; 167 + 168 + // Find the index of the highlighted reply 169 + int? highlightedIndex; 170 + for (var i = 0; i < replies.length; i++) { 171 + final reply = replies[i] as ThreadViewPost; 172 + if (reply.post.uri.toString() == _highlightedReplyUri) { 173 + highlightedIndex = i; 174 + break; 175 + } 176 + } 177 + 178 + if (highlightedIndex != null && _scrollController.hasClients) { 179 + // Estimate scroll position (assuming ~100 pixels per item) 180 + final estimatedOffset = highlightedIndex * 100.0; 181 + _scrollController.animateTo( 182 + estimatedOffset.clamp(0, _scrollController.position.maxScrollExtent), 183 + duration: const Duration(milliseconds: 400), 184 + curve: Curves.easeOut, 185 + ); 186 + } 187 + } 188 + 159 189 @override 160 190 Widget build(BuildContext context) { 161 191 final asyncState = ref.watch(commentsPageProvider(postUri: _postAtUri)); ··· 222 252 if (data.thread.replies == null || data.thread.replies!.isEmpty) { 223 253 return const Center(child: Text('No comments yet.')); 224 254 } 255 + 256 + // Find the index of the highlighted reply and scroll to it 257 + if (_highlightedReplyUri != null && !_hasScrolledToHighlighted) { 258 + WidgetsBinding.instance.addPostFrameCallback((_) { 259 + _scrollToHighlightedReply(data.thread.replies!); 260 + }); 261 + } 262 + 225 263 return ListView.builder( 226 264 controller: _scrollController, 227 265 padding: const EdgeInsets.only(bottom: 16), 228 266 itemCount: data.thread.replies?.length ?? 0, 229 267 itemBuilder: (context, index) { 230 268 final comment = data.thread.replies![index] as ThreadViewPost; 269 + final isHighlighted = 270 + _highlightedReplyUri != null && 271 + comment.post.uri.toString() == _highlightedReplyUri; 231 272 return CommentItem( 232 273 key: ValueKey('comment-${comment.post.cid}'), 233 274 thread: comment, 234 275 mainPostUri: _postAtUri, 276 + isHighlighted: isHighlighted, 235 277 ); 236 278 }, 237 279 );
+119 -110
lib/src/features/comments/ui/widgets/comment_item.dart
··· 25 25 required this.thread, 26 26 required this.mainPostUri, 27 27 super.key, 28 + this.isHighlighted = false, 28 29 }); 29 30 final ThreadViewPost thread; 30 31 final AtUri mainPostUri; 32 + final bool isHighlighted; 31 33 32 34 @override 33 35 ConsumerState<CommentItem> createState() => _CommentItemState(); ··· 132 134 // The adapter transforms Bluesky comments to this format 133 135 final hasImages = commentState.thread.post.media is MediaViewImage; 134 136 135 - return Column( 136 - crossAxisAlignment: CrossAxisAlignment.start, 137 - children: [ 138 - Padding( 139 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 140 - child: Row( 141 - crossAxisAlignment: CrossAxisAlignment.start, 142 - children: [ 143 - GestureDetector( 144 - onTap: _navigateToProfile, 145 - child: _Avatar(widget: widget), 146 - ), 147 - const SizedBox(width: 12), 148 - Expanded( 149 - child: Column( 150 - crossAxisAlignment: CrossAxisAlignment.start, 151 - children: [ 152 - Row( 153 - mainAxisAlignment: MainAxisAlignment.spaceBetween, 154 - children: [ 155 - Expanded( 156 - child: GestureDetector( 157 - onTap: _navigateToProfile, 158 - child: Row( 159 - children: [ 160 - Text( 161 - commentState.thread.post.author.handle, 162 - style: TextStyle( 163 - fontWeight: FontWeight.bold, 164 - color: Theme.of( 165 - context, 166 - ).textTheme.bodyLarge?.color, 137 + return ColoredBox( 138 + color: widget.isHighlighted 139 + ? AppColors.pink.withValues(alpha: 0.15) 140 + : Colors.transparent, 141 + child: Column( 142 + crossAxisAlignment: CrossAxisAlignment.start, 143 + children: [ 144 + Padding( 145 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 146 + child: Row( 147 + crossAxisAlignment: CrossAxisAlignment.start, 148 + children: [ 149 + GestureDetector( 150 + onTap: _navigateToProfile, 151 + child: _Avatar(widget: widget), 152 + ), 153 + const SizedBox(width: 12), 154 + Expanded( 155 + child: Column( 156 + crossAxisAlignment: CrossAxisAlignment.start, 157 + children: [ 158 + Row( 159 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 160 + children: [ 161 + Expanded( 162 + child: GestureDetector( 163 + onTap: _navigateToProfile, 164 + child: Row( 165 + children: [ 166 + Text( 167 + commentState.thread.post.author.handle, 168 + style: TextStyle( 169 + fontWeight: FontWeight.bold, 170 + color: Theme.of( 171 + context, 172 + ).textTheme.bodyLarge?.color, 173 + ), 174 + ), 175 + const SizedBox(width: 8), 176 + Text( 177 + _formatDate( 178 + commentState.thread.post.indexedAt 179 + .toLocal() 180 + .toString(), 181 + ), 182 + style: TextStyle( 183 + fontSize: 12, 184 + color: Theme.of( 185 + context, 186 + ).textTheme.bodyMedium?.color, 187 + ), 167 188 ), 189 + ], 190 + ), 191 + ), 192 + ), 193 + Builder( 194 + builder: (context) { 195 + final authRepository = 196 + GetIt.instance<AuthRepository>(); 197 + final userDid = authRepository.did; 198 + final isCurrentUserAuthor = 199 + userDid == 200 + commentState.thread.post.author.did; 201 + final theme = Theme.of(context); 202 + final isDark = 203 + theme.brightness == Brightness.dark; 204 + final iconColor = isDark 205 + ? AppColors.white 206 + : AppColors.black; 207 + 208 + return GestureDetector( 209 + onTap: () => OptionsPanel.show( 210 + context: context, 211 + onReport: _handleReportComment, 212 + onDelete: isCurrentUserAuthor 213 + ? _handleDeleteComment 214 + : null, 168 215 ), 169 - const SizedBox(width: 8), 170 - Text( 171 - _formatDate( 172 - commentState.thread.post.indexedAt 173 - .toLocal() 174 - .toString(), 175 - ), 176 - style: TextStyle( 177 - fontSize: 12, 178 - color: Theme.of( 179 - context, 180 - ).textTheme.bodyMedium?.color, 216 + child: SizedBox( 217 + width: 28, 218 + height: 28, 219 + child: Icon( 220 + Icons.more_horiz, 221 + color: iconColor, 222 + size: 16, 181 223 ), 182 224 ), 183 - ], 184 - ), 225 + ); 226 + }, 185 227 ), 228 + ], 229 + ), 230 + const SizedBox(height: 4), 231 + 232 + if (commentState.thread.post.displayText.isNotEmpty) 233 + Text( 234 + commentState.thread.post.displayText, 235 + style: Theme.of(context).textTheme.bodyMedium, 186 236 ), 187 - Builder( 188 - builder: (context) { 189 - final authRepository = 190 - GetIt.instance<AuthRepository>(); 191 - final userDid = authRepository.did; 192 - final isCurrentUserAuthor = 193 - userDid == commentState.thread.post.author.did; 194 - final theme = Theme.of(context); 195 - final isDark = theme.brightness == Brightness.dark; 196 - final iconColor = isDark 197 - ? AppColors.white 198 - : AppColors.black; 199 237 200 - return GestureDetector( 201 - onTap: () => OptionsPanel.show( 202 - context: context, 203 - onReport: _handleReportComment, 204 - onDelete: isCurrentUserAuthor 205 - ? _handleDeleteComment 206 - : null, 207 - ), 208 - child: SizedBox( 209 - width: 28, 210 - height: 28, 211 - child: Icon( 212 - Icons.more_horiz, 213 - color: iconColor, 214 - size: 16, 215 - ), 216 - ), 217 - ); 218 - }, 238 + if (commentState.thread.post.media != null && 239 + hasImages) ...[ 240 + const SizedBox(height: 8), 241 + ImageContent( 242 + imageUrls: commentState.thread.post.imageUrls, 243 + borderRadius: borderRadius, 244 + thumbnailSize: thumbnailSize, 219 245 ), 220 246 ], 221 - ), 222 - const SizedBox(height: 4), 223 247 224 - if (commentState.thread.post.displayText.isNotEmpty) 225 - Text( 226 - commentState.thread.post.displayText, 227 - style: Theme.of(context).textTheme.bodyMedium, 228 - ), 229 - 230 - if (commentState.thread.post.media != null && 231 - hasImages) ...[ 232 248 const SizedBox(height: 8), 233 - ImageContent( 234 - imageUrls: commentState.thread.post.imageUrls, 235 - borderRadius: borderRadius, 236 - thumbnailSize: thumbnailSize, 249 + _ActionButtons( 250 + ref: ref, 251 + commentState: commentState, 252 + widget: widget, 253 + secondaryTextColor: Theme.of( 254 + context, 255 + ).textTheme.bodyMedium!.color!, 237 256 ), 238 257 ], 239 - 240 - const SizedBox(height: 8), 241 - _ActionButtons( 242 - ref: ref, 243 - commentState: commentState, 244 - widget: widget, 245 - secondaryTextColor: Theme.of( 246 - context, 247 - ).textTheme.bodyMedium!.color!, 248 - ), 249 - ], 258 + ), 250 259 ), 251 - ), 252 - ], 260 + ], 261 + ), 253 262 ), 254 - ), 255 263 256 - if (commentState.thread.post.replyCount != null && 257 - commentState.thread.post.replyCount! > 0) 258 - _RepliesSection(commentState), 264 + if (commentState.thread.post.replyCount != null && 265 + commentState.thread.post.replyCount! > 0) 266 + _RepliesSection(commentState), 259 267 260 - Container(height: 0.5, color: Theme.of(context).colorScheme.surface), 261 - ], 268 + Container(height: 0.5, color: Theme.of(context).colorScheme.surface), 269 + ], 270 + ), 262 271 ); 263 272 } 264 273
+30 -1
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 20 20 21 21 @RoutePage() 22 22 class StandalonePostPage extends ConsumerStatefulWidget { 23 - const StandalonePostPage({required this.postUri, super.key}); 23 + const StandalonePostPage({ 24 + required this.postUri, 25 + super.key, 26 + this.highlightedReplyUri, 27 + }); 24 28 25 29 final String postUri; 26 30 31 + /// If provided, automatically opens comments modal with reply highlighted 32 + final String? highlightedReplyUri; 33 + 27 34 @override 28 35 ConsumerState<StandalonePostPage> createState() => _StandalonePostPageState(); 29 36 } ··· 36 43 bool _showWarningOverlay = false; 37 44 List<String> _warningLabels = []; 38 45 bool _shouldBlurContent = false; 46 + bool _hasOpenedHighlightedReply = false; 39 47 40 48 @override 41 49 void initState() { ··· 48 56 _postFuture?.then((post) { 49 57 if (mounted) { 50 58 _checkContentWarning(post); 59 + _openHighlightedReplyIfNeeded(post); 51 60 } 52 61 }); 62 + } 63 + 64 + void _openHighlightedReplyIfNeeded(PostView post) { 65 + if (widget.highlightedReplyUri != null && !_hasOpenedHighlightedReply) { 66 + _hasOpenedHighlightedReply = true; 67 + // Open comments modal with highlighted reply after a short delay 68 + // to ensure the page is fully rendered 69 + WidgetsBinding.instance.addPostFrameCallback((_) { 70 + if (mounted) { 71 + context.router.push( 72 + CommentsRoute( 73 + postUri: post.uri.toString(), 74 + isSprk: post.isSprk, 75 + post: post, 76 + highlightedReplyUri: widget.highlightedReplyUri, 77 + ), 78 + ); 79 + } 80 + }); 81 + } 53 82 } 54 83 55 84 Future<PostView> _loadPostWithFallback() async {
+8 -29
lib/src/features/home/ui/pages/main_page.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter/services.dart'; 5 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 - import 'package:spark/src/core/design_system/components/molecules/create_media_sheet.dart'; 7 6 import 'package:spark/src/core/design_system/components/organisms/bottom_nav_bar.dart'; 8 - import 'package:spark/src/core/media/create_media_actions.dart'; 9 7 import 'package:spark/src/core/routing/app_router.dart'; 10 8 import 'package:spark/src/core/ui/theme/data/models/app_theme.dart'; 11 9 import 'package:spark/src/features/auth/providers/auth_providers.dart'; ··· 49 47 } 50 48 } 51 49 52 - void _showCreateMenu(BuildContext context) { 53 - showCreateMediaSheet( 54 - context, 55 - onRecord: CreateMediaActions.onRecord(context, storyMode: false), 56 - onUploadVideo: CreateMediaActions.onUploadVideo( 57 - context, 58 - storyMode: false, 59 - ), 60 - onUploadImages: CreateMediaActions.onUploadImages( 61 - context, 62 - storyMode: false, 63 - ), 64 - ); 65 - } 66 - 67 50 @override 68 51 Widget build(BuildContext context) { 69 52 final userDid = ref.watch(currentDidProvider); ··· 73 56 routes: const [ 74 57 FeedsRoute(), 75 58 SearchRoute(), 76 - EmptyRoute(), 77 59 MessagesRoute(), 60 + NotificationsRoute(), 78 61 UserProfileRoute(), 79 62 ], 80 63 transitionBuilder: (context, child, animation) => child, ··· 107 90 currentIndex: tabsRouter.activeIndex, 108 91 userAvatar: avatarProvider, 109 92 onTap: (index) { 110 - if (index == 2) { 111 - _showCreateMenu(context); 93 + if (tabsRouter.activeIndex == index && index == 0) { 94 + final activeFeed = ref.read(settingsProvider).activeFeed; 95 + ref 96 + .read(feedRefreshTriggerProvider(activeFeed).notifier) 97 + .trigger(); 112 98 } else { 113 - if (tabsRouter.activeIndex == index && index == 0) { 114 - final activeFeed = ref.read(settingsProvider).activeFeed; 115 - ref 116 - .read(feedRefreshTriggerProvider(activeFeed).notifier) 117 - .trigger(); 118 - } else { 119 - tabsRouter.setActiveIndex(index); 120 - ref.read(navigationProvider.notifier).updateIndex(index); 121 - } 99 + tabsRouter.setActiveIndex(index); 100 + ref.read(navigationProvider.notifier).updateIndex(index); 122 101 } 123 102 }, 124 103 ),
+154
lib/src/features/notifications/models/grouped_notification.dart
··· 1 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 2 + 3 + /// Represents group of similar notifications that should be displayed together. 4 + /// For example: "X and 5 others liked your post" 5 + class GroupedNotification { 6 + /// The primary notification (most recent in the group) 7 + final Notification primaryNotification; 8 + 9 + /// All notifications in this group (including the primary) 10 + final List<Notification> notifications; 11 + 12 + /// The reason for the notification (like, repost, follow, etc.) 13 + String get reason => primaryNotification.reason; 14 + 15 + /// The subject being acted upon (e.g., the post being liked) 16 + /// Null for follows 17 + String? get reasonSubject => primaryNotification.reasonSubject?.toString(); 18 + 19 + /// Number of unique actors in this group 20 + int get actorCount => _uniqueActors.length; 21 + 22 + /// Whether this notification group has been read 23 + bool get isRead => notifications.every((n) => n.isRead); 24 + 25 + /// The most recent indexedAt in the group 26 + DateTime get indexedAt => primaryNotification.indexedAt; 27 + 28 + /// Unique actors in this group (deduplicated by DID) 29 + List<Notification> get _uniqueActors { 30 + final seen = <String>{}; 31 + return notifications.where((n) { 32 + final did = n.author.did; 33 + if (seen.contains(did)) return false; 34 + seen.add(did); 35 + return true; 36 + }).toList(); 37 + } 38 + 39 + /// Get unique authors for display (limited to first N) 40 + List<Notification> getUniqueAuthors({int limit = 5}) { 41 + return _uniqueActors.take(limit).toList(); 42 + } 43 + 44 + /// Get the "others" count for display 45 + int get othersCount => actorCount > 1 ? actorCount - 1 : 0; 46 + 47 + const GroupedNotification({ 48 + required this.primaryNotification, 49 + required this.notifications, 50 + }); 51 + 52 + /// Create a single-notification group 53 + factory GroupedNotification.single(Notification notification) { 54 + return GroupedNotification( 55 + primaryNotification: notification, 56 + notifications: [notification], 57 + ); 58 + } 59 + } 60 + 61 + /// Groups notifications by type and subject. 62 + /// - Follows are grouped together (follow-backs are shown separately) 63 + /// - Likes on the same post are grouped 64 + /// - Reposts of the same post are grouped 65 + /// - Replies and mentions are NOT grouped 66 + List<GroupedNotification> groupNotifications(List<Notification> notifications) { 67 + if (notifications.isEmpty) return []; 68 + 69 + final result = <GroupedNotification>[]; 70 + final followGroups = <String, List<Notification>>{}; 71 + final likeGroups = <String, List<Notification>>{}; 72 + final repostGroups = <String, List<Notification>>{}; 73 + 74 + // First pass: collect all groupable notifications 75 + for (final notification in notifications) { 76 + switch (notification.reason) { 77 + case 'follow': 78 + // Check if this is a follow-back (viewer follows the author) 79 + final isFollowBack = notification.author.viewer?.following != null; 80 + if (isFollowBack) { 81 + // Follow-backs are shown individually, not grouped 82 + result.add(GroupedNotification.single(notification)); 83 + } else { 84 + // Regular follows are grouped together 85 + followGroups.putIfAbsent('follows', () => []).add(notification); 86 + } 87 + case 'like': 88 + // Group likes by reasonSubject (the post/reply being liked) 89 + if (notification.reasonSubject != null) { 90 + final key = notification.reasonSubject.toString(); 91 + likeGroups.putIfAbsent(key, () => []).add(notification); 92 + } else { 93 + // No subject, don't group 94 + result.add(GroupedNotification.single(notification)); 95 + } 96 + case 'repost': 97 + // Group reposts by reasonSubject 98 + if (notification.reasonSubject != null) { 99 + final key = notification.reasonSubject.toString(); 100 + repostGroups.putIfAbsent(key, () => []).add(notification); 101 + } else { 102 + result.add(GroupedNotification.single(notification)); 103 + } 104 + default: 105 + // reply, mention, etc. - don't group 106 + result.add(GroupedNotification.single(notification)); 107 + } 108 + } 109 + 110 + // Second pass: create grouped notifications & interleave them chronologically 111 + final allGroups = <GroupedNotification>[...result]; 112 + 113 + // Add follow groups 114 + if (followGroups['follows']?.isNotEmpty ?? false) { 115 + final follows = followGroups['follows']! 116 + // Sort by most recent first 117 + ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 118 + allGroups.add( 119 + GroupedNotification( 120 + primaryNotification: follows.first, 121 + notifications: follows, 122 + ), 123 + ); 124 + } 125 + 126 + // Add like groups 127 + for (final entry in likeGroups.entries) { 128 + final likes = entry.value 129 + ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 130 + allGroups.add( 131 + GroupedNotification( 132 + primaryNotification: likes.first, 133 + notifications: likes, 134 + ), 135 + ); 136 + } 137 + 138 + // Add repost groups 139 + for (final entry in repostGroups.entries) { 140 + final reposts = entry.value 141 + ..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 142 + allGroups.add( 143 + GroupedNotification( 144 + primaryNotification: reposts.first, 145 + notifications: reposts, 146 + ), 147 + ); 148 + } 149 + 150 + // Sort all groups by most recent notification 151 + allGroups.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 152 + 153 + return allGroups; 154 + }
+198
lib/src/features/notifications/providers/notification_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart' 4 + as models; 5 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository.dart'; 6 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 7 + import 'package:spark/src/core/utils/logging/log_service.dart'; 8 + import 'package:spark/src/core/utils/logging/logger.dart'; 9 + import 'package:spark/src/features/notifications/providers/notification_state.dart'; 10 + import 'package:spark/src/features/notifications/providers/unread_count_provider.dart'; 11 + 12 + part 'notification_provider.g.dart'; 13 + 14 + @riverpod 15 + class NotificationNotifier extends _$NotificationNotifier { 16 + late final NotificationRepository _notificationRepository; 17 + late final SparkLogger _logger; 18 + bool _isLoading = false; 19 + 20 + @override 21 + NotificationState build({ 22 + bool? priority, 23 + List<String>? reasons, 24 + }) { 25 + _notificationRepository = GetIt.instance<SprkRepository>().notification; 26 + _logger = GetIt.instance<LogService>().getLogger('NotificationNotifier'); 27 + 28 + // Schedule initial load after build completes 29 + Future.microtask(() { 30 + loadNotifications(priority: priority, reasons: reasons); 31 + }); 32 + 33 + return const NotificationState( 34 + notifications: [], 35 + isLoading: true, 36 + ); 37 + } 38 + 39 + /// Load initial notifications or refresh 40 + Future<void> loadNotifications({ 41 + bool? priority, 42 + List<String>? reasons, 43 + bool refresh = false, 44 + }) async { 45 + if (_isLoading && !refresh) { 46 + _logger.w('Load already in progress, skipping'); 47 + return; 48 + } 49 + 50 + _isLoading = true; 51 + state = state.copyWith( 52 + isLoading: !refresh, 53 + isRefreshing: refresh, 54 + hasError: false, 55 + errorMessage: null, 56 + ); 57 + 58 + try { 59 + final response = await _notificationRepository.listNotifications( 60 + priority: priority, 61 + reasons: reasons, 62 + ); 63 + 64 + _logger.d( 65 + 'Loaded ${response.notifications.length} notifications, ' 66 + 'cursor: ${response.cursor}', 67 + ); 68 + 69 + state = state.copyWith( 70 + notifications: response.notifications, 71 + cursor: response.cursor, 72 + isLoading: false, 73 + isRefreshing: false, 74 + hasError: false, 75 + ); 76 + } catch (e, stackTrace) { 77 + _logger.e( 78 + 'Error loading notifications: $e', 79 + error: e, 80 + stackTrace: stackTrace, 81 + ); 82 + state = state.copyWith( 83 + isLoading: false, 84 + isRefreshing: false, 85 + hasError: true, 86 + errorMessage: e.toString(), 87 + ); 88 + } finally { 89 + _isLoading = false; 90 + } 91 + } 92 + 93 + /// Load more notifications (pagination) 94 + Future<void> loadMore({ 95 + bool? priority, 96 + List<String>? reasons, 97 + }) async { 98 + if (_isLoading || state.isLoadingMore || !state.hasMore) { 99 + _logger.w( 100 + 'Cannot load more: isLoading=$_isLoading, ' 101 + 'isLoadingMore=${state.isLoadingMore}, hasMore=${state.hasMore}', 102 + ); 103 + return; 104 + } 105 + 106 + _isLoading = true; 107 + state = state.copyWith(isLoadingMore: true); 108 + 109 + try { 110 + final response = await _notificationRepository.listNotifications( 111 + cursor: state.cursor, 112 + priority: priority, 113 + reasons: reasons, 114 + ); 115 + 116 + _logger.d( 117 + 'Loaded ${response.notifications.length} more notifications, ' 118 + 'cursor: ${response.cursor}', 119 + ); 120 + 121 + state = state.copyWith( 122 + notifications: [...state.notifications, ...response.notifications], 123 + cursor: response.cursor, 124 + isLoadingMore: false, 125 + ); 126 + } catch (e, stackTrace) { 127 + _logger.e( 128 + 'Error loading more notifications: $e', 129 + error: e, 130 + stackTrace: stackTrace, 131 + ); 132 + state = state.copyWith( 133 + isLoadingMore: false, 134 + hasError: true, 135 + errorMessage: e.toString(), 136 + ); 137 + } finally { 138 + _isLoading = false; 139 + } 140 + } 141 + 142 + /// Mark notifications as seen 143 + Future<void> markAsSeen() async { 144 + if (state.notifications.isEmpty) { 145 + return; 146 + } 147 + 148 + try { 149 + // Use the most recent notification's indexedAt as seenAt 150 + final mostRecent = state.notifications.first; 151 + await _notificationRepository.updateSeen(mostRecent.indexedAt); 152 + // Refresh the unread count 153 + ref.invalidate(unreadCountProvider); 154 + } catch (e, stackTrace) { 155 + _logger.e( 156 + 'Error marking notifications as seen: $e', 157 + error: e, 158 + stackTrace: stackTrace, 159 + ); 160 + } 161 + } 162 + 163 + /// Mark a specific notification as viewed (when it comes into viewport) 164 + /// This will update the seen timestamp on the server 165 + Future<void> markNotificationAsViewed( 166 + models.Notification notification, 167 + ) async { 168 + // Only mark unread notifications as viewed 169 + if (notification.isRead) { 170 + return; 171 + } 172 + 173 + try { 174 + // Use this notification's indexedAt as the seenAt timestamp 175 + await _notificationRepository.updateSeen(notification.indexedAt); 176 + // Refresh the unread count 177 + ref.invalidate(unreadCountProvider); 178 + } catch (e, stackTrace) { 179 + _logger.e( 180 + 'Error marking notification as viewed: $e', 181 + error: e, 182 + stackTrace: stackTrace, 183 + ); 184 + } 185 + } 186 + 187 + /// Refresh notifications 188 + Future<void> refresh({ 189 + bool? priority, 190 + List<String>? reasons, 191 + }) async { 192 + await loadNotifications( 193 + priority: priority, 194 + reasons: reasons, 195 + refresh: true, 196 + ); 197 + } 198 + }
+33
lib/src/features/notifications/providers/notification_state.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart'; 3 + import 'package:spark/src/features/notifications/models/grouped_notification.dart'; 4 + 5 + part 'notification_state.freezed.dart'; 6 + 7 + @freezed 8 + abstract class NotificationState with _$NotificationState { 9 + const factory NotificationState({ 10 + required List<Notification> notifications, 11 + String? cursor, 12 + @Default(false) bool isLoading, 13 + @Default(false) bool isLoadingMore, 14 + @Default(false) bool hasError, 15 + String? errorMessage, 16 + @Default(false) bool isRefreshing, 17 + }) = _NotificationState; 18 + const NotificationState._(); 19 + 20 + int get length => notifications.length; 21 + bool get hasMore => cursor != null && cursor!.isNotEmpty; 22 + int get unreadCount => notifications.where((n) => !n.isRead).length; 23 + 24 + /// Get notifications grouped by type and subject 25 + List<GroupedNotification> get groupedNotifications => 26 + groupNotifications(notifications); 27 + 28 + /// Number of grouped notification items 29 + int get groupedLength => groupedNotifications.length; 30 + 31 + static const int fetchLimit = 32 + 50; // number of notifications to fetch at a time 33 + }
+45
lib/src/features/notifications/providers/unread_count_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:spark/src/core/network/atproto/data/repositories/notification_repository.dart'; 4 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 5 + import 'package:spark/src/core/utils/logging/log_service.dart'; 6 + import 'package:spark/src/core/utils/logging/logger.dart'; 7 + 8 + part 'unread_count_provider.g.dart'; 9 + 10 + @riverpod 11 + class UnreadCountNotifier extends _$UnreadCountNotifier { 12 + late final NotificationRepository _notificationRepository; 13 + late final SparkLogger _logger; 14 + 15 + @override 16 + Future<int> build({bool? priority}) async { 17 + _notificationRepository = GetIt.instance<SprkRepository>().notification; 18 + _logger = GetIt.instance<LogService>().getLogger('UnreadCountNotifier'); 19 + 20 + return _loadUnreadCount(priority: priority); 21 + } 22 + 23 + Future<int> _loadUnreadCount({bool? priority}) async { 24 + try { 25 + final response = await _notificationRepository.getUnreadCount( 26 + priority: priority, 27 + ); 28 + _logger.d('Unread count: ${response.count}'); 29 + return response.count; 30 + } catch (e, stackTrace) { 31 + _logger.e( 32 + 'Error loading unread count: $e', 33 + error: e, 34 + stackTrace: stackTrace, 35 + ); 36 + return 0; 37 + } 38 + } 39 + 40 + /// Refresh the unread count 41 + Future<void> refresh({bool? priority}) async { 42 + state = const AsyncValue.loading(); 43 + state = await AsyncValue.guard(() => _loadUnreadCount(priority: priority)); 44 + } 45 + }
+49
lib/src/features/notifications/ui/pages/notifications_page.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:spark/src/core/ui/foundation/colors.dart'; 5 + import 'package:spark/src/features/notifications/providers/notification_provider.dart' 6 + show notificationProvider; 7 + import 'package:spark/src/features/notifications/providers/unread_count_provider.dart' 8 + show unreadCountProvider; 9 + import 'package:spark/src/features/notifications/ui/widgets/notifications_list.dart'; 10 + 11 + @RoutePage() 12 + class NotificationsPage extends ConsumerStatefulWidget { 13 + const NotificationsPage({super.key}); 14 + 15 + @override 16 + ConsumerState<NotificationsPage> createState() => _NotificationsPageState(); 17 + } 18 + 19 + class _NotificationsPageState extends ConsumerState<NotificationsPage> { 20 + @override 21 + void initState() { 22 + super.initState(); 23 + // Mark notifications as seen when page is viewed 24 + WidgetsBinding.instance.addPostFrameCallback((_) { 25 + ref 26 + .read( 27 + notificationProvider().notifier, 28 + ) 29 + .markAsSeen(); 30 + // Refresh unread count after marking as seen 31 + ref.read(unreadCountProvider().notifier).refresh(); 32 + }); 33 + } 34 + 35 + @override 36 + Widget build(BuildContext context) { 37 + return Scaffold( 38 + backgroundColor: AppColors.black, 39 + appBar: AppBar( 40 + backgroundColor: AppColors.black, 41 + title: const Text( 42 + 'Notifications', 43 + style: TextStyle(color: Colors.white), 44 + ), 45 + ), 46 + body: const NotificationsList(), 47 + ); 48 + } 49 + }
+652
lib/src/features/notifications/ui/widgets/notification_item.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:spark/src/core/design_system/components/atoms/icons.dart'; 7 + import 'package:spark/src/core/network/atproto/data/models/notification_models.dart' 8 + as models; 9 + import 'package:spark/src/core/network/atproto/data/models/record_models.dart' 10 + hide Image; 11 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 12 + import 'package:spark/src/core/routing/app_router.dart'; 13 + import 'package:spark/src/core/ui/foundation/colors.dart'; 14 + import 'package:spark/src/core/ui/widgets/user_avatar.dart'; 15 + import 'package:spark/src/features/messages/ui/pages/chat_page.dart'; 16 + import 'package:spark/src/features/notifications/models/grouped_notification.dart'; 17 + 18 + class NotificationItem extends ConsumerStatefulWidget { 19 + const NotificationItem({ 20 + required this.groupedNotification, 21 + this.onViewed, 22 + super.key, 23 + }); 24 + 25 + final GroupedNotification groupedNotification; 26 + final VoidCallback? onViewed; 27 + 28 + @override 29 + ConsumerState<NotificationItem> createState() => _NotificationItemState(); 30 + } 31 + 32 + class _NotificationItemState extends ConsumerState<NotificationItem> { 33 + bool _hasBeenViewed = false; 34 + 35 + SprkRepository get _sprkRepository => GetIt.instance<SprkRepository>(); 36 + 37 + /// The primary notification (most recent in the group) 38 + models.Notification get notification => 39 + widget.groupedNotification.primaryNotification; 40 + 41 + @override 42 + void initState() { 43 + super.initState(); 44 + // Mark as viewed after a short delay to ensure it's actually visible 45 + WidgetsBinding.instance.addPostFrameCallback((_) { 46 + Future.delayed(const Duration(milliseconds: 500), () { 47 + if (mounted && !_hasBeenViewed && !widget.groupedNotification.isRead) { 48 + _hasBeenViewed = true; 49 + _markAsViewed(); 50 + } 51 + }); 52 + }); 53 + } 54 + 55 + void _markAsViewed() { 56 + if (widget.groupedNotification.isRead) { 57 + return; 58 + } 59 + 60 + // Notify parent that this notification was viewed 61 + // The parent will handle the API call and state update 62 + widget.onViewed?.call(); 63 + } 64 + 65 + Widget _getReasonIcon(String reason, Color color) { 66 + switch (reason) { 67 + case 'like': 68 + return AppIcons.likeFilled(color: color); 69 + case 'repost': 70 + return AppIcons.repost(color: color); 71 + case 'follow': 72 + return AppIcons.addUser(color: color); 73 + case 'mention': 74 + case 'reply': 75 + return AppIcons.commentFilled(color: color); 76 + default: 77 + return AppIcons.like(color: color); 78 + } 79 + } 80 + 81 + Color _getReasonColor(String reason) { 82 + switch (reason) { 83 + case 'like': 84 + return AppColors.likeColor; 85 + case 'repost': 86 + return AppColors.repostColor; 87 + case 'follow': 88 + return AppColors.followColor; 89 + case 'mention': 90 + case 'reply': 91 + return AppColors.commentColor; 92 + default: 93 + return AppColors.primary; 94 + } 95 + } 96 + 97 + String _getReasonText(String reason, int othersCount) { 98 + final hasOthers = othersCount > 0; 99 + final othersText = hasOthers ? ' and $othersCount others' : ''; 100 + 101 + switch (reason) { 102 + case 'like': 103 + // Check if reasonSubject is a reply or post 104 + if (notification.reasonSubject != null) { 105 + final collection = notification.reasonSubject!.collection.toString(); 106 + if (collection.contains('reply')) { 107 + return '$othersText liked your reply'; 108 + } 109 + } 110 + return '$othersText liked your post'; 111 + case 'repost': 112 + // Check if reasonSubject is a reply or post 113 + if (notification.reasonSubject != null) { 114 + final collection = notification.reasonSubject!.collection.toString(); 115 + if (collection.contains('reply')) { 116 + return '$othersText reposted your reply'; 117 + } 118 + } 119 + return '$othersText reposted your post'; 120 + case 'follow': 121 + // Check if this is a follow-back (viewer follows the author) 122 + final isFollowBack = notification.author.viewer?.following != null; 123 + if (isFollowBack) { 124 + return 'followed you back'; 125 + } 126 + return '$othersText followed you'; 127 + case 'mention': 128 + return 'mentioned you'; 129 + case 'reply': 130 + // Check if reasonSubject is a reply or post 131 + if (notification.reasonSubject != null) { 132 + final collection = notification.reasonSubject!.collection.toString(); 133 + if (collection.contains('reply')) { 134 + return 'replied to your reply'; 135 + } 136 + } 137 + return 'replied to your post'; 138 + default: 139 + return 'notified you'; 140 + } 141 + } 142 + 143 + Future<void> _handleTap(BuildContext context) async { 144 + // Navigate based on notification type 145 + if (notification.reason == 'follow') { 146 + // Navigate to profile (use first author for grouped follows) 147 + context.router.push( 148 + ProfileRoute(did: notification.author.did), 149 + ); 150 + } else if (notification.reason == 'reply') { 151 + // Reply notification - navigate to root post with reply highlighted 152 + final replyUri = notification.uri.toString(); 153 + final rootPostUri = _getRootPostUri(); 154 + if (rootPostUri != null) { 155 + context.router.push( 156 + StandalonePostRoute( 157 + postUri: rootPostUri, 158 + highlightedReplyUri: replyUri, 159 + ), 160 + ); 161 + } else { 162 + // Fallback to standalone post 163 + context.router.push( 164 + StandalonePostRoute(postUri: replyUri), 165 + ); 166 + } 167 + } else if (notification.reason == 'like' && _isReplySubject()) { 168 + // Like on a reply - get root post URI from embedded subject or fetch it 169 + final replyUri = notification.reasonSubject!.toString(); 170 + 171 + // First try to get root from embedded subject record 172 + var rootPostUri = _getRootPostUriFromEmbeddedSubject(); 173 + 174 + // If not available, fetch the reply record 175 + rootPostUri ??= await _fetchRootPostUriFromReply(replyUri); 176 + 177 + if (rootPostUri != null && context.mounted) { 178 + context.router.push( 179 + StandalonePostRoute( 180 + postUri: rootPostUri, 181 + highlightedReplyUri: replyUri, 182 + ), 183 + ); 184 + } else if (context.mounted) { 185 + // Fallback to standalone post showing the reply 186 + context.router.push( 187 + StandalonePostRoute(postUri: replyUri), 188 + ); 189 + } 190 + } else if (notification.reasonSubject != null) { 191 + // Navigate to the post/thread 192 + final reasonSubjectStr = notification.reasonSubject!.toString(); 193 + context.router.push( 194 + StandalonePostRoute(postUri: reasonSubjectStr), 195 + ); 196 + } else { 197 + final collectionStr = notification.uri.collection.toString(); 198 + if (collectionStr.startsWith('so.sprk.feed.post') || 199 + collectionStr.startsWith('app.bsky.feed.post')) { 200 + // Navigate to the post 201 + final uriStr = notification.uri.toString(); 202 + context.router.push( 203 + StandalonePostRoute(postUri: uriStr), 204 + ); 205 + return; 206 + } 207 + // Fallback to author profile 208 + context.router.push( 209 + ProfileRoute(did: notification.author.did), 210 + ); 211 + } 212 + } 213 + 214 + /// Check if the reasonSubject is a reply (not a post) 215 + bool _isReplySubject() { 216 + if (notification.reasonSubject == null) return false; 217 + final collection = notification.reasonSubject!.collection.toString(); 218 + return collection.contains('reply'); 219 + } 220 + 221 + /// Get the root post URI from a reply notification's record 222 + String? _getRootPostUri() { 223 + try { 224 + final record = notification.record; 225 + final reply = record['reply'] as Map<String, dynamic>?; 226 + if (reply != null) { 227 + final root = reply['root'] as Map<String, dynamic>?; 228 + if (root != null) { 229 + return root['uri'] as String?; 230 + } 231 + } 232 + } catch (e) { 233 + // Ignore parsing errors 234 + } 235 + return null; 236 + } 237 + 238 + /// Get the root post URI from the embedded subject record (for like/repost on reply) 239 + String? _getRootPostUriFromEmbeddedSubject() { 240 + try { 241 + final record = notification.record; 242 + // The backend embeds the subject record in notification.record['subject'] 243 + final subject = record['subject'] as Map<String, dynamic>?; 244 + if (subject != null) { 245 + final reply = subject['reply'] as Map<String, dynamic>?; 246 + if (reply != null) { 247 + final root = reply['root'] as Map<String, dynamic>?; 248 + if (root != null) { 249 + return root['uri'] as String?; 250 + } 251 + } 252 + } 253 + } catch (e) { 254 + // Ignore parsing errors 255 + } 256 + return null; 257 + } 258 + 259 + /// Fetch the reply record and extract the root post URI 260 + Future<String?> _fetchRootPostUriFromReply(String replyUriStr) async { 261 + try { 262 + final replyUri = AtUri.parse(replyUriStr); 263 + final result = await _sprkRepository.repo.getRecord(uri: replyUri); 264 + final record = result.record; 265 + 266 + // Check if it's a reply record and extract the root URI 267 + if (record is ReplyRecord) { 268 + return record.reply.root.uri.toString(); 269 + } else if (record is BskyPostRecord && record.reply != null) { 270 + return record.reply!.root.uri.toString(); 271 + } 272 + } catch (e) { 273 + // If we can't fetch the record, return null to use fallback 274 + } 275 + return null; 276 + } 277 + 278 + String? _getContentPreview() { 279 + // Try to extract text from the record 280 + try { 281 + var recordToCheck = notification.record; 282 + 283 + // For like/repost notifications, get text from the subject record 284 + if (notification.reason == 'like' || notification.reason == 'repost') { 285 + final subject = notification.record['subject'] as Map<String, dynamic>?; 286 + if (subject != null) { 287 + recordToCheck = subject; 288 + } 289 + } 290 + 291 + // Check for text in caption (Spark format) 292 + final caption = recordToCheck['caption'] as Map<String, dynamic>?; 293 + if (caption != null) { 294 + final text = caption['text'] as String?; 295 + if (text != null && text.isNotEmpty) { 296 + return text; 297 + } 298 + } 299 + // Check for text directly (Bluesky format) 300 + final text = recordToCheck['text'] as String?; 301 + if (text != null && text.isNotEmpty) { 302 + return text; 303 + } 304 + } catch (e) { 305 + // Ignore errors when extracting preview 306 + } 307 + return null; 308 + } 309 + 310 + /// Extract first media URL (image or video thumbnail) from the notification 311 + String? _getMediaUrl() { 312 + try { 313 + final record = notification.record; 314 + Map<String, dynamic>? media; 315 + 316 + // For like/repost notifications, check for subjectMedia at top level first 317 + if (notification.reason == 'like' || notification.reason == 'repost') { 318 + // Backend embeds subjectMedia at top level for like/repost notifications 319 + media = record['subjectMedia'] as Map<String, dynamic>?; 320 + // Fallback: check if subject has media directly 321 + if (media == null) { 322 + final subject = record['subject'] as Map<String, dynamic>?; 323 + if (subject != null) { 324 + media = subject['media'] as Map<String, dynamic>?; 325 + } 326 + } 327 + } else { 328 + // For reply/post notifications, media is in the record itself 329 + media = record['media'] as Map<String, dynamic>?; 330 + } 331 + 332 + if (media == null) { 333 + return null; 334 + } 335 + 336 + final mediaType = media[r'$type'] as String?; 337 + if (mediaType == null) { 338 + return null; 339 + } 340 + 341 + // Handle different media types 342 + switch (mediaType) { 343 + // Single image - thumb/fullsize are at top level 344 + case 'so.sprk.media.image#view': 345 + final thumb = media['thumb']; 346 + if (thumb != null) { 347 + return thumb is String ? thumb : thumb.toString(); 348 + } 349 + final fullsize = media['fullsize']; 350 + if (fullsize != null) { 351 + return fullsize is String ? fullsize : fullsize.toString(); 352 + } 353 + 354 + // Multiple images - get first one 355 + case 'so.sprk.media.images#view': 356 + final images = media['images'] as List<dynamic>?; 357 + if (images != null && images.isNotEmpty) { 358 + final firstImage = images[0] as Map<String, dynamic>?; 359 + if (firstImage != null) { 360 + final thumb = firstImage['thumb']; 361 + if (thumb != null) { 362 + return thumb is String ? thumb : thumb.toString(); 363 + } 364 + final fullsize = firstImage['fullsize']; 365 + if (fullsize != null) { 366 + return fullsize is String ? fullsize : fullsize.toString(); 367 + } 368 + } 369 + } 370 + 371 + // Video - get thumbnail 372 + case 'so.sprk.media.video#view': 373 + case 'app.bsky.embed.video#view': 374 + final thumbnail = media['thumbnail']; 375 + if (thumbnail != null) { 376 + return thumbnail is String ? thumbnail : thumbnail.toString(); 377 + } 378 + 379 + // Bluesky images 380 + case 'app.bsky.embed.images#view': 381 + final images = media['images'] as List<dynamic>?; 382 + if (images != null && images.isNotEmpty) { 383 + final firstImage = images[0] as Map<String, dynamic>?; 384 + if (firstImage != null) { 385 + final thumb = firstImage['thumb']; 386 + if (thumb != null) { 387 + return thumb is String ? thumb : thumb.toString(); 388 + } 389 + final fullsize = firstImage['fullsize']; 390 + if (fullsize != null) { 391 + return fullsize is String ? fullsize : fullsize.toString(); 392 + } 393 + } 394 + } 395 + 396 + // Record with media - check nested media 397 + case 'app.bsky.embed.recordWithMedia#view': 398 + final nestedMedia = media['media'] as Map<String, dynamic>?; 399 + if (nestedMedia != null) { 400 + final nestedType = nestedMedia[r'$type'] as String?; 401 + if (nestedType == 'app.bsky.embed.images#view') { 402 + final images = nestedMedia['images'] as List<dynamic>?; 403 + if (images != null && images.isNotEmpty) { 404 + final firstImage = images[0] as Map<String, dynamic>?; 405 + if (firstImage != null) { 406 + final thumb = firstImage['thumb']; 407 + if (thumb != null) { 408 + return thumb is String ? thumb : thumb.toString(); 409 + } 410 + } 411 + } 412 + } else if (nestedType == 'app.bsky.embed.video#view') { 413 + final thumbnail = nestedMedia['thumbnail']; 414 + if (thumbnail != null) { 415 + return thumbnail is String ? thumbnail : thumbnail.toString(); 416 + } 417 + } 418 + } 419 + } 420 + } catch (e) { 421 + // Ignore errors when extracting media 422 + } 423 + return null; 424 + } 425 + 426 + String _formatTimeAgoShort(Duration difference) { 427 + if (difference.inDays > 0) { 428 + return '${difference.inDays}d'; 429 + } else if (difference.inHours > 0) { 430 + return '${difference.inHours}h'; 431 + } else if (difference.inMinutes > 0) { 432 + return '${difference.inMinutes}m'; 433 + } else { 434 + return 'now'; 435 + } 436 + } 437 + 438 + Widget _buildAvatarsSection() { 439 + final authors = widget.groupedNotification.getUniqueAuthors(); 440 + final totalCount = widget.groupedNotification.actorCount; 441 + final extraCount = totalCount - authors.length; 442 + 443 + if (authors.length == 1) { 444 + // Single avatar 445 + final author = authors[0].author; 446 + final avatarUrl = author.avatar?.toString() ?? ''; 447 + final username = author.displayName ?? author.handle; 448 + final handleHash = author.handle.hashCode; 449 + 450 + return SizedBox( 451 + width: 40, 452 + child: UserAvatar( 453 + imageUrl: avatarUrl, 454 + username: username, 455 + size: 32, 456 + backgroundColor: getAvatarColor(handleHash), 457 + ), 458 + ); 459 + } 460 + 461 + // Multiple avatars in a row 462 + return SizedBox( 463 + height: 32, 464 + child: Row( 465 + mainAxisSize: MainAxisSize.min, 466 + children: [ 467 + // Show avatars with overlap 468 + ...authors.asMap().entries.map((entry) { 469 + final index = entry.key; 470 + final author = entry.value.author; 471 + final avatarUrl = author.avatar?.toString() ?? ''; 472 + final username = author.displayName ?? author.handle; 473 + final handleHash = author.handle.hashCode; 474 + 475 + return Transform.translate( 476 + offset: Offset(-index * 8.0, 0), 477 + child: UserAvatar( 478 + imageUrl: avatarUrl, 479 + username: username, 480 + size: 28, 481 + backgroundColor: getAvatarColor(handleHash), 482 + ), 483 + ); 484 + }), 485 + // Show +N count if there are more 486 + if (extraCount > 0) 487 + Transform.translate( 488 + offset: Offset(-authors.length * 8.0, 0), 489 + child: Container( 490 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 491 + decoration: BoxDecoration( 492 + color: Colors.grey[800], 493 + borderRadius: BorderRadius.circular(12), 494 + ), 495 + child: Row( 496 + mainAxisSize: MainAxisSize.min, 497 + children: [ 498 + Text( 499 + '+$extraCount', 500 + style: const TextStyle( 501 + color: Colors.white70, 502 + fontSize: 12, 503 + fontWeight: FontWeight.w500, 504 + ), 505 + ), 506 + const SizedBox(width: 2), 507 + const Icon( 508 + Icons.keyboard_arrow_down, 509 + size: 14, 510 + color: Colors.white54, 511 + ), 512 + ], 513 + ), 514 + ), 515 + ), 516 + ], 517 + ), 518 + ); 519 + } 520 + 521 + @override 522 + Widget build(BuildContext context) { 523 + final reason = widget.groupedNotification.reason; 524 + final othersCount = widget.groupedNotification.othersCount; 525 + final reasonColor = _getReasonColor(reason); 526 + final reasonIcon = _getReasonIcon(reason, reasonColor); 527 + final reasonText = _getReasonText(reason, othersCount); 528 + final contentPreview = _getContentPreview(); 529 + final mediaUrl = _getMediaUrl(); 530 + final now = DateTime.now(); 531 + final difference = now.difference(widget.groupedNotification.indexedAt); 532 + final timeAgo = _formatTimeAgoShort(difference); 533 + 534 + final primaryAuthor = notification.author; 535 + final username = primaryAuthor.displayName ?? primaryAuthor.handle; 536 + 537 + return Material( 538 + color: widget.groupedNotification.isRead 539 + ? Colors.transparent 540 + : AppColors.pink.withValues(alpha: 0.15), 541 + child: InkWell( 542 + onTap: () => _handleTap(context), 543 + child: Padding( 544 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 545 + child: Row( 546 + crossAxisAlignment: CrossAxisAlignment.start, 547 + children: [ 548 + // Action icon on the left (fixed width for alignment) 549 + SizedBox( 550 + width: 24, 551 + child: Padding( 552 + padding: const EdgeInsets.only(top: 2), 553 + child: reasonIcon, 554 + ), 555 + ), 556 + const SizedBox(width: 12), 557 + // Avatars section 558 + _buildAvatarsSection(), 559 + const SizedBox(width: 12), 560 + // Main content 561 + Expanded( 562 + child: Column( 563 + crossAxisAlignment: CrossAxisAlignment.start, 564 + children: [ 565 + // Username, action, and timestamp in one line 566 + Wrap( 567 + crossAxisAlignment: WrapCrossAlignment.center, 568 + spacing: 4, 569 + children: [ 570 + Text( 571 + username, 572 + style: const TextStyle( 573 + color: Colors.white, 574 + fontWeight: FontWeight.bold, 575 + fontSize: 15, 576 + ), 577 + ), 578 + Text( 579 + reasonText, 580 + style: const TextStyle( 581 + color: Colors.white70, 582 + fontSize: 15, 583 + ), 584 + ), 585 + Text( 586 + '· $timeAgo', 587 + style: const TextStyle( 588 + color: Colors.white38, 589 + fontSize: 14, 590 + ), 591 + ), 592 + ], 593 + ), 594 + // Content preview below (if available) 595 + if (contentPreview != null && 596 + contentPreview.isNotEmpty) ...[ 597 + const SizedBox(height: 8), 598 + Text( 599 + contentPreview, 600 + style: const TextStyle( 601 + color: Colors.white60, 602 + fontSize: 14, 603 + ), 604 + maxLines: 3, 605 + overflow: TextOverflow.ellipsis, 606 + ), 607 + ], 608 + ], 609 + ), 610 + ), 611 + // Media thumbnail on the right (if available) 612 + if (mediaUrl != null) ...[ 613 + const SizedBox(width: 12), 614 + ClipRRect( 615 + borderRadius: BorderRadius.circular(8), 616 + child: Image.network( 617 + mediaUrl, 618 + width: 56, 619 + height: 56, 620 + fit: BoxFit.cover, 621 + errorBuilder: (context, error, stackTrace) { 622 + // If image fails to load, don't show anything 623 + return const SizedBox.shrink(); 624 + }, 625 + loadingBuilder: (context, child, loadingProgress) { 626 + if (loadingProgress == null) return child; 627 + return Container( 628 + width: 56, 629 + height: 56, 630 + color: Colors.grey[800], 631 + child: const Center( 632 + child: SizedBox( 633 + width: 20, 634 + height: 20, 635 + child: CircularProgressIndicator( 636 + strokeWidth: 2, 637 + color: Colors.white54, 638 + ), 639 + ), 640 + ), 641 + ); 642 + }, 643 + ), 644 + ), 645 + ], 646 + ], 647 + ), 648 + ), 649 + ), 650 + ); 651 + } 652 + }
+253
lib/src/features/notifications/ui/widgets/notifications_list.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:spark/src/features/notifications/providers/notification_provider.dart'; 4 + import 'package:spark/src/features/notifications/ui/widgets/notification_item.dart'; 5 + 6 + class NotificationsList extends ConsumerStatefulWidget { 7 + const NotificationsList({ 8 + this.priority, 9 + this.reasons, 10 + super.key, 11 + }); 12 + 13 + final bool? priority; 14 + final List<String>? reasons; 15 + 16 + @override 17 + ConsumerState<NotificationsList> createState() => _NotificationsListState(); 18 + } 19 + 20 + class _NotificationsListState extends ConsumerState<NotificationsList> { 21 + final ScrollController _scrollController = ScrollController(); 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _scrollController.addListener(_onScroll); 27 + } 28 + 29 + @override 30 + void dispose() { 31 + _scrollController 32 + ..removeListener(_onScroll) 33 + ..dispose(); 34 + super.dispose(); 35 + } 36 + 37 + void _onScroll() { 38 + if (_scrollController.position.pixels >= 39 + _scrollController.position.maxScrollExtent * 0.8) { 40 + // Load more when 80% scrolled 41 + ref 42 + .read( 43 + notificationProvider( 44 + priority: widget.priority, 45 + reasons: widget.reasons, 46 + ).notifier, 47 + ) 48 + .loadMore( 49 + priority: widget.priority, 50 + reasons: widget.reasons, 51 + ); 52 + } 53 + } 54 + 55 + @override 56 + Widget build(BuildContext context) { 57 + final notificationState = ref.watch( 58 + notificationProvider( 59 + priority: widget.priority, 60 + reasons: widget.reasons, 61 + ), 62 + ); 63 + 64 + final isLoading = notificationState.isLoading; 65 + final isEmpty = notificationState.notifications.isEmpty; 66 + final hasError = notificationState.hasError; 67 + 68 + if (isLoading && isEmpty) { 69 + return const Center( 70 + child: CircularProgressIndicator(), 71 + ); 72 + } 73 + 74 + if (hasError && isEmpty) { 75 + final errorMsg = notificationState.errorMessage; 76 + return RefreshIndicator( 77 + onRefresh: () async { 78 + await ref 79 + .read( 80 + notificationProvider( 81 + priority: widget.priority, 82 + reasons: widget.reasons, 83 + ).notifier, 84 + ) 85 + .refresh( 86 + priority: widget.priority, 87 + reasons: widget.reasons, 88 + ); 89 + }, 90 + child: SingleChildScrollView( 91 + physics: const AlwaysScrollableScrollPhysics(), 92 + child: SizedBox( 93 + height: MediaQuery.of(context).size.height * 0.8, 94 + child: Center( 95 + child: Column( 96 + mainAxisAlignment: MainAxisAlignment.center, 97 + children: [ 98 + const Icon( 99 + Icons.error_outline, 100 + size: 64, 101 + color: Colors.white54, 102 + ), 103 + const SizedBox(height: 16), 104 + const Text( 105 + 'Failed to load notifications', 106 + style: TextStyle( 107 + color: Colors.white70, 108 + fontSize: 16, 109 + ), 110 + ), 111 + if (errorMsg != null) ...[ 112 + const SizedBox(height: 8), 113 + Text( 114 + errorMsg, 115 + style: const TextStyle( 116 + color: Colors.white38, 117 + fontSize: 12, 118 + ), 119 + textAlign: TextAlign.center, 120 + ), 121 + ], 122 + const SizedBox(height: 16), 123 + ElevatedButton( 124 + onPressed: () { 125 + ref 126 + .read( 127 + notificationProvider( 128 + priority: widget.priority, 129 + reasons: widget.reasons, 130 + ).notifier, 131 + ) 132 + .refresh( 133 + priority: widget.priority, 134 + reasons: widget.reasons, 135 + ); 136 + }, 137 + child: const Text('Retry'), 138 + ), 139 + ], 140 + ), 141 + ), 142 + ), 143 + ), 144 + ); 145 + } 146 + 147 + if (notificationState.notifications.isEmpty) { 148 + return RefreshIndicator( 149 + onRefresh: () async { 150 + await ref 151 + .read( 152 + notificationProvider( 153 + priority: widget.priority, 154 + reasons: widget.reasons, 155 + ).notifier, 156 + ) 157 + .refresh( 158 + priority: widget.priority, 159 + reasons: widget.reasons, 160 + ); 161 + }, 162 + child: SingleChildScrollView( 163 + physics: const AlwaysScrollableScrollPhysics(), 164 + child: SizedBox( 165 + height: MediaQuery.of(context).size.height * 0.8, 166 + child: const Center( 167 + child: Column( 168 + mainAxisAlignment: MainAxisAlignment.center, 169 + children: [ 170 + Icon( 171 + Icons.notifications_none, 172 + size: 64, 173 + color: Colors.white38, 174 + ), 175 + SizedBox(height: 16), 176 + Text( 177 + 'No notifications', 178 + style: TextStyle( 179 + color: Colors.white70, 180 + fontSize: 18, 181 + fontWeight: FontWeight.w500, 182 + ), 183 + ), 184 + SizedBox(height: 8), 185 + Text( 186 + "You're all caught up!", 187 + style: TextStyle( 188 + color: Colors.white38, 189 + fontSize: 14, 190 + ), 191 + ), 192 + ], 193 + ), 194 + ), 195 + ), 196 + ), 197 + ); 198 + } 199 + 200 + final groupedNotifications = notificationState.groupedNotifications; 201 + 202 + return RefreshIndicator( 203 + onRefresh: () async { 204 + await ref 205 + .read( 206 + notificationProvider( 207 + priority: widget.priority, 208 + reasons: widget.reasons, 209 + ).notifier, 210 + ) 211 + .refresh( 212 + priority: widget.priority, 213 + reasons: widget.reasons, 214 + ); 215 + }, 216 + child: ListView.builder( 217 + controller: _scrollController, 218 + physics: const AlwaysScrollableScrollPhysics(), 219 + itemCount: 220 + groupedNotifications.length + 221 + (notificationState.isLoadingMore ? 1 : 0), 222 + itemBuilder: (context, index) { 223 + if (index >= groupedNotifications.length) { 224 + // Loading more indicator 225 + return const Padding( 226 + padding: EdgeInsets.all(16), 227 + child: Center( 228 + child: CircularProgressIndicator(), 229 + ), 230 + ); 231 + } 232 + 233 + final groupedNotification = groupedNotifications[index]; 234 + final notifier = ref.read( 235 + notificationProvider( 236 + priority: widget.priority, 237 + reasons: widget.reasons, 238 + ).notifier, 239 + ); 240 + return NotificationItem( 241 + groupedNotification: groupedNotification, 242 + onViewed: () { 243 + // Mark all notifications in the group as viewed 244 + groupedNotification.notifications.forEach( 245 + notifier.markNotificationAsViewed, 246 + ); 247 + }, 248 + ); 249 + }, 250 + ), 251 + ); 252 + } 253 + }
+31
lib/src/features/profile/ui/pages/profile_page.dart
··· 169 169 ); 170 170 } 171 171 172 + void _showCreateMenu(BuildContext context) { 173 + showCreateMediaSheet( 174 + context, 175 + onRecord: CreateMediaActions.onRecord(context, storyMode: false), 176 + onUploadVideo: CreateMediaActions.onUploadVideo( 177 + context, 178 + storyMode: false, 179 + ), 180 + onUploadImages: CreateMediaActions.onUploadImages( 181 + context, 182 + storyMode: false, 183 + ), 184 + ); 185 + } 186 + 172 187 @override 173 188 Widget build(BuildContext context) { 174 189 final profileStateAsync = ref.watch( ··· 305 320 onMentionTap: _handleUsernameTap, 306 321 onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, 307 322 appBarTitle: profile.handle, 323 + leading: isCurrentUser && !context.router.canPop() 324 + ? SizedBox( 325 + width: 40, 326 + height: 40, 327 + child: IconButton( 328 + padding: EdgeInsets.zero, 329 + constraints: const BoxConstraints(), 330 + splashColor: Colors.transparent, 331 + highlightColor: Colors.transparent, 332 + onPressed: () => _showCreateMenu(context), 333 + icon: AppIcons.addPostFilled( 334 + size: 30, 335 + ), 336 + ), 337 + ) 338 + : null, 308 339 appBarActions: [ 309 340 if (isCurrentUser) 310 341 IconButton(