[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.

DMs (#72)

* começar do zero aqui

* comecin do auth

* fazer do zero aqui

* Message Model

* minha maquina nao

* interface

* providers

* pedreiro.ai

* tome

* sobrou

* quanto vale uma vulva saudável de pedreiro?

Changed Message model to use a list of Embed objects instead of a single Embed. Added isEmpty and isNotEmpty getters to Embed. Updated MessagesRepository to clarify sendMessage should return a Future<Message>. Introduced MessagesRepositoryImpl with HTTP methods for sending and retrieving messages and conversations.

* consertadas

* calma vida ta de boa

* login novo

* as request

* 9 reais

* volvo do padroeiro

* ei

* arigatoooo

* generated stuffs

* refresh forgor to commit

* vou dormir mas ta quase, falta os links e testar e o upload em si

* pivotada dos cria

* marromeno

* falta so o video spark

* FUNCIONO

* user avatar on list

* use getProfiles endpoint

* update ci envs

* lint

* melhorzin ali em cima

---------

Co-authored-by: Jean Carlo Polo <vaniapolo@gmail.com>
Co-authored-by: daviirodrig <30713947+daviirodrig@users.noreply.github.com>

authored by

C3B
Jean Carlo Polo
daviirodrig
and committed by
GitHub
d2d68935 68c393cc

+1961 -1623
+2 -1
.env.example
··· 1 1 VIDEO_SERVICE_URL=https://video.sprk.so 2 - SPRK_APPVIEW_URL=https://api.sprk.so 2 + SPRK_APPVIEW_URL=https://api.sprk.so 3 + MESSAGES_SERVICE_URL=https://chat.sprk.so
+4 -3
.github/workflows/android-internal-release.yml
··· 49 49 50 50 - name: Setup env 51 51 run: | 52 - echo "VIDEO_SERVICE_URL=${{ secrets.VIDEO_SERVICE_URL }}" >> .env 53 - echo "SPRK_APPVIEW_URL=${{ secrets.SPRK_APPVIEW_URL }}" >> .env 54 - echo "SIGNUPS_DISABLED=${{ secrets.SIGNUPS_DISABLED }}" >> .env 52 + echo "VIDEO_SERVICE_URL=https://video.sprk.so" >> .env 53 + echo "SPRK_APPVIEW_URL=https://api.sprk.so" >> .env 54 + echo "MESSAGES_SERVICE_URL=https://chat.sprk.so" >> .env 55 + echo "SIGNUPS_DISABLED=false" >> .env 55 56 56 57 - name: Set versionCode (commit count + run attempt) 57 58 run: |
+1 -1
ios/Podfile.lock
··· 142 142 :path: ".symlinks/plugins/video_player_avfoundation/darwin" 143 143 144 144 SPEC CHECKSUMS: 145 - camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 145 + camera_avfoundation: 04b44aeb14070126c6529e5ab82cc7c9fca107cf 146 146 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 147 147 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf 148 148 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
-4
ios/Runner.xcodeproj/project.pbxproj
··· 295 295 inputFileListPaths = ( 296 296 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 297 297 ); 298 - inputPaths = ( 299 - ); 300 298 name = "[CP] Embed Pods Frameworks"; 301 299 outputFileListPaths = ( 302 300 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 303 - ); 304 - outputPaths = ( 305 301 ); 306 302 runOnlyForDeploymentPostprocessing = 0; 307 303 shellPath = /bin/sh;
+1
ios/ci_scripts/ci_post_clone.sh
··· 20 20 cat > .env << EOL 21 21 VIDEO_SERVICE_URL=https://video.sprk.so 22 22 SPRK_APPVIEW_URL=https://api.sprk.so 23 + MESSAGES_SERVICE_URL=https://chat.sprk.so 23 24 EOL 24 25 25 26 # Install CocoaPods using Homebrew.
+18
lib/src/core/auth/data/models/messages_auth_models.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'messages_auth_models.freezed.dart'; 4 + part 'messages_auth_models.g.dart'; 5 + 6 + @freezed 7 + abstract class MessagesUser with _$MessagesUser { 8 + factory MessagesUser({required String did, required String handle, String? displayName}) = _MessagesUser; 9 + factory MessagesUser.fromJson(Map<String, dynamic> json) => _$MessagesUserFromJson(json); 10 + } 11 + 12 + @freezed 13 + abstract class MessagesAuthResponse with _$MessagesAuthResponse { 14 + factory MessagesAuthResponse({required String accessToken, required String refreshToken, required MessagesUser user}) = 15 + _MessagesAuthResponse; 16 + 17 + factory MessagesAuthResponse.fromJson(Map<String, dynamic> json) => _$MessagesAuthResponseFromJson(json); 18 + }
+10
lib/src/core/auth/data/repositories/auth_repository.dart
··· 13 13 /// Gets the AT Protocol client 14 14 ATProto? get atproto; 15 15 16 + String? get dmAccessToken; 17 + 18 + String? get dmRefreshToken; 19 + 16 20 /// Attempts to log in a user with the provided credentials 17 21 /// 18 22 /// [handle] - The user handle ··· 40 44 /// Refreshes the authentication token 41 45 /// Returns true if the session was successfully refreshed 42 46 Future<bool> refreshToken(); 47 + 48 + /// Refreshes the DM token 49 + Future<bool> refreshDMToken(); 50 + 51 + /// Logs in to the message service only 52 + Future<void> loginMessageService(); 43 53 }
+103 -10
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 7 7 import 'package:http/http.dart' as http; 8 8 import 'package:sparksocial/src/core/auth/data/models/login_result.dart'; 9 9 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 10 + import 'package:sparksocial/src/core/config/app_config.dart'; 10 11 import 'package:sparksocial/src/core/storage/storage.dart'; 11 12 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 12 13 ··· 14 15 class AuthRepositoryImpl implements AuthRepository { 15 16 Session? _session; 16 17 ATProto? _atProto; 18 + String? _dmAccessToken; 19 + String? _dmRefreshToken; 20 + 17 21 final _logger = GetIt.instance<LogService>().getLogger('AuthRepository'); 18 22 19 23 final Completer<void> _initCompleter = Completer<void>(); ··· 27 31 28 32 @override 29 33 ATProto? get atproto => _atProto; 34 + 35 + @override 36 + String? get dmAccessToken => _dmAccessToken; 37 + 38 + @override 39 + String? get dmRefreshToken => _dmRefreshToken; 30 40 31 41 AuthRepositoryImpl() { 32 42 _logger.i('Initializing AuthRepository'); ··· 67 77 return DateTime.now().isAfter(token.exp.subtract(const Duration(minutes: 5))); 68 78 } 69 79 80 + /// Logs in the message service 81 + @override 82 + Future<void> loginMessageService() async { 83 + try { 84 + _logger.i('Logging in to message service'); 85 + final response = await http.post( 86 + Uri.parse('${AppConfig.messagesServiceUrl}/auth/login-jwt'), 87 + headers: {'Content-Type': 'application/json'}, 88 + body: jsonEncode({'did': _session!.did, 'jwt': _session!.accessJwt}), 89 + ); 90 + _logger.d('Message service login response status: ${response.body}'); 91 + 92 + if (response.statusCode == 200) { 93 + final data = jsonDecode(response.body); 94 + _dmAccessToken = data['accessJwt']; 95 + _dmRefreshToken = data['refreshJwt']; 96 + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, _dmAccessToken!); 97 + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, _dmRefreshToken!); 98 + _logger.i('Logged in to message service successfully'); 99 + } else { 100 + throw Exception('Failed to login to message service: ${response.statusCode}'); 101 + } 102 + } catch (e) { 103 + _logger.e('Failed to login to message service', error: e); 104 + rethrow; 105 + } 106 + } 107 + 70 108 Future<void> _loadSavedSession() async { 71 109 try { 72 110 _logger.d('Loading saved session'); ··· 90 128 } 91 129 92 130 _atProto = ATProto.fromSession(_session!); 131 + 132 + _dmAccessToken = await StorageManager.instance.secure.getString(StorageKeys.dmAccessToken); 133 + _dmRefreshToken = await StorageManager.instance.secure.getString(StorageKeys.dmRefreshToken); 134 + 135 + try { 136 + if (_dmAccessToken == null) { 137 + _logger.w('DM access token not found, refreshing DM token'); 138 + if (!await refreshDMToken()) { 139 + throw Exception('Failed to refresh DM token'); 140 + } 141 + } 142 + final response = await http.get( 143 + Uri.parse('${AppConfig.messagesServiceUrl}/auth/session'), 144 + headers: {'Authorization': 'Bearer $_dmAccessToken'}, 145 + ); 146 + _logger.d('DM session response status: ${response.statusCode}'); 147 + 148 + if (response.statusCode == 200) { 149 + final data = jsonDecode(response.body); 150 + _dmAccessToken = data['access_token']; 151 + _dmRefreshToken = data['refresh_token']; 152 + } else { 153 + if (!await refreshDMToken()) { 154 + throw Exception('Failed to refresh DM token'); 155 + } 156 + } 157 + } catch (e) { 158 + if (!await refreshDMToken()) { 159 + _logger.e('Failed to refresh DM token, trying to login again'); 160 + await loginMessageService(); 161 + } 162 + } 163 + 93 164 _logger.i('Session loaded successfully for user: ${_session!.handle}'); 94 165 } catch (e) { 95 166 _logger.e('Error loading saved session', error: e); ··· 97 168 } 98 169 } 99 170 171 + @override 172 + Future<bool> refreshDMToken() async { 173 + _logger.i('Refreshing DM token $_dmRefreshToken'); 174 + final response = await http.post( 175 + Uri.parse('${AppConfig.messagesServiceUrl}/auth/refresh'), 176 + headers: {'Content-Type': 'application/json', 'Cookie': 'refresh_token=$_dmRefreshToken'}, 177 + ); 178 + 179 + if (response.statusCode == 200) { 180 + final data = jsonDecode(response.body); 181 + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, data['access_token']); 182 + _dmAccessToken = data['access_token']; 183 + _dmRefreshToken = data['refresh_token']; 184 + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, data['refresh_token']); 185 + return true; 186 + } 187 + _logger.e('Failed to refresh DM token: ${response.statusCode} - ${response.body}'); 188 + return false; 189 + } 190 + 100 191 Future<void> _refreshSession() async { 101 192 try { 102 193 if (_session == null) { ··· 130 221 131 222 _session = response.data; 132 223 224 + await refreshDMToken(); 225 + 133 226 await _saveSession(_session!); 134 227 _atProto = ATProto.fromSession(_session!); 135 228 _logger.i('Session refreshed successfully'); ··· 146 239 _logger.d('Saving session for user: ${sessionData.handle}'); 147 240 final sessionJson = sessionData.toJson(); 148 241 await StorageManager.instance.secure.setString(StorageKeys.userSession, json.encode(sessionJson)); 242 + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, _dmAccessToken ?? ''); 243 + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, _dmRefreshToken ?? ''); 149 244 _logger.d('Session saved successfully'); 150 245 } catch (e) { 151 246 _logger.e('Failed to save session', error: e); ··· 214 309 _atProto = ATProto.fromSession(_session!); 215 310 await _saveSession(_session!); 216 311 _logger.i('Login successful for user: $handle'); 312 + await loginMessageService(); 217 313 218 314 return LoginResult.success(); 219 315 } catch (e) { ··· 250 346 } 251 347 252 348 _logger.d('Account created, creating session'); 253 - Session session = Session.fromJson({ 254 - 'did': createResponse.data.did, 255 - 'handle': handle, 256 - 'email': email, 257 - 'emailConfirmed': false, 258 - 'accessJwt': createResponse.data.accessJwt, 259 - 'refreshJwt': createResponse.data.refreshJwt, 260 - 'didDoc': createResponse.data.didDoc, 261 - 'active': true, 262 - }); 349 + await login(handle, password); 263 350 264 351 _session = session; 265 352 if (_session == null) { ··· 287 374 await _clearSavedSession(); 288 375 _session = null; 289 376 _atProto = null; 377 + _dmAccessToken = null; 378 + _dmRefreshToken = null; 379 + await http.post( 380 + Uri.parse('${AppConfig.messagesServiceUrl}/auth/logout'), 381 + headers: {'Authorization': 'Bearer $_dmAccessToken', 'Cookie': 'refresh_token=$_dmRefreshToken'}, 382 + ); 290 383 _logger.i('Logout successful'); 291 384 } 292 385 } catch (e) {
+9 -16
lib/src/core/config/app_config.dart
··· 8 8 /// to maintain a single source of truth for application settings. 9 9 class AppConfig { 10 10 /// Base URL for the video processing service. 11 - static String get videoServiceUrl => 12 - _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); 11 + static String get videoServiceUrl => _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); 13 12 14 13 /// URL for the app view (web view display). 15 - static String get appViewUrl => 16 - _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); 14 + static String get appViewUrl => _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); 15 + 16 + /// Base URL for the messages service (chat service). 17 + static String get messagesServiceUrl => _getStringValue('MESSAGES_SERVICE_URL', 'http://localhost:3000'); 17 18 18 19 /// Whether new user registrations are disabled. 19 - static bool get signupsDisabled => 20 - _getBoolValue('SIGNUPS_DISABLED', false); 20 + static bool get signupsDisabled => _getBoolValue('SIGNUPS_DISABLED', false); 21 21 22 22 /// API request timeout in seconds. 23 - static int get apiTimeoutSeconds => 24 - _getIntValue('API_TIMEOUT_SECONDS', 30); 23 + static int get apiTimeoutSeconds => _getIntValue('API_TIMEOUT_SECONDS', 30); 25 24 26 25 /// Maximum upload file size in MB. 27 - static double get maxUploadSizeMB => 28 - _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100.0); 26 + static double get maxUploadSizeMB => _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100.0); 29 27 30 28 /// The current application environment (development, production, etc.) 31 - static String get environment => 32 - _getStringValue('ENVIRONMENT', 'development'); 29 + static String get environment => _getStringValue('ENVIRONMENT', 'development'); 33 30 34 31 /// Checks if the app is running in development mode. 35 32 static bool get isDevelopment => environment == 'development'; 36 33 37 34 /// Checks if the app is running in production mode. 38 35 static bool get isProduction => environment == 'production'; 39 - 40 - /// Base URL for the chat service (Socket.io server). 41 - static String get chatServiceUrl => 42 - _getStringValue('CHAT_SERVICE_URL', 'http://localhost:3000'); 43 36 44 37 /// Helper method to retrieve string values from environment with defaults 45 38 static String _getStringValue(String key, String defaultValue) {
+3 -3
lib/src/core/di/service_locator.dart
··· 1 1 import 'package:get_it/get_it.dart'; 2 2 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository_impl.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 4 + import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository_impl.dart'; 3 5 import 'package:sparksocial/src/core/storage/cache/cache_manager_impl.dart'; 4 6 import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; 5 7 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; ··· 16 18 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 17 19 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 18 20 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 19 - import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 20 - import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository_impl.dart'; 21 21 22 22 // This is the ONLY PLACE IN THE ENTIRE APP where implementations are imported 23 23 // All the other files should import interfaces only (polymorphism) to keep everything decoupled ··· 54 54 sl.registerSingleton<AuthRepository>(AuthRepositoryImpl()); 55 55 56 56 // Register Chat dependencies 57 - sl.registerSingleton<ChatRepository>(ChatRepositoryImpl()); 57 + sl.registerSingleton<MessagesRepository>(MessagesRepositoryImpl(sl<AuthRepository>())); 58 58 59 59 // Register SprkRepository with its interface 60 60 sl.registerSingleton<SprkRepository>(SprkRepositoryImpl(sl<AuthRepository>()));
+5
lib/src/core/network/atproto/data/repositories/actor_repository.dart
··· 8 8 /// [did] The DID of the profile to get 9 9 Future<ProfileViewDetailed> getProfile(String did); 10 10 11 + /// Get multiple profiles by their DIDs 12 + /// 13 + /// [dids] A list of DIDs to fetch profiles for 14 + Future<List<ProfileViewDetailed>> getProfiles(List<String> dids); 15 + 11 16 /// Search actors by query string. 12 17 /// 13 18 /// [query] The search query.
+34
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 192 192 _logger.d('Preferences updated successfully'); 193 193 }); 194 194 } 195 + 196 + @override 197 + Future<List<ProfileViewDetailed>> getProfiles(List<String> dids) { 198 + _logger.d('Getting profiles for DIDs: $dids'); 199 + return _client.executeWithRetry(() async { 200 + if (!_client.authRepository.isAuthenticated) { 201 + _logger.w('Not authenticated'); 202 + throw Exception('Not authenticated'); 203 + } 204 + 205 + final atproto = _client.authRepository.atproto; 206 + if (atproto == null) { 207 + _logger.e('AtProto not initialized'); 208 + throw Exception('AtProto not initialized'); 209 + } 210 + try { 211 + final result = await atproto.get( 212 + NSID.parse('so.sprk.actor.getProfiles'), 213 + parameters: {'actors': dids}, 214 + headers: {'atproto-proxy': _client.sprkDid}, 215 + to: (jsonMap) => jsonMap, 216 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), 217 + ); 218 + return (result.data["profiles"] as List).map((json) => ProfileViewDetailed.fromJson(json)).toList(); 219 + } catch (e) { 220 + _logger.e('Failed to retrieve profile for DIDs: $dids', error: e); 221 + _logger.i('Trying to get profiles from bluesky'); 222 + final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); 223 + final profiles = await bluesky.actor.getProfiles(actors: dids); 224 + _logger.d('Profiles retrieved successfully from bluesky'); 225 + return profiles.data.profiles.map((p) => ProfileViewDetailed.fromJson(p.toJson())).toList(); 226 + } 227 + }); 228 + } 195 229 }
+6 -1
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 79 79 /// 80 80 /// [imageFiles] List of image files to upload 81 81 /// [altTexts] Map of file paths to alt texts 82 - Future<List<Image>> uploadImages(List<XFile> imageFiles, Map<String, String> altTexts); 82 + Future<List<Image>> uploadImages({required List<XFile> imageFiles, Map<String, String>? altTexts}); 83 + 84 + /// Upload a video to the server 85 + /// 86 + /// [videoPath] The path to the video file 87 + Future<Blob> uploadVideo(String videoPath); 83 88 84 89 /// Post a video to the user's feed 85 90 ///
+86 -4
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1 1 import 'dart:convert'; 2 + import 'dart:io'; 2 3 import 'dart:typed_data'; 3 4 4 5 import 'package:atproto/core.dart'; 5 6 import 'package:atproto/atproto.dart'; 6 7 import 'package:bluesky/bluesky.dart' as bsky; 7 8 import 'package:get_it/get_it.dart'; 9 + import 'package:http/http.dart' as http; 8 10 import 'package:image/image.dart' as img; 9 11 import 'package:image_picker/image_picker.dart'; 12 + import 'package:path/path.dart' as path; 13 + import 'package:sparksocial/src/core/config/app_config.dart'; 10 14 import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 11 15 import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 12 16 import 'package:sparksocial/src/core/feed_algorithms/hardcoded_feed_algorithm.dart'; ··· 330 334 Map<String, dynamic>? embedJson; 331 335 if (imageFiles case List<XFile> files when files.isNotEmpty) { 332 336 _logger.d('Uploading ${files.length} images for comment'); 333 - final List<Image> uploadedImageMaps = await uploadImages(files, altTexts ?? {}); 337 + final List<Image> uploadedImageMaps = await uploadImages(imageFiles: files, altTexts: altTexts); 334 338 embedJson = EmbedImage(images: uploadedImageMaps).toJson(); 335 339 } 336 340 ··· 398 402 } 399 403 400 404 if (_client.authRepository.atproto case final atproto?) { 401 - final List<Image> uploadedImageMaps = await uploadImages(imageFiles, altTexts); 405 + final List<Image> uploadedImageMaps = await uploadImages(imageFiles: imageFiles, altTexts: altTexts); 402 406 403 407 // Create Sprk post first 404 408 final record = PostRecord( ··· 432 436 433 437 /// Helper to upload multiple images, stripping EXIF, and return a list of JSON maps for embedding 434 438 @override 435 - Future<List<Image>> uploadImages(List<XFile> imageFiles, Map<String, String> altTexts) async { 439 + Future<List<Image>> uploadImages({required List<XFile> imageFiles, Map<String, String>? altTexts}) async { 436 440 _logger.d('Processing ${imageFiles.length} images for upload'); 437 441 438 442 final List<Image> uploadedImageMaps = []; ··· 464 468 _logger.d('Image uploaded successfully: ${imageFile.name}'); 465 469 466 470 // Add the uploaded image to our result list 467 - uploadedImageMaps.add(Image(alt: altTexts[imageFile.path] ?? '', image: response.data.blob)); 471 + uploadedImageMaps.add(Image(alt: altTexts?[imageFile.path] ?? '', image: response.data.blob)); 468 472 break; 469 473 default: 470 474 _logger.e('Failed to upload image blob: ${response.status.code}'); ··· 482 486 return uploadedImageMaps; 483 487 } 484 488 489 + @override 490 + Future<Blob> uploadVideo(String videoPath) async { 491 + _logger.d('Uploading video from path: $videoPath'); 492 + 493 + return _client.executeWithRetry(() async { 494 + if (!_client.authRepository.isAuthenticated) { 495 + _logger.w('Not authenticated'); 496 + throw Exception('Not authenticated'); 497 + } 498 + final authAtProto = _client.authRepository.atproto; 499 + if (authAtProto == null || authAtProto.session == null) { 500 + throw Exception('AtProto not initialized'); 501 + } 502 + 503 + // Handle file:// URL scheme 504 + String cleanVideoPath = videoPath; 505 + if (videoPath.startsWith('file://')) { 506 + cleanVideoPath = videoPath.replaceFirst('file://', ''); 507 + } 508 + 509 + // Validate the video file 510 + final file = File(cleanVideoPath); 511 + if (!await file.exists()) { 512 + throw Exception('Video file not found: $cleanVideoPath'); 513 + } 514 + 515 + // Check if the video is in a compatible format 516 + final videoBytes = await file.readAsBytes(); 517 + if (videoBytes.isEmpty) { 518 + throw Exception('Video file is empty'); 519 + } 520 + 521 + _logger.i('Video file size: ${videoBytes.length} bytes'); 522 + 523 + final pdsService = authAtProto.service; 524 + final serviceTokenRes = await authAtProto.server.getServiceAuth( 525 + aud: 'did:web:$pdsService', 526 + lxm: NSID.parse('com.atproto.repo.uploadBlob'), 527 + ); 528 + 529 + final serviceToken = serviceTokenRes.data.token; 530 + final response = await http.post( 531 + Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo'), 532 + headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, 533 + body: videoBytes, 534 + ); 535 + 536 + if (response.statusCode != 200) { 537 + throw Exception('Failed to upload video: ${response.statusCode} ${response.body}'); 538 + } 539 + 540 + // Parse the response 541 + final responseData = jsonDecode(response.body); 542 + Blob blob; 543 + //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon 544 + blob = Blob.fromJson(responseData['blobRef']); 545 + return blob; 546 + }); 547 + } 548 + 485 549 /// Crosspost images to Bluesky using same blobs but Bluesky models 486 550 Future<void> _crosspostToBlueSky( 487 551 String text, ··· 826 890 throw Exception('Failed to post story: ${response.status} ${response.data}'); 827 891 } 828 892 }); 893 + } 894 + 895 + /// Helper method to determine content type based on file extension 896 + String _getContentType(String videoPath) { 897 + final extension = path.extension(videoPath).toLowerCase(); 898 + 899 + switch (extension) { 900 + case '.mp4': 901 + return 'video/mp4'; 902 + case '.mov': 903 + return 'video/quicktime'; 904 + case '.avi': 905 + return 'video/x-msvideo'; 906 + case '.webm': 907 + return 'video/webm'; 908 + default: 909 + return 'video/mp4'; // Default to mp4 910 + } 829 911 } 830 912 }
-4
lib/src/core/network/chat/chat.dart
··· 1 - library; 2 - 3 - export 'data/models/models.dart'; 4 - export 'data/repositories/repositories.dart';
-153
lib/src/core/network/chat/data/models/chat_models.dart
··· 1 - import 'package:freezed_annotation/freezed_annotation.dart'; 2 - 3 - part 'chat_models.freezed.dart'; 4 - part 'chat_models.g.dart'; 5 - 6 - /// Represents a chat message 7 - @freezed 8 - class ChatMessage with _$ChatMessage { 9 - const ChatMessage._(); 10 - 11 - @JsonSerializable(explicitToJson: true) 12 - const factory ChatMessage({ 13 - required String id, 14 - required String message, 15 - required String senderDid, 16 - required String receiverDid, 17 - required DateTime timestamp, 18 - }) = _ChatMessage; 19 - 20 - factory ChatMessage.fromJson(Map<String, dynamic> json) => _$ChatMessageFromJson(json); 21 - } 22 - 23 - /// Request model for sending a message 24 - @freezed 25 - class SendMessageRequest with _$SendMessageRequest { 26 - const SendMessageRequest._(); 27 - 28 - @JsonSerializable(explicitToJson: true) 29 - const factory SendMessageRequest({ 30 - required String message, 31 - required String receiverDid, 32 - }) = _SendMessageRequest; 33 - 34 - factory SendMessageRequest.fromJson(Map<String, dynamic> json) => _$SendMessageRequestFromJson(json); 35 - } 36 - 37 - /// Response model for sending a message 38 - @freezed 39 - class SendMessageResponse with _$SendMessageResponse { 40 - const SendMessageResponse._(); 41 - 42 - @JsonSerializable(explicitToJson: true) 43 - const factory SendMessageResponse({ 44 - required String messageId, 45 - required DateTime timestamp, 46 - }) = _SendMessageResponse; 47 - 48 - factory SendMessageResponse.fromJson(Map<String, dynamic> json) => _$SendMessageResponseFromJson(json); 49 - } 50 - 51 - /// Response model for getting messages 52 - @freezed 53 - class GetMessagesResponse with _$GetMessagesResponse { 54 - const GetMessagesResponse._(); 55 - 56 - @JsonSerializable(explicitToJson: true) 57 - const factory GetMessagesResponse({ 58 - required List<ChatMessage> messages, 59 - }) = _GetMessagesResponse; 60 - 61 - factory GetMessagesResponse.fromJson(Map<String, dynamic> json) => _$GetMessagesResponseFromJson(json); 62 - } 63 - 64 - /// Response model for getting chats list 65 - @freezed 66 - class GetChatsResponse with _$GetChatsResponse { 67 - const GetChatsResponse._(); 68 - 69 - @JsonSerializable(explicitToJson: true) 70 - const factory GetChatsResponse({ 71 - required List<String> chats, 72 - }) = _GetChatsResponse; 73 - 74 - factory GetChatsResponse.fromJson(Map<String, dynamic> json) => _$GetChatsResponseFromJson(json); 75 - } 76 - 77 - /// WebSocket message types 78 - enum WebSocketMessageType { 79 - @JsonValue('new_message') 80 - newMessage, 81 - @JsonValue('message_read') 82 - messageRead, 83 - @JsonValue('typing') 84 - typing, 85 - @JsonValue('error') 86 - error, 87 - } 88 - 89 - /// WebSocket message data for new message events 90 - @freezed 91 - class WebSocketMessageData with _$WebSocketMessageData { 92 - const WebSocketMessageData._(); 93 - 94 - @JsonSerializable(explicitToJson: true) 95 - const factory WebSocketMessageData({ 96 - required String id, 97 - required String message, 98 - @JsonKey(name: 'sender_did') required String senderDid, 99 - @JsonKey(name: 'receiver_did') required String receiverDid, 100 - required DateTime timestamp, 101 - }) = _WebSocketMessageData; 102 - 103 - factory WebSocketMessageData.fromJson(Map<String, dynamic> json) => _$WebSocketMessageDataFromJson(json); 104 - } 105 - 106 - /// WebSocket message wrapper 107 - @freezed 108 - class WebSocketMessage with _$WebSocketMessage { 109 - const WebSocketMessage._(); 110 - 111 - @JsonSerializable(explicitToJson: true) 112 - const factory WebSocketMessage({ 113 - required WebSocketMessageType type, 114 - WebSocketMessageData? data, 115 - String? error, 116 - }) = _WebSocketMessage; 117 - 118 - factory WebSocketMessage.fromJson(Map<String, dynamic> json) => _$WebSocketMessageFromJson(json); 119 - } 120 - 121 - /// Health check response 122 - @freezed 123 - class HealthCheckResponse with _$HealthCheckResponse { 124 - const HealthCheckResponse._(); 125 - 126 - @JsonSerializable(explicitToJson: true) 127 - const factory HealthCheckResponse({ 128 - required String status, 129 - required DateTime timestamp, 130 - required int connectedClients, 131 - }) = _HealthCheckResponse; 132 - 133 - factory HealthCheckResponse.fromJson(Map<String, dynamic> json) => _$HealthCheckResponseFromJson(json); 134 - } 135 - 136 - /// Chat conversation model for UI purposes 137 - @freezed 138 - class ChatConversation with _$ChatConversation { 139 - const ChatConversation._(); 140 - 141 - @JsonSerializable(explicitToJson: true) 142 - const factory ChatConversation({ 143 - required String otherUserDid, 144 - String? otherUserHandle, 145 - String? otherUserDisplayName, 146 - String? otherUserAvatar, 147 - ChatMessage? lastMessage, 148 - @Default(0) int unreadCount, 149 - DateTime? lastActivity, 150 - }) = _ChatConversation; 151 - 152 - factory ChatConversation.fromJson(Map<String, dynamic> json) => _$ChatConversationFromJson(json); 153 - }
-1
lib/src/core/network/chat/data/models/models.dart
··· 1 - export 'chat_models.dart';
-36
lib/src/core/network/chat/data/repositories/chat_repository.dart
··· 1 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 2 - import 'package:web_socket_channel/web_socket_channel.dart'; 3 - 4 - /// Interface for Spark Chat API endpoints 5 - abstract class ChatRepository { 6 - /// Send a message to another user 7 - /// 8 - /// [message] The message content to send 9 - /// [receiverDid] The DID of the user to send the message to 10 - Future<SendMessageResponse> sendMessage(String message, String receiverDid); 11 - 12 - /// Get messages for a conversation with another user 13 - /// 14 - /// [otherDid] The DID of the other user in the conversation 15 - /// [limit] Maximum number of messages to retrieve (default 50) 16 - Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}); 17 - 18 - /// Get list of all chats (conversations) for the current user 19 - Future<GetChatsResponse> getChats(); 20 - 21 - /// Connect to WebSocket for real-time messaging 22 - /// 23 - /// Returns a WebSocketChannel for receiving real-time messages 24 - WebSocketChannel connectWebSocket(); 25 - 26 - /// Check the health status of the chat service 27 - Future<HealthCheckResponse> healthCheck(); 28 - 29 - /// Set the JWT token for authentication 30 - /// 31 - /// [token] The JWT token from ATProtocol authentication 32 - void setAuthToken(String token); 33 - 34 - /// Close any open WebSocket connections 35 - void closeWebSocket(); 36 - }
-194
lib/src/core/network/chat/data/repositories/chat_repository_impl.dart
··· 1 - import 'dart:convert'; 2 - 3 - import 'package:get_it/get_it.dart'; 4 - import 'package:http/http.dart' as http; 5 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 6 - import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 7 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 8 - import 'package:web_socket_channel/web_socket_channel.dart'; 9 - 10 - /// Implementation of Spark Chat API endpoints 11 - class ChatRepositoryImpl implements ChatRepository { 12 - static const String _baseUrl = 'https://chat.sprk.so'; 13 - static const String _wsUrl = 'wss://chat.sprk.so/ws'; 14 - 15 - final _logger = GetIt.instance<LogService>().getLogger('ChatRepository'); 16 - final http.Client _httpClient; 17 - 18 - String? _jwtToken; 19 - WebSocketChannel? _webSocketChannel; 20 - 21 - ChatRepositoryImpl([http.Client? httpClient]) : _httpClient = httpClient ?? http.Client() { 22 - _logger.v('ChatRepository initialized'); 23 - } 24 - 25 - @override 26 - void setAuthToken(String token) { 27 - _jwtToken = token; 28 - _logger.d('JWT token set for chat authentication'); 29 - } 30 - 31 - Map<String, String> get _authHeaders { 32 - if (_jwtToken == null) { 33 - throw Exception('JWT token not set. Call setAuthToken() first.'); 34 - } 35 - return { 36 - 'Authorization': 'Bearer $_jwtToken', 37 - 'Content-Type': 'application/json', 38 - }; 39 - } 40 - 41 - @override 42 - Future<SendMessageResponse> sendMessage(String message, String receiverDid) async { 43 - _logger.d('Sending message to DID: $receiverDid'); 44 - 45 - try { 46 - final request = SendMessageRequest( 47 - message: message, 48 - receiverDid: receiverDid, 49 - ); 50 - 51 - final response = await _httpClient.post( 52 - Uri.parse('$_baseUrl/xrpc/so.sprk.chat.sendMessage'), 53 - headers: _authHeaders, 54 - body: jsonEncode(request.toJson()), 55 - ); 56 - 57 - if (response.statusCode == 200) { 58 - final responseData = jsonDecode(response.body) as Map<String, dynamic>; 59 - _logger.d('Message sent successfully'); 60 - return SendMessageResponse.fromJson(responseData); 61 - } else { 62 - _logger.e('Failed to send message: ${response.statusCode} ${response.body}'); 63 - throw Exception('Failed to send message: ${response.statusCode} ${response.body}'); 64 - } 65 - } catch (e) { 66 - _logger.e('Error sending message', error: e); 67 - rethrow; 68 - } 69 - } 70 - 71 - @override 72 - Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}) async { 73 - _logger.d('Getting messages for conversation with DID: $otherDid, limit: $limit'); 74 - 75 - try { 76 - final uri = Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getMessages').replace( 77 - queryParameters: { 78 - 'otherDid': otherDid, 79 - 'limit': limit.toString(), 80 - }, 81 - ); 82 - 83 - final response = await _httpClient.get( 84 - uri, 85 - headers: _authHeaders, 86 - ); 87 - 88 - if (response.statusCode == 200) { 89 - final responseData = jsonDecode(response.body) as Map<String, dynamic>; 90 - _logger.d('Messages retrieved successfully'); 91 - return GetMessagesResponse.fromJson(responseData); 92 - } else { 93 - _logger.e('Failed to get messages: ${response.statusCode} ${response.body}'); 94 - throw Exception('Failed to get messages: ${response.statusCode} ${response.body}'); 95 - } 96 - } catch (e) { 97 - _logger.e('Error getting messages', error: e); 98 - rethrow; 99 - } 100 - } 101 - 102 - @override 103 - Future<GetChatsResponse> getChats() async { 104 - _logger.d('Getting chats list'); 105 - 106 - try { 107 - final response = await _httpClient.get( 108 - Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getChats'), 109 - headers: _authHeaders, 110 - ); 111 - 112 - if (response.statusCode == 200) { 113 - final responseData = jsonDecode(response.body) as Map<String, dynamic>; 114 - _logger.d('Chats list retrieved successfully'); 115 - return GetChatsResponse.fromJson(responseData); 116 - } else { 117 - _logger.e('Failed to get chats: ${response.statusCode} ${response.body}'); 118 - throw Exception('Failed to get chats: ${response.statusCode} ${response.body}'); 119 - } 120 - } catch (e) { 121 - _logger.e('Error getting chats', error: e); 122 - rethrow; 123 - } 124 - } 125 - 126 - @override 127 - WebSocketChannel connectWebSocket() { 128 - _logger.d('Connecting to WebSocket'); 129 - 130 - try { 131 - if (_jwtToken == null) { 132 - throw Exception('JWT token not set. Call setAuthToken() first.'); 133 - } 134 - 135 - // Close existing connection if any 136 - closeWebSocket(); 137 - 138 - _webSocketChannel = WebSocketChannel.connect( 139 - Uri.parse(_wsUrl), 140 - protocols: ['Bearer $_jwtToken'], // Some WebSocket implementations expect protocols 141 - ); 142 - 143 - _logger.d('WebSocket connected successfully'); 144 - return _webSocketChannel!; 145 - } catch (e) { 146 - _logger.e('Error connecting to WebSocket', error: e); 147 - rethrow; 148 - } 149 - } 150 - 151 - @override 152 - void closeWebSocket() { 153 - _logger.d('Closing WebSocket connection'); 154 - 155 - try { 156 - _webSocketChannel?.sink.close(); 157 - _webSocketChannel = null; 158 - _logger.d('WebSocket connection closed'); 159 - } catch (e) { 160 - _logger.w('Error closing WebSocket', error: e); 161 - } 162 - } 163 - 164 - @override 165 - Future<HealthCheckResponse> healthCheck() async { 166 - _logger.d('Performing health check'); 167 - 168 - try { 169 - final response = await _httpClient.get( 170 - Uri.parse('$_baseUrl/health'), 171 - // Health check doesn't require authentication 172 - ); 173 - 174 - if (response.statusCode == 200) { 175 - final responseData = jsonDecode(response.body) as Map<String, dynamic>; 176 - _logger.d('Health check successful'); 177 - return HealthCheckResponse.fromJson(responseData); 178 - } else { 179 - _logger.e('Health check failed: ${response.statusCode} ${response.body}'); 180 - throw Exception('Health check failed: ${response.statusCode} ${response.body}'); 181 - } 182 - } catch (e) { 183 - _logger.e('Error during health check', error: e); 184 - rethrow; 185 - } 186 - } 187 - 188 - /// Dispose of resources 189 - void dispose() { 190 - _logger.d('Disposing ChatRepository'); 191 - closeWebSocket(); 192 - _httpClient.close(); 193 - } 194 - }
-2
lib/src/core/network/chat/data/repositories/repositories.dart
··· 1 - export 'chat_repository.dart'; 2 - export 'chat_repository_impl.dart';
+33
lib/src/core/network/messages/data/models/message_models.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'message_models.freezed.dart'; 4 + part 'message_models.g.dart'; 5 + 6 + @freezed 7 + class Embed with _$Embed { 8 + const Embed._(); 9 + 10 + @JsonSerializable(explicitToJson: true) 11 + const factory Embed({String? url, String? type, String? preview}) = _Embed; 12 + 13 + factory Embed.fromJson(Map<String, dynamic> json) => _$EmbedFromJson(json); 14 + 15 + bool get isEmpty => url == null && type == null && preview == null; 16 + bool get isNotEmpty => !isEmpty; 17 + } 18 + 19 + @freezed 20 + class Message with _$Message { 21 + const Message._(); 22 + @JsonSerializable(explicitToJson: true) 23 + const factory Message({ 24 + required int id, 25 + @JsonKey(name: 'sender_did') required String senderDid, 26 + @JsonKey(name: 'receiver_did') required String receiverDid, 27 + required String message, 28 + @JsonKey(name: 'timestampz') required DateTime timestamp, 29 + List<Embed>? embed, 30 + }) = _Message; 31 + 32 + factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(json); 33 + }
+26
lib/src/core/network/messages/data/repository/messages_repository.dart
··· 1 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart' hide Embed; 2 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 3 + 4 + /// Interface for Messages-related API endpoints 5 + abstract class MessagesRepository { 6 + /// Get messages for a conversation 7 + /// 8 + /// [did] The DID of the other user in the conversation 9 + /// [cursor] Optional cursor for pagination (eventually) 10 + /// [limit] Optional limit for number of messages to fetch (eventually) 11 + Future<({List<Message> messages, String? cursor})> getConversation(String did, {String? cursor, int? limit}); 12 + 13 + /// Get messages for all conversations 14 + /// 15 + /// [cursor] Optional cursor for pagination (eventually) 16 + /// [limit] Optional limit for number of messages to fetch (eventually) 17 + Future<({List<(ProfileViewDetailed, Message)> messages, String? cursor})> getAllConversations({String? cursor, int? limit}); 18 + 19 + /// Send a message to a user 20 + /// [did] The DID of the user to send the message to 21 + /// [message] The message content 22 + /// [embed] Optional embed data to include with the message 23 + /// 24 + Future<Message> sendMessage(String did, String message, {List<Embed>? embed}); 25 + // tem que retornar um future message 26 + }
+145
lib/src/core/network/messages/data/repository/messages_repository_impl.dart
··· 1 + import 'dart:convert'; 2 + import 'package:get_it/get_it.dart'; 3 + import 'package:http/http.dart' as http; 4 + import 'package:sparksocial/src/core/config/app_config.dart'; 5 + import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Embed; 6 + import 'package:sparksocial/src/core/utils/utils.dart'; 7 + import 'package:sparksocial/src/features/auth/auth.dart'; 8 + import 'messages_repository.dart'; 9 + import '../models/message_models.dart'; 10 + 11 + class MessagesRepositoryImpl implements MessagesRepository { 12 + final AuthRepository _authRepository; 13 + late final SparkLogger _logger; 14 + 15 + MessagesRepositoryImpl(this._authRepository) { 16 + _logger = GetIt.I<LogService>().getLogger('MessagesRepository'); 17 + } 18 + 19 + String? get accessToken => _authRepository.dmAccessToken; 20 + 21 + Map<String, String> get _headers => { 22 + 'Content-Type': 'application/json', 23 + if (accessToken?.isNotEmpty == true) 'Authorization': 'Bearer $accessToken', 24 + }; 25 + 26 + Future<void> _refreshIfExpired() async { 27 + if (_authRepository.isAuthenticated && _authRepository.dmAccessToken == null) { 28 + _logger.w('DM access token is null, refreshing...'); 29 + final refreshed = await _authRepository.refreshDMToken(); 30 + if (!refreshed) { 31 + _logger.e('Failed to refresh DM token'); 32 + await _authRepository.loginMessageService(); 33 + } 34 + } 35 + } 36 + 37 + @override 38 + Future<({List<Message> messages, String? cursor})> getConversation(String did, {String? cursor, int? limit = 30}) async { 39 + try { 40 + final queryParameters = <String, String>{ 41 + 'with': did, 42 + if (cursor != null) 'cursor': cursor, 43 + if (limit != null) 'limit': limit.toString(), 44 + }; 45 + await _refreshIfExpired(); 46 + 47 + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/conversation').replace(queryParameters: queryParameters); 48 + 49 + final response = await http.get(uri, headers: _headers); 50 + 51 + if (response.statusCode == 200) { 52 + final data = jsonDecode(response.body) as Map<String, dynamic>; 53 + final messagesList = (data['messages'] as List).map((json) => Message.fromJson(json)).toList(); 54 + 55 + return (messages: messagesList, cursor: data['cursor'] as String?); 56 + } else if (response.statusCode == 401) { 57 + await _refreshIfExpired(); 58 + 59 + throw Exception('Não autorizado, vê aí se o token tá valido memo'); 60 + } else { 61 + throw Exception('Erro na requisição: ${response.statusCode} - ${response.body}'); 62 + } 63 + } catch (e) { 64 + if (e.toString().contains('valido')) { 65 + await _refreshIfExpired(); 66 + return getConversation(did, cursor: cursor, limit: limit); // FUCK IT WE BALL 67 + } 68 + rethrow; 69 + } 70 + } 71 + 72 + @override 73 + Future<({List<(ProfileViewDetailed, Message)> messages, String? cursor})> getAllConversations({ 74 + String? cursor, 75 + int? limit, 76 + }) async { 77 + try { 78 + await _refreshIfExpired(); 79 + final actorRepository = GetIt.I<SprkRepository>().actor; 80 + final queryParameters = <String, String>{ 81 + if (cursor != null) 'cursor': cursor, 82 + if (limit != null) 'limit': limit.toString(), 83 + }; 84 + 85 + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/conversations').replace(queryParameters: queryParameters); 86 + 87 + final response = await http.get(uri, headers: _headers); 88 + 89 + if (response.statusCode == 200) { 90 + final userDid = _authRepository.session?.did; 91 + final data = jsonDecode(response.body) as Map<String, dynamic>; 92 + final messages = (data['conversations'] as List).map((json) => Message.fromJson(json)).toList(); 93 + final profileDids = (data['conversations'] as List) 94 + .map((json) => json['sender_did'] != userDid ? json['sender_did'] as String : json['receiver_did'] as String) 95 + .where((did) => did.startsWith('did:plc:')) 96 + .toList(); 97 + final profiles = await actorRepository.getProfiles(profileDids); 98 + final conversationsList = <(ProfileViewDetailed, Message)>[]; 99 + profiles.asMap().forEach((index, profile) { 100 + conversationsList.add((profile, messages[index])); 101 + }); 102 + 103 + return (messages: conversationsList, cursor: data['cursor'] as String?); 104 + } else if (response.statusCode == 401) { 105 + throw Exception('Não autorizado, vê aí se o token tá valido memo'); 106 + } else { 107 + throw Exception('Erro ao buscar conversas: ${response.statusCode} - ${response.body}'); 108 + } 109 + } catch (e) { 110 + throw Exception('Erro ao buscar conversas: $e'); 111 + } 112 + } 113 + 114 + @override 115 + Future<Message> sendMessage(String did, String message, {List<Embed>? embed}) async { 116 + try { 117 + await _refreshIfExpired(); 118 + final requestBody = <String, dynamic>{ 119 + 'receiver_did': did, 120 + 'message': message, 121 + if (embed != null && embed.isNotEmpty) 'embed': embed, 122 + }; 123 + _logger.d('Sending message to $did: $requestBody'); 124 + 125 + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/send'); 126 + 127 + final response = await http.post(uri, headers: _headers, body: jsonEncode(requestBody), encoding: utf8); 128 + _logger.d('Response status code: ${response.statusCode}'); 129 + _logger.d('Response body: ${response.body}'); 130 + 131 + if (response.statusCode == 200 || response.statusCode == 201) { 132 + final data = jsonDecode(response.body) as Map<String, dynamic>; 133 + return Message.fromJson(data['message']); 134 + } else if (response.statusCode == 401) { 135 + await _refreshIfExpired(); 136 + 137 + throw Exception('Não autorizado, vê aí se o token tá valido memo'); 138 + } else { 139 + throw Exception('Erro ao enviar mensagem: ${response.statusCode} - ${response.body}'); 140 + } 141 + } catch (e) { 142 + throw Exception('Erro ao enviar mensagem: $e'); 143 + } 144 + } 145 + }
+2
lib/src/core/storage/preferences/storage_constants.dart
··· 1 1 /// Storage keys used throughout the application 2 2 class StorageKeys { 3 3 static const String userSession = 'user_session'; 4 + static const String dmAccessToken = 'dm_access_token'; 5 + static const String dmRefreshToken = 'dm_refresh_token'; 4 6 5 7 static const String themeKey = 'app_theme_mode'; 6 8
+90
lib/src/core/widgets/image_content.dart
··· 1 + import 'package:auto_route/auto_route.dart'; 2 + import 'package:cached_network_image/cached_network_image.dart'; 3 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 6 + import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 7 + 8 + class ImageContent extends StatelessWidget { 9 + const ImageContent({super.key, required this.imageUrls, required this.borderRadius, this.thumbnailSize = 100}); 10 + final List<String> imageUrls; 11 + final BorderRadius borderRadius; 12 + final double thumbnailSize; 13 + 14 + void _showImageCarousel(BuildContext context) { 15 + showDialog( 16 + context: context, 17 + barrierColor: Colors.black.withValues(alpha: 217), 18 + builder: (BuildContext context) { 19 + return Dialog( 20 + backgroundColor: Colors.transparent, 21 + insetPadding: EdgeInsets.zero, 22 + child: Stack( 23 + children: [ 24 + ImageCarousel(imageUrls: imageUrls), 25 + Positioned( 26 + top: MediaQuery.of(context).padding.top + 10, 27 + right: 10, 28 + child: IconButton( 29 + icon: const Icon(FluentIcons.dismiss_24_filled, color: Colors.white, size: 30), 30 + onPressed: () => context.router.maybePop(), 31 + style: IconButton.styleFrom(backgroundColor: Colors.black.withValues(alpha: 77)), 32 + ), 33 + ), 34 + ], 35 + ), 36 + ); 37 + }, 38 + ); 39 + } 40 + 41 + @override 42 + Widget build(BuildContext context) { 43 + return GestureDetector( 44 + onTap: () => _showImageCarousel(context), 45 + child: Container( 46 + width: thumbnailSize, 47 + height: thumbnailSize, 48 + clipBehavior: Clip.antiAlias, 49 + decoration: BoxDecoration( 50 + borderRadius: borderRadius, 51 + border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), 52 + color: Theme.of(context).colorScheme.surface, 53 + ), 54 + child: Stack( 55 + fit: StackFit.expand, 56 + children: [ 57 + CachedNetworkImage( 58 + imageUrl: imageUrls.first, 59 + fit: BoxFit.cover, 60 + placeholder: (context, url) => Container( 61 + color: Colors.grey[850]?.withValues(alpha: 128), 62 + child: const Center( 63 + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)), 64 + ), 65 + ), 66 + errorWidget: (context, url, error) => Container( 67 + color: AppColors.darkPurple.withValues(alpha: 26), 68 + child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70)), 69 + ), 70 + ), 71 + 72 + if (imageUrls.length > 1) 73 + Positioned( 74 + top: 4, 75 + right: 4, 76 + child: Container( 77 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 78 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 179), borderRadius: BorderRadius.circular(10)), 79 + child: Text( 80 + '+${imageUrls.length - 1}', 81 + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), 82 + ), 83 + ), 84 + ), 85 + ], 86 + ), 87 + ), 88 + ); 89 + } 90 + }
+76
lib/src/core/widgets/video_content.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 + import 'package:video_player/video_player.dart'; 5 + 6 + class VideoContent extends StatefulWidget { 7 + const VideoContent({super.key, required this.borderRadius, required this.videoUrl}); 8 + final BorderRadius borderRadius; 9 + final String videoUrl; 10 + 11 + @override 12 + State<VideoContent> createState() => _VideoContentState(); 13 + } 14 + 15 + class _VideoContentState extends State<VideoContent> { 16 + 17 + VideoPlayerController? videoController; 18 + 19 + @override 20 + void initState() { 21 + super.initState(); 22 + _initializeVideoPlayer(); 23 + } 24 + 25 + void _initializeVideoPlayer() { 26 + videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) 27 + ..initialize().then((_) { 28 + setState(() {}); 29 + }); 30 + } 31 + 32 + @override 33 + void dispose() { 34 + videoController?.dispose(); 35 + super.dispose(); 36 + } 37 + 38 + @override 39 + Widget build(BuildContext context) { 40 + return GestureDetector( 41 + onTap: () { 42 + if (videoController != null && videoController!.value.isInitialized) { 43 + videoController!.value.isPlaying ? videoController!.pause() : videoController!.play(); 44 + } 45 + }, 46 + 47 + child: Container( 48 + width: double.infinity, 49 + height: 200, 50 + decoration: BoxDecoration( 51 + borderRadius: widget.borderRadius, 52 + border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), 53 + color: Colors.black, 54 + ), 55 + clipBehavior: Clip.antiAlias, 56 + child: Stack( 57 + alignment: Alignment.center, 58 + children: [ 59 + if (videoController != null && videoController!.value.isInitialized) 60 + AspectRatio(aspectRatio: videoController!.value.aspectRatio, child: VideoPlayer(videoController!)), 61 + 62 + if (!videoController!.value.isInitialized) const CircularProgressIndicator(color: AppColors.white), 63 + 64 + if (videoController!.value.isInitialized && !videoController!.value.isPlaying) 65 + Container( 66 + width: 60, 67 + height: 60, 68 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 128), shape: BoxShape.circle), 69 + child: const Icon(FluentIcons.play_24_filled, size: 24, color: Colors.white), 70 + ), 71 + ], 72 + ), 73 + ), 74 + ); 75 + } 76 + }
+1
lib/src/features/auth/providers/auth_state.dart
··· 10 10 const factory AuthState({ 11 11 @Default(false) bool isAuthenticated, 12 12 Session? session, 13 + String? dmAccessToken, 13 14 ATProto? atproto, 14 15 @Default(false) bool isLoading, 15 16 String? error,
-18
lib/src/features/comments/providers/comment_provider.dart
··· 8 8 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 9 9 import 'package:sparksocial/src/features/comments/providers/comment_state.dart'; 10 10 import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; 11 - import 'package:video_player/video_player.dart'; 12 11 13 12 part 'comment_provider.g.dart'; 14 13 ··· 17 16 @override 18 17 CommentState build(Thread thread) { 19 18 _feedRepository = GetIt.instance<SprkRepository>().feed; 20 - ref.onDispose(() { 21 - state.videoController?.dispose(); 22 - }); 23 19 switch (thread) { 24 20 case ThreadViewPost(): 25 21 return CommentState(thread: thread); ··· 88 84 } 89 85 } 90 86 91 - void initializeVideoPlayer() { 92 - final videoUrl = state.thread.post.videoUrl; 93 - if (videoUrl != '') { 94 - final videoController = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); 95 - state = state.copyWith(videoController: videoController, isVideoInitialized: true); 96 - } 97 - } 98 - 99 87 void preloadFirstImage(BuildContext context) { 100 88 final imageUrls = state.thread.post.imageUrls; 101 89 if (imageUrls.isNotEmpty) { 102 90 precacheImage(CachedNetworkImageProvider(imageUrls.first), context); 103 - } 104 - } 105 - 106 - void toggleVideoPlayback() { 107 - if (state.videoController != null && state.isVideoInitialized) { 108 - state.videoController!.value.isPlaying ? state.videoController!.pause() : state.videoController!.play(); 109 91 } 110 92 } 111 93 }
+1 -2
lib/src/features/comments/providers/comment_state.dart
··· 1 1 import 'package:freezed_annotation/freezed_annotation.dart'; 2 2 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 3 - import 'package:video_player/video_player.dart'; 4 3 5 4 part 'comment_state.freezed.dart'; 6 5 ··· 12 11 required ThreadViewPost thread, 13 12 @Default(false) bool isVideoInitialized, 14 13 @Default(false) bool isFirstImagePrecached, 15 - VideoPlayerController? videoController, 14 + String? videoUrl, 16 15 }) = _CommentState; 17 16 18 17 // Derive isLiked from the viewer state
+1 -1
lib/src/features/comments/ui/widgets/comment_input.dart
··· 82 82 error: (error, stackTrace) => null, 83 83 loading: () => null, 84 84 ), 85 - username: session?.did ?? '', 85 + username: session?.handle ?? '', 86 86 size: 28, 87 87 borderWidth: 0, 88 88 ),
+7 -138
lib/src/features/comments/ui/widgets/comment_item.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:auto_route/auto_route.dart'; 3 - import 'package:cached_network_image/cached_network_image.dart'; 4 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_riverpod/flutter_riverpod.dart'; ··· 9 8 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 10 9 import 'package:sparksocial/src/core/routing/app_router.dart'; 11 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 11 + import 'package:sparksocial/src/core/widgets/image_content.dart'; 12 12 import 'package:sparksocial/src/core/widgets/menu_action_button.dart'; 13 13 import 'package:sparksocial/src/core/widgets/report_dialog.dart'; 14 14 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 15 + import 'package:sparksocial/src/core/widgets/video_content.dart'; 15 16 import 'package:sparksocial/src/features/comments/providers/comment_provider.dart'; 16 17 import 'package:sparksocial/src/features/comments/providers/comment_state.dart'; 17 18 import 'package:sparksocial/src/features/comments/providers/comments_page_provider.dart'; 18 - import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 19 - import 'package:video_player/video_player.dart'; 20 19 21 20 class CommentItem extends ConsumerStatefulWidget { 22 21 final ThreadViewPost thread; ··· 37 36 38 37 void _navigateToProfile() { 39 38 context.router.push(ProfileRoute(did: commentState.thread.post.author.did)); 40 - } 41 - 42 - void _showImageCarousel() { 43 - if (commentState.thread.post.embed == null) return; 44 - 45 - showDialog( 46 - context: context, 47 - barrierColor: Colors.black.withValues(alpha: 217), 48 - builder: (BuildContext context) { 49 - return Dialog( 50 - backgroundColor: Colors.transparent, 51 - insetPadding: EdgeInsets.zero, 52 - child: Stack( 53 - children: [ 54 - ImageCarousel( 55 - imageUrls: commentState.thread.post.imageUrls, 56 - alts: (commentState.thread.post.embed as EmbedViewImage).images.map((e) => e.alt ?? '').toList(), 57 - ), 58 - Positioned( 59 - top: MediaQuery.of(context).padding.top + 10, 60 - right: 10, 61 - child: IconButton( 62 - icon: const Icon(FluentIcons.dismiss_24_filled, color: Colors.white, size: 30), 63 - onPressed: () => context.router.maybePop(), 64 - style: IconButton.styleFrom(backgroundColor: Colors.black.withValues(alpha: 77)), 65 - ), 66 - ), 67 - ], 68 - ), 69 - ); 70 - }, 71 - ); 72 39 } 73 40 74 41 void _handleReportComment() { ··· 136 103 @override 137 104 Widget build(BuildContext context) { 138 105 commentState = ref.watch(commentNotifierProvider(widget.thread)); 139 - final imageCount = commentState.thread.post.imageUrls.length; 140 106 const double thumbnailSize = 120.0; 141 107 142 108 final borderRadius = BorderRadius.circular(8); ··· 206 172 if (commentState.thread.post.embed != null) ...[ 207 173 const SizedBox(height: 8), 208 174 if (hasImages) 209 - GestureDetector( 210 - onTap: _showImageCarousel, 211 - child: Container( 212 - width: thumbnailSize, 213 - height: thumbnailSize, 214 - clipBehavior: Clip.antiAlias, 215 - decoration: BoxDecoration( 216 - borderRadius: borderRadius, 217 - border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), 218 - color: Theme.of(context).colorScheme.surface, 219 - ), 220 - child: Stack( 221 - fit: StackFit.expand, 222 - children: [ 223 - CachedNetworkImage( 224 - imageUrl: commentState.thread.post.imageUrls.first, 225 - fit: BoxFit.cover, 226 - placeholder: (context, url) => Container( 227 - color: Colors.grey[850]?.withValues(alpha: 128), 228 - child: const Center( 229 - child: SizedBox( 230 - width: 20, 231 - height: 20, 232 - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54), 233 - ), 234 - ), 235 - ), 236 - errorWidget: (context, url, error) => Container( 237 - color: AppColors.darkPurple.withValues(alpha: 26), 238 - child: const Center( 239 - child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70), 240 - ), 241 - ), 242 - ), 243 - 244 - if (imageCount > 1) 245 - Positioned( 246 - top: 4, 247 - right: 4, 248 - child: Container( 249 - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 250 - decoration: BoxDecoration( 251 - color: Colors.black.withValues(alpha: 179), 252 - borderRadius: BorderRadius.circular(10), 253 - ), 254 - child: Text( 255 - '+${imageCount - 1}', 256 - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), 257 - ), 258 - ), 259 - ), 260 - ], 261 - ), 262 - ), 175 + ImageContent( 176 + imageUrls: commentState.thread.post.imageUrls, 177 + borderRadius: borderRadius, 178 + thumbnailSize: thumbnailSize, 263 179 ) 264 180 else if (hasVideo) 265 - _VideoContent(ref: ref, commentState: commentState, context: context, borderRadius: borderRadius), 181 + VideoContent(borderRadius: borderRadius, videoUrl: commentState.thread.post.videoUrl), 266 182 ], 267 183 268 184 const SizedBox(height: 8), ··· 374 290 child: Text('Reply', style: TextStyle(fontSize: 12, color: secondaryTextColor)), 375 291 ), 376 292 ], 377 - ); 378 - } 379 - } 380 - 381 - class _VideoContent extends StatelessWidget { 382 - const _VideoContent({required this.ref, required this.commentState, required this.context, required this.borderRadius}); 383 - 384 - final WidgetRef ref; 385 - final CommentState commentState; 386 - final BuildContext context; 387 - final BorderRadius borderRadius; 388 - 389 - @override 390 - Widget build(BuildContext context) { 391 - final notifier = ref.read(CommentNotifierProvider(commentState.thread).notifier); 392 - return GestureDetector( 393 - onTap: notifier.toggleVideoPlayback, 394 - child: Container( 395 - width: double.infinity, 396 - height: 200, 397 - decoration: BoxDecoration( 398 - borderRadius: borderRadius, 399 - border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), 400 - color: Colors.black, 401 - ), 402 - clipBehavior: Clip.antiAlias, 403 - child: Stack( 404 - alignment: Alignment.center, 405 - children: [ 406 - if (commentState.videoController != null && commentState.isVideoInitialized) 407 - AspectRatio( 408 - aspectRatio: commentState.videoController!.value.aspectRatio, 409 - child: VideoPlayer(commentState.videoController!), 410 - ), 411 - 412 - if (!commentState.isVideoInitialized) const CircularProgressIndicator(color: AppColors.white), 413 - 414 - if (commentState.isVideoInitialized && !commentState.videoController!.value.isPlaying) 415 - Container( 416 - width: 60, 417 - height: 60, 418 - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 128), shape: BoxShape.circle), 419 - child: const Icon(FluentIcons.play_24_filled, size: 24, color: Colors.white), 420 - ), 421 - ], 422 - ), 423 - ), 424 293 ); 425 294 } 426 295 }
+6 -1
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 53 53 // If cache fails, fetch from network 54 54 final feedRepository = GetIt.instance<SprkRepository>().feed; 55 55 final uri = AtUri.parse(widget.postUri); 56 - final isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 56 + bool isBlueskyPost = false; 57 + // try { 58 + isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); 59 + // } catch (e) { 60 + // what 61 + // } 57 62 final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); 58 63 59 64 if (networkPost.isEmpty) {
-2
lib/src/features/messages/providers/chat_providers_new.dart
··· 1 - export 'chat_service_provider.dart'; 2 - export 'chat_websocket_provider.dart';
-170
lib/src/features/messages/providers/chat_service_provider.dart
··· 1 - import 'package:get_it/get_it.dart'; 2 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 4 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 5 - import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 6 - import 'package:sparksocial/src/core/utils/logging/logging.dart'; 7 - import 'package:web_socket_channel/web_socket_channel.dart'; 8 - 9 - part 'chat_service_provider.g.dart'; 10 - 11 - @riverpod 12 - class ChatService extends _$ChatService { 13 - late final ChatRepository _chatRepository; 14 - late final AuthRepository _authRepository; 15 - late final SparkLogger _logger; 16 - 17 - @override 18 - ChatServiceState build() { 19 - _chatRepository = GetIt.instance<ChatRepository>(); 20 - _authRepository = GetIt.instance<AuthRepository>(); 21 - _logger = GetIt.instance<LogService>().getLogger('ChatService'); 22 - 23 - _logger.v('ChatService provider initialized'); 24 - _initializeAuth(); 25 - 26 - return const ChatServiceState(); 27 - } 28 - 29 - /// Initialize authentication for chat service 30 - void _initializeAuth() { 31 - if (_authRepository.isAuthenticated && _authRepository.session?.accessJwt != null) { 32 - _chatRepository.setAuthToken(_authRepository.session!.accessJwt); 33 - _logger.d('Chat service authenticated with existing session'); 34 - } 35 - } 36 - 37 - /// Ensure the service is authenticated before making requests 38 - void _ensureAuthenticated() { 39 - if (!_authRepository.isAuthenticated) { 40 - throw Exception('User not authenticated. Please log in first.'); 41 - } 42 - 43 - final accessJwt = _authRepository.session?.accessJwt; 44 - if (accessJwt == null) { 45 - throw Exception('No access token available. Please log in again.'); 46 - } 47 - 48 - _chatRepository.setAuthToken(accessJwt); 49 - } 50 - 51 - /// Send a message to another user 52 - Future<SendMessageResponse> sendMessage(String message, String receiverDid) async { 53 - _logger.d('Sending message via ChatService to: $receiverDid'); 54 - _ensureAuthenticated(); 55 - 56 - state = state.copyWith(isSending: true, error: null); 57 - 58 - try { 59 - final response = await _chatRepository.sendMessage(message, receiverDid); 60 - state = state.copyWith(isSending: false); 61 - return response; 62 - } catch (e) { 63 - state = state.copyWith(isSending: false, error: e.toString()); 64 - rethrow; 65 - } 66 - } 67 - 68 - /// Get messages for a conversation with another user 69 - Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}) async { 70 - _logger.d('Getting messages via ChatService for: $otherDid'); 71 - _ensureAuthenticated(); 72 - 73 - state = state.copyWith(isLoading: true, error: null); 74 - 75 - try { 76 - final response = await _chatRepository.getMessages(otherDid, limit: limit); 77 - state = state.copyWith(isLoading: false); 78 - return response; 79 - } catch (e) { 80 - state = state.copyWith(isLoading: false, error: e.toString()); 81 - rethrow; 82 - } 83 - } 84 - 85 - /// Get list of all chats (conversations) for the current user 86 - Future<GetChatsResponse> getChats() async { 87 - _logger.d('Getting chats list via ChatService'); 88 - _ensureAuthenticated(); 89 - 90 - state = state.copyWith(isLoading: true, error: null); 91 - 92 - try { 93 - final response = await _chatRepository.getChats(); 94 - state = state.copyWith(isLoading: false); 95 - return response; 96 - } catch (e) { 97 - state = state.copyWith(isLoading: false, error: e.toString()); 98 - rethrow; 99 - } 100 - } 101 - 102 - /// Connect to WebSocket for real-time messaging 103 - WebSocketChannel connectWebSocket() { 104 - _logger.d('Connecting to WebSocket via ChatService'); 105 - _ensureAuthenticated(); 106 - 107 - try { 108 - state = state.copyWith(isConnecting: true, error: null); 109 - final channel = _chatRepository.connectWebSocket(); 110 - state = state.copyWith(isConnecting: false, isConnected: true); 111 - return channel; 112 - } catch (e) { 113 - state = state.copyWith(isConnecting: false, error: e.toString()); 114 - rethrow; 115 - } 116 - } 117 - 118 - /// Check the health status of the chat service 119 - Future<HealthCheckResponse> healthCheck() async { 120 - _logger.d('Performing health check via ChatService'); 121 - // Health check doesn't require authentication 122 - return _chatRepository.healthCheck(); 123 - } 124 - 125 - /// Close any open WebSocket connections 126 - void closeWebSocket() { 127 - _logger.d('Closing WebSocket via ChatService'); 128 - _chatRepository.closeWebSocket(); 129 - state = state.copyWith(isConnected: false); 130 - } 131 - 132 - /// Get the current user's DID 133 - String? get currentUserDid => _authRepository.session?.did; 134 - 135 - /// Check if the service is authenticated 136 - bool get isAuthenticated => _authRepository.isAuthenticated; 137 - } 138 - 139 - /// State class for the ChatService 140 - class ChatServiceState { 141 - final bool isLoading; 142 - final bool isSending; 143 - final bool isConnecting; 144 - final bool isConnected; 145 - final String? error; 146 - 147 - const ChatServiceState({ 148 - this.isLoading = false, 149 - this.isSending = false, 150 - this.isConnecting = false, 151 - this.isConnected = false, 152 - this.error, 153 - }); 154 - 155 - ChatServiceState copyWith({ 156 - bool? isLoading, 157 - bool? isSending, 158 - bool? isConnecting, 159 - bool? isConnected, 160 - String? error, 161 - }) { 162 - return ChatServiceState( 163 - isLoading: isLoading ?? this.isLoading, 164 - isSending: isSending ?? this.isSending, 165 - isConnecting: isConnecting ?? this.isConnecting, 166 - isConnected: isConnected ?? this.isConnected, 167 - error: error, 168 - ); 169 - } 170 - }
-175
lib/src/features/messages/providers/chat_websocket_provider.dart
··· 1 - import 'dart:async'; 2 - import 'dart:convert'; 3 - 4 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 6 - import 'package:sparksocial/src/features/messages/providers/chat_service_provider.dart'; 7 - import 'package:web_socket_channel/web_socket_channel.dart'; 8 - 9 - part 'chat_websocket_provider.g.dart'; 10 - 11 - @riverpod 12 - class ChatWebSocket extends _$ChatWebSocket { 13 - WebSocketChannel? _channel; 14 - StreamSubscription? _subscription; 15 - 16 - @override 17 - ChatWebSocketState build() { 18 - // Clean up when provider is disposed 19 - ref.onDispose(() { 20 - _subscription?.cancel(); 21 - _channel?.sink.close(); 22 - }); 23 - 24 - return const ChatWebSocketState(); 25 - } 26 - 27 - /// Connect to WebSocket for real-time messaging 28 - void connect() async { 29 - if (state.isConnected || state.isConnecting) return; 30 - 31 - state = state.copyWith(isConnecting: true, error: null); 32 - 33 - try { 34 - final chatService = ref.read(chatServiceProvider.notifier); 35 - _channel = chatService.connectWebSocket(); 36 - 37 - // Listen to incoming messages 38 - _subscription = _channel!.stream.listen( 39 - _onMessage, 40 - onError: _onError, 41 - onDone: _onDisconnected, 42 - ); 43 - 44 - state = state.copyWith(isConnecting: false, isConnected: true); 45 - } catch (e) { 46 - state = state.copyWith( 47 - isConnecting: false, 48 - isConnected: false, 49 - error: 'Failed to connect: ${e.toString()}', 50 - ); 51 - } 52 - } 53 - 54 - /// Disconnect from WebSocket 55 - void disconnect() { 56 - _subscription?.cancel(); 57 - _subscription = null; 58 - 59 - _channel?.sink.close(); 60 - _channel = null; 61 - 62 - final chatService = ref.read(chatServiceProvider.notifier); 63 - chatService.closeWebSocket(); 64 - 65 - state = state.copyWith(isConnected: false, isConnecting: false); 66 - } 67 - 68 - /// Handle incoming WebSocket messages 69 - void _onMessage(dynamic data) { 70 - try { 71 - final jsonData = jsonDecode(data) as Map<String, dynamic>; 72 - final wsMessage = WebSocketMessage.fromJson(jsonData); 73 - 74 - state = state.copyWith(lastMessage: wsMessage); 75 - 76 - // You can add specific message type handling here 77 - switch (wsMessage.type) { 78 - case WebSocketMessageType.newMessage: 79 - // Handle new message 80 - _handleNewMessage(wsMessage.data); 81 - break; 82 - case WebSocketMessageType.messageRead: 83 - // Handle message read status 84 - break; 85 - case WebSocketMessageType.typing: 86 - // Handle typing indicators 87 - break; 88 - case WebSocketMessageType.error: 89 - state = state.copyWith(error: wsMessage.error); 90 - break; 91 - } 92 - } catch (e) { 93 - state = state.copyWith(error: 'Failed to parse message: ${e.toString()}'); 94 - } 95 - } 96 - 97 - /// Handle new message from WebSocket 98 - void _handleNewMessage(WebSocketMessageData? messageData) { 99 - if (messageData == null) return; 100 - 101 - // Convert WebSocket message to ChatMessage 102 - final chatMessage = ChatMessage( 103 - id: messageData.id, 104 - message: messageData.message, 105 - senderDid: messageData.senderDid, 106 - receiverDid: messageData.receiverDid, 107 - timestamp: messageData.timestamp, 108 - ); 109 - 110 - // Add to recent messages list 111 - final updatedMessages = [...state.recentMessages, chatMessage]; 112 - // Keep only last 100 messages to avoid memory issues 113 - if (updatedMessages.length > 100) { 114 - updatedMessages.removeAt(0); 115 - } 116 - 117 - state = state.copyWith(recentMessages: updatedMessages); 118 - } 119 - 120 - /// Handle WebSocket errors 121 - // ignore: strict_top_level_inference 122 - void _onError(error) { 123 - state = state.copyWith( 124 - isConnected: false, 125 - error: 'WebSocket error: ${error.toString()}', 126 - ); 127 - } 128 - 129 - /// Handle WebSocket disconnection 130 - void _onDisconnected() { 131 - state = state.copyWith(isConnected: false); 132 - } 133 - 134 - /// Send a message through WebSocket (if needed for real-time features) 135 - void sendWebSocketMessage(Map<String, dynamic> message) { 136 - if (!state.isConnected || _channel == null) { 137 - throw Exception('WebSocket not connected'); 138 - } 139 - 140 - _channel!.sink.add(jsonEncode(message)); 141 - } 142 - } 143 - 144 - /// State for WebSocket connection 145 - class ChatWebSocketState { 146 - final bool isConnecting; 147 - final bool isConnected; 148 - final String? error; 149 - final WebSocketMessage? lastMessage; 150 - final List<ChatMessage> recentMessages; 151 - 152 - const ChatWebSocketState({ 153 - this.isConnecting = false, 154 - this.isConnected = false, 155 - this.error, 156 - this.lastMessage, 157 - this.recentMessages = const [], 158 - }); 159 - 160 - ChatWebSocketState copyWith({ 161 - bool? isConnecting, 162 - bool? isConnected, 163 - String? error, 164 - WebSocketMessage? lastMessage, 165 - List<ChatMessage>? recentMessages, 166 - }) { 167 - return ChatWebSocketState( 168 - isConnecting: isConnecting ?? this.isConnecting, 169 - isConnected: isConnected ?? this.isConnected, 170 - error: error, 171 - lastMessage: lastMessage ?? this.lastMessage, 172 - recentMessages: recentMessages ?? this.recentMessages, 173 - ); 174 - } 175 - }
+56
lib/src/features/messages/providers/conversation_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Embed; 4 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 5 + import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 6 + import 'package:sparksocial/src/features/messages/providers/conversation_state.dart'; 7 + 8 + part 'conversation_provider.g.dart'; 9 + 10 + @Riverpod(keepAlive: true) 11 + class Conversation extends _$Conversation { 12 + String? cursor; 13 + 14 + @override 15 + FutureOr<ConversationState> build(String otherDid) async { 16 + final other = await GetIt.I<SprkRepository>().actor.getProfile(otherDid); 17 + final (cursor: newCursor, messages: messages) = await GetIt.I<MessagesRepository>().getConversation(otherDid); 18 + cursor = newCursor; 19 + return ConversationState(other, messages); 20 + } 21 + 22 + Future<Message> sendMessage(String otherDid, String message, {List<Embed>? embed, String? currentUserDid}) async { 23 + final other = state.value?.other ?? await GetIt.I<SprkRepository>().actor.getProfile(otherDid); 24 + final messages = state.value?.messages ?? []; 25 + 26 + try { 27 + // Send message to server and get the actual result 28 + final sentMessage = await GetIt.I<MessagesRepository>().sendMessage(otherDid, message, embed: embed); 29 + 30 + // Update state with the new message 31 + state = AsyncValue.data( 32 + ConversationState(other, [...messages, sentMessage]), 33 + ); 34 + 35 + return sentMessage; 36 + } catch (e) { 37 + // If sending fails, keep the current state and rethrow 38 + rethrow; 39 + } 40 + } 41 + 42 + Future<void> checkForNewMessages() async { 43 + if (state.value == null) return; 44 + final otherDid = state.value!.other.did; 45 + final (cursor: _, messages: newBatch) = await GetIt.I<MessagesRepository>().getConversation(otherDid, cursor: cursor); 46 + final newestMessage = newBatch.isNotEmpty ? newBatch.last : null; 47 + if (newestMessage != null && (state.value!.messages.isEmpty || newestMessage.timestamp.compareTo(state.value!.messages.last.timestamp) > 0)) { 48 + // only new messages from the new batch 49 + final newMessages = newBatch.where((msg) => !state.value!.messages.any((m) => m.id == msg.id)).toList(); 50 + final updatedMessages = [...state.value!.messages, ...newMessages]; 51 + state = AsyncValue.data(ConversationState(state.value!.other, updatedMessages)); 52 + } 53 + } 54 + 55 + // TODO: loadmore 56 + }
+10
lib/src/features/messages/providers/conversation_state.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 4 + 5 + part 'conversation_state.freezed.dart'; 6 + 7 + @freezed 8 + abstract class ConversationState with _$ConversationState { 9 + factory ConversationState(ProfileViewDetailed other, List<Message> messages) = _ConversationState; 10 + }
+10
lib/src/features/messages/providers/conversations._state.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 4 + 5 + part 'conversations._state.freezed.dart'; 6 + 7 + @freezed 8 + abstract class ConversationsState with _$ConversationsState { 9 + factory ConversationsState(List<(ProfileViewDetailed, Message)> conversations) = _ConversationsState; 10 + }
+20
lib/src/features/messages/providers/conversations_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; 4 + import 'package:sparksocial/src/features/messages/providers/conversations._state.dart'; 5 + 6 + part 'conversations_provider.g.dart'; 7 + 8 + @Riverpod(keepAlive: true) 9 + class Conversations extends _$Conversations { 10 + String? cursor; 11 + 12 + @override 13 + FutureOr<ConversationsState> build() async { 14 + final (cursor: newCursor, messages: messages) = await GetIt.I<MessagesRepository>().getAllConversations(); 15 + cursor = newCursor; 16 + return ConversationsState(messages); 17 + } 18 + 19 + // TODO: loadmore 20 + }
+18
lib/src/features/messages/providers/polling_timer.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 + import 'package:sparksocial/src/features/messages/providers/conversation_provider.dart'; 6 + 7 + part 'polling_timer.g.dart'; 8 + 9 + @riverpod 10 + void pollingTrigger(Ref ref, String otherDid) { 11 + final timer = Timer.periodic(const Duration(seconds: 5), (timer) { 12 + ref.read(conversationProvider(otherDid).notifier).checkForNewMessages(); 13 + }); 14 + 15 + ref.onDispose(() { 16 + timer.cancel(); 17 + }); 18 + }
+99 -343
lib/src/features/messages/ui/pages/chat_page.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 7 + import 'package:image_picker/image_picker.dart'; 8 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 9 + import 'package:sparksocial/src/core/routing/app_router.dart'; 6 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 11 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 8 12 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 9 - import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; 13 + import 'package:sparksocial/src/features/messages/providers/conversation_provider.dart'; 14 + import 'package:sparksocial/src/features/messages/providers/polling_timer.dart'; 15 + import 'package:sparksocial/src/features/messages/ui/widgets/message_input.dart'; 16 + import 'package:sparksocial/src/features/messages/ui/widgets/messages_list.dart'; 10 17 11 18 @RoutePage() 12 19 class ChatPage extends ConsumerStatefulWidget { ··· 15 22 final String? otherUserDisplayName; 16 23 final String? otherUserAvatar; 17 24 18 - const ChatPage({ 19 - super.key, 20 - required this.otherUserDid, 21 - this.otherUserHandle, 22 - this.otherUserDisplayName, 23 - this.otherUserAvatar, 24 - }); 25 + const ChatPage({super.key, required this.otherUserDid, this.otherUserHandle, this.otherUserDisplayName, this.otherUserAvatar}); 25 26 26 27 @override 27 28 ConsumerState<ChatPage> createState() => _ChatPageState(); ··· 29 30 30 31 class _ChatPageState extends ConsumerState<ChatPage> { 31 32 final TextEditingController _messageController = TextEditingController(); 33 + final ImagePicker _imagePicker = ImagePicker(); 32 34 final ScrollController _scrollController = ScrollController(); 33 - List<ChatMessage> _messages = []; 34 35 String? _currentUserDid; 35 36 36 37 @override 37 38 void initState() { 38 39 super.initState(); 39 40 _initializeUser(); 40 - _loadMessages(); 41 - _connectWebSocket(); 42 41 } 43 42 44 43 void _initializeUser() { ··· 49 48 return widget.otherUserDisplayName ?? widget.otherUserHandle ?? 'Chat'; 50 49 } 51 50 52 - Future<void> _loadMessages() async { 53 - try { 54 - final chatService = ref.read(chatServiceProvider.notifier); 55 - final response = await chatService.getMessages(widget.otherUserDid); 56 - setState(() { 57 - _messages = response.messages; 58 - }); 59 - _scrollToBottom(); 60 - } catch (e) { 61 - if (mounted) { 62 - ScaffoldMessenger.of(context).showSnackBar( 63 - SnackBar(content: Text('Failed to load messages: ${e.toString()}')), 64 - ); 65 - } 66 - } 51 + List<String> _extractLinks(String text) { 52 + final urlRegex = RegExp( 53 + r'https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*|www\.[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*', 54 + caseSensitive: false, 55 + multiLine: false, 56 + ); 57 + return urlRegex.allMatches(text).map((match) => match.group(0)!).toList(); 67 58 } 68 59 69 - void _connectWebSocket() { 70 - final wsProvider = ref.read(chatWebSocketProvider.notifier); 71 - wsProvider.connect(); 60 + Future<void> _sendMessage() async { 61 + String content = _messageController.text.trim(); 62 + final links = _extractLinks(content); 63 + 64 + // remove links from content 65 + for (final link in links) { 66 + content = content.replaceAll(link, ''); 67 + } 68 + content = content.trim(); // Remove extra whitespace after link removal 72 69 73 - // Listen for new messages 74 - ref.listen<ChatWebSocketState>(chatWebSocketProvider, (previous, next) { 75 - if (next.recentMessages.isNotEmpty && 76 - (next.recentMessages.last.senderDid == widget.otherUserDid || 77 - next.recentMessages.last.receiverDid == widget.otherUserDid)) { 78 - setState(() { 79 - _messages = [..._messages, next.recentMessages.last]; 80 - }); 81 - _scrollToBottom(); 82 - } 83 - }); 84 - } 70 + final linkEmbeds = <Embed>[]; 71 + for (final link in links) { 72 + linkEmbeds.add(Embed(type: 'link', url: link, preview: link)); 73 + } 85 74 86 - Future<void> _sendMessage() async { 87 - final content = _messageController.text.trim(); 88 - if (content.isEmpty) return; 75 + if (content.isEmpty && linkEmbeds.isEmpty) return; 89 76 90 77 _messageController.clear(); 91 78 92 79 try { 93 - final chatService = ref.read(chatServiceProvider.notifier); 94 - final response = await chatService.sendMessage(content, widget.otherUserDid); 80 + final chatService = ref.read(conversationProvider(widget.otherUserDid).notifier); 81 + await chatService.sendMessage(widget.otherUserDid, content, embed: linkEmbeds.isNotEmpty ? linkEmbeds : null); 95 82 96 - // Add the sent message to local list 97 - final sentMessage = ChatMessage( 98 - id: response.messageId, 99 - message: content, 100 - senderDid: _currentUserDid!, 101 - receiverDid: widget.otherUserDid, 102 - timestamp: response.timestamp, 103 - ); 104 - 105 - setState(() { 106 - _messages = [..._messages, sentMessage]; 107 - }); 83 + // No need to manage local state since the provider handles it 108 84 _scrollToBottom(); 109 85 } catch (e) { 110 86 if (mounted) { 111 - ScaffoldMessenger.of(context).showSnackBar( 112 - SnackBar(content: Text('Failed to send message: ${e.toString()}')), 113 - ); 87 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to send message: ${e.toString()}'))); 114 88 } 115 89 } 116 90 } ··· 129 103 130 104 @override 131 105 Widget build(BuildContext context) { 132 - final chatServiceState = ref.watch(chatServiceProvider); 133 - 106 + final state = ref.watch(conversationProvider(widget.otherUserDid)); 107 + ref.listen(pollingTriggerProvider(widget.otherUserDid), (previous, next) {}); 134 108 return Scaffold( 135 109 backgroundColor: Theme.of(context).colorScheme.surface, 136 110 appBar: AppBar( 137 - title: Row( 138 - children: [ 139 - UserAvatar( 140 - imageUrl: widget.otherUserAvatar, 141 - username: widget.otherUserHandle ?? 'User', 142 - size: 36, 143 - backgroundColor: getAvatarColor((widget.otherUserHandle ?? 'User').hashCode), 144 - ), 145 - const SizedBox(width: 12), 146 - Expanded( 147 - child: Column( 148 - crossAxisAlignment: CrossAxisAlignment.start, 149 - children: [ 150 - Text( 151 - _getConversationTitle(), 152 - style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16), 153 - ), 154 - Text( 155 - '@${widget.otherUserHandle ?? 'user'}', 156 - style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(178), fontSize: 12), 157 - ), 158 - ], 111 + title: GestureDetector( 112 + onTap: () => context.router.push(ProfileRoute(did: widget.otherUserDid)), 113 + child: Row( 114 + children: [ 115 + UserAvatar( 116 + imageUrl: widget.otherUserAvatar, 117 + username: widget.otherUserHandle ?? 'User', 118 + size: 36, 119 + backgroundColor: getAvatarColor((widget.otherUserHandle ?? 'User').hashCode), 120 + ), 121 + const SizedBox(width: 12), 122 + Expanded( 123 + child: Column( 124 + crossAxisAlignment: CrossAxisAlignment.start, 125 + children: [ 126 + Text( 127 + _getConversationTitle(), 128 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16), 129 + ), 130 + Text( 131 + '@${widget.otherUserHandle ?? 'user'}', 132 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(178), fontSize: 12), 133 + ), 134 + ], 135 + ), 159 136 ), 160 - ), 161 - ], 137 + ], 138 + ), 162 139 ), 163 - actions: [ 164 - IconButton( 165 - onPressed: () {}, 166 - icon: Icon(FluentIcons.more_vertical_24_regular, color: Theme.of(context).colorScheme.onSurface), 167 - ), 168 - ], 140 + // actions: [ 141 + // IconButton( 142 + // onPressed: () {}, 143 + // icon: Icon(FluentIcons.more_vertical_24_regular, color: Theme.of(context).colorScheme.onSurface), 144 + // ), 145 + // ], 169 146 backgroundColor: Theme.of(context).colorScheme.surface, 170 147 elevation: 0, 171 148 ), ··· 173 150 children: [ 174 151 Container(height: 0.5, width: double.infinity, color: Theme.of(context).colorScheme.outline), 175 152 Expanded( 176 - child: chatServiceState.isLoading && _messages.isEmpty 177 - ? const Center(child: CircularProgressIndicator()) 178 - : chatServiceState.error != null && _messages.isEmpty 179 - ? Center( 180 - child: Column( 181 - mainAxisAlignment: MainAxisAlignment.center, 182 - children: [ 183 - Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), 184 - const SizedBox(height: 16), 185 - Text( 186 - 'Failed to load messages', 187 - style: TextStyle(color: Theme.of(context).colorScheme.error), 188 - ), 189 - const SizedBox(height: 8), 190 - ElevatedButton( 191 - onPressed: _loadMessages, 192 - child: const Text('Retry'), 193 - ), 194 - ], 195 - ), 196 - ) 197 - : MessagesList( 198 - messages: _messages, 199 - scrollController: _scrollController, 200 - currentUserDid: _currentUserDid, 201 - otherUserHandle: widget.otherUserHandle, 202 - otherUserAvatar: widget.otherUserAvatar, 153 + child: state.when( 154 + data: (data) => MessagesList( 155 + messages: data.messages, 156 + scrollController: _scrollController, 157 + currentUserDid: _currentUserDid, 158 + otherUserHandle: widget.otherUserHandle, 159 + otherUserAvatar: widget.otherUserAvatar, 160 + ), 161 + loading: () => const Center(child: CircularProgressIndicator()), 162 + error: (error, stack) { 163 + return Center( 164 + child: Column( 165 + mainAxisAlignment: MainAxisAlignment.center, 166 + children: [ 167 + Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), 168 + const SizedBox(height: 16), 169 + Text('Failed to load messages', style: TextStyle(color: Theme.of(context).colorScheme.error)), 170 + const SizedBox(height: 8), 171 + ElevatedButton( 172 + onPressed: () => ref.invalidate(conversationProvider(widget.otherUserDid)), 173 + child: const Text('Retry'), 203 174 ), 175 + ], 176 + ), 177 + ); 178 + }, 179 + ), 204 180 ), 205 181 MessageInput( 206 182 controller: _messageController, 207 183 onSend: _sendMessage, 208 - isLoading: chatServiceState.isSending, 184 + otherDid: widget.otherUserDid, 185 + imagePicker: _imagePicker, 209 186 ), 210 187 ], 211 188 ), ··· 214 191 215 192 @override 216 193 void dispose() { 217 - final wsProvider = ref.read(chatWebSocketProvider.notifier); 218 - wsProvider.disconnect(); 219 194 _messageController.dispose(); 220 195 _scrollController.dispose(); 221 196 super.dispose(); ··· 235 210 ]; 236 211 return colors[seed.abs() % colors.length]; 237 212 } 238 - 239 - // ------------------------- EXTRACTED WIDGETS ------------------------- 240 - 241 - class SenderAvatar extends StatelessWidget { 242 - const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); 243 - 244 - final bool isCurrentUser; 245 - final String? otherUserAvatar; 246 - final String? otherUserHandle; 247 - 248 - @override 249 - Widget build(BuildContext context) { 250 - if (isCurrentUser) { 251 - return UserAvatar( 252 - imageUrl: null, // Current user avatar - can be added later 253 - username: 'You', 254 - size: 32, 255 - backgroundColor: AppColors.primary, 256 - ); 257 - } 258 - 259 - return UserAvatar( 260 - imageUrl: otherUserAvatar, 261 - username: otherUserHandle ?? 'User', 262 - size: 32, 263 - backgroundColor: getAvatarColor((otherUserHandle ?? 'User').hashCode), 264 - ); 265 - } 266 - } 267 - 268 - class MessageBubble extends StatelessWidget { 269 - const MessageBubble({ 270 - super.key, 271 - required this.message, 272 - required this.isCurrentUser, 273 - required this.showAvatar, 274 - required this.otherUserAvatar, 275 - required this.otherUserHandle, 276 - }); 277 - 278 - final ChatMessage message; 279 - final bool isCurrentUser; 280 - final bool showAvatar; 281 - final String? otherUserAvatar; 282 - final String? otherUserHandle; 283 - 284 - @override 285 - Widget build(BuildContext context) { 286 - final brightness = MediaQuery.of(context).platformBrightness; 287 - final isDarkMode = brightness == Brightness.dark; 288 - 289 - return Padding( 290 - padding: const EdgeInsets.symmetric(vertical: 2), 291 - child: Row( 292 - mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, 293 - crossAxisAlignment: CrossAxisAlignment.end, 294 - children: [ 295 - if (!isCurrentUser && showAvatar) ...[ 296 - SenderAvatar( 297 - isCurrentUser: false, 298 - otherUserAvatar: otherUserAvatar, 299 - otherUserHandle: otherUserHandle, 300 - ), 301 - const SizedBox(width: 8), 302 - ] else if (!isCurrentUser) ...[ 303 - const SizedBox(width: 40), 304 - ], 305 - Flexible( 306 - child: Container( 307 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 308 - decoration: BoxDecoration( 309 - color: isCurrentUser 310 - ? AppColors.primary 311 - : isDarkMode 312 - ? Colors.grey.shade800 313 - : Colors.grey.shade200, 314 - borderRadius: BorderRadius.circular(20), 315 - ), 316 - child: Text( 317 - message.message, 318 - style: TextStyle( 319 - color: isCurrentUser 320 - ? Colors.white 321 - : isDarkMode 322 - ? Colors.white 323 - : Colors.black, 324 - fontSize: 16, 325 - ), 326 - ), 327 - ), 328 - ), 329 - ], 330 - ), 331 - ); 332 - } 333 - } 334 - 335 - class MessagesList extends StatelessWidget { 336 - const MessagesList({ 337 - super.key, 338 - required this.messages, 339 - required this.scrollController, 340 - required this.currentUserDid, 341 - required this.otherUserHandle, 342 - required this.otherUserAvatar, 343 - }); 344 - 345 - final List<ChatMessage> messages; 346 - final ScrollController scrollController; 347 - final String? currentUserDid; 348 - final String? otherUserHandle; 349 - final String? otherUserAvatar; 350 - 351 - @override 352 - Widget build(BuildContext context) { 353 - if (messages.isEmpty) { 354 - return Center( 355 - child: Column( 356 - mainAxisAlignment: MainAxisAlignment.center, 357 - children: [ 358 - Icon(FluentIcons.chat_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface), 359 - const SizedBox(height: 16), 360 - Text( 361 - 'No messages yet', 362 - style: TextStyle(fontSize: 18, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500), 363 - ), 364 - const SizedBox(height: 8), 365 - Text( 366 - 'Send a message to start the conversation', 367 - style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface), 368 - ), 369 - ], 370 - ), 371 - ); 372 - } 373 - 374 - return ListView.builder( 375 - controller: scrollController, 376 - padding: const EdgeInsets.all(16), 377 - itemCount: messages.length, 378 - itemBuilder: (context, index) { 379 - final message = messages[index]; 380 - final isCurrentUser = message.senderDid == currentUserDid; 381 - final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderDid != message.senderDid); 382 - 383 - return MessageBubble( 384 - message: message, 385 - isCurrentUser: isCurrentUser, 386 - showAvatar: showAvatar, 387 - otherUserAvatar: otherUserAvatar, 388 - otherUserHandle: otherUserHandle, 389 - ); 390 - }, 391 - ); 392 - } 393 - } 394 - 395 - class MessageInput extends StatelessWidget { 396 - const MessageInput({ 397 - super.key, 398 - required this.controller, 399 - required this.onSend, 400 - this.isLoading = false, 401 - }); 402 - 403 - final TextEditingController controller; 404 - final VoidCallback onSend; 405 - final bool isLoading; 406 - 407 - @override 408 - Widget build(BuildContext context) { 409 - return Container( 410 - padding: const EdgeInsets.all(16), 411 - decoration: BoxDecoration( 412 - color: Theme.of(context).colorScheme.surface, 413 - border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outline, width: 0.5)), 414 - ), 415 - child: SafeArea( 416 - child: Row( 417 - children: [ 418 - Expanded( 419 - child: TextField( 420 - controller: controller, 421 - decoration: InputDecoration( 422 - hintText: 'Type a message...', 423 - hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface), 424 - border: OutlineInputBorder(borderRadius: BorderRadius.circular(25), borderSide: BorderSide.none), 425 - filled: true, 426 - fillColor: Theme.of(context).colorScheme.surface, 427 - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 428 - ), 429 - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 430 - maxLines: null, 431 - textCapitalization: TextCapitalization.sentences, 432 - ), 433 - ), 434 - const SizedBox(width: 8), 435 - Container( 436 - decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle), 437 - child: IconButton( 438 - onPressed: isLoading ? null : onSend, 439 - icon: isLoading 440 - ? const SizedBox( 441 - width: 16, 442 - height: 16, 443 - child: CircularProgressIndicator( 444 - strokeWidth: 2, 445 - valueColor: AlwaysStoppedAnimation<Color>(Colors.white), 446 - ), 447 - ) 448 - : const Icon(FluentIcons.send_24_filled, color: Colors.white), 449 - ), 450 - ), 451 - ], 452 - ), 453 - ), 454 - ); 455 - } 456 - }
+126 -205
lib/src/features/messages/ui/pages/messages_page.dart
··· 6 6 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 7 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 - import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; 10 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 9 + import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 10 + import 'package:sparksocial/src/features/messages/providers/conversations_provider.dart'; 11 11 12 12 @RoutePage() 13 13 class MessagesPage extends ConsumerStatefulWidget { ··· 19 19 20 20 class _MessagesPageState extends ConsumerState<MessagesPage> { 21 21 int _selectedTabIndex = 0; 22 - List<ChatConversation> _conversations = []; 23 - 24 - @override 25 - void initState() { 26 - super.initState(); 27 - // Load chats when the page is initialized 28 - WidgetsBinding.instance.addPostFrameCallback((_) { 29 - _loadChats(); 30 - }); 31 - } 32 - 33 - Future<void> _loadChats() async { 34 - try { 35 - final chatService = ref.read(chatServiceProvider.notifier); 36 - final response = await chatService.getChats(); 37 - 38 - // For now, create simple conversation objects from DIDs 39 - // In a real implementation, you'd fetch user details for each DID 40 - final conversations = response.chats.map((did) => ChatConversation( 41 - otherUserDid: did, 42 - otherUserHandle: did.split(':').last, // Simple handle extraction 43 - lastActivity: DateTime.now(), 44 - )).toList(); 45 - 46 - setState(() { 47 - _conversations = conversations; 48 - }); 49 - } catch (e) { 50 - // Error will be shown in UI via provider state 51 - } 52 - } 53 22 54 23 @override 55 24 Widget build(BuildContext context) { 56 25 final theme = Theme.of(context); 57 26 final logger = GetIt.instance<LogService>().getLogger('MessagesPage'); 58 - final chatServiceState = ref.watch(chatServiceProvider); 27 + final chatServiceState = ref.watch(conversationsProvider); 59 28 60 29 logger.d('Building MessagesPage'); 61 30 62 - return Scaffold( 63 - backgroundColor: theme.scaffoldBackgroundColor, 64 - appBar: AppBar( 65 - title: Text('Inbox', style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold)), 66 - leading: IconButton( 67 - padding: EdgeInsets.zero, 68 - onPressed: () { 69 - context.router.push(const NewChatSearchRoute()); 70 - }, 71 - icon: Icon(FluentIcons.add_24_regular, color: theme.colorScheme.onSurface, size: 24), 72 - ), 73 - actions: [ 74 - IconButton( 31 + return chatServiceState.when( 32 + data: (data) => Scaffold( 33 + backgroundColor: theme.scaffoldBackgroundColor, 34 + appBar: AppBar( 35 + title: Text( 36 + 'Inbox', 37 + style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold), 38 + ), 39 + leading: IconButton( 75 40 padding: EdgeInsets.zero, 76 41 onPressed: () { 77 - // TODO: Implement search functionality 42 + context.router.push(const NewChatSearchRoute()); 78 43 }, 79 - icon: Icon(FluentIcons.search_24_regular, color: theme.colorScheme.onSurface, size: 24), 44 + icon: Icon(FluentIcons.add_24_regular, color: theme.colorScheme.onSurface, size: 24), 80 45 ), 81 - ], 82 - backgroundColor: theme.scaffoldBackgroundColor, 83 - elevation: 0, 84 - centerTitle: true, 85 - ), 86 - body: SafeArea( 87 - child: Stack( 88 - children: [ 89 - Column( 90 - children: [ 91 - CustomTabBar( 92 - selectedTabIndex: _selectedTabIndex, 93 - onTabChanged: (index) => setState(() => _selectedTabIndex = index), 94 - ), 95 - Container( 96 - height: 0.5, 97 - width: double.infinity, 98 - color: theme.colorScheme.outline, 99 - ), 100 - Expanded( 101 - child: _selectedTabIndex == 0 102 - ? MessagesTab( 103 - chatServiceState: chatServiceState, 104 - conversations: _conversations, 105 - onRefresh: _loadChats, 106 - ) 107 - : const ActivitiesTab(), 108 - ), 109 - ], 46 + actions: [ 47 + IconButton( 48 + padding: EdgeInsets.zero, 49 + onPressed: () { 50 + // TODO: Implement search functionality 51 + }, 52 + icon: Icon(FluentIcons.search_24_regular, color: theme.colorScheme.onSurface, size: 24), 110 53 ), 111 54 ], 55 + backgroundColor: theme.scaffoldBackgroundColor, 56 + elevation: 0, 57 + centerTitle: true, 58 + ), 59 + body: SafeArea( 60 + child: Stack( 61 + children: [ 62 + Column( 63 + children: [ 64 + CustomTabBar( 65 + selectedTabIndex: _selectedTabIndex, 66 + onTabChanged: (index) => setState(() => _selectedTabIndex = index), 67 + ), 68 + Container(height: 0.5, width: double.infinity, color: theme.colorScheme.outline), 69 + Expanded( 70 + child: _selectedTabIndex == 0 71 + ? MessagesTab(onRefresh: () => {ref.invalidate(conversationsProvider)}) 72 + : const ActivitiesTab(), 73 + ), 74 + ], 75 + ), 76 + ], 77 + ), 112 78 ), 113 79 ), 80 + loading: () => const Center(child: CircularProgressIndicator()), 81 + error: (error, stack) { 82 + final theme = Theme.of(context); 83 + return Center( 84 + child: Column( 85 + mainAxisAlignment: MainAxisAlignment.center, 86 + children: [ 87 + Icon(FluentIcons.error_circle_24_regular, size: 48, color: theme.colorScheme.error), 88 + const SizedBox(height: 16), 89 + Text('Failed to load conversations', style: TextStyle(color: theme.colorScheme.error)), 90 + const SizedBox(height: 8), 91 + ElevatedButton(onPressed: () => ref.invalidate(conversationsProvider), child: const Text('Retry')), 92 + ], 93 + ), 94 + ); 95 + }, 114 96 ); 115 97 } 116 98 } ··· 119 101 final int selectedTabIndex; 120 102 final Function(int) onTabChanged; 121 103 122 - const CustomTabBar({ 123 - super.key, 124 - required this.selectedTabIndex, 125 - required this.onTabChanged, 126 - }); 104 + const CustomTabBar({super.key, required this.selectedTabIndex, required this.onTabChanged}); 127 105 128 106 @override 129 107 Widget build(BuildContext context) { ··· 132 110 child: Row( 133 111 children: [ 134 112 Expanded( 135 - child: TabItem( 136 - isSelected: selectedTabIndex == 0, 137 - label: 'Messages', 138 - onTap: () => onTabChanged(0), 139 - ), 113 + child: TabItem(isSelected: selectedTabIndex == 0, label: 'Messages', onTap: () => onTabChanged(0)), 140 114 ), 141 115 Expanded( 142 - child: TabItem( 143 - isSelected: selectedTabIndex == 1, 144 - label: 'Activities', 145 - onTap: () => onTabChanged(1), 146 - ), 116 + child: TabItem(isSelected: selectedTabIndex == 1, label: 'Activities', onTap: () => onTabChanged(1)), 147 117 ), 148 118 ], 149 119 ), ··· 156 126 final String label; 157 127 final VoidCallback onTap; 158 128 159 - const TabItem({ 160 - super.key, 161 - required this.isSelected, 162 - required this.label, 163 - required this.onTap, 164 - }); 129 + const TabItem({super.key, required this.isSelected, required this.label, required this.onTap}); 165 130 166 131 @override 167 132 Widget build(BuildContext context) { ··· 176 141 border: Border( 177 142 bottom: BorderSide( 178 143 color: isSelected 179 - ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) 180 - : Colors.transparent, 144 + ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) 145 + : Colors.transparent, 181 146 width: 2, 182 147 ), 183 148 ), ··· 189 154 fontSize: 16, 190 155 fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 191 156 color: isSelected 192 - ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) 193 - : theme.colorScheme.onSurface.withAlpha(179), 157 + ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) 158 + : theme.colorScheme.onSurface.withAlpha(179), 194 159 ), 195 160 ), 196 161 ), ··· 200 165 } 201 166 202 167 class MessagesTab extends ConsumerWidget { 203 - final ChatServiceState chatServiceState; 204 - final List<ChatConversation> conversations; 205 168 final VoidCallback onRefresh; 206 169 207 - const MessagesTab({ 208 - super.key, 209 - required this.chatServiceState, 210 - required this.conversations, 211 - required this.onRefresh, 212 - }); 170 + const MessagesTab({super.key, required this.onRefresh}); 213 171 214 172 @override 215 173 Widget build(BuildContext context, WidgetRef ref) { 216 - if (chatServiceState.isLoading && conversations.isEmpty) { 217 - return const Center( 218 - child: CircularProgressIndicator(), 219 - ); 220 - } 174 + final state = ref.watch(conversationsProvider); 221 175 222 - if (chatServiceState.error != null && conversations.isEmpty) { 223 - return Center( 224 - child: Column( 225 - mainAxisAlignment: MainAxisAlignment.center, 226 - children: [ 227 - Icon( 228 - FluentIcons.error_circle_24_regular, 229 - size: 48, 230 - color: Theme.of(context).colorScheme.error 231 - ), 232 - const SizedBox(height: 16), 233 - Text( 234 - 'Failed to load conversations', 235 - style: TextStyle(color: Theme.of(context).colorScheme.error), 236 - ), 237 - const SizedBox(height: 8), 238 - ElevatedButton( 239 - onPressed: onRefresh, 240 - child: const Text('Retry'), 241 - ), 242 - ], 243 - ), 244 - ); 245 - } 176 + return state.when( 177 + data: (data) { 178 + return ListView.builder( 179 + itemCount: data.conversations.length, 180 + padding: EdgeInsets.zero, 181 + itemBuilder: (context, index) { 182 + // final conversation = data.conversations[index]; 246 183 247 - if (conversations.isEmpty) { 248 - return const Center( 249 - child: Column( 250 - mainAxisAlignment: MainAxisAlignment.center, 251 - children: [ 252 - Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), 253 - SizedBox(height: 16), 254 - Text( 255 - 'No conversations yet', 256 - style: TextStyle(fontSize: 18, color: Colors.grey, fontWeight: FontWeight.w500), 257 - ), 258 - SizedBox(height: 8), 259 - Text('Start a conversation to see it here', style: TextStyle(fontSize: 14, color: Colors.grey)), 260 - ], 261 - ), 262 - ); 263 - } 264 - 265 - return ListView.builder( 266 - itemCount: conversations.length, 267 - padding: EdgeInsets.zero, 268 - itemBuilder: (context, index) { 269 - final conversation = conversations[index]; 270 - 271 - return ListTile( 272 - leading: CircleAvatar( 273 - backgroundColor: Theme.of(context).colorScheme.primary, 274 - child: Text( 275 - (conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'U')[0].toUpperCase(), 276 - style: const TextStyle(color: Colors.white), 277 - ), 278 - ), 279 - title: Text( 280 - conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', 281 - style: const TextStyle(fontWeight: FontWeight.w500), 282 - ), 283 - subtitle: Text('@${conversation.otherUserHandle ?? 'unknown'}'), 284 - trailing: conversation.unreadCount > 0 285 - ? Container( 286 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 287 - decoration: BoxDecoration( 288 - color: Theme.of(context).colorScheme.primary, 289 - borderRadius: BorderRadius.circular(10), 184 + return ListTile( 185 + leading: UserAvatar( 186 + imageUrl: data.conversations[index].$1.avatar.toString(), 187 + username: data.conversations[index].$1.handle, 188 + size: 36, 189 + ), 190 + title: Text( 191 + data.conversations[index].$1.displayName ?? data.conversations[index].$1.handle, 192 + style: const TextStyle(fontWeight: FontWeight.w500), 193 + ), 194 + subtitle: Text('@${data.conversations[index].$1.handle}'), 195 + // trailing: data.conversations[index].unreadCount > 0 196 + // ? Container( 197 + // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 198 + // decoration: BoxDecoration( 199 + // color: Theme.of(context).colorScheme.primary, 200 + // borderRadius: BorderRadius.circular(10), 201 + // ), 202 + // child: Text(conversation.unreadCount.toString(), style: const TextStyle(color: Colors.white, fontSize: 12)), 203 + // ) 204 + // : null, 205 + onTap: () { 206 + context.router.push( 207 + ChatRoute( 208 + otherUserDid: data.conversations[index].$1.did, 209 + otherUserHandle: data.conversations[index].$1.handle, 210 + otherUserDisplayName: data.conversations[index].$1.displayName, 211 + otherUserAvatar: data.conversations[index].$1.avatar.toString(), 290 212 ), 291 - child: Text( 292 - conversation.unreadCount.toString(), 293 - style: const TextStyle(color: Colors.white, fontSize: 12), 294 - ), 295 - ) 296 - : null, 297 - onTap: () { 298 - context.router.push(ChatRoute( 299 - otherUserDid: conversation.otherUserDid, 300 - otherUserHandle: conversation.otherUserHandle, 301 - otherUserDisplayName: conversation.otherUserDisplayName, 302 - otherUserAvatar: conversation.otherUserAvatar, 303 - )); 213 + ); 214 + }, 215 + ); 304 216 }, 305 217 ); 306 218 }, 219 + loading: () => const Center(child: CircularProgressIndicator()), 220 + error: (error, stack) { 221 + final theme = Theme.of(context); 222 + return Center( 223 + child: Column( 224 + mainAxisAlignment: MainAxisAlignment.center, 225 + children: [ 226 + Icon(FluentIcons.error_circle_24_regular, size: 48, color: theme.colorScheme.error), 227 + const SizedBox(height: 16), 228 + Text('Failed to load conversations', style: TextStyle(color: theme.colorScheme.error)), 229 + const SizedBox(height: 8), 230 + ElevatedButton(onPressed: onRefresh, child: const Text('Retry')), 231 + ], 232 + ), 233 + ); 234 + }, 307 235 ); 308 236 } 309 237 } ··· 318 246 child: Column( 319 247 mainAxisAlignment: MainAxisAlignment.center, 320 248 children: [ 321 - Icon( 322 - FluentIcons.star_24_regular, 323 - size: 64, 324 - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), 325 - ), 249 + Icon(FluentIcons.star_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), 326 250 const SizedBox(height: 16), 327 251 Text( 328 252 'Activities', ··· 335 259 const SizedBox(height: 8), 336 260 Text( 337 261 'Activity features coming soon', 338 - style: TextStyle( 339 - fontSize: 14, 340 - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), 341 - ), 262 + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), 342 263 ), 343 264 ], 344 265 ),
+7 -10
lib/src/features/messages/ui/widgets/conversation_list.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 3 4 import 'conversation_list_item.dart'; 4 5 5 6 class ConversationList extends StatelessWidget { 6 - final List<ChatConversation> conversations; 7 - final Function(ChatConversation)? onConversationTap; 8 - final Function(ChatConversation)? onConversationLongPress; 7 + final List<(ProfileViewDetailed, Message)> conversations; 8 + final Function((ProfileViewDetailed, Message))? onConversationTap; 9 + final Function((ProfileViewDetailed, Message))? onConversationLongPress; 9 10 10 11 const ConversationList({super.key, required this.conversations, this.onConversationTap, this.onConversationLongPress}); 11 12 ··· 17 18 mainAxisAlignment: MainAxisAlignment.center, 18 19 children: [ 19 20 Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), 20 - SizedBox(height: 16), 21 - Text( 22 - 'DMs feature is coming soon', 23 - style: TextStyle(fontSize: 18, color: Colors.grey, fontWeight: FontWeight.w500), 24 - ), 25 21 SizedBox(height: 8), 26 22 Text('Start a conversation to see it here', style: TextStyle(fontSize: 14, color: Colors.grey)), 27 23 ], ··· 36 32 final conversation = conversations[index]; 37 33 38 34 return ConversationListItem( 39 - conversation: conversation, 35 + message: conversation.$2, 36 + otherUserProfile: conversation.$1, 40 37 onTap: () => onConversationTap?.call(conversation), 41 38 onLongPress: () => onConversationLongPress?.call(conversation), 42 39 );
+54 -52
lib/src/features/messages/ui/widgets/conversation_list_item.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 2 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 3 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 3 4 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 5 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 5 6 6 7 class ConversationListItem extends StatelessWidget { 7 - final ChatConversation conversation; 8 + final Message message; 9 + final ProfileViewDetailed otherUserProfile; 8 10 final VoidCallback? onTap; 9 11 final VoidCallback? onLongPress; 10 12 11 - const ConversationListItem({super.key, required this.conversation, this.onTap, this.onLongPress}); 13 + const ConversationListItem({ 14 + super.key, 15 + required this.message, 16 + required this.otherUserProfile, 17 + this.onTap, 18 + this.onLongPress, 19 + }); 12 20 13 21 @override 14 22 Widget build(BuildContext context) { ··· 23 31 ), 24 32 child: Row( 25 33 children: [ 26 - ConversationAvatar(conversation: conversation), 34 + ConversationAvatar(otherUserProfile: otherUserProfile), 27 35 const SizedBox(width: 12), 28 36 Expanded( 29 37 child: Column( ··· 33 41 children: [ 34 42 Expanded( 35 43 child: Text( 36 - conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', 37 - style: TextStyle( 38 - fontWeight: conversation.unreadCount > 0 ? FontWeight.bold : FontWeight.w500, 39 - fontSize: 16, 40 - color: Theme.of(context).colorScheme.onSurface, 41 - ), 44 + otherUserProfile.displayName ?? otherUserProfile.handle, 45 + // style: TextStyle( 46 + // fontWeight: conversation.unreadCount > 0 ? FontWeight.bold : FontWeight.w500, 47 + // fontSize: 16, 48 + // color: Theme.of(context).colorScheme.onSurface, 49 + // ), 42 50 maxLines: 1, 43 51 overflow: TextOverflow.ellipsis, 44 52 ), ··· 50 58 children: [ 51 59 Expanded( 52 60 child: Text( 53 - '@${conversation.otherUserHandle ?? 'unknown'}', 61 + '@${otherUserProfile.handle}', 54 62 style: TextStyle( 55 63 fontWeight: FontWeight.normal, 56 64 fontSize: 14, ··· 65 73 ], 66 74 ), 67 75 ), 68 - Column( 69 - crossAxisAlignment: CrossAxisAlignment.end, 70 - children: [ 71 - if (conversation.lastActivity != null) 72 - Text( 73 - _formatTime(conversation.lastActivity!), 74 - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withAlpha(178)), 75 - ), 76 - const SizedBox(height: 4), 77 - if (conversation.unreadCount > 0) 78 - Container( 79 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 80 - decoration: BoxDecoration( 81 - color: Theme.of(context).colorScheme.primary, 82 - borderRadius: BorderRadius.circular(10), 83 - ), 84 - child: Text( 85 - conversation.unreadCount > 99 ? '99+' : conversation.unreadCount.toString(), 86 - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), 87 - ), 88 - ), 89 - ], 90 - ), 76 + // Column( 77 + // crossAxisAlignment: CrossAxisAlignment.end, 78 + // children: [ 79 + // if (conversation.unreadCount > 0) 80 + // Container( 81 + // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 82 + // decoration: BoxDecoration( 83 + // color: Theme.of(context).colorScheme.primary, 84 + // borderRadius: BorderRadius.circular(10), 85 + // ), 86 + // child: Text( 87 + // conversation.unreadCount > 99 ? '99+' : conversation.unreadCount.toString(), 88 + // style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), 89 + // ), 90 + // ), 91 + // ], 92 + // ), 91 93 ], 92 94 ), 93 95 ), 94 96 ); 95 97 } 96 98 97 - String _formatTime(DateTime dateTime) { 98 - final now = DateTime.now(); 99 - final difference = now.difference(dateTime); 99 + // String _formatTime(DateTime dateTime) { 100 + // final now = DateTime.now(); 101 + // final difference = now.difference(dateTime); 100 102 101 - if (difference.inDays > 0) { 102 - return '${difference.inDays}d'; 103 - } else if (difference.inHours > 0) { 104 - return '${difference.inHours}h'; 105 - } else if (difference.inMinutes > 0) { 106 - return '${difference.inMinutes}m'; 107 - } else { 108 - return 'now'; 109 - } 110 - } 103 + // if (difference.inDays > 0) { 104 + // return '${difference.inDays}d'; 105 + // } else if (difference.inHours > 0) { 106 + // return '${difference.inHours}h'; 107 + // } else if (difference.inMinutes > 0) { 108 + // return '${difference.inMinutes}m'; 109 + // } else { 110 + // return 'now'; 111 + // } 112 + // } 111 113 } 112 114 113 115 class ConversationAvatar extends StatelessWidget { 114 - final ChatConversation conversation; 115 - const ConversationAvatar({super.key, required this.conversation}); 116 + final ProfileViewDetailed otherUserProfile; 117 + const ConversationAvatar({super.key, required this.otherUserProfile}); 116 118 117 119 Color _getAvatarColor(int seed) { 118 120 final colors = [ ··· 136 138 decoration: const BoxDecoration(shape: BoxShape.circle), 137 139 clipBehavior: Clip.antiAlias, 138 140 child: UserAvatar( 139 - imageUrl: conversation.otherUserAvatar, 140 - username: conversation.otherUserHandle ?? 'User', 141 + imageUrl: otherUserProfile.avatar.toString(), 142 + username: otherUserProfile.handle, 141 143 size: 48, 142 - backgroundColor: _getAvatarColor((conversation.otherUserHandle ?? 'User').hashCode), 144 + backgroundColor: _getAvatarColor((otherUserProfile.handle).hashCode), 143 145 ), 144 146 ); 145 147 }
+89
lib/src/features/messages/ui/widgets/message_bubble.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 3 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 4 + import 'package:sparksocial/src/features/messages/ui/widgets/sender_avatar.dart'; 5 + 6 + class MessageBubble extends StatelessWidget { 7 + const MessageBubble({ 8 + super.key, 9 + required this.message, 10 + required this.isCurrentUser, 11 + required this.showAvatar, 12 + required this.otherUserAvatar, 13 + required this.otherUserHandle, 14 + }); 15 + 16 + final Message message; 17 + final bool isCurrentUser; 18 + final bool showAvatar; 19 + final String? otherUserAvatar; 20 + final String? otherUserHandle; 21 + String _removeLinksFromText(String text) { 22 + // Regex pattern to match URLs 23 + final urlPattern = RegExp( 24 + r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)', 25 + caseSensitive: false, 26 + ); 27 + 28 + String cleanedText = text.replaceAll(urlPattern, '').trim(); 29 + 30 + // Clean up multiple spaces and newlines that might be left after URL removal 31 + cleanedText = cleanedText.replaceAll(RegExp(r'\s+'), ' ').trim(); 32 + 33 + return cleanedText; 34 + } 35 + 36 + @override 37 + Widget build(BuildContext context) { 38 + final brightness = MediaQuery.of(context).platformBrightness; 39 + final isDarkMode = brightness == Brightness.dark; 40 + 41 + return Padding( 42 + padding: const EdgeInsets.symmetric(vertical: 2), 43 + child: Row( 44 + mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, 45 + crossAxisAlignment: CrossAxisAlignment.end, 46 + children: [ 47 + if (!isCurrentUser && showAvatar) ...[ 48 + SenderAvatar(isCurrentUser: false, otherUserAvatar: otherUserAvatar, otherUserHandle: otherUserHandle), 49 + const SizedBox(width: 8), 50 + ] else if (!isCurrentUser) ...[ 51 + const SizedBox(width: 40), 52 + ], 53 + Flexible( 54 + child: () { 55 + final cleanedMessage = _removeLinksFromText(message.message); 56 + // Only show the bubble if there's text content after removing links 57 + if (cleanedMessage.isEmpty) { 58 + return const SizedBox.shrink(); 59 + } 60 + 61 + return Container( 62 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 63 + decoration: BoxDecoration( 64 + color: isCurrentUser 65 + ? AppColors.primary 66 + : isDarkMode 67 + ? Colors.grey.shade800 68 + : Colors.grey.shade200, 69 + borderRadius: BorderRadius.circular(20), 70 + ), 71 + child: Text( 72 + cleanedMessage, 73 + style: TextStyle( 74 + color: isCurrentUser 75 + ? Colors.white 76 + : isDarkMode 77 + ? Colors.white 78 + : Colors.black, 79 + fontSize: 16, 80 + ), 81 + ), 82 + ); 83 + }(), 84 + ), 85 + ], 86 + ), 87 + ); 88 + } 89 + }
+189
lib/src/features/messages/ui/widgets/message_input.dart
··· 1 + 2 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:image_picker/image_picker.dart'; 6 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 + import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 8 + import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 9 + import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 10 + 11 + class MessageInput extends ConsumerWidget { 12 + const MessageInput({super.key, required this.controller, required this.onSend, this.isLoading = false, required this.otherDid, required this.imagePicker}); 13 + 14 + final TextEditingController controller; 15 + final ImagePicker imagePicker; 16 + final VoidCallback onSend; 17 + final bool isLoading; 18 + final String otherDid; 19 + 20 + @override 21 + Widget build(BuildContext context, WidgetRef ref) { 22 + final session = ref.watch(authProvider).session; 23 + return Container( 24 + padding: const EdgeInsets.all(16), 25 + decoration: BoxDecoration( 26 + color: Theme.of(context).colorScheme.surface, 27 + border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outline, width: 0.5)), 28 + ), 29 + child: SafeArea( 30 + child: Column( 31 + children: [ 32 + Row( 33 + crossAxisAlignment: CrossAxisAlignment.center, 34 + children: [ 35 + UserAvatar( 36 + imageUrl: ref 37 + .read(profileNotifierProvider(did: session?.did ?? '')) 38 + .when( 39 + data: (profileData) => profileData.profile?.avatar?.toString(), 40 + error: (error, stackTrace) => null, 41 + loading: () => null, 42 + ), 43 + username: session?.handle ?? '', 44 + size: 28, 45 + borderWidth: 0, 46 + ), 47 + const SizedBox(width: 8), 48 + // _AttachmentButton( 49 + // state: state, 50 + // notifier: notifier, 51 + // context: context, 52 + // borderColor: Theme.of(context).colorScheme.outline, 53 + // textColor: Theme.of(context).colorScheme.onSurface, 54 + // ), 55 + // const SizedBox(width: 5), 56 + Expanded( 57 + child: TextField( 58 + controller: controller, 59 + decoration: InputDecoration( 60 + hintText: 'Type a message...', 61 + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface), 62 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(25), borderSide: BorderSide.none), 63 + filled: true, 64 + fillColor: Theme.of(context).colorScheme.surface, 65 + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), 66 + ), 67 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), 68 + maxLines: null, 69 + textCapitalization: TextCapitalization.sentences, 70 + ), 71 + ), 72 + const SizedBox(width: 8), 73 + Container( 74 + decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle), 75 + child: IconButton( 76 + onPressed: isLoading ? null : onSend, 77 + icon: isLoading 78 + ? const SizedBox( 79 + width: 16, 80 + height: 16, 81 + child: CircularProgressIndicator( 82 + strokeWidth: 2, 83 + valueColor: AlwaysStoppedAnimation<Color>(Colors.white), 84 + ), 85 + ) 86 + : const Icon(FluentIcons.send_24_filled, color: Colors.white), 87 + ), 88 + ), 89 + ], 90 + ), 91 + // if (state.selectedImages.isNotEmpty) 92 + // Padding( 93 + // padding: const EdgeInsets.only(top: 8.0), 94 + // child: _SelectedImagesPreview(state: state, notifier: notifier), 95 + // ), 96 + ], 97 + ), 98 + ), 99 + ); 100 + } 101 + } 102 + 103 + // class _AttachmentButton extends StatelessWidget { 104 + // const _AttachmentButton({ 105 + // required this.state, 106 + // required this.notifier, 107 + // required this.context, 108 + // required this.borderColor, 109 + // required this.textColor, 110 + // }); 111 + 112 + // final EmbedInputState state; 113 + // final EmbedInput notifier; 114 + // final BuildContext context; 115 + // final Color borderColor; 116 + // final Color textColor; 117 + 118 + // @override 119 + // Widget build(BuildContext context) { 120 + // final bool canAddMoreImages = state.selectedImages.length < 4; 121 + // final bool enabled = !state.isPosting && canAddMoreImages; 122 + 123 + // return IconButton( 124 + // padding: EdgeInsets.zero, 125 + // visualDensity: VisualDensity.compact, 126 + // constraints: const BoxConstraints(minWidth: 16, minHeight: 16), 127 + // onPressed: enabled ? () => notifier.pickMedia(context) : null, 128 + // tooltip: enabled ? 'Add media (up to 4)' : (state.isPosting ? 'Posting...' : 'Maximum files reached'), 129 + // icon: Icon(FluentIcons.image_24_regular, size: 24, color: Theme.of(context).colorScheme.primary), 130 + // ); 131 + // } 132 + // } 133 + 134 + // class _SelectedImagesPreview extends StatelessWidget { 135 + // const _SelectedImagesPreview({required this.state, required this.notifier}); 136 + 137 + // final EmbedInputState state; 138 + // final EmbedInput notifier; 139 + 140 + // @override 141 + // Widget build(BuildContext context) { 142 + // return SizedBox( 143 + // height: 72, 144 + // child: ListView.builder( 145 + // scrollDirection: Axis.horizontal, 146 + // itemCount: state.selectedImages.length, 147 + // itemBuilder: (context, index) { 148 + // final imageFile = state.selectedImages[index]; 149 + // return Padding( 150 + // padding: const EdgeInsets.only(right: 8.0), 151 + // child: Stack( 152 + // alignment: Alignment.bottomRight, 153 + // children: [ 154 + // // Image Thumbnail with rounded corners and shadow 155 + // Container( 156 + // width: 72, 157 + // height: 72, 158 + // decoration: BoxDecoration( 159 + // borderRadius: BorderRadius.circular(12), 160 + // border: Border.all(color: Theme.of(context).colorScheme.outline, width: 0.5), 161 + // boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 26), blurRadius: 4, offset: const Offset(0, 2))], 162 + // image: DecorationImage(image: FileImage(File(imageFile.path)), fit: BoxFit.cover), 163 + // ), 164 + // ), 165 + // // Remove Button (top right) 166 + // Positioned( 167 + // top: 4, 168 + // right: 4, 169 + // child: Material( 170 + // color: Colors.black.withValues(alpha: 128), 171 + // shape: const CircleBorder(), 172 + // child: InkWell( 173 + // onTap: () => notifier.removeImage(index), 174 + // customBorder: const CircleBorder(), 175 + // child: Container( 176 + // padding: const EdgeInsets.all(2), 177 + // child: const Icon(FluentIcons.dismiss_16_filled, color: Colors.white, size: 12), 178 + // ), 179 + // ), 180 + // ), 181 + // ), 182 + // ], 183 + // ), 184 + // ); 185 + // }, 186 + // ), 187 + // ); 188 + // } 189 + // }
+556
lib/src/features/messages/ui/widgets/messages_list.dart
··· 1 + import 'package:any_link_preview/any_link_preview.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:http/http.dart' as http; 7 + import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; 8 + import 'package:sparksocial/src/core/routing/app_router.dart'; 9 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 10 + import 'package:sparksocial/src/core/widgets/image_content.dart'; 11 + import 'package:sparksocial/src/core/widgets/video_content.dart'; 12 + import 'package:sparksocial/src/features/messages/ui/widgets/message_bubble.dart'; 13 + import 'package:url_launcher/url_launcher.dart'; 14 + 15 + class MessagesList extends StatelessWidget { 16 + const MessagesList({ 17 + super.key, 18 + required this.messages, 19 + required this.scrollController, 20 + required this.currentUserDid, 21 + required this.otherUserHandle, 22 + required this.otherUserAvatar, 23 + }); 24 + 25 + final List<Message> messages; 26 + final ScrollController scrollController; 27 + final String? currentUserDid; 28 + final String? otherUserHandle; 29 + final String? otherUserAvatar; 30 + 31 + Future<void> logLinkMetadata(List<String> links) async { 32 + if (links.isEmpty) return; 33 + for (var link in links) { 34 + try { 35 + final metadata = await AnyLinkPreview.getMetadata(link: link); 36 + GetIt.I<LogService>().getLogger('MessagesList').i('Link metadata for $link: $metadata'); 37 + } catch (e) { 38 + GetIt.I<LogService>().getLogger('MessagesList').e('Failed to get metadata for link $link: $e'); 39 + } 40 + } 41 + } 42 + 43 + Future<bool> validateImage(String imageUrl) async { 44 + http.Response res; 45 + try { 46 + res = await http.get(Uri.parse(imageUrl)); 47 + } catch (e) { 48 + return false; 49 + } 50 + if (res.statusCode != 200) return false; 51 + Map<String, dynamic> data = res.headers; 52 + return checkIfImage(data['content-type']); 53 + } 54 + 55 + bool checkIfImage(String param) { 56 + if (param == 'image/jpeg' || 57 + param == 'image/png' || 58 + param == 'image/gif' || 59 + param == 'image/webp' || 60 + param == 'image/bmp' || 61 + param == 'image/svg+xml') { 62 + return true; 63 + } 64 + return false; 65 + } 66 + 67 + Future<bool> validateVideo(String videoUrl) async { 68 + http.Response res; 69 + try { 70 + res = await http.get(Uri.parse(videoUrl)); 71 + } catch (e) { 72 + return false; 73 + } 74 + if (res.statusCode != 200) return false; 75 + Map<String, dynamic> data = res.headers; 76 + return checkIfVideo(data['content-type']); 77 + } 78 + 79 + bool checkIfVideo(String param) { 80 + if (param == 'video/mp4' || 81 + param == 'video/webm' || 82 + param == 'video/ogg' || 83 + param == 'video/avi' || 84 + param == 'video/mov' || 85 + param == 'video/quicktime') { 86 + return true; 87 + } 88 + return false; 89 + } 90 + 91 + /// Checks if a URL is a sprk.so watch URL and extracts the post URI 92 + String? extractSprkPostUri(String url) { 93 + try { 94 + final uri = Uri.parse(url); 95 + if (uri.host == 'watch.sprk.so' && uri.queryParameters.containsKey('uri')) { 96 + return uri.queryParameters['uri']; 97 + } 98 + } catch (e) { 99 + // Invalid URL 100 + } 101 + return null; 102 + } 103 + 104 + Future<List<Widget>?> validateAndCreateEmbeds(List<Embed>? embed) async { 105 + List<Widget>? embeds; 106 + 107 + if (embed?.isNotEmpty ?? false) { 108 + List<String> images = []; 109 + List<String> videos = []; 110 + List<String> links = []; 111 + List<String> sprkPosts = []; 112 + for (final embed in embed!) { 113 + if (embed.type == 'image') { 114 + if (embed.url?.isNotEmpty ?? false) { 115 + images.add(embed.url!); 116 + } 117 + } else if (embed.type == 'video') { 118 + if (embed.url?.isNotEmpty ?? false) { 119 + videos.add(embed.url!); 120 + } 121 + } else if (embed.type == 'link') { 122 + if (embed.url?.isNotEmpty ?? false) { 123 + // Check if this is a sprk.so watch URL 124 + final sprkPostUri = extractSprkPostUri(embed.url!); 125 + if (sprkPostUri != null) { 126 + sprkPosts.add(sprkPostUri); 127 + } else { 128 + links.add(embed.url!); 129 + } 130 + } 131 + } // eventually audios perhaps.. 132 + } 133 + 134 + // Check links for images/videos/sprk posts and reclassify them 135 + List<String> linksToRemove = []; 136 + for (var link in links) { 137 + if (link.isEmpty) continue; 138 + if (Uri.tryParse(link)?.hasScheme != true) continue; // Skip invalid links 139 + 140 + // Check if this is a sprk.so watch URL 141 + final sprkPostUri = extractSprkPostUri(link); 142 + if (sprkPostUri != null) { 143 + sprkPosts.add(sprkPostUri); 144 + linksToRemove.add(link); 145 + } else if (await validateImage(link)) { 146 + // If the link is a valid image, add it to images 147 + images.add(link); 148 + linksToRemove.add(link); // Mark for removal from links 149 + } else if (await validateVideo(link)) { 150 + // If the link is a valid video, add it to videos 151 + videos.add(link); 152 + linksToRemove.add(link); // Mark for removal from links 153 + } 154 + } 155 + 156 + // Remove reclassified links 157 + for (var linkToRemove in linksToRemove) { 158 + links.remove(linkToRemove); 159 + } 160 + 161 + if (images.isNotEmpty) { 162 + embeds ??= []; 163 + embeds.add(ImageContent(imageUrls: images, borderRadius: BorderRadius.circular(12), thumbnailSize: 200)); 164 + } 165 + if (videos.isNotEmpty) { 166 + embeds ??= []; 167 + for (var videoUrl in videos) { 168 + embeds.add(VideoContent(borderRadius: BorderRadius.circular(12), videoUrl: videoUrl)); 169 + } 170 + } 171 + if (sprkPosts.isNotEmpty) { 172 + embeds ??= []; 173 + for (var postUri in sprkPosts) { 174 + embeds.add(_SprkPostThumbnail(postUri: postUri)); 175 + } 176 + } 177 + if (links.isNotEmpty) { 178 + embeds ??= []; 179 + GetIt.I<LogService>().getLogger('MessagesList').i('Links found in message: $links'); 180 + //logLinkMetadata(links); 181 + embeds.add( 182 + ListView.builder( 183 + shrinkWrap: true, 184 + cacheExtent: 50, 185 + physics: const NeverScrollableScrollPhysics(), 186 + itemCount: links.length, 187 + itemBuilder: (context, index) { 188 + return Padding( 189 + padding: const EdgeInsets.only(top: 8.0), 190 + child: _LinkPreview(url: links[index]), 191 + ); 192 + }, 193 + ), 194 + ); 195 + } 196 + } 197 + return embeds; 198 + } 199 + 200 + @override 201 + Widget build(BuildContext context) { 202 + if (messages.isEmpty) { 203 + return Center( 204 + child: Column( 205 + mainAxisAlignment: MainAxisAlignment.center, 206 + children: [ 207 + Icon(FluentIcons.chat_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface), 208 + const SizedBox(height: 16), 209 + Text( 210 + 'No messages yet', 211 + style: TextStyle(fontSize: 18, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500), 212 + ), 213 + const SizedBox(height: 8), 214 + Text( 215 + 'Send a message to start the conversation', 216 + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface), 217 + ), 218 + ], 219 + ), 220 + ); 221 + } 222 + 223 + return ListView.builder( 224 + controller: scrollController, 225 + padding: const EdgeInsets.all(16), 226 + itemCount: messages.length, 227 + itemBuilder: (context, index) { 228 + final message = messages[index]; 229 + final isCurrentUser = message.senderDid == currentUserDid; 230 + final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderDid != message.senderDid); 231 + 232 + return Column( 233 + children: [ 234 + MessageBubble( 235 + message: message, 236 + isCurrentUser: isCurrentUser, 237 + showAvatar: showAvatar, 238 + otherUserAvatar: otherUserAvatar, 239 + otherUserHandle: otherUserHandle, 240 + ), 241 + FutureBuilder<List<Widget>?>( 242 + future: validateAndCreateEmbeds(message.embed), 243 + builder: (context, snapshot) { 244 + if (snapshot.connectionState == ConnectionState.waiting) { 245 + return const SizedBox.shrink(); 246 + } 247 + if (snapshot.hasError) { 248 + GetIt.I<LogService>().getLogger('MessagesList').e('Error validating embeds: ${snapshot.error}'); 249 + return const SizedBox.shrink(); // Show nothing on error 250 + } 251 + final embeds = snapshot.data; 252 + if (embeds == null || embeds.isEmpty) return const SizedBox.shrink(); 253 + return Padding( 254 + padding: const EdgeInsets.only(top: 8), 255 + child: Column( 256 + crossAxisAlignment: isCurrentUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, 257 + children: embeds 258 + .map( 259 + (embed) => Row( 260 + mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, 261 + children: [Flexible(child: embed)], 262 + ), 263 + ) 264 + .toList(), 265 + ), 266 + ); 267 + }, 268 + ), 269 + const SizedBox(height: 8), 270 + ], 271 + ); 272 + }, 273 + ); 274 + } 275 + } 276 + 277 + class _LinkPreview extends StatelessWidget { 278 + const _LinkPreview({required this.url}); 279 + 280 + final String url; 281 + 282 + @override 283 + Widget build(BuildContext context) { 284 + final theme = Theme.of(context); 285 + 286 + return GestureDetector( 287 + onTap: () => _launchUrl(url), 288 + child: AnyLinkPreview.builder( 289 + link: url, 290 + placeholderWidget: const _LinkPreviewPlaceholder(), 291 + errorWidget: _LinkPreviewError(url: url), 292 + itemBuilder: (_, metadata, imageProvider, svgPicture) => Container( 293 + decoration: BoxDecoration( 294 + borderRadius: BorderRadius.circular(12), 295 + border: Border.all(color: theme.dividerColor, width: 0.5), 296 + ), 297 + height: 100, 298 + child: Row( 299 + children: [ 300 + if (imageProvider != null) 301 + ClipRRect( 302 + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(12), topLeft: Radius.circular(12)), 303 + child: Image(width: 100, height: 100, fit: BoxFit.cover, image: imageProvider), 304 + ), 305 + Expanded(child: _LinkPreviewText(metadata: metadata)), 306 + ], 307 + ), 308 + ), 309 + ), 310 + ); 311 + } 312 + 313 + Future<void> _launchUrl(String url) async { 314 + try { 315 + final uri = Uri.parse(url); 316 + if (await canLaunchUrl(uri)) { 317 + await launchUrl(uri, mode: LaunchMode.externalApplication); 318 + } else { 319 + await launchUrl(uri, mode: LaunchMode.platformDefault); 320 + } 321 + } catch (e) { 322 + GetIt.I<LogService>().getLogger('_LinkPreview').e('Failed to launch URL $url: $e'); 323 + } 324 + } 325 + } 326 + 327 + class _LinkPreviewPlaceholder extends StatelessWidget { 328 + const _LinkPreviewPlaceholder(); 329 + 330 + @override 331 + Widget build(BuildContext context) { 332 + final theme = Theme.of(context); 333 + 334 + return GestureDetector( 335 + // empty on tap to prevent tap gestures on loading placeholder 336 + onTap: () {}, 337 + child: Container( 338 + height: 100, 339 + width: double.infinity, 340 + decoration: BoxDecoration( 341 + color: theme.colorScheme.surface, 342 + borderRadius: BorderRadius.circular(12), 343 + border: Border.all(color: theme.dividerColor), 344 + ), 345 + child: const Center(child: CircularProgressIndicator()), 346 + ), 347 + ); 348 + } 349 + } 350 + 351 + class _LinkPreviewError extends StatelessWidget { 352 + const _LinkPreviewError({required this.url}); 353 + 354 + final String url; 355 + 356 + String get urlStr { 357 + if (url.length > 40) { 358 + return '${url.substring(0, 40)}...'; 359 + } 360 + return url; 361 + } 362 + 363 + @override 364 + Widget build(BuildContext context) { 365 + final theme = Theme.of(context); 366 + 367 + return Container( 368 + width: double.infinity, 369 + height: 100, 370 + decoration: BoxDecoration( 371 + borderRadius: BorderRadius.circular(12), 372 + border: Border.all(color: theme.dividerColor), 373 + ), 374 + child: Row( 375 + children: [ 376 + Container( 377 + padding: const EdgeInsets.all(12), 378 + width: 100, 379 + height: 100, 380 + decoration: BoxDecoration( 381 + color: theme.colorScheme.surface, 382 + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), bottomLeft: Radius.circular(12)), 383 + ), 384 + child: const FittedBox(child: Icon(FluentIcons.link_24_regular)), 385 + ), 386 + Expanded( 387 + child: Padding( 388 + padding: const EdgeInsets.all(8.0), 389 + child: FittedBox(child: Text(urlStr, style: theme.textTheme.titleSmall)), 390 + ), 391 + ), 392 + ], 393 + ), 394 + ); 395 + } 396 + } 397 + 398 + class _LinkPreviewText extends StatelessWidget { 399 + const _LinkPreviewText({required this.metadata}); 400 + 401 + final Metadata metadata; 402 + 403 + @override 404 + Widget build(BuildContext context) { 405 + final theme = Theme.of(context); 406 + 407 + final title = metadata.title?.isNotEmpty == true && metadata.title != 'null' ? metadata.title : null; 408 + final desc = metadata.desc?.isNotEmpty == true && metadata.desc != 'null' ? metadata.desc : null; 409 + 410 + return Padding( 411 + padding: const EdgeInsets.all(8.0), 412 + child: Column( 413 + crossAxisAlignment: CrossAxisAlignment.start, 414 + children: [ 415 + Expanded( 416 + child: Column( 417 + crossAxisAlignment: CrossAxisAlignment.start, 418 + children: [ 419 + if (title?.isNotEmpty ?? false) ...[ 420 + Text( 421 + _limitLength(title!, 40), 422 + maxLines: 2, 423 + overflow: TextOverflow.ellipsis, 424 + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), 425 + ), 426 + const SizedBox(height: 2), 427 + ], 428 + if (desc?.isNotEmpty ?? false) 429 + Text( 430 + desc!, 431 + maxLines: 2, 432 + overflow: TextOverflow.ellipsis, 433 + style: theme.textTheme.bodyMedium?.copyWith( 434 + height: 1.15, 435 + fontSize: theme.textTheme.bodyMedium!.fontSize! - 2, 436 + color: theme.colorScheme.onSurface.withAlpha(150), 437 + ), 438 + ), 439 + ], 440 + ), 441 + ), 442 + Text( 443 + metadata.url ?? '', 444 + maxLines: 1, 445 + overflow: TextOverflow.ellipsis, 446 + style: theme.textTheme.bodySmall?.copyWith( 447 + fontSize: theme.textTheme.bodySmall!.fontSize! - 2, 448 + color: theme.colorScheme.onSurface.withAlpha(150), 449 + ), 450 + ), 451 + ], 452 + ), 453 + ); 454 + } 455 + 456 + String _limitLength(String text, int maxLength) { 457 + if (text.length <= maxLength) return text; 458 + return '${text.substring(0, maxLength)}...'; 459 + } 460 + } 461 + 462 + class _SprkPostThumbnail extends StatelessWidget { 463 + const _SprkPostThumbnail({required this.postUri}); 464 + 465 + final String postUri; 466 + 467 + @override 468 + Widget build(BuildContext context) { 469 + final theme = Theme.of(context); 470 + 471 + return GestureDetector( 472 + onTap: () => _navigateToPost(context), 473 + child: Container( 474 + width: double.infinity, 475 + height: 100, 476 + decoration: BoxDecoration( 477 + borderRadius: BorderRadius.circular(12), 478 + border: Border.all(color: theme.dividerColor, width: 0.5), 479 + color: theme.colorScheme.surface, 480 + ), 481 + child: Row( 482 + children: [ 483 + Container( 484 + width: 100, 485 + height: 100, 486 + decoration: BoxDecoration( 487 + color: theme.colorScheme.primary.withAlpha(30), 488 + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), bottomLeft: Radius.circular(12)), 489 + ), 490 + child: Center( 491 + child: Column( 492 + mainAxisAlignment: MainAxisAlignment.center, 493 + children: [ 494 + Icon(FluentIcons.play_circle_24_filled, size: 32, color: theme.colorScheme.primary), 495 + const SizedBox(height: 4), 496 + Text( 497 + 'SPRK', 498 + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.bold), 499 + ), 500 + ], 501 + ), 502 + ), 503 + ), 504 + Expanded( 505 + child: Padding( 506 + padding: const EdgeInsets.all(12.0), 507 + child: Column( 508 + crossAxisAlignment: CrossAxisAlignment.start, 509 + mainAxisAlignment: MainAxisAlignment.center, 510 + children: [ 511 + Text('View Post', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), 512 + const SizedBox(height: 4), 513 + Text( 514 + 'Tap to view this post on Spark Social', 515 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withAlpha(150)), 516 + ), 517 + const SizedBox(height: 8), 518 + Text( 519 + postUri, 520 + maxLines: 1, 521 + overflow: TextOverflow.ellipsis, 522 + style: theme.textTheme.bodySmall?.copyWith( 523 + fontSize: theme.textTheme.bodySmall!.fontSize! - 2, 524 + color: theme.colorScheme.onSurface.withAlpha(100), 525 + fontFamily: 'monospace', 526 + ), 527 + ), 528 + ], 529 + ), 530 + ), 531 + ), 532 + ], 533 + ), 534 + ), 535 + ); 536 + } 537 + 538 + void _navigateToPost(BuildContext context) { 539 + try { 540 + // Transform the URI format: insert /so.sprk.feed.post before the post ID 541 + String transformedUri = postUri; 542 + 543 + // Find the last slash and insert /so.sprk.feed.post before the post ID 544 + int lastSlashIndex = postUri.lastIndexOf('/'); 545 + if (lastSlashIndex != -1) { 546 + String beforePostId = postUri.substring(0, lastSlashIndex); 547 + String postId = postUri.substring(lastSlashIndex + 1); 548 + transformedUri = '$beforePostId/so.sprk.feed.post/$postId'; 549 + } 550 + 551 + context.router.push(StandalonePostRoute(postUri: transformedUri)); 552 + } catch (e) { 553 + GetIt.I<LogService>().getLogger('_SprkPostThumbnail').e('Failed to navigate to post $postUri: $e'); 554 + } 555 + } 556 + }
+31
lib/src/features/messages/ui/widgets/sender_avatar.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 3 + import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 4 + import 'package:sparksocial/src/features/messages/ui/pages/chat_page.dart'; 5 + 6 + class SenderAvatar extends StatelessWidget { 7 + const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); 8 + 9 + final bool isCurrentUser; 10 + final String? otherUserAvatar; 11 + final String? otherUserHandle; 12 + 13 + @override 14 + Widget build(BuildContext context) { 15 + if (isCurrentUser) { 16 + return UserAvatar( 17 + imageUrl: null, // Current user avatar - can be added later 18 + username: 'You', 19 + size: 32, 20 + backgroundColor: AppColors.primary, 21 + ); 22 + } 23 + 24 + return UserAvatar( 25 + imageUrl: otherUserAvatar, 26 + username: otherUserHandle ?? 'User', 27 + size: 32, 28 + backgroundColor: getAvatarColor((otherUserHandle ?? 'User').hashCode), 29 + ); 30 + } 31 + }
+3 -70
lib/src/features/posting/providers/video_upload_provider.dart
··· 1 - import 'dart:convert'; 2 - import 'dart:io'; 3 1 4 2 import 'package:atproto/core.dart'; 5 3 import 'package:get_it/get_it.dart'; 6 - import 'package:http/http.dart' as http; 7 - import 'package:path/path.dart' as path; 8 4 import 'package:riverpod_annotation/riverpod_annotation.dart'; 9 5 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 10 6 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 11 7 12 8 import '../../../core/auth/data/repositories/auth_repository.dart'; 13 - import '../../../core/config/app_config.dart'; 14 9 import '../../../core/utils/logging/log_service.dart'; 15 10 import 'video_upload_state.dart'; 16 11 ··· 19 14 @riverpod 20 15 class VideoUpload extends _$VideoUpload { 21 16 late final AuthRepository _authRepository; 17 + late final FeedRepository _feedRepository; 22 18 late final SparkLogger _logger; 23 19 24 20 @override 25 21 VideoUploadState build(String videoPath) { 26 22 _authRepository = GetIt.instance<SprkRepository>().authRepository; 23 + _feedRepository = GetIt.instance<SprkRepository>().feed; 27 24 _logger = GetIt.instance<LogService>().getLogger('VideoService'); 28 25 return VideoUploadState.initial(videoPath: videoPath); 29 26 } ··· 34 31 state = VideoUploadState.processingVideo(videoPath: videoPath); 35 32 _logger.i('Starting video processing for: $videoPath'); 36 33 37 - final authAtProto = _authRepository.atproto; 38 - if (authAtProto == null || authAtProto.session == null) { 39 - throw Exception('AtProto not initialized'); 40 - } 41 - 42 - // Handle file:// URL scheme 43 - String cleanVideoPath = videoPath; 44 - if (videoPath.startsWith('file://')) { 45 - cleanVideoPath = videoPath.replaceFirst('file://', ''); 46 - } 47 - 48 - // Validate the video file 49 - final file = File(cleanVideoPath); 50 - if (!await file.exists()) { 51 - throw Exception('Video file not found: $cleanVideoPath'); 52 - } 53 - 54 - // Check if the video is in a compatible format 55 - final videoBytes = await file.readAsBytes(); 56 - if (videoBytes.isEmpty) { 57 - throw Exception('Video file is empty'); 58 - } 59 - 60 - _logger.i('Video file size: ${videoBytes.length} bytes'); 61 - 62 - final pdsService = authAtProto.service; 63 - final serviceTokenRes = await authAtProto.server.getServiceAuth( 64 - aud: 'did:web:$pdsService', 65 - lxm: NSID.parse('com.atproto.repo.uploadBlob'), 66 - ); 67 - 68 - final serviceToken = serviceTokenRes.data.token; 69 - final response = await http.post( 70 - Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo'), 71 - headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, 72 - body: videoBytes, 73 - ); 74 - 75 - if (response.statusCode != 200) { 76 - throw Exception('Failed to upload video: ${response.statusCode} ${response.body}'); 77 - } 78 - 79 - // Parse the response 80 - final responseData = jsonDecode(response.body); 81 - Blob blob; 82 - //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon 83 - blob = Blob.fromJson(responseData['blobRef']); 34 + final blob = await _feedRepository.uploadVideo(videoPath); 84 35 85 36 state = VideoUploadState.videoProcessed(videoPath: videoPath, blob: blob); 86 37 ··· 197 148 ); 198 149 199 150 _logger.i('Successfully crossposted video to Bluesky: ${bskyResult.data.uri}'); 200 - } 201 - 202 - /// Helper method to determine content type based on file extension 203 - String _getContentType(String videoPath) { 204 - final extension = path.extension(videoPath).toLowerCase(); 205 - 206 - switch (extension) { 207 - case '.mp4': 208 - return 'video/mp4'; 209 - case '.mov': 210 - return 'video/quicktime'; 211 - case '.avi': 212 - return 'video/x-msvideo'; 213 - case '.webm': 214 - return 'video/webm'; 215 - default: 216 - return 'video/mp4'; // Default to mp4 217 - } 218 151 } 219 152 }
+4 -1
lib/src/features/posting/ui/pages/story_review_page.dart
··· 120 120 121 121 Future<void> _postImageStory() async { 122 122 final feedRepository = GetIt.I<SprkRepository>().feed; 123 - final uploadedImageMaps = await feedRepository.uploadImages([widget.imageFile], {widget.imageFile.path: _altText}); 123 + final uploadedImageMaps = await feedRepository.uploadImages( 124 + imageFiles: [widget.imageFile], 125 + altTexts: {widget.imageFile.path: _altText}, 126 + ); 124 127 125 128 if (uploadedImageMaps.isNotEmpty) { 126 129 ref.read(
+18 -2
pubspec.lock
··· 33 33 url: "https://pub.dev" 34 34 source: hosted 35 35 version: "2.0.3" 36 + any_link_preview: 37 + dependency: "direct main" 38 + description: 39 + name: any_link_preview 40 + sha256: "0617bd49a58dd0478cd5c4c83bcf7a2d1b7d301aad229817d13693fe86807ea1" 41 + url: "https://pub.dev" 42 + source: hosted 43 + version: "3.0.3" 36 44 archive: 37 45 dependency: transitive 38 46 description: ··· 724 732 dependency: transitive 725 733 description: 726 734 name: html 727 - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" 735 + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" 728 736 url: "https://pub.dev" 729 737 source: hosted 730 - version: "0.15.5" 738 + version: "0.15.6" 731 739 http: 732 740 dependency: "direct main" 733 741 description: ··· 1445 1453 url: "https://pub.dev" 1446 1454 source: hosted 1447 1455 version: "1.4.1" 1456 + string_validator: 1457 + dependency: transitive 1458 + description: 1459 + name: string_validator 1460 + sha256: "240f4c98027dfbe8639c8271ef18cc9de735b47067aa15a720cfed9576a512b1" 1461 + url: "https://pub.dev" 1462 + source: hosted 1463 + version: "1.2.0" 1448 1464 synchronized: 1449 1465 dependency: "direct main" 1450 1466 description:
+1
pubspec.yaml
··· 55 55 imgly_editor: ^1.51.0 56 56 socket_io_client: ^3.1.2 57 57 web_socket_channel: ^3.0.3 58 + any_link_preview: ^3.0.3 58 59 59 60 dev_dependencies: 60 61 flutter_test: