this repo has no description

feat: Implement WebSocket connection for real-time notifications and update notification handling

+214 -89
+88
lib/api.dart
··· 30 30 List<Gallery> galleries = []; 31 31 32 32 String get _apiUrl => AppConfig.apiUrl; 33 + String get _wsUrl => AppConfig.wsUrl; 33 34 34 35 Future<void> loadToken() async { 35 36 _accessToken = await _storage.read(key: 'access_token'); ··· 1041 1042 } 1042 1043 } 1043 1044 return photoRecords; 1045 + } 1046 + 1047 + /// Connects to a WebSocket and listens for messages. 1048 + /// Uses the same headers as other authenticated requests. 1049 + /// [wsUrl]: The WebSocket URL to connect to. 1050 + /// [onMessage]: Callback for each incoming message. 1051 + /// Returns the WebSocket instance. 1052 + Future<WebSocket?> connectAndListenWs({required void Function(dynamic message) onMessage}) async { 1053 + final session = await auth.getValidSession(); 1054 + if (session == null) { 1055 + appLogger.w('No valid session for WebSocket connection'); 1056 + return null; 1057 + } 1058 + int attempt = 0; 1059 + const int maxRetries = 5; 1060 + Duration delay = const Duration(seconds: 2); 1061 + WebSocket? ws; 1062 + final headers = {'Authorization': 'Bearer $_accessToken', 'Content-Type': 'application/json'}; 1063 + 1064 + late Future<WebSocket?> Function() connect; 1065 + late Future<void> Function() retry; 1066 + 1067 + connect = () async { 1068 + try { 1069 + appLogger.i('Connecting to WebSocket: $_wsUrl (attempt \\${attempt + 1})'); 1070 + ws = await WebSocket.connect(_wsUrl, headers: headers); 1071 + ws!.listen( 1072 + (message) => onMessage(message), 1073 + onError: (error) async { 1074 + appLogger.w('WebSocket error: $error'); 1075 + await retry(); 1076 + }, 1077 + onDone: () async { 1078 + appLogger.i('WebSocket connection closed'); 1079 + await retry(); 1080 + }, 1081 + ); 1082 + appLogger.i('Connected to WebSocket: $_wsUrl'); 1083 + return ws; 1084 + } catch (e) { 1085 + appLogger.e('Failed to connect to WebSocket: $e'); 1086 + await retry(); 1087 + return null; 1088 + } 1089 + }; 1090 + 1091 + retry = () async { 1092 + if (attempt < maxRetries) { 1093 + attempt++; 1094 + appLogger.i('Retrying WebSocket connection in \\${delay.inSeconds} seconds...'); 1095 + await Future.delayed(delay); 1096 + delay *= 2; 1097 + await connect(); 1098 + } else { 1099 + appLogger.e('Max WebSocket retry attempts reached.'); 1100 + } 1101 + }; 1102 + 1103 + return await connect(); 1104 + } 1105 + 1106 + /// Notifies the server that the requesting account has seen notifications. 1107 + /// Sends a POST request with the current ISO timestamp as seenAt. 1108 + Future<bool> updateSeen() async { 1109 + if (_accessToken == null) { 1110 + appLogger.w('No access token for updateSeen'); 1111 + return false; 1112 + } 1113 + final url = Uri.parse('$_apiUrl/xrpc/social.grain.notification.updateSeen'); 1114 + final seenAt = DateTime.now().toUtc().toIso8601String(); 1115 + final body = jsonEncode({'seenAt': seenAt}); 1116 + final headers = {'Authorization': 'Bearer $_accessToken', 'Content-Type': 'application/json'}; 1117 + try { 1118 + final response = await http.post(url, headers: headers, body: body); 1119 + if (response.statusCode == 200) { 1120 + appLogger.i('Successfully updated seen notifications at $seenAt'); 1121 + return true; 1122 + } else { 1123 + appLogger.w( 1124 + 'Failed to update seen notifications: \\${response.statusCode} \\${response.body}', 1125 + ); 1126 + return false; 1127 + } 1128 + } catch (e) { 1129 + appLogger.e('Error updating seen notifications: $e'); 1130 + return false; 1131 + } 1044 1132 } 1045 1133 } 1046 1134
+4
lib/main.dart
··· 14 14 15 15 class AppConfig { 16 16 static late final String apiUrl; 17 + static late final String wsUrl; 17 18 18 19 static Future<void> init() async { 19 20 if (!kReleaseMode) { ··· 22 23 apiUrl = kReleaseMode 23 24 ? const String.fromEnvironment('API_URL', defaultValue: 'https://grain.social') 24 25 : dotenv.env['API_URL'] ?? 'http://localhost:8080'; 26 + wsUrl = kReleaseMode 27 + ? const String.fromEnvironment('WS_URL', defaultValue: 'wss://grain.social/ws') 28 + : dotenv.env['WS_URL'] ?? 'ws://localhost:8080/ws'; 25 29 } 26 30 } 27 31
+57
lib/providers/notifications_provider.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + import 'dart:io'; 4 + 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:grain/api.dart'; 7 + import 'package:grain/models/notification.dart'; 8 + 9 + final notificationsProvider = StateNotifierProvider<NotificationsNotifier, List<Notification>>(( 10 + ref, 11 + ) { 12 + return NotificationsNotifier(); 13 + }); 14 + 15 + class NotificationsNotifier extends StateNotifier<List<Notification>> { 16 + NotificationsNotifier() : super([]) { 17 + _connectAndListen(); 18 + } 19 + 20 + WebSocket? _ws; 21 + StreamSubscription? _wsSubscription; 22 + 23 + void _connectAndListen() async { 24 + _ws = await apiService.connectAndListenWs( 25 + onMessage: (message) async { 26 + try { 27 + final data = message is String ? jsonDecode(message) : message; 28 + if (data is Map<String, dynamic> && data['type'] == 'refresh-notifications') { 29 + final notifications = await apiService.getNotifications(); 30 + state = notifications; 31 + } else { 32 + // You may need to adjust this if your WS sends a list or other format 33 + final notification = Notification.fromJson(data); 34 + state = [...state, notification]; 35 + } 36 + } catch (e) { 37 + // Handle parse error or ignore non-notification messages 38 + } 39 + }, 40 + ); 41 + } 42 + 43 + /// Marks all notifications as seen both on the server and locally. 44 + Future<void> updateSeen() async { 45 + final success = await apiService.updateSeen(); 46 + if (success) { 47 + state = [for (final n in state) n.copyWith(isRead: true)]; 48 + } 49 + } 50 + 51 + @override 52 + void dispose() { 53 + _wsSubscription?.cancel(); 54 + _ws?.close(); 55 + super.dispose(); 56 + } 57 + }
-2
lib/screens/home_page.dart
··· 132 132 ); 133 133 } 134 134 135 - // ...existing code... 136 - 137 135 @override 138 136 Widget build(BuildContext context) { 139 137 final theme = Theme.of(context);
-1
lib/screens/library_photos_select_sheet.dart
··· 139 139 ], 140 140 ), 141 141 ), 142 - // ...existing code... 143 142 ], 144 143 ), 145 144 );
+20 -78
lib/screens/notifications_page.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 3 import 'package:grain/api.dart'; 3 4 import 'package:grain/app_icons.dart'; 4 5 import 'package:grain/models/notification.dart' as grain; 6 + import 'package:grain/providers/notifications_provider.dart'; 5 7 import 'package:grain/screens/gallery_page.dart'; 6 8 import 'package:grain/screens/profile_page.dart'; 7 9 import 'package:grain/utils.dart'; 8 10 import 'package:grain/widgets/gallery_preview.dart'; 9 11 10 - class NotificationsPage extends StatefulWidget { 12 + class NotificationsPage extends ConsumerStatefulWidget { 11 13 const NotificationsPage({super.key}); 12 14 13 15 @override 14 - State<NotificationsPage> createState() => _NotificationsPageState(); 16 + ConsumerState<NotificationsPage> createState() => _NotificationsPageState(); 15 17 } 16 18 17 - class _NotificationsPageState extends State<NotificationsPage> { 18 - bool _loading = true; 19 - bool _error = false; 20 - List<grain.Notification> _notifications = []; 19 + class _NotificationsPageState extends ConsumerState<NotificationsPage> { 20 + bool _seenCalled = false; 21 21 22 22 @override 23 23 void initState() { 24 24 super.initState(); 25 - _fetchNotifications(); 26 - } 27 - 28 - Future<void> _fetchNotifications() async { 29 - setState(() { 30 - _loading = true; 31 - _error = false; 25 + // Only call updateSeen once per visit 26 + WidgetsBinding.instance.addPostFrameCallback((_) { 27 + if (!_seenCalled) { 28 + ref.read(notificationsProvider.notifier).updateSeen(); 29 + _seenCalled = true; 30 + } 32 31 }); 33 - try { 34 - final notifications = await apiService.getNotifications(); 35 - if (!mounted) return; 36 - setState(() { 37 - _notifications = notifications; 38 - _loading = false; 39 - }); 40 - } catch (e) { 41 - if (!mounted) return; 42 - setState(() { 43 - _error = true; 44 - _loading = false; 45 - }); 46 - } 47 32 } 48 33 49 - Widget _buildNotificationTile(grain.Notification notification) { 34 + Widget _buildNotificationTile(BuildContext context, grain.Notification notification) { 50 35 final theme = Theme.of(context); 51 36 final author = notification.author; 52 37 String message = ''; ··· 195 180 ); 196 181 } 197 182 198 - Widget _buildSkeletonTile(BuildContext context) { 199 - final theme = Theme.of(context); 200 - return ListTile( 201 - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 202 - leading: Container( 203 - width: 40, 204 - height: 40, 205 - decoration: BoxDecoration( 206 - color: theme.colorScheme.surfaceContainerHighest.withAlpha(128), 207 - shape: BoxShape.circle, 208 - ), 209 - ), 210 - title: Container( 211 - width: 120, 212 - height: 16, 213 - color: theme.colorScheme.surfaceContainerHighest.withAlpha(128), 214 - margin: const EdgeInsets.only(bottom: 4), 215 - ), 216 - subtitle: Column( 217 - crossAxisAlignment: CrossAxisAlignment.start, 218 - children: [ 219 - Container( 220 - width: 180, 221 - height: 14, 222 - color: theme.colorScheme.surfaceContainerHighest.withAlpha(128), 223 - margin: const EdgeInsets.only(bottom: 8), 224 - ), 225 - Container( 226 - width: 140, 227 - height: 12, 228 - color: theme.colorScheme.surfaceContainerHighest.withAlpha(128), 229 - ), 230 - ], 231 - ), 232 - isThreeLine: true, 233 - ); 234 - } 235 - 236 183 @override 237 184 Widget build(BuildContext context) { 185 + final ref = this.ref; 238 186 final theme = Theme.of(context); 187 + final notifications = ref.watch(notificationsProvider); 188 + 239 189 return Scaffold( 240 190 backgroundColor: theme.scaffoldBackgroundColor, 241 - body: _loading 242 - ? ListView.separated( 243 - itemCount: 6, 244 - separatorBuilder: (context, index) => Divider(height: 1, color: theme.dividerColor), 245 - itemBuilder: (context, index) => _buildSkeletonTile(context), 246 - ) 247 - : _error 248 - ? Center(child: Text('Failed to load notifications.', style: theme.textTheme.bodyMedium)) 249 - : _notifications.isEmpty 191 + body: notifications.isEmpty 250 192 ? Center(child: Text('No notifications yet.', style: theme.textTheme.bodyMedium)) 251 193 : ListView.separated( 252 - itemCount: _notifications.length, 194 + itemCount: notifications.length, 253 195 separatorBuilder: (context, index) => Divider(height: 1, color: theme.dividerColor), 254 196 itemBuilder: (context, index) { 255 - final notification = _notifications[index]; 256 - return _buildNotificationTile(notification); 197 + final notification = notifications[index]; 198 + return _buildNotificationTile(context, notification); 257 199 }, 258 200 ), 259 201 );
+45 -8
lib/widgets/bottom_nav_bar.dart
··· 5 5 import 'package:grain/app_icons.dart'; 6 6 import 'package:grain/app_theme.dart'; 7 7 import 'package:grain/models/profile_with_galleries.dart'; 8 + import 'package:grain/providers/notifications_provider.dart'; 8 9 import 'package:grain/providers/profile_provider.dart'; 9 10 import 'package:grain/widgets/app_image.dart'; 10 11 ··· 35 36 data: (profileWithGalleries) => profileWithGalleries?.profile.avatar, 36 37 orElse: () => null, 37 38 ); 39 + 40 + final theme = Theme.of(context); 41 + 42 + // Get unread notifications count 43 + final notifications = ref.watch(notificationsProvider); 44 + final unreadCount = notifications.where((n) => n.isRead == false).length; 38 45 39 46 return Container( 40 47 decoration: BoxDecoration( ··· 95 102 height: 42 + MediaQuery.of(context).padding.bottom, 96 103 child: Transform.translate( 97 104 offset: const Offset(0, -10), 98 - child: Center( 99 - child: FaIcon( 100 - AppIcons.solidBell, 101 - size: 20, 102 - color: navIndex == 2 103 - ? AppTheme.primaryColor 104 - : Theme.of(context).colorScheme.onSurfaceVariant, 105 - ), 105 + child: Stack( 106 + alignment: Alignment.center, 107 + children: [ 108 + Center( 109 + child: FaIcon( 110 + AppIcons.solidBell, 111 + size: 20, 112 + color: navIndex == 2 113 + ? AppTheme.primaryColor 114 + : Theme.of(context).colorScheme.onSurfaceVariant, 115 + ), 116 + ), 117 + if (unreadCount > 0) 118 + Align( 119 + alignment: Alignment.center, 120 + child: Transform.translate( 121 + offset: const Offset(10, -10), 122 + child: Container( 123 + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), 124 + decoration: BoxDecoration( 125 + color: theme.colorScheme.primary, 126 + borderRadius: BorderRadius.circular(10), 127 + border: Border.all(color: theme.scaffoldBackgroundColor, width: 1), 128 + ), 129 + constraints: const BoxConstraints(minWidth: 16, minHeight: 16), 130 + child: Text( 131 + unreadCount > 99 ? '99+' : unreadCount.toString(), 132 + style: const TextStyle( 133 + color: Colors.white, 134 + fontSize: 10, 135 + fontWeight: FontWeight.bold, 136 + ), 137 + textAlign: TextAlign.center, 138 + ), 139 + ), 140 + ), 141 + ), 142 + ], 106 143 ), 107 144 ), 108 145 ),