diff --git a/.env.example b/.env.example index 5a35b19e..290dc96e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VIDEO_SERVICE_URL=https://video.sprk.so -SPRK_APPVIEW_URL=https://api.sprk.so \ No newline at end of file +SPRK_APPVIEW_URL=https://api.sprk.so +MESSAGES_SERVICE_URL=https://chat.sprk.so \ No newline at end of file diff --git a/.github/workflows/android-internal-release.yml b/.github/workflows/android-internal-release.yml index ca006734..9a872738 100644 --- a/.github/workflows/android-internal-release.yml +++ b/.github/workflows/android-internal-release.yml @@ -49,9 +49,10 @@ jobs: - name: Setup env run: | - echo "VIDEO_SERVICE_URL=${{ secrets.VIDEO_SERVICE_URL }}" >> .env - echo "SPRK_APPVIEW_URL=${{ secrets.SPRK_APPVIEW_URL }}" >> .env - echo "SIGNUPS_DISABLED=${{ secrets.SIGNUPS_DISABLED }}" >> .env + echo "VIDEO_SERVICE_URL=https://video.sprk.so" >> .env + echo "SPRK_APPVIEW_URL=https://api.sprk.so" >> .env + echo "MESSAGES_SERVICE_URL=https://chat.sprk.so" >> .env + echo "SIGNUPS_DISABLED=false" >> .env - name: Set versionCode (commit count + run attempt) run: | diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f177ee26..f314d035 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -142,7 +142,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: - camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 + camera_avfoundation: 04b44aeb14070126c6529e5ab82cc7c9fca107cf Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c03aaf93..ae4a9728 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -295,14 +295,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index c8e8d7d8..72d2e93d 100755 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -20,6 +20,7 @@ flutter pub get cat > .env << EOL VIDEO_SERVICE_URL=https://video.sprk.so SPRK_APPVIEW_URL=https://api.sprk.so +MESSAGES_SERVICE_URL=https://chat.sprk.so EOL # Install CocoaPods using Homebrew. diff --git a/lib/src/core/auth/data/models/messages_auth_models.dart b/lib/src/core/auth/data/models/messages_auth_models.dart new file mode 100644 index 00000000..72d26b8c --- /dev/null +++ b/lib/src/core/auth/data/models/messages_auth_models.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'messages_auth_models.freezed.dart'; +part 'messages_auth_models.g.dart'; + +@freezed +abstract class MessagesUser with _$MessagesUser { + factory MessagesUser({required String did, required String handle, String? displayName}) = _MessagesUser; + factory MessagesUser.fromJson(Map json) => _$MessagesUserFromJson(json); +} + +@freezed +abstract class MessagesAuthResponse with _$MessagesAuthResponse { + factory MessagesAuthResponse({required String accessToken, required String refreshToken, required MessagesUser user}) = + _MessagesAuthResponse; + + factory MessagesAuthResponse.fromJson(Map json) => _$MessagesAuthResponseFromJson(json); +} diff --git a/lib/src/core/auth/data/repositories/auth_repository.dart b/lib/src/core/auth/data/repositories/auth_repository.dart index fd9e6970..f750b261 100644 --- a/lib/src/core/auth/data/repositories/auth_repository.dart +++ b/lib/src/core/auth/data/repositories/auth_repository.dart @@ -13,6 +13,10 @@ abstract class AuthRepository { /// Gets the AT Protocol client ATProto? get atproto; + String? get dmAccessToken; + + String? get dmRefreshToken; + /// Attempts to log in a user with the provided credentials /// /// [handle] - The user handle @@ -40,4 +44,10 @@ abstract class AuthRepository { /// Refreshes the authentication token /// Returns true if the session was successfully refreshed Future refreshToken(); + + /// Refreshes the DM token + Future refreshDMToken(); + + /// Logs in to the message service only + Future loginMessageService(); } diff --git a/lib/src/core/auth/data/repositories/auth_repository_impl.dart b/lib/src/core/auth/data/repositories/auth_repository_impl.dart index 2fe44241..353ca8f4 100644 --- a/lib/src/core/auth/data/repositories/auth_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/auth_repository_impl.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:http/http.dart' as http; import 'package:sparksocial/src/core/auth/data/models/login_result.dart'; import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; +import 'package:sparksocial/src/core/config/app_config.dart'; import 'package:sparksocial/src/core/storage/storage.dart'; import 'package:sparksocial/src/core/utils/logging/log_service.dart'; @@ -14,6 +15,9 @@ import 'package:sparksocial/src/core/utils/logging/log_service.dart'; class AuthRepositoryImpl implements AuthRepository { Session? _session; ATProto? _atProto; + String? _dmAccessToken; + String? _dmRefreshToken; + final _logger = GetIt.instance().getLogger('AuthRepository'); final Completer _initCompleter = Completer(); @@ -28,6 +32,12 @@ class AuthRepositoryImpl implements AuthRepository { @override ATProto? get atproto => _atProto; + @override + String? get dmAccessToken => _dmAccessToken; + + @override + String? get dmRefreshToken => _dmRefreshToken; + AuthRepositoryImpl() { _logger.i('Initializing AuthRepository'); _initialize(); @@ -67,6 +77,34 @@ class AuthRepositoryImpl implements AuthRepository { return DateTime.now().isAfter(token.exp.subtract(const Duration(minutes: 5))); } + /// Logs in the message service + @override + Future loginMessageService() async { + try { + _logger.i('Logging in to message service'); + final response = await http.post( + Uri.parse('${AppConfig.messagesServiceUrl}/auth/login-jwt'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'did': _session!.did, 'jwt': _session!.accessJwt}), + ); + _logger.d('Message service login response status: ${response.body}'); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _dmAccessToken = data['accessJwt']; + _dmRefreshToken = data['refreshJwt']; + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, _dmAccessToken!); + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, _dmRefreshToken!); + _logger.i('Logged in to message service successfully'); + } else { + throw Exception('Failed to login to message service: ${response.statusCode}'); + } + } catch (e) { + _logger.e('Failed to login to message service', error: e); + rethrow; + } + } + Future _loadSavedSession() async { try { _logger.d('Loading saved session'); @@ -90,6 +128,39 @@ class AuthRepositoryImpl implements AuthRepository { } _atProto = ATProto.fromSession(_session!); + + _dmAccessToken = await StorageManager.instance.secure.getString(StorageKeys.dmAccessToken); + _dmRefreshToken = await StorageManager.instance.secure.getString(StorageKeys.dmRefreshToken); + + try { + if (_dmAccessToken == null) { + _logger.w('DM access token not found, refreshing DM token'); + if (!await refreshDMToken()) { + throw Exception('Failed to refresh DM token'); + } + } + final response = await http.get( + Uri.parse('${AppConfig.messagesServiceUrl}/auth/session'), + headers: {'Authorization': 'Bearer $_dmAccessToken'}, + ); + _logger.d('DM session response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + _dmAccessToken = data['access_token']; + _dmRefreshToken = data['refresh_token']; + } else { + if (!await refreshDMToken()) { + throw Exception('Failed to refresh DM token'); + } + } + } catch (e) { + if (!await refreshDMToken()) { + _logger.e('Failed to refresh DM token, trying to login again'); + await loginMessageService(); + } + } + _logger.i('Session loaded successfully for user: ${_session!.handle}'); } catch (e) { _logger.e('Error loading saved session', error: e); @@ -97,6 +168,26 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future refreshDMToken() async { + _logger.i('Refreshing DM token $_dmRefreshToken'); + final response = await http.post( + Uri.parse('${AppConfig.messagesServiceUrl}/auth/refresh'), + headers: {'Content-Type': 'application/json', 'Cookie': 'refresh_token=$_dmRefreshToken'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, data['access_token']); + _dmAccessToken = data['access_token']; + _dmRefreshToken = data['refresh_token']; + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, data['refresh_token']); + return true; + } + _logger.e('Failed to refresh DM token: ${response.statusCode} - ${response.body}'); + return false; + } + Future _refreshSession() async { try { if (_session == null) { @@ -130,6 +221,8 @@ class AuthRepositoryImpl implements AuthRepository { _session = response.data; + await refreshDMToken(); + await _saveSession(_session!); _atProto = ATProto.fromSession(_session!); _logger.i('Session refreshed successfully'); @@ -146,6 +239,8 @@ class AuthRepositoryImpl implements AuthRepository { _logger.d('Saving session for user: ${sessionData.handle}'); final sessionJson = sessionData.toJson(); await StorageManager.instance.secure.setString(StorageKeys.userSession, json.encode(sessionJson)); + await StorageManager.instance.secure.setString(StorageKeys.dmAccessToken, _dmAccessToken ?? ''); + await StorageManager.instance.secure.setString(StorageKeys.dmRefreshToken, _dmRefreshToken ?? ''); _logger.d('Session saved successfully'); } catch (e) { _logger.e('Failed to save session', error: e); @@ -214,6 +309,7 @@ class AuthRepositoryImpl implements AuthRepository { _atProto = ATProto.fromSession(_session!); await _saveSession(_session!); _logger.i('Login successful for user: $handle'); + await loginMessageService(); return LoginResult.success(); } catch (e) { @@ -250,16 +346,7 @@ class AuthRepositoryImpl implements AuthRepository { } _logger.d('Account created, creating session'); - Session session = Session.fromJson({ - 'did': createResponse.data.did, - 'handle': handle, - 'email': email, - 'emailConfirmed': false, - 'accessJwt': createResponse.data.accessJwt, - 'refreshJwt': createResponse.data.refreshJwt, - 'didDoc': createResponse.data.didDoc, - 'active': true, - }); + await login(handle, password); _session = session; if (_session == null) { @@ -287,6 +374,12 @@ class AuthRepositoryImpl implements AuthRepository { await _clearSavedSession(); _session = null; _atProto = null; + _dmAccessToken = null; + _dmRefreshToken = null; + await http.post( + Uri.parse('${AppConfig.messagesServiceUrl}/auth/logout'), + headers: {'Authorization': 'Bearer $_dmAccessToken', 'Cookie': 'refresh_token=$_dmRefreshToken'}, + ); _logger.i('Logout successful'); } } catch (e) { diff --git a/lib/src/core/config/app_config.dart b/lib/src/core/config/app_config.dart index b0bcd730..69630b13 100644 --- a/lib/src/core/config/app_config.dart +++ b/lib/src/core/config/app_config.dart @@ -8,28 +8,25 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; /// to maintain a single source of truth for application settings. class AppConfig { /// Base URL for the video processing service. - static String get videoServiceUrl => - _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); + static String get videoServiceUrl => _getStringValue('VIDEO_SERVICE_URL', 'http://localhost:3000'); /// URL for the app view (web view display). - static String get appViewUrl => - _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); + static String get appViewUrl => _getStringValue('SPRK_APPVIEW_URL', 'http://localhost:3000'); + + /// Base URL for the messages service (chat service). + static String get messagesServiceUrl => _getStringValue('MESSAGES_SERVICE_URL', 'http://localhost:3000'); /// Whether new user registrations are disabled. - static bool get signupsDisabled => - _getBoolValue('SIGNUPS_DISABLED', false); + static bool get signupsDisabled => _getBoolValue('SIGNUPS_DISABLED', false); /// API request timeout in seconds. - static int get apiTimeoutSeconds => - _getIntValue('API_TIMEOUT_SECONDS', 30); + static int get apiTimeoutSeconds => _getIntValue('API_TIMEOUT_SECONDS', 30); /// Maximum upload file size in MB. - static double get maxUploadSizeMB => - _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100.0); + static double get maxUploadSizeMB => _getDoubleValue('MAX_UPLOAD_SIZE_MB', 100.0); /// The current application environment (development, production, etc.) - static String get environment => - _getStringValue('ENVIRONMENT', 'development'); + static String get environment => _getStringValue('ENVIRONMENT', 'development'); /// Checks if the app is running in development mode. static bool get isDevelopment => environment == 'development'; @@ -37,10 +34,6 @@ class AppConfig { /// Checks if the app is running in production mode. static bool get isProduction => environment == 'production'; - /// Base URL for the chat service (Socket.io server). - static String get chatServiceUrl => - _getStringValue('CHAT_SERVICE_URL', 'http://localhost:3000'); - /// Helper method to retrieve string values from environment with defaults static String _getStringValue(String key, String defaultValue) { return dotenv.env[key] ?? defaultValue; diff --git a/lib/src/core/di/service_locator.dart b/lib/src/core/di/service_locator.dart index 6276b128..4ef741c1 100644 --- a/lib/src/core/di/service_locator.dart +++ b/lib/src/core/di/service_locator.dart @@ -1,5 +1,7 @@ import 'package:get_it/get_it.dart'; import 'package:sparksocial/src/core/auth/data/repositories/auth_repository_impl.dart'; +import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; +import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository_impl.dart'; import 'package:sparksocial/src/core/storage/cache/cache_manager_impl.dart'; import 'package:sparksocial/src/core/storage/cache/download_manager_interface.dart'; import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; @@ -16,8 +18,6 @@ import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repositor import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; -import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; -import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository_impl.dart'; // This is the ONLY PLACE IN THE ENTIRE APP where implementations are imported // All the other files should import interfaces only (polymorphism) to keep everything decoupled @@ -54,7 +54,7 @@ Future initServiceLocator() async { sl.registerSingleton(AuthRepositoryImpl()); // Register Chat dependencies - sl.registerSingleton(ChatRepositoryImpl()); + sl.registerSingleton(MessagesRepositoryImpl(sl())); // Register SprkRepository with its interface sl.registerSingleton(SprkRepositoryImpl(sl())); diff --git a/lib/src/core/network/atproto/data/repositories/actor_repository.dart b/lib/src/core/network/atproto/data/repositories/actor_repository.dart index a71a6554..84abab31 100644 --- a/lib/src/core/network/atproto/data/repositories/actor_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/actor_repository.dart @@ -8,6 +8,11 @@ abstract class ActorRepository { /// [did] The DID of the profile to get Future getProfile(String did); + /// Get multiple profiles by their DIDs + /// + /// [dids] A list of DIDs to fetch profiles for + Future> getProfiles(List dids); + /// Search actors by query string. /// /// [query] The search query. diff --git a/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart index 10442105..d2519328 100644 --- a/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart @@ -192,4 +192,38 @@ class ActorRepositoryImpl implements ActorRepository { _logger.d('Preferences updated successfully'); }); } + + @override + Future> getProfiles(List dids) { + _logger.d('Getting profiles for DIDs: $dids'); + return _client.executeWithRetry(() async { + if (!_client.authRepository.isAuthenticated) { + _logger.w('Not authenticated'); + throw Exception('Not authenticated'); + } + + final atproto = _client.authRepository.atproto; + if (atproto == null) { + _logger.e('AtProto not initialized'); + throw Exception('AtProto not initialized'); + } + try { + final result = await atproto.get( + NSID.parse('so.sprk.actor.getProfiles'), + parameters: {'actors': dids}, + headers: {'atproto-proxy': _client.sprkDid}, + to: (jsonMap) => jsonMap, + adaptor: (uint8) => jsonDecode(utf8.decode(uint8)), + ); + return (result.data["profiles"] as List).map((json) => ProfileViewDetailed.fromJson(json)).toList(); + } catch (e) { + _logger.e('Failed to retrieve profile for DIDs: $dids', error: e); + _logger.i('Trying to get profiles from bluesky'); + final bluesky = bsky.Bluesky.fromSession(_client.authRepository.session!); + final profiles = await bluesky.actor.getProfiles(actors: dids); + _logger.d('Profiles retrieved successfully from bluesky'); + return profiles.data.profiles.map((p) => ProfileViewDetailed.fromJson(p.toJson())).toList(); + } + }); + } } diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository.dart b/lib/src/core/network/atproto/data/repositories/feed_repository.dart index 6d3b98a6..2698d6ea 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository.dart @@ -79,7 +79,12 @@ abstract class FeedRepository { /// /// [imageFiles] List of image files to upload /// [altTexts] Map of file paths to alt texts - Future> uploadImages(List imageFiles, Map altTexts); + Future> uploadImages({required List imageFiles, Map? altTexts}); + + /// Upload a video to the server + /// + /// [videoPath] The path to the video file + Future uploadVideo(String videoPath); /// Post a video to the user's feed /// diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index c00624f9..1555402e 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -1,12 +1,16 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:atproto/core.dart'; import 'package:atproto/atproto.dart'; import 'package:bluesky/bluesky.dart' as bsky; import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; import 'package:image/image.dart' as img; import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as path; +import 'package:sparksocial/src/core/config/app_config.dart'; import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; import 'package:sparksocial/src/core/feed_algorithms/hardcoded_feed_algorithm.dart'; @@ -330,7 +334,7 @@ class FeedRepositoryImpl implements FeedRepository { Map? embedJson; if (imageFiles case List files when files.isNotEmpty) { _logger.d('Uploading ${files.length} images for comment'); - final List uploadedImageMaps = await uploadImages(files, altTexts ?? {}); + final List uploadedImageMaps = await uploadImages(imageFiles: files, altTexts: altTexts); embedJson = EmbedImage(images: uploadedImageMaps).toJson(); } @@ -398,7 +402,7 @@ class FeedRepositoryImpl implements FeedRepository { } if (_client.authRepository.atproto case final atproto?) { - final List uploadedImageMaps = await uploadImages(imageFiles, altTexts); + final List uploadedImageMaps = await uploadImages(imageFiles: imageFiles, altTexts: altTexts); // Create Sprk post first final record = PostRecord( @@ -432,7 +436,7 @@ class FeedRepositoryImpl implements FeedRepository { /// Helper to upload multiple images, stripping EXIF, and return a list of JSON maps for embedding @override - Future> uploadImages(List imageFiles, Map altTexts) async { + Future> uploadImages({required List imageFiles, Map? altTexts}) async { _logger.d('Processing ${imageFiles.length} images for upload'); final List uploadedImageMaps = []; @@ -464,7 +468,7 @@ class FeedRepositoryImpl implements FeedRepository { _logger.d('Image uploaded successfully: ${imageFile.name}'); // Add the uploaded image to our result list - uploadedImageMaps.add(Image(alt: altTexts[imageFile.path] ?? '', image: response.data.blob)); + uploadedImageMaps.add(Image(alt: altTexts?[imageFile.path] ?? '', image: response.data.blob)); break; default: _logger.e('Failed to upload image blob: ${response.status.code}'); @@ -482,6 +486,66 @@ class FeedRepositoryImpl implements FeedRepository { return uploadedImageMaps; } + @override + Future uploadVideo(String videoPath) async { + _logger.d('Uploading video from path: $videoPath'); + + return _client.executeWithRetry(() async { + if (!_client.authRepository.isAuthenticated) { + _logger.w('Not authenticated'); + throw Exception('Not authenticated'); + } + final authAtProto = _client.authRepository.atproto; + if (authAtProto == null || authAtProto.session == null) { + throw Exception('AtProto not initialized'); + } + + // Handle file:// URL scheme + String cleanVideoPath = videoPath; + if (videoPath.startsWith('file://')) { + cleanVideoPath = videoPath.replaceFirst('file://', ''); + } + + // Validate the video file + final file = File(cleanVideoPath); + if (!await file.exists()) { + throw Exception('Video file not found: $cleanVideoPath'); + } + + // Check if the video is in a compatible format + final videoBytes = await file.readAsBytes(); + if (videoBytes.isEmpty) { + throw Exception('Video file is empty'); + } + + _logger.i('Video file size: ${videoBytes.length} bytes'); + + final pdsService = authAtProto.service; + final serviceTokenRes = await authAtProto.server.getServiceAuth( + aud: 'did:web:$pdsService', + lxm: NSID.parse('com.atproto.repo.uploadBlob'), + ); + + final serviceToken = serviceTokenRes.data.token; + final response = await http.post( + Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo'), + headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, + body: videoBytes, + ); + + if (response.statusCode != 200) { + throw Exception('Failed to upload video: ${response.statusCode} ${response.body}'); + } + + // Parse the response + final responseData = jsonDecode(response.body); + Blob blob; + //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon + blob = Blob.fromJson(responseData['blobRef']); + return blob; + }); + } + /// Crosspost images to Bluesky using same blobs but Bluesky models Future _crosspostToBlueSky( String text, @@ -827,4 +891,22 @@ class FeedRepositoryImpl implements FeedRepository { } }); } + + /// Helper method to determine content type based on file extension + String _getContentType(String videoPath) { + final extension = path.extension(videoPath).toLowerCase(); + + switch (extension) { + case '.mp4': + return 'video/mp4'; + case '.mov': + return 'video/quicktime'; + case '.avi': + return 'video/x-msvideo'; + case '.webm': + return 'video/webm'; + default: + return 'video/mp4'; // Default to mp4 + } + } } diff --git a/lib/src/core/network/chat/chat.dart b/lib/src/core/network/chat/chat.dart deleted file mode 100644 index 751e4d9d..00000000 --- a/lib/src/core/network/chat/chat.dart +++ /dev/null @@ -1,4 +0,0 @@ -library; - -export 'data/models/models.dart'; -export 'data/repositories/repositories.dart'; \ No newline at end of file diff --git a/lib/src/core/network/chat/data/models/chat_models.dart b/lib/src/core/network/chat/data/models/chat_models.dart deleted file mode 100644 index a1dfdb33..00000000 --- a/lib/src/core/network/chat/data/models/chat_models.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'chat_models.freezed.dart'; -part 'chat_models.g.dart'; - -/// Represents a chat message -@freezed -class ChatMessage with _$ChatMessage { - const ChatMessage._(); - - @JsonSerializable(explicitToJson: true) - const factory ChatMessage({ - required String id, - required String message, - required String senderDid, - required String receiverDid, - required DateTime timestamp, - }) = _ChatMessage; - - factory ChatMessage.fromJson(Map json) => _$ChatMessageFromJson(json); -} - -/// Request model for sending a message -@freezed -class SendMessageRequest with _$SendMessageRequest { - const SendMessageRequest._(); - - @JsonSerializable(explicitToJson: true) - const factory SendMessageRequest({ - required String message, - required String receiverDid, - }) = _SendMessageRequest; - - factory SendMessageRequest.fromJson(Map json) => _$SendMessageRequestFromJson(json); -} - -/// Response model for sending a message -@freezed -class SendMessageResponse with _$SendMessageResponse { - const SendMessageResponse._(); - - @JsonSerializable(explicitToJson: true) - const factory SendMessageResponse({ - required String messageId, - required DateTime timestamp, - }) = _SendMessageResponse; - - factory SendMessageResponse.fromJson(Map json) => _$SendMessageResponseFromJson(json); -} - -/// Response model for getting messages -@freezed -class GetMessagesResponse with _$GetMessagesResponse { - const GetMessagesResponse._(); - - @JsonSerializable(explicitToJson: true) - const factory GetMessagesResponse({ - required List messages, - }) = _GetMessagesResponse; - - factory GetMessagesResponse.fromJson(Map json) => _$GetMessagesResponseFromJson(json); -} - -/// Response model for getting chats list -@freezed -class GetChatsResponse with _$GetChatsResponse { - const GetChatsResponse._(); - - @JsonSerializable(explicitToJson: true) - const factory GetChatsResponse({ - required List chats, - }) = _GetChatsResponse; - - factory GetChatsResponse.fromJson(Map json) => _$GetChatsResponseFromJson(json); -} - -/// WebSocket message types -enum WebSocketMessageType { - @JsonValue('new_message') - newMessage, - @JsonValue('message_read') - messageRead, - @JsonValue('typing') - typing, - @JsonValue('error') - error, -} - -/// WebSocket message data for new message events -@freezed -class WebSocketMessageData with _$WebSocketMessageData { - const WebSocketMessageData._(); - - @JsonSerializable(explicitToJson: true) - const factory WebSocketMessageData({ - required String id, - required String message, - @JsonKey(name: 'sender_did') required String senderDid, - @JsonKey(name: 'receiver_did') required String receiverDid, - required DateTime timestamp, - }) = _WebSocketMessageData; - - factory WebSocketMessageData.fromJson(Map json) => _$WebSocketMessageDataFromJson(json); -} - -/// WebSocket message wrapper -@freezed -class WebSocketMessage with _$WebSocketMessage { - const WebSocketMessage._(); - - @JsonSerializable(explicitToJson: true) - const factory WebSocketMessage({ - required WebSocketMessageType type, - WebSocketMessageData? data, - String? error, - }) = _WebSocketMessage; - - factory WebSocketMessage.fromJson(Map json) => _$WebSocketMessageFromJson(json); -} - -/// Health check response -@freezed -class HealthCheckResponse with _$HealthCheckResponse { - const HealthCheckResponse._(); - - @JsonSerializable(explicitToJson: true) - const factory HealthCheckResponse({ - required String status, - required DateTime timestamp, - required int connectedClients, - }) = _HealthCheckResponse; - - factory HealthCheckResponse.fromJson(Map json) => _$HealthCheckResponseFromJson(json); -} - -/// Chat conversation model for UI purposes -@freezed -class ChatConversation with _$ChatConversation { - const ChatConversation._(); - - @JsonSerializable(explicitToJson: true) - const factory ChatConversation({ - required String otherUserDid, - String? otherUserHandle, - String? otherUserDisplayName, - String? otherUserAvatar, - ChatMessage? lastMessage, - @Default(0) int unreadCount, - DateTime? lastActivity, - }) = _ChatConversation; - - factory ChatConversation.fromJson(Map json) => _$ChatConversationFromJson(json); -} \ No newline at end of file diff --git a/lib/src/core/network/chat/data/models/models.dart b/lib/src/core/network/chat/data/models/models.dart deleted file mode 100644 index 63fbc21d..00000000 --- a/lib/src/core/network/chat/data/models/models.dart +++ /dev/null @@ -1 +0,0 @@ -export 'chat_models.dart'; \ No newline at end of file diff --git a/lib/src/core/network/chat/data/repositories/chat_repository.dart b/lib/src/core/network/chat/data/repositories/chat_repository.dart deleted file mode 100644 index 4b102c29..00000000 --- a/lib/src/core/network/chat/data/repositories/chat_repository.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Interface for Spark Chat API endpoints -abstract class ChatRepository { - /// Send a message to another user - /// - /// [message] The message content to send - /// [receiverDid] The DID of the user to send the message to - Future sendMessage(String message, String receiverDid); - - /// Get messages for a conversation with another user - /// - /// [otherDid] The DID of the other user in the conversation - /// [limit] Maximum number of messages to retrieve (default 50) - Future getMessages(String otherDid, {int limit = 50}); - - /// Get list of all chats (conversations) for the current user - Future getChats(); - - /// Connect to WebSocket for real-time messaging - /// - /// Returns a WebSocketChannel for receiving real-time messages - WebSocketChannel connectWebSocket(); - - /// Check the health status of the chat service - Future healthCheck(); - - /// Set the JWT token for authentication - /// - /// [token] The JWT token from ATProtocol authentication - void setAuthToken(String token); - - /// Close any open WebSocket connections - void closeWebSocket(); -} \ No newline at end of file diff --git a/lib/src/core/network/chat/data/repositories/chat_repository_impl.dart b/lib/src/core/network/chat/data/repositories/chat_repository_impl.dart deleted file mode 100644 index 2e806eb2..00000000 --- a/lib/src/core/network/chat/data/repositories/chat_repository_impl.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:convert'; - -import 'package:get_it/get_it.dart'; -import 'package:http/http.dart' as http; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; -import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; -import 'package:sparksocial/src/core/utils/logging/log_service.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -/// Implementation of Spark Chat API endpoints -class ChatRepositoryImpl implements ChatRepository { - static const String _baseUrl = 'https://chat.sprk.so'; - static const String _wsUrl = 'wss://chat.sprk.so/ws'; - - final _logger = GetIt.instance().getLogger('ChatRepository'); - final http.Client _httpClient; - - String? _jwtToken; - WebSocketChannel? _webSocketChannel; - - ChatRepositoryImpl([http.Client? httpClient]) : _httpClient = httpClient ?? http.Client() { - _logger.v('ChatRepository initialized'); - } - - @override - void setAuthToken(String token) { - _jwtToken = token; - _logger.d('JWT token set for chat authentication'); - } - - Map get _authHeaders { - if (_jwtToken == null) { - throw Exception('JWT token not set. Call setAuthToken() first.'); - } - return { - 'Authorization': 'Bearer $_jwtToken', - 'Content-Type': 'application/json', - }; - } - - @override - Future sendMessage(String message, String receiverDid) async { - _logger.d('Sending message to DID: $receiverDid'); - - try { - final request = SendMessageRequest( - message: message, - receiverDid: receiverDid, - ); - - final response = await _httpClient.post( - Uri.parse('$_baseUrl/xrpc/so.sprk.chat.sendMessage'), - headers: _authHeaders, - body: jsonEncode(request.toJson()), - ); - - if (response.statusCode == 200) { - final responseData = jsonDecode(response.body) as Map; - _logger.d('Message sent successfully'); - return SendMessageResponse.fromJson(responseData); - } else { - _logger.e('Failed to send message: ${response.statusCode} ${response.body}'); - throw Exception('Failed to send message: ${response.statusCode} ${response.body}'); - } - } catch (e) { - _logger.e('Error sending message', error: e); - rethrow; - } - } - - @override - Future getMessages(String otherDid, {int limit = 50}) async { - _logger.d('Getting messages for conversation with DID: $otherDid, limit: $limit'); - - try { - final uri = Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getMessages').replace( - queryParameters: { - 'otherDid': otherDid, - 'limit': limit.toString(), - }, - ); - - final response = await _httpClient.get( - uri, - headers: _authHeaders, - ); - - if (response.statusCode == 200) { - final responseData = jsonDecode(response.body) as Map; - _logger.d('Messages retrieved successfully'); - return GetMessagesResponse.fromJson(responseData); - } else { - _logger.e('Failed to get messages: ${response.statusCode} ${response.body}'); - throw Exception('Failed to get messages: ${response.statusCode} ${response.body}'); - } - } catch (e) { - _logger.e('Error getting messages', error: e); - rethrow; - } - } - - @override - Future getChats() async { - _logger.d('Getting chats list'); - - try { - final response = await _httpClient.get( - Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getChats'), - headers: _authHeaders, - ); - - if (response.statusCode == 200) { - final responseData = jsonDecode(response.body) as Map; - _logger.d('Chats list retrieved successfully'); - return GetChatsResponse.fromJson(responseData); - } else { - _logger.e('Failed to get chats: ${response.statusCode} ${response.body}'); - throw Exception('Failed to get chats: ${response.statusCode} ${response.body}'); - } - } catch (e) { - _logger.e('Error getting chats', error: e); - rethrow; - } - } - - @override - WebSocketChannel connectWebSocket() { - _logger.d('Connecting to WebSocket'); - - try { - if (_jwtToken == null) { - throw Exception('JWT token not set. Call setAuthToken() first.'); - } - - // Close existing connection if any - closeWebSocket(); - - _webSocketChannel = WebSocketChannel.connect( - Uri.parse(_wsUrl), - protocols: ['Bearer $_jwtToken'], // Some WebSocket implementations expect protocols - ); - - _logger.d('WebSocket connected successfully'); - return _webSocketChannel!; - } catch (e) { - _logger.e('Error connecting to WebSocket', error: e); - rethrow; - } - } - - @override - void closeWebSocket() { - _logger.d('Closing WebSocket connection'); - - try { - _webSocketChannel?.sink.close(); - _webSocketChannel = null; - _logger.d('WebSocket connection closed'); - } catch (e) { - _logger.w('Error closing WebSocket', error: e); - } - } - - @override - Future healthCheck() async { - _logger.d('Performing health check'); - - try { - final response = await _httpClient.get( - Uri.parse('$_baseUrl/health'), - // Health check doesn't require authentication - ); - - if (response.statusCode == 200) { - final responseData = jsonDecode(response.body) as Map; - _logger.d('Health check successful'); - return HealthCheckResponse.fromJson(responseData); - } else { - _logger.e('Health check failed: ${response.statusCode} ${response.body}'); - throw Exception('Health check failed: ${response.statusCode} ${response.body}'); - } - } catch (e) { - _logger.e('Error during health check', error: e); - rethrow; - } - } - - /// Dispose of resources - void dispose() { - _logger.d('Disposing ChatRepository'); - closeWebSocket(); - _httpClient.close(); - } -} \ No newline at end of file diff --git a/lib/src/core/network/chat/data/repositories/repositories.dart b/lib/src/core/network/chat/data/repositories/repositories.dart deleted file mode 100644 index 7517feb8..00000000 --- a/lib/src/core/network/chat/data/repositories/repositories.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'chat_repository.dart'; -export 'chat_repository_impl.dart'; \ No newline at end of file diff --git a/lib/src/core/network/messages/data/models/message_models.dart b/lib/src/core/network/messages/data/models/message_models.dart new file mode 100644 index 00000000..139b08b5 --- /dev/null +++ b/lib/src/core/network/messages/data/models/message_models.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'message_models.freezed.dart'; +part 'message_models.g.dart'; + +@freezed +class Embed with _$Embed { + const Embed._(); + + @JsonSerializable(explicitToJson: true) + const factory Embed({String? url, String? type, String? preview}) = _Embed; + + factory Embed.fromJson(Map json) => _$EmbedFromJson(json); + + bool get isEmpty => url == null && type == null && preview == null; + bool get isNotEmpty => !isEmpty; +} + +@freezed +class Message with _$Message { + const Message._(); + @JsonSerializable(explicitToJson: true) + const factory Message({ + required int id, + @JsonKey(name: 'sender_did') required String senderDid, + @JsonKey(name: 'receiver_did') required String receiverDid, + required String message, + @JsonKey(name: 'timestampz') required DateTime timestamp, + List? embed, + }) = _Message; + + factory Message.fromJson(Map json) => _$MessageFromJson(json); +} diff --git a/lib/src/core/network/messages/data/repository/messages_repository.dart b/lib/src/core/network/messages/data/repository/messages_repository.dart new file mode 100644 index 00000000..c968280c --- /dev/null +++ b/lib/src/core/network/messages/data/repository/messages_repository.dart @@ -0,0 +1,26 @@ +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart' hide Embed; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; + +/// Interface for Messages-related API endpoints +abstract class MessagesRepository { + /// Get messages for a conversation + /// + /// [did] The DID of the other user in the conversation + /// [cursor] Optional cursor for pagination (eventually) + /// [limit] Optional limit for number of messages to fetch (eventually) + Future<({List messages, String? cursor})> getConversation(String did, {String? cursor, int? limit}); + + /// Get messages for all conversations + /// + /// [cursor] Optional cursor for pagination (eventually) + /// [limit] Optional limit for number of messages to fetch (eventually) + Future<({List<(ProfileViewDetailed, Message)> messages, String? cursor})> getAllConversations({String? cursor, int? limit}); + + /// Send a message to a user + /// [did] The DID of the user to send the message to + /// [message] The message content + /// [embed] Optional embed data to include with the message + /// + Future sendMessage(String did, String message, {List? embed}); + // tem que retornar um future message +} diff --git a/lib/src/core/network/messages/data/repository/messages_repository_impl.dart b/lib/src/core/network/messages/data/repository/messages_repository_impl.dart new file mode 100644 index 00000000..f6113450 --- /dev/null +++ b/lib/src/core/network/messages/data/repository/messages_repository_impl.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:sparksocial/src/core/config/app_config.dart'; +import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Embed; +import 'package:sparksocial/src/core/utils/utils.dart'; +import 'package:sparksocial/src/features/auth/auth.dart'; +import 'messages_repository.dart'; +import '../models/message_models.dart'; + +class MessagesRepositoryImpl implements MessagesRepository { + final AuthRepository _authRepository; + late final SparkLogger _logger; + + MessagesRepositoryImpl(this._authRepository) { + _logger = GetIt.I().getLogger('MessagesRepository'); + } + + String? get accessToken => _authRepository.dmAccessToken; + + Map get _headers => { + 'Content-Type': 'application/json', + if (accessToken?.isNotEmpty == true) 'Authorization': 'Bearer $accessToken', + }; + + Future _refreshIfExpired() async { + if (_authRepository.isAuthenticated && _authRepository.dmAccessToken == null) { + _logger.w('DM access token is null, refreshing...'); + final refreshed = await _authRepository.refreshDMToken(); + if (!refreshed) { + _logger.e('Failed to refresh DM token'); + await _authRepository.loginMessageService(); + } + } + } + + @override + Future<({List messages, String? cursor})> getConversation(String did, {String? cursor, int? limit = 30}) async { + try { + final queryParameters = { + 'with': did, + if (cursor != null) 'cursor': cursor, + if (limit != null) 'limit': limit.toString(), + }; + await _refreshIfExpired(); + + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/conversation').replace(queryParameters: queryParameters); + + final response = await http.get(uri, headers: _headers); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final messagesList = (data['messages'] as List).map((json) => Message.fromJson(json)).toList(); + + return (messages: messagesList, cursor: data['cursor'] as String?); + } else if (response.statusCode == 401) { + await _refreshIfExpired(); + + throw Exception('Não autorizado, vê aí se o token tá valido memo'); + } else { + throw Exception('Erro na requisição: ${response.statusCode} - ${response.body}'); + } + } catch (e) { + if (e.toString().contains('valido')) { + await _refreshIfExpired(); + return getConversation(did, cursor: cursor, limit: limit); // FUCK IT WE BALL + } + rethrow; + } + } + + @override + Future<({List<(ProfileViewDetailed, Message)> messages, String? cursor})> getAllConversations({ + String? cursor, + int? limit, + }) async { + try { + await _refreshIfExpired(); + final actorRepository = GetIt.I().actor; + final queryParameters = { + if (cursor != null) 'cursor': cursor, + if (limit != null) 'limit': limit.toString(), + }; + + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/conversations').replace(queryParameters: queryParameters); + + final response = await http.get(uri, headers: _headers); + + if (response.statusCode == 200) { + final userDid = _authRepository.session?.did; + final data = jsonDecode(response.body) as Map; + final messages = (data['conversations'] as List).map((json) => Message.fromJson(json)).toList(); + final profileDids = (data['conversations'] as List) + .map((json) => json['sender_did'] != userDid ? json['sender_did'] as String : json['receiver_did'] as String) + .where((did) => did.startsWith('did:plc:')) + .toList(); + final profiles = await actorRepository.getProfiles(profileDids); + final conversationsList = <(ProfileViewDetailed, Message)>[]; + profiles.asMap().forEach((index, profile) { + conversationsList.add((profile, messages[index])); + }); + + return (messages: conversationsList, cursor: data['cursor'] as String?); + } else if (response.statusCode == 401) { + throw Exception('Não autorizado, vê aí se o token tá valido memo'); + } else { + throw Exception('Erro ao buscar conversas: ${response.statusCode} - ${response.body}'); + } + } catch (e) { + throw Exception('Erro ao buscar conversas: $e'); + } + } + + @override + Future sendMessage(String did, String message, {List? embed}) async { + try { + await _refreshIfExpired(); + final requestBody = { + 'receiver_did': did, + 'message': message, + if (embed != null && embed.isNotEmpty) 'embed': embed, + }; + _logger.d('Sending message to $did: $requestBody'); + + final uri = Uri.parse('${AppConfig.messagesServiceUrl}/messages/send'); + + final response = await http.post(uri, headers: _headers, body: jsonEncode(requestBody), encoding: utf8); + _logger.d('Response status code: ${response.statusCode}'); + _logger.d('Response body: ${response.body}'); + + if (response.statusCode == 200 || response.statusCode == 201) { + final data = jsonDecode(response.body) as Map; + return Message.fromJson(data['message']); + } else if (response.statusCode == 401) { + await _refreshIfExpired(); + + throw Exception('Não autorizado, vê aí se o token tá valido memo'); + } else { + throw Exception('Erro ao enviar mensagem: ${response.statusCode} - ${response.body}'); + } + } catch (e) { + throw Exception('Erro ao enviar mensagem: $e'); + } + } +} diff --git a/lib/src/core/storage/preferences/storage_constants.dart b/lib/src/core/storage/preferences/storage_constants.dart index 5f8dc960..98309f1c 100644 --- a/lib/src/core/storage/preferences/storage_constants.dart +++ b/lib/src/core/storage/preferences/storage_constants.dart @@ -1,6 +1,8 @@ /// Storage keys used throughout the application class StorageKeys { static const String userSession = 'user_session'; + static const String dmAccessToken = 'dm_access_token'; + static const String dmRefreshToken = 'dm_refresh_token'; static const String themeKey = 'app_theme_mode'; diff --git a/lib/src/core/widgets/image_content.dart b/lib/src/core/widgets/image_content.dart new file mode 100644 index 00000000..571027d1 --- /dev/null +++ b/lib/src/core/widgets/image_content.dart @@ -0,0 +1,90 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; + +class ImageContent extends StatelessWidget { + const ImageContent({super.key, required this.imageUrls, required this.borderRadius, this.thumbnailSize = 100}); + final List imageUrls; + final BorderRadius borderRadius; + final double thumbnailSize; + + void _showImageCarousel(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 217), + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: Stack( + children: [ + ImageCarousel(imageUrls: imageUrls), + Positioned( + top: MediaQuery.of(context).padding.top + 10, + right: 10, + child: IconButton( + icon: const Icon(FluentIcons.dismiss_24_filled, color: Colors.white, size: 30), + onPressed: () => context.router.maybePop(), + style: IconButton.styleFrom(backgroundColor: Colors.black.withValues(alpha: 77)), + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => _showImageCarousel(context), + child: Container( + width: thumbnailSize, + height: thumbnailSize, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), + color: Theme.of(context).colorScheme.surface, + ), + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: imageUrls.first, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Colors.grey[850]?.withValues(alpha: 128), + child: const Center( + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54)), + ), + ), + errorWidget: (context, url, error) => Container( + color: AppColors.darkPurple.withValues(alpha: 26), + child: const Center(child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70)), + ), + ), + + if (imageUrls.length > 1) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 179), borderRadius: BorderRadius.circular(10)), + child: Text( + '+${imageUrls.length - 1}', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/core/widgets/video_content.dart b/lib/src/core/widgets/video_content.dart new file mode 100644 index 00000000..d2154814 --- /dev/null +++ b/lib/src/core/widgets/video_content.dart @@ -0,0 +1,76 @@ +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:video_player/video_player.dart'; + +class VideoContent extends StatefulWidget { + const VideoContent({super.key, required this.borderRadius, required this.videoUrl}); + final BorderRadius borderRadius; + final String videoUrl; + + @override + State createState() => _VideoContentState(); +} + +class _VideoContentState extends State { + + VideoPlayerController? videoController; + + @override + void initState() { + super.initState(); + _initializeVideoPlayer(); + } + + void _initializeVideoPlayer() { + videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) + ..initialize().then((_) { + setState(() {}); + }); + } + + @override + void dispose() { + videoController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + if (videoController != null && videoController!.value.isInitialized) { + videoController!.value.isPlaying ? videoController!.pause() : videoController!.play(); + } + }, + + child: Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + borderRadius: widget.borderRadius, + border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), + color: Colors.black, + ), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + if (videoController != null && videoController!.value.isInitialized) + AspectRatio(aspectRatio: videoController!.value.aspectRatio, child: VideoPlayer(videoController!)), + + if (!videoController!.value.isInitialized) const CircularProgressIndicator(color: AppColors.white), + + if (videoController!.value.isInitialized && !videoController!.value.isPlaying) + Container( + width: 60, + height: 60, + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 128), shape: BoxShape.circle), + child: const Icon(FluentIcons.play_24_filled, size: 24, color: Colors.white), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/auth/providers/auth_state.dart b/lib/src/features/auth/providers/auth_state.dart index b1393c4d..6a661fff 100644 --- a/lib/src/features/auth/providers/auth_state.dart +++ b/lib/src/features/auth/providers/auth_state.dart @@ -10,6 +10,7 @@ class AuthState with _$AuthState { const factory AuthState({ @Default(false) bool isAuthenticated, Session? session, + String? dmAccessToken, ATProto? atproto, @Default(false) bool isLoading, String? error, diff --git a/lib/src/features/comments/providers/comment_provider.dart b/lib/src/features/comments/providers/comment_provider.dart index b96231fa..6ecc5d8b 100644 --- a/lib/src/features/comments/providers/comment_provider.dart +++ b/lib/src/features/comments/providers/comment_provider.dart @@ -8,7 +8,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sparksocial/src/core/network/atproto/atproto.dart'; import 'package:sparksocial/src/features/comments/providers/comment_state.dart'; import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; -import 'package:video_player/video_player.dart'; part 'comment_provider.g.dart'; @@ -17,9 +16,6 @@ class CommentNotifier extends _$CommentNotifier { @override CommentState build(Thread thread) { _feedRepository = GetIt.instance().feed; - ref.onDispose(() { - state.videoController?.dispose(); - }); switch (thread) { case ThreadViewPost(): return CommentState(thread: thread); @@ -88,26 +84,12 @@ class CommentNotifier extends _$CommentNotifier { } } - void initializeVideoPlayer() { - final videoUrl = state.thread.post.videoUrl; - if (videoUrl != '') { - final videoController = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); - state = state.copyWith(videoController: videoController, isVideoInitialized: true); - } - } - void preloadFirstImage(BuildContext context) { final imageUrls = state.thread.post.imageUrls; if (imageUrls.isNotEmpty) { precacheImage(CachedNetworkImageProvider(imageUrls.first), context); } } - - void toggleVideoPlayback() { - if (state.videoController != null && state.isVideoInitialized) { - state.videoController!.value.isPlaying ? state.videoController!.pause() : state.videoController!.play(); - } - } } Future postComment( diff --git a/lib/src/features/comments/providers/comment_state.dart b/lib/src/features/comments/providers/comment_state.dart index f77dfc42..9039a48b 100644 --- a/lib/src/features/comments/providers/comment_state.dart +++ b/lib/src/features/comments/providers/comment_state.dart @@ -1,6 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; -import 'package:video_player/video_player.dart'; part 'comment_state.freezed.dart'; @@ -12,7 +11,7 @@ class CommentState with _$CommentState { required ThreadViewPost thread, @Default(false) bool isVideoInitialized, @Default(false) bool isFirstImagePrecached, - VideoPlayerController? videoController, + String? videoUrl, }) = _CommentState; // Derive isLiked from the viewer state diff --git a/lib/src/features/comments/ui/widgets/comment_input.dart b/lib/src/features/comments/ui/widgets/comment_input.dart index c87fd66c..938f8e8a 100644 --- a/lib/src/features/comments/ui/widgets/comment_input.dart +++ b/lib/src/features/comments/ui/widgets/comment_input.dart @@ -82,7 +82,7 @@ class _CommentInputState extends ConsumerState { error: (error, stackTrace) => null, loading: () => null, ), - username: session?.did ?? '', + username: session?.handle ?? '', size: 28, borderWidth: 0, ), diff --git a/lib/src/features/comments/ui/widgets/comment_item.dart b/lib/src/features/comments/ui/widgets/comment_item.dart index 1780eb3d..67ca6c7e 100644 --- a/lib/src/features/comments/ui/widgets/comment_item.dart +++ b/lib/src/features/comments/ui/widgets/comment_item.dart @@ -1,6 +1,5 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,14 +8,14 @@ import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dar import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:sparksocial/src/core/routing/app_router.dart'; import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:sparksocial/src/core/widgets/image_content.dart'; import 'package:sparksocial/src/core/widgets/menu_action_button.dart'; import 'package:sparksocial/src/core/widgets/report_dialog.dart'; import 'package:sparksocial/src/core/widgets/user_avatar.dart'; +import 'package:sparksocial/src/core/widgets/video_content.dart'; import 'package:sparksocial/src/features/comments/providers/comment_provider.dart'; import 'package:sparksocial/src/features/comments/providers/comment_state.dart'; import 'package:sparksocial/src/features/comments/providers/comments_page_provider.dart'; -import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; -import 'package:video_player/video_player.dart'; class CommentItem extends ConsumerStatefulWidget { final ThreadViewPost thread; @@ -39,38 +38,6 @@ class _CommentItemState extends ConsumerState { context.router.push(ProfileRoute(did: commentState.thread.post.author.did)); } - void _showImageCarousel() { - if (commentState.thread.post.embed == null) return; - - showDialog( - context: context, - barrierColor: Colors.black.withValues(alpha: 217), - builder: (BuildContext context) { - return Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.zero, - child: Stack( - children: [ - ImageCarousel( - imageUrls: commentState.thread.post.imageUrls, - alts: (commentState.thread.post.embed as EmbedViewImage).images.map((e) => e.alt ?? '').toList(), - ), - Positioned( - top: MediaQuery.of(context).padding.top + 10, - right: 10, - child: IconButton( - icon: const Icon(FluentIcons.dismiss_24_filled, color: Colors.white, size: 30), - onPressed: () => context.router.maybePop(), - style: IconButton.styleFrom(backgroundColor: Colors.black.withValues(alpha: 77)), - ), - ), - ], - ), - ); - }, - ); - } - void _handleReportComment() { final scaffoldMessenger = ScaffoldMessenger.of(context); final sprkRepository = GetIt.instance(); @@ -136,7 +103,6 @@ class _CommentItemState extends ConsumerState { @override Widget build(BuildContext context) { commentState = ref.watch(commentNotifierProvider(widget.thread)); - final imageCount = commentState.thread.post.imageUrls.length; const double thumbnailSize = 120.0; final borderRadius = BorderRadius.circular(8); @@ -206,63 +172,13 @@ class _CommentItemState extends ConsumerState { if (commentState.thread.post.embed != null) ...[ const SizedBox(height: 8), if (hasImages) - GestureDetector( - onTap: _showImageCarousel, - child: Container( - width: thumbnailSize, - height: thumbnailSize, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: borderRadius, - border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), - color: Theme.of(context).colorScheme.surface, - ), - child: Stack( - fit: StackFit.expand, - children: [ - CachedNetworkImage( - imageUrl: commentState.thread.post.imageUrls.first, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey[850]?.withValues(alpha: 128), - child: const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white54), - ), - ), - ), - errorWidget: (context, url, error) => Container( - color: AppColors.darkPurple.withValues(alpha: 26), - child: const Center( - child: Icon(FluentIcons.image_off_24_regular, size: 24, color: Colors.white70), - ), - ), - ), - - if (imageCount > 1) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 179), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '+${imageCount - 1}', - style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ), - ], - ), - ), + ImageContent( + imageUrls: commentState.thread.post.imageUrls, + borderRadius: borderRadius, + thumbnailSize: thumbnailSize, ) else if (hasVideo) - _VideoContent(ref: ref, commentState: commentState, context: context, borderRadius: borderRadius), + VideoContent(borderRadius: borderRadius, videoUrl: commentState.thread.post.videoUrl), ], const SizedBox(height: 8), @@ -378,53 +294,6 @@ class _ActionButtons extends StatelessWidget { } } -class _VideoContent extends StatelessWidget { - const _VideoContent({required this.ref, required this.commentState, required this.context, required this.borderRadius}); - - final WidgetRef ref; - final CommentState commentState; - final BuildContext context; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - final notifier = ref.read(CommentNotifierProvider(commentState.thread).notifier); - return GestureDetector( - onTap: notifier.toggleVideoPlayback, - child: Container( - width: double.infinity, - height: 200, - decoration: BoxDecoration( - borderRadius: borderRadius, - border: Border.all(color: Theme.of(context).colorScheme.onSurface, width: 0.5), - color: Colors.black, - ), - clipBehavior: Clip.antiAlias, - child: Stack( - alignment: Alignment.center, - children: [ - if (commentState.videoController != null && commentState.isVideoInitialized) - AspectRatio( - aspectRatio: commentState.videoController!.value.aspectRatio, - child: VideoPlayer(commentState.videoController!), - ), - - if (!commentState.isVideoInitialized) const CircularProgressIndicator(color: AppColors.white), - - if (commentState.isVideoInitialized && !commentState.videoController!.value.isPlaying) - Container( - width: 60, - height: 60, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 128), shape: BoxShape.circle), - child: const Icon(FluentIcons.play_24_filled, size: 24, color: Colors.white), - ), - ], - ), - ), - ); - } -} - class _Avatar extends StatelessWidget { const _Avatar({required this.widget}); diff --git a/lib/src/features/feed/ui/pages/standalone_post_page.dart b/lib/src/features/feed/ui/pages/standalone_post_page.dart index bba31971..629c068b 100644 --- a/lib/src/features/feed/ui/pages/standalone_post_page.dart +++ b/lib/src/features/feed/ui/pages/standalone_post_page.dart @@ -53,7 +53,12 @@ class _StandalonePostPageState extends ConsumerState { // If cache fails, fetch from network final feedRepository = GetIt.instance().feed; final uri = AtUri.parse(widget.postUri); - final isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); + bool isBlueskyPost = false; + // try { + isBlueskyPost = uri.collection.toString().startsWith('app.bsky.feed.post'); + // } catch (e) { + // what + // } final networkPost = await feedRepository.getPosts([uri], bluesky: isBlueskyPost); if (networkPost.isEmpty) { diff --git a/lib/src/features/messages/providers/chat_providers_new.dart b/lib/src/features/messages/providers/chat_providers_new.dart deleted file mode 100644 index 19cc36ba..00000000 --- a/lib/src/features/messages/providers/chat_providers_new.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'chat_service_provider.dart'; -export 'chat_websocket_provider.dart'; \ No newline at end of file diff --git a/lib/src/features/messages/providers/chat_service_provider.dart b/lib/src/features/messages/providers/chat_service_provider.dart deleted file mode 100644 index 2c4649e8..00000000 --- a/lib/src/features/messages/providers/chat_service_provider.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:get_it/get_it.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; -import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; -import 'package:sparksocial/src/core/utils/logging/logging.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -part 'chat_service_provider.g.dart'; - -@riverpod -class ChatService extends _$ChatService { - late final ChatRepository _chatRepository; - late final AuthRepository _authRepository; - late final SparkLogger _logger; - - @override - ChatServiceState build() { - _chatRepository = GetIt.instance(); - _authRepository = GetIt.instance(); - _logger = GetIt.instance().getLogger('ChatService'); - - _logger.v('ChatService provider initialized'); - _initializeAuth(); - - return const ChatServiceState(); - } - - /// Initialize authentication for chat service - void _initializeAuth() { - if (_authRepository.isAuthenticated && _authRepository.session?.accessJwt != null) { - _chatRepository.setAuthToken(_authRepository.session!.accessJwt); - _logger.d('Chat service authenticated with existing session'); - } - } - - /// Ensure the service is authenticated before making requests - void _ensureAuthenticated() { - if (!_authRepository.isAuthenticated) { - throw Exception('User not authenticated. Please log in first.'); - } - - final accessJwt = _authRepository.session?.accessJwt; - if (accessJwt == null) { - throw Exception('No access token available. Please log in again.'); - } - - _chatRepository.setAuthToken(accessJwt); - } - - /// Send a message to another user - Future sendMessage(String message, String receiverDid) async { - _logger.d('Sending message via ChatService to: $receiverDid'); - _ensureAuthenticated(); - - state = state.copyWith(isSending: true, error: null); - - try { - final response = await _chatRepository.sendMessage(message, receiverDid); - state = state.copyWith(isSending: false); - return response; - } catch (e) { - state = state.copyWith(isSending: false, error: e.toString()); - rethrow; - } - } - - /// Get messages for a conversation with another user - Future getMessages(String otherDid, {int limit = 50}) async { - _logger.d('Getting messages via ChatService for: $otherDid'); - _ensureAuthenticated(); - - state = state.copyWith(isLoading: true, error: null); - - try { - final response = await _chatRepository.getMessages(otherDid, limit: limit); - state = state.copyWith(isLoading: false); - return response; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); - rethrow; - } - } - - /// Get list of all chats (conversations) for the current user - Future getChats() async { - _logger.d('Getting chats list via ChatService'); - _ensureAuthenticated(); - - state = state.copyWith(isLoading: true, error: null); - - try { - final response = await _chatRepository.getChats(); - state = state.copyWith(isLoading: false); - return response; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); - rethrow; - } - } - - /// Connect to WebSocket for real-time messaging - WebSocketChannel connectWebSocket() { - _logger.d('Connecting to WebSocket via ChatService'); - _ensureAuthenticated(); - - try { - state = state.copyWith(isConnecting: true, error: null); - final channel = _chatRepository.connectWebSocket(); - state = state.copyWith(isConnecting: false, isConnected: true); - return channel; - } catch (e) { - state = state.copyWith(isConnecting: false, error: e.toString()); - rethrow; - } - } - - /// Check the health status of the chat service - Future healthCheck() async { - _logger.d('Performing health check via ChatService'); - // Health check doesn't require authentication - return _chatRepository.healthCheck(); - } - - /// Close any open WebSocket connections - void closeWebSocket() { - _logger.d('Closing WebSocket via ChatService'); - _chatRepository.closeWebSocket(); - state = state.copyWith(isConnected: false); - } - - /// Get the current user's DID - String? get currentUserDid => _authRepository.session?.did; - - /// Check if the service is authenticated - bool get isAuthenticated => _authRepository.isAuthenticated; -} - -/// State class for the ChatService -class ChatServiceState { - final bool isLoading; - final bool isSending; - final bool isConnecting; - final bool isConnected; - final String? error; - - const ChatServiceState({ - this.isLoading = false, - this.isSending = false, - this.isConnecting = false, - this.isConnected = false, - this.error, - }); - - ChatServiceState copyWith({ - bool? isLoading, - bool? isSending, - bool? isConnecting, - bool? isConnected, - String? error, - }) { - return ChatServiceState( - isLoading: isLoading ?? this.isLoading, - isSending: isSending ?? this.isSending, - isConnecting: isConnecting ?? this.isConnecting, - isConnected: isConnected ?? this.isConnected, - error: error, - ); - } -} \ No newline at end of file diff --git a/lib/src/features/messages/providers/chat_websocket_provider.dart b/lib/src/features/messages/providers/chat_websocket_provider.dart deleted file mode 100644 index d0c5d6b8..00000000 --- a/lib/src/features/messages/providers/chat_websocket_provider.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; -import 'package:sparksocial/src/features/messages/providers/chat_service_provider.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -part 'chat_websocket_provider.g.dart'; - -@riverpod -class ChatWebSocket extends _$ChatWebSocket { - WebSocketChannel? _channel; - StreamSubscription? _subscription; - - @override - ChatWebSocketState build() { - // Clean up when provider is disposed - ref.onDispose(() { - _subscription?.cancel(); - _channel?.sink.close(); - }); - - return const ChatWebSocketState(); - } - - /// Connect to WebSocket for real-time messaging - void connect() async { - if (state.isConnected || state.isConnecting) return; - - state = state.copyWith(isConnecting: true, error: null); - - try { - final chatService = ref.read(chatServiceProvider.notifier); - _channel = chatService.connectWebSocket(); - - // Listen to incoming messages - _subscription = _channel!.stream.listen( - _onMessage, - onError: _onError, - onDone: _onDisconnected, - ); - - state = state.copyWith(isConnecting: false, isConnected: true); - } catch (e) { - state = state.copyWith( - isConnecting: false, - isConnected: false, - error: 'Failed to connect: ${e.toString()}', - ); - } - } - - /// Disconnect from WebSocket - void disconnect() { - _subscription?.cancel(); - _subscription = null; - - _channel?.sink.close(); - _channel = null; - - final chatService = ref.read(chatServiceProvider.notifier); - chatService.closeWebSocket(); - - state = state.copyWith(isConnected: false, isConnecting: false); - } - - /// Handle incoming WebSocket messages - void _onMessage(dynamic data) { - try { - final jsonData = jsonDecode(data) as Map; - final wsMessage = WebSocketMessage.fromJson(jsonData); - - state = state.copyWith(lastMessage: wsMessage); - - // You can add specific message type handling here - switch (wsMessage.type) { - case WebSocketMessageType.newMessage: - // Handle new message - _handleNewMessage(wsMessage.data); - break; - case WebSocketMessageType.messageRead: - // Handle message read status - break; - case WebSocketMessageType.typing: - // Handle typing indicators - break; - case WebSocketMessageType.error: - state = state.copyWith(error: wsMessage.error); - break; - } - } catch (e) { - state = state.copyWith(error: 'Failed to parse message: ${e.toString()}'); - } - } - - /// Handle new message from WebSocket - void _handleNewMessage(WebSocketMessageData? messageData) { - if (messageData == null) return; - - // Convert WebSocket message to ChatMessage - final chatMessage = ChatMessage( - id: messageData.id, - message: messageData.message, - senderDid: messageData.senderDid, - receiverDid: messageData.receiverDid, - timestamp: messageData.timestamp, - ); - - // Add to recent messages list - final updatedMessages = [...state.recentMessages, chatMessage]; - // Keep only last 100 messages to avoid memory issues - if (updatedMessages.length > 100) { - updatedMessages.removeAt(0); - } - - state = state.copyWith(recentMessages: updatedMessages); - } - - /// Handle WebSocket errors - // ignore: strict_top_level_inference - void _onError(error) { - state = state.copyWith( - isConnected: false, - error: 'WebSocket error: ${error.toString()}', - ); - } - - /// Handle WebSocket disconnection - void _onDisconnected() { - state = state.copyWith(isConnected: false); - } - - /// Send a message through WebSocket (if needed for real-time features) - void sendWebSocketMessage(Map message) { - if (!state.isConnected || _channel == null) { - throw Exception('WebSocket not connected'); - } - - _channel!.sink.add(jsonEncode(message)); - } -} - -/// State for WebSocket connection -class ChatWebSocketState { - final bool isConnecting; - final bool isConnected; - final String? error; - final WebSocketMessage? lastMessage; - final List recentMessages; - - const ChatWebSocketState({ - this.isConnecting = false, - this.isConnected = false, - this.error, - this.lastMessage, - this.recentMessages = const [], - }); - - ChatWebSocketState copyWith({ - bool? isConnecting, - bool? isConnected, - String? error, - WebSocketMessage? lastMessage, - List? recentMessages, - }) { - return ChatWebSocketState( - isConnecting: isConnecting ?? this.isConnecting, - isConnected: isConnected ?? this.isConnected, - error: error, - lastMessage: lastMessage ?? this.lastMessage, - recentMessages: recentMessages ?? this.recentMessages, - ); - } -} \ No newline at end of file diff --git a/lib/src/features/messages/providers/conversation_provider.dart b/lib/src/features/messages/providers/conversation_provider.dart new file mode 100644 index 00000000..32076d51 --- /dev/null +++ b/lib/src/features/messages/providers/conversation_provider.dart @@ -0,0 +1,56 @@ +import 'package:get_it/get_it.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sparksocial/src/core/network/atproto/atproto.dart' hide Embed; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; +import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; +import 'package:sparksocial/src/features/messages/providers/conversation_state.dart'; + +part 'conversation_provider.g.dart'; + +@Riverpod(keepAlive: true) +class Conversation extends _$Conversation { + String? cursor; + + @override + FutureOr build(String otherDid) async { + final other = await GetIt.I().actor.getProfile(otherDid); + final (cursor: newCursor, messages: messages) = await GetIt.I().getConversation(otherDid); + cursor = newCursor; + return ConversationState(other, messages); + } + + Future sendMessage(String otherDid, String message, {List? embed, String? currentUserDid}) async { + final other = state.value?.other ?? await GetIt.I().actor.getProfile(otherDid); + final messages = state.value?.messages ?? []; + + try { + // Send message to server and get the actual result + final sentMessage = await GetIt.I().sendMessage(otherDid, message, embed: embed); + + // Update state with the new message + state = AsyncValue.data( + ConversationState(other, [...messages, sentMessage]), + ); + + return sentMessage; + } catch (e) { + // If sending fails, keep the current state and rethrow + rethrow; + } + } + + Future checkForNewMessages() async { + if (state.value == null) return; + final otherDid = state.value!.other.did; + final (cursor: _, messages: newBatch) = await GetIt.I().getConversation(otherDid, cursor: cursor); + final newestMessage = newBatch.isNotEmpty ? newBatch.last : null; + if (newestMessage != null && (state.value!.messages.isEmpty || newestMessage.timestamp.compareTo(state.value!.messages.last.timestamp) > 0)) { + // only new messages from the new batch + final newMessages = newBatch.where((msg) => !state.value!.messages.any((m) => m.id == msg.id)).toList(); + final updatedMessages = [...state.value!.messages, ...newMessages]; + state = AsyncValue.data(ConversationState(state.value!.other, updatedMessages)); + } + } + + // TODO: loadmore +} diff --git a/lib/src/features/messages/providers/conversation_state.dart b/lib/src/features/messages/providers/conversation_state.dart new file mode 100644 index 00000000..651d9a19 --- /dev/null +++ b/lib/src/features/messages/providers/conversation_state.dart @@ -0,0 +1,10 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; + +part 'conversation_state.freezed.dart'; + +@freezed +abstract class ConversationState with _$ConversationState { + factory ConversationState(ProfileViewDetailed other, List messages) = _ConversationState; +} diff --git a/lib/src/features/messages/providers/conversations._state.dart b/lib/src/features/messages/providers/conversations._state.dart new file mode 100644 index 00000000..27d5cb93 --- /dev/null +++ b/lib/src/features/messages/providers/conversations._state.dart @@ -0,0 +1,10 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; + +part 'conversations._state.freezed.dart'; + +@freezed +abstract class ConversationsState with _$ConversationsState { + factory ConversationsState(List<(ProfileViewDetailed, Message)> conversations) = _ConversationsState; +} diff --git a/lib/src/features/messages/providers/conversations_provider.dart b/lib/src/features/messages/providers/conversations_provider.dart new file mode 100644 index 00000000..eecee52b --- /dev/null +++ b/lib/src/features/messages/providers/conversations_provider.dart @@ -0,0 +1,20 @@ +import 'package:get_it/get_it.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sparksocial/src/core/network/messages/data/repository/messages_repository.dart'; +import 'package:sparksocial/src/features/messages/providers/conversations._state.dart'; + +part 'conversations_provider.g.dart'; + +@Riverpod(keepAlive: true) +class Conversations extends _$Conversations { + String? cursor; + + @override + FutureOr build() async { + final (cursor: newCursor, messages: messages) = await GetIt.I().getAllConversations(); + cursor = newCursor; + return ConversationsState(messages); + } + + // TODO: loadmore +} diff --git a/lib/src/features/messages/providers/polling_timer.dart b/lib/src/features/messages/providers/polling_timer.dart new file mode 100644 index 00000000..c1847032 --- /dev/null +++ b/lib/src/features/messages/providers/polling_timer.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sparksocial/src/features/messages/providers/conversation_provider.dart'; + +part 'polling_timer.g.dart'; + +@riverpod +void pollingTrigger(Ref ref, String otherDid) { + final timer = Timer.periodic(const Duration(seconds: 5), (timer) { + ref.read(conversationProvider(otherDid).notifier).checkForNewMessages(); + }); + + ref.onDispose(() { + timer.cancel(); + }); +} diff --git a/lib/src/features/messages/ui/pages/chat_page.dart b/lib/src/features/messages/ui/pages/chat_page.dart index 2d45efa5..53823aba 100644 --- a/lib/src/features/messages/ui/pages/chat_page.dart +++ b/lib/src/features/messages/ui/pages/chat_page.dart @@ -1,12 +1,19 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; +import 'package:sparksocial/src/core/routing/app_router.dart'; import 'package:sparksocial/src/core/theme/data/models/colors.dart'; import 'package:sparksocial/src/core/widgets/user_avatar.dart'; import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; -import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; +import 'package:sparksocial/src/features/messages/providers/conversation_provider.dart'; +import 'package:sparksocial/src/features/messages/providers/polling_timer.dart'; +import 'package:sparksocial/src/features/messages/ui/widgets/message_input.dart'; +import 'package:sparksocial/src/features/messages/ui/widgets/messages_list.dart'; @RoutePage() class ChatPage extends ConsumerStatefulWidget { @@ -15,13 +22,7 @@ class ChatPage extends ConsumerStatefulWidget { final String? otherUserDisplayName; final String? otherUserAvatar; - const ChatPage({ - super.key, - required this.otherUserDid, - this.otherUserHandle, - this.otherUserDisplayName, - this.otherUserAvatar, - }); + const ChatPage({super.key, required this.otherUserDid, this.otherUserHandle, this.otherUserDisplayName, this.otherUserAvatar}); @override ConsumerState createState() => _ChatPageState(); @@ -29,16 +30,14 @@ class ChatPage extends ConsumerStatefulWidget { class _ChatPageState extends ConsumerState { final TextEditingController _messageController = TextEditingController(); + final ImagePicker _imagePicker = ImagePicker(); final ScrollController _scrollController = ScrollController(); - List _messages = []; String? _currentUserDid; @override void initState() { super.initState(); _initializeUser(); - _loadMessages(); - _connectWebSocket(); } void _initializeUser() { @@ -49,68 +48,43 @@ class _ChatPageState extends ConsumerState { return widget.otherUserDisplayName ?? widget.otherUserHandle ?? 'Chat'; } - Future _loadMessages() async { - try { - final chatService = ref.read(chatServiceProvider.notifier); - final response = await chatService.getMessages(widget.otherUserDid); - setState(() { - _messages = response.messages; - }); - _scrollToBottom(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to load messages: ${e.toString()}')), - ); - } - } + List _extractLinks(String text) { + final urlRegex = RegExp( + r'https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*|www\.[a-zA-Z0-9-]+(?:\.[a-zA-Z]+)+\S*', + caseSensitive: false, + multiLine: false, + ); + return urlRegex.allMatches(text).map((match) => match.group(0)!).toList(); } - void _connectWebSocket() { - final wsProvider = ref.read(chatWebSocketProvider.notifier); - wsProvider.connect(); + Future _sendMessage() async { + String content = _messageController.text.trim(); + final links = _extractLinks(content); - // Listen for new messages - ref.listen(chatWebSocketProvider, (previous, next) { - if (next.recentMessages.isNotEmpty && - (next.recentMessages.last.senderDid == widget.otherUserDid || - next.recentMessages.last.receiverDid == widget.otherUserDid)) { - setState(() { - _messages = [..._messages, next.recentMessages.last]; - }); - _scrollToBottom(); - } - }); - } + // remove links from content + for (final link in links) { + content = content.replaceAll(link, ''); + } + content = content.trim(); // Remove extra whitespace after link removal - Future _sendMessage() async { - final content = _messageController.text.trim(); - if (content.isEmpty) return; + final linkEmbeds = []; + for (final link in links) { + linkEmbeds.add(Embed(type: 'link', url: link, preview: link)); + } + + if (content.isEmpty && linkEmbeds.isEmpty) return; _messageController.clear(); try { - final chatService = ref.read(chatServiceProvider.notifier); - final response = await chatService.sendMessage(content, widget.otherUserDid); - - // Add the sent message to local list - final sentMessage = ChatMessage( - id: response.messageId, - message: content, - senderDid: _currentUserDid!, - receiverDid: widget.otherUserDid, - timestamp: response.timestamp, - ); + final chatService = ref.read(conversationProvider(widget.otherUserDid).notifier); + await chatService.sendMessage(widget.otherUserDid, content, embed: linkEmbeds.isNotEmpty ? linkEmbeds : null); - setState(() { - _messages = [..._messages, sentMessage]; - }); + // No need to manage local state since the provider handles it _scrollToBottom(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to send message: ${e.toString()}')), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to send message: ${e.toString()}'))); } } } @@ -129,43 +103,46 @@ class _ChatPageState extends ConsumerState { @override Widget build(BuildContext context) { - final chatServiceState = ref.watch(chatServiceProvider); - + final state = ref.watch(conversationProvider(widget.otherUserDid)); + ref.listen(pollingTriggerProvider(widget.otherUserDid), (previous, next) {}); return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( - title: Row( - children: [ - UserAvatar( - imageUrl: widget.otherUserAvatar, - username: widget.otherUserHandle ?? 'User', - size: 36, - backgroundColor: getAvatarColor((widget.otherUserHandle ?? 'User').hashCode), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _getConversationTitle(), - style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16), - ), - Text( - '@${widget.otherUserHandle ?? 'user'}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(178), fontSize: 12), - ), - ], + title: GestureDetector( + onTap: () => context.router.push(ProfileRoute(did: widget.otherUserDid)), + child: Row( + children: [ + UserAvatar( + imageUrl: widget.otherUserAvatar, + username: widget.otherUserHandle ?? 'User', + size: 36, + backgroundColor: getAvatarColor((widget.otherUserHandle ?? 'User').hashCode), ), - ), - ], - ), - actions: [ - IconButton( - onPressed: () {}, - icon: Icon(FluentIcons.more_vertical_24_regular, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _getConversationTitle(), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16), + ), + Text( + '@${widget.otherUserHandle ?? 'user'}', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(178), fontSize: 12), + ), + ], + ), + ), + ], ), - ], + ), + // actions: [ + // IconButton( + // onPressed: () {}, + // icon: Icon(FluentIcons.more_vertical_24_regular, color: Theme.of(context).colorScheme.onSurface), + // ), + // ], backgroundColor: Theme.of(context).colorScheme.surface, elevation: 0, ), @@ -173,39 +150,39 @@ class _ChatPageState extends ConsumerState { children: [ Container(height: 0.5, width: double.infinity, color: Theme.of(context).colorScheme.outline), Expanded( - child: chatServiceState.isLoading && _messages.isEmpty - ? const Center(child: CircularProgressIndicator()) - : chatServiceState.error != null && _messages.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), - const SizedBox(height: 16), - Text( - 'Failed to load messages', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _loadMessages, - child: const Text('Retry'), - ), - ], - ), - ) - : MessagesList( - messages: _messages, - scrollController: _scrollController, - currentUserDid: _currentUserDid, - otherUserHandle: widget.otherUserHandle, - otherUserAvatar: widget.otherUserAvatar, + child: state.when( + data: (data) => MessagesList( + messages: data.messages, + scrollController: _scrollController, + currentUserDid: _currentUserDid, + otherUserHandle: widget.otherUserHandle, + otherUserAvatar: widget.otherUserAvatar, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), + const SizedBox(height: 16), + Text('Failed to load messages', style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () => ref.invalidate(conversationProvider(widget.otherUserDid)), + child: const Text('Retry'), ), + ], + ), + ); + }, + ), ), MessageInput( controller: _messageController, onSend: _sendMessage, - isLoading: chatServiceState.isSending, + otherDid: widget.otherUserDid, + imagePicker: _imagePicker, ), ], ), @@ -214,8 +191,6 @@ class _ChatPageState extends ConsumerState { @override void dispose() { - final wsProvider = ref.read(chatWebSocketProvider.notifier); - wsProvider.disconnect(); _messageController.dispose(); _scrollController.dispose(); super.dispose(); @@ -235,222 +210,3 @@ Color getAvatarColor(int seed) { ]; return colors[seed.abs() % colors.length]; } - -// ------------------------- EXTRACTED WIDGETS ------------------------- - -class SenderAvatar extends StatelessWidget { - const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); - - final bool isCurrentUser; - final String? otherUserAvatar; - final String? otherUserHandle; - - @override - Widget build(BuildContext context) { - if (isCurrentUser) { - return UserAvatar( - imageUrl: null, // Current user avatar - can be added later - username: 'You', - size: 32, - backgroundColor: AppColors.primary, - ); - } - - return UserAvatar( - imageUrl: otherUserAvatar, - username: otherUserHandle ?? 'User', - size: 32, - backgroundColor: getAvatarColor((otherUserHandle ?? 'User').hashCode), - ); - } -} - -class MessageBubble extends StatelessWidget { - const MessageBubble({ - super.key, - required this.message, - required this.isCurrentUser, - required this.showAvatar, - required this.otherUserAvatar, - required this.otherUserHandle, - }); - - final ChatMessage message; - final bool isCurrentUser; - final bool showAvatar; - final String? otherUserAvatar; - final String? otherUserHandle; - - @override - Widget build(BuildContext context) { - final brightness = MediaQuery.of(context).platformBrightness; - final isDarkMode = brightness == Brightness.dark; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isCurrentUser && showAvatar) ...[ - SenderAvatar( - isCurrentUser: false, - otherUserAvatar: otherUserAvatar, - otherUserHandle: otherUserHandle, - ), - const SizedBox(width: 8), - ] else if (!isCurrentUser) ...[ - const SizedBox(width: 40), - ], - Flexible( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: isCurrentUser - ? AppColors.primary - : isDarkMode - ? Colors.grey.shade800 - : Colors.grey.shade200, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - message.message, - style: TextStyle( - color: isCurrentUser - ? Colors.white - : isDarkMode - ? Colors.white - : Colors.black, - fontSize: 16, - ), - ), - ), - ), - ], - ), - ); - } -} - -class MessagesList extends StatelessWidget { - const MessagesList({ - super.key, - required this.messages, - required this.scrollController, - required this.currentUserDid, - required this.otherUserHandle, - required this.otherUserAvatar, - }); - - final List messages; - final ScrollController scrollController; - final String? currentUserDid; - final String? otherUserHandle; - final String? otherUserAvatar; - - @override - Widget build(BuildContext context) { - if (messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(FluentIcons.chat_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(height: 16), - Text( - 'No messages yet', - style: TextStyle(fontSize: 18, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - Text( - 'Send a message to start the conversation', - style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface), - ), - ], - ), - ); - } - - return ListView.builder( - controller: scrollController, - padding: const EdgeInsets.all(16), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - final isCurrentUser = message.senderDid == currentUserDid; - final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderDid != message.senderDid); - - return MessageBubble( - message: message, - isCurrentUser: isCurrentUser, - showAvatar: showAvatar, - otherUserAvatar: otherUserAvatar, - otherUserHandle: otherUserHandle, - ); - }, - ); - } -} - -class MessageInput extends StatelessWidget { - const MessageInput({ - super.key, - required this.controller, - required this.onSend, - this.isLoading = false, - }); - - final TextEditingController controller; - final VoidCallback onSend; - final bool isLoading; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outline, width: 0.5)), - ), - child: SafeArea( - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(25), borderSide: BorderSide.none), - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - ), - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), - maxLines: null, - textCapitalization: TextCapitalization.sentences, - ), - ), - const SizedBox(width: 8), - Container( - decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle), - child: IconButton( - onPressed: isLoading ? null : onSend, - icon: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(FluentIcons.send_24_filled, color: Colors.white), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/features/messages/ui/pages/messages_page.dart b/lib/src/features/messages/ui/pages/messages_page.dart index 8e1fba5d..7a365039 100644 --- a/lib/src/features/messages/ui/pages/messages_page.dart +++ b/lib/src/features/messages/ui/pages/messages_page.dart @@ -6,8 +6,8 @@ import 'package:get_it/get_it.dart'; import 'package:sparksocial/src/core/utils/logging/log_service.dart'; import 'package:sparksocial/src/core/utils/logging/logging.dart'; import 'package:sparksocial/src/core/routing/app_router.dart'; -import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; +import 'package:sparksocial/src/core/widgets/user_avatar.dart'; +import 'package:sparksocial/src/features/messages/providers/conversations_provider.dart'; @RoutePage() class MessagesPage extends ConsumerStatefulWidget { @@ -19,98 +19,80 @@ class MessagesPage extends ConsumerStatefulWidget { class _MessagesPageState extends ConsumerState { int _selectedTabIndex = 0; - List _conversations = []; - - @override - void initState() { - super.initState(); - // Load chats when the page is initialized - WidgetsBinding.instance.addPostFrameCallback((_) { - _loadChats(); - }); - } - - Future _loadChats() async { - try { - final chatService = ref.read(chatServiceProvider.notifier); - final response = await chatService.getChats(); - - // For now, create simple conversation objects from DIDs - // In a real implementation, you'd fetch user details for each DID - final conversations = response.chats.map((did) => ChatConversation( - otherUserDid: did, - otherUserHandle: did.split(':').last, // Simple handle extraction - lastActivity: DateTime.now(), - )).toList(); - - setState(() { - _conversations = conversations; - }); - } catch (e) { - // Error will be shown in UI via provider state - } - } @override Widget build(BuildContext context) { final theme = Theme.of(context); final logger = GetIt.instance().getLogger('MessagesPage'); - final chatServiceState = ref.watch(chatServiceProvider); + final chatServiceState = ref.watch(conversationsProvider); logger.d('Building MessagesPage'); - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - appBar: AppBar( - title: Text('Inbox', style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold)), - leading: IconButton( - padding: EdgeInsets.zero, - onPressed: () { - context.router.push(const NewChatSearchRoute()); - }, - icon: Icon(FluentIcons.add_24_regular, color: theme.colorScheme.onSurface, size: 24), - ), - actions: [ - IconButton( + return chatServiceState.when( + data: (data) => Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: Text( + 'Inbox', + style: TextStyle(color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold), + ), + leading: IconButton( padding: EdgeInsets.zero, onPressed: () { - // TODO: Implement search functionality + context.router.push(const NewChatSearchRoute()); }, - icon: Icon(FluentIcons.search_24_regular, color: theme.colorScheme.onSurface, size: 24), + icon: Icon(FluentIcons.add_24_regular, color: theme.colorScheme.onSurface, size: 24), ), - ], - backgroundColor: theme.scaffoldBackgroundColor, - elevation: 0, - centerTitle: true, - ), - body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - CustomTabBar( - selectedTabIndex: _selectedTabIndex, - onTabChanged: (index) => setState(() => _selectedTabIndex = index), - ), - Container( - height: 0.5, - width: double.infinity, - color: theme.colorScheme.outline, - ), - Expanded( - child: _selectedTabIndex == 0 - ? MessagesTab( - chatServiceState: chatServiceState, - conversations: _conversations, - onRefresh: _loadChats, - ) - : const ActivitiesTab(), - ), - ], + actions: [ + IconButton( + padding: EdgeInsets.zero, + onPressed: () { + // TODO: Implement search functionality + }, + icon: Icon(FluentIcons.search_24_regular, color: theme.colorScheme.onSurface, size: 24), ), ], + backgroundColor: theme.scaffoldBackgroundColor, + elevation: 0, + centerTitle: true, + ), + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + CustomTabBar( + selectedTabIndex: _selectedTabIndex, + onTabChanged: (index) => setState(() => _selectedTabIndex = index), + ), + Container(height: 0.5, width: double.infinity, color: theme.colorScheme.outline), + Expanded( + child: _selectedTabIndex == 0 + ? MessagesTab(onRefresh: () => {ref.invalidate(conversationsProvider)}) + : const ActivitiesTab(), + ), + ], + ), + ], + ), ), ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.error_circle_24_regular, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('Failed to load conversations', style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 8), + ElevatedButton(onPressed: () => ref.invalidate(conversationsProvider), child: const Text('Retry')), + ], + ), + ); + }, ); } } @@ -119,11 +101,7 @@ class CustomTabBar extends StatelessWidget { final int selectedTabIndex; final Function(int) onTabChanged; - const CustomTabBar({ - super.key, - required this.selectedTabIndex, - required this.onTabChanged, - }); + const CustomTabBar({super.key, required this.selectedTabIndex, required this.onTabChanged}); @override Widget build(BuildContext context) { @@ -132,18 +110,10 @@ class CustomTabBar extends StatelessWidget { child: Row( children: [ Expanded( - child: TabItem( - isSelected: selectedTabIndex == 0, - label: 'Messages', - onTap: () => onTabChanged(0), - ), + child: TabItem(isSelected: selectedTabIndex == 0, label: 'Messages', onTap: () => onTabChanged(0)), ), Expanded( - child: TabItem( - isSelected: selectedTabIndex == 1, - label: 'Activities', - onTap: () => onTabChanged(1), - ), + child: TabItem(isSelected: selectedTabIndex == 1, label: 'Activities', onTap: () => onTabChanged(1)), ), ], ), @@ -156,12 +126,7 @@ class TabItem extends StatelessWidget { final String label; final VoidCallback onTap; - const TabItem({ - super.key, - required this.isSelected, - required this.label, - required this.onTap, - }); + const TabItem({super.key, required this.isSelected, required this.label, required this.onTap}); @override Widget build(BuildContext context) { @@ -176,8 +141,8 @@ class TabItem extends StatelessWidget { border: Border( bottom: BorderSide( color: isSelected - ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) - : Colors.transparent, + ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) + : Colors.transparent, width: 2, ), ), @@ -189,8 +154,8 @@ class TabItem extends StatelessWidget { fontSize: 16, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected - ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) - : theme.colorScheme.onSurface.withAlpha(179), + ? (label == 'Messages' ? theme.colorScheme.primary : theme.colorScheme.secondary) + : theme.colorScheme.onSurface.withAlpha(179), ), ), ), @@ -200,110 +165,73 @@ class TabItem extends StatelessWidget { } class MessagesTab extends ConsumerWidget { - final ChatServiceState chatServiceState; - final List conversations; final VoidCallback onRefresh; - const MessagesTab({ - super.key, - required this.chatServiceState, - required this.conversations, - required this.onRefresh, - }); + const MessagesTab({super.key, required this.onRefresh}); @override Widget build(BuildContext context, WidgetRef ref) { - if (chatServiceState.isLoading && conversations.isEmpty) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (chatServiceState.error != null && conversations.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - FluentIcons.error_circle_24_regular, - size: 48, - color: Theme.of(context).colorScheme.error - ), - const SizedBox(height: 16), - Text( - 'Failed to load conversations', - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: onRefresh, - child: const Text('Retry'), - ), - ], - ), - ); - } - - if (conversations.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text( - 'No conversations yet', - style: TextStyle(fontSize: 18, color: Colors.grey, fontWeight: FontWeight.w500), - ), - SizedBox(height: 8), - Text('Start a conversation to see it here', style: TextStyle(fontSize: 14, color: Colors.grey)), - ], - ), - ); - } + final state = ref.watch(conversationsProvider); - return ListView.builder( - itemCount: conversations.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final conversation = conversations[index]; + return state.when( + data: (data) { + return ListView.builder( + itemCount: data.conversations.length, + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + // final conversation = data.conversations[index]; - return ListTile( - leading: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, - child: Text( - (conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'U')[0].toUpperCase(), - style: const TextStyle(color: Colors.white), - ), - ), - title: Text( - conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text('@${conversation.otherUserHandle ?? 'unknown'}'), - trailing: conversation.unreadCount > 0 - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - conversation.unreadCount.toString(), - style: const TextStyle(color: Colors.white, fontSize: 12), + return ListTile( + leading: UserAvatar( + imageUrl: data.conversations[index].$1.avatar.toString(), + username: data.conversations[index].$1.handle, + size: 36, + ), + title: Text( + data.conversations[index].$1.displayName ?? data.conversations[index].$1.handle, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text('@${data.conversations[index].$1.handle}'), + // trailing: data.conversations[index].unreadCount > 0 + // ? Container( + // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + // decoration: BoxDecoration( + // color: Theme.of(context).colorScheme.primary, + // borderRadius: BorderRadius.circular(10), + // ), + // child: Text(conversation.unreadCount.toString(), style: const TextStyle(color: Colors.white, fontSize: 12)), + // ) + // : null, + onTap: () { + context.router.push( + ChatRoute( + otherUserDid: data.conversations[index].$1.did, + otherUserHandle: data.conversations[index].$1.handle, + otherUserDisplayName: data.conversations[index].$1.displayName, + otherUserAvatar: data.conversations[index].$1.avatar.toString(), ), - ) - : null, - onTap: () { - context.router.push(ChatRoute( - otherUserDid: conversation.otherUserDid, - otherUserHandle: conversation.otherUserHandle, - otherUserDisplayName: conversation.otherUserDisplayName, - otherUserAvatar: conversation.otherUserAvatar, - )); + ); + }, + ); }, ); }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.error_circle_24_regular, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('Failed to load conversations', style: TextStyle(color: theme.colorScheme.error)), + const SizedBox(height: 8), + ElevatedButton(onPressed: onRefresh, child: const Text('Retry')), + ], + ), + ); + }, ); } } @@ -318,11 +246,7 @@ class ActivitiesTab extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - FluentIcons.star_24_regular, - size: 64, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), - ), + Icon(FluentIcons.star_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), const SizedBox(height: 16), Text( 'Activities', @@ -335,10 +259,7 @@ class ActivitiesTab extends StatelessWidget { const SizedBox(height: 8), Text( 'Activity features coming soon', - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurface.withAlpha(128), - ), + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), ), ], ), diff --git a/lib/src/features/messages/ui/widgets/conversation_list.dart b/lib/src/features/messages/ui/widgets/conversation_list.dart index 9fdc3466..9c5cf893 100644 --- a/lib/src/features/messages/ui/widgets/conversation_list.dart +++ b/lib/src/features/messages/ui/widgets/conversation_list.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; import 'conversation_list_item.dart'; class ConversationList extends StatelessWidget { - final List conversations; - final Function(ChatConversation)? onConversationTap; - final Function(ChatConversation)? onConversationLongPress; + final List<(ProfileViewDetailed, Message)> conversations; + final Function((ProfileViewDetailed, Message))? onConversationTap; + final Function((ProfileViewDetailed, Message))? onConversationLongPress; const ConversationList({super.key, required this.conversations, this.onConversationTap, this.onConversationLongPress}); @@ -17,11 +18,6 @@ class ConversationList extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text( - 'DMs feature is coming soon', - style: TextStyle(fontSize: 18, color: Colors.grey, fontWeight: FontWeight.w500), - ), SizedBox(height: 8), Text('Start a conversation to see it here', style: TextStyle(fontSize: 14, color: Colors.grey)), ], @@ -36,7 +32,8 @@ class ConversationList extends StatelessWidget { final conversation = conversations[index]; return ConversationListItem( - conversation: conversation, + message: conversation.$2, + otherUserProfile: conversation.$1, onTap: () => onConversationTap?.call(conversation), onLongPress: () => onConversationLongPress?.call(conversation), ); diff --git a/lib/src/features/messages/ui/widgets/conversation_list_item.dart b/lib/src/features/messages/ui/widgets/conversation_list_item.dart index 9bbe5dba..e84ea998 100644 --- a/lib/src/features/messages/ui/widgets/conversation_list_item.dart +++ b/lib/src/features/messages/ui/widgets/conversation_list_item.dart @@ -1,14 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; +import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; import 'package:sparksocial/src/core/theme/data/models/colors.dart'; import 'package:sparksocial/src/core/widgets/user_avatar.dart'; class ConversationListItem extends StatelessWidget { - final ChatConversation conversation; + final Message message; + final ProfileViewDetailed otherUserProfile; final VoidCallback? onTap; final VoidCallback? onLongPress; - const ConversationListItem({super.key, required this.conversation, this.onTap, this.onLongPress}); + const ConversationListItem({ + super.key, + required this.message, + required this.otherUserProfile, + this.onTap, + this.onLongPress, + }); @override Widget build(BuildContext context) { @@ -23,7 +31,7 @@ class ConversationListItem extends StatelessWidget { ), child: Row( children: [ - ConversationAvatar(conversation: conversation), + ConversationAvatar(otherUserProfile: otherUserProfile), const SizedBox(width: 12), Expanded( child: Column( @@ -33,12 +41,12 @@ class ConversationListItem extends StatelessWidget { children: [ Expanded( child: Text( - conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', - style: TextStyle( - fontWeight: conversation.unreadCount > 0 ? FontWeight.bold : FontWeight.w500, - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface, - ), + otherUserProfile.displayName ?? otherUserProfile.handle, + // style: TextStyle( + // fontWeight: conversation.unreadCount > 0 ? FontWeight.bold : FontWeight.w500, + // fontSize: 16, + // color: Theme.of(context).colorScheme.onSurface, + // ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -50,7 +58,7 @@ class ConversationListItem extends StatelessWidget { children: [ Expanded( child: Text( - '@${conversation.otherUserHandle ?? 'unknown'}', + '@${otherUserProfile.handle}', style: TextStyle( fontWeight: FontWeight.normal, fontSize: 14, @@ -65,54 +73,48 @@ class ConversationListItem extends StatelessWidget { ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (conversation.lastActivity != null) - Text( - _formatTime(conversation.lastActivity!), - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withAlpha(178)), - ), - const SizedBox(height: 4), - if (conversation.unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - conversation.unreadCount > 99 ? '99+' : conversation.unreadCount.toString(), - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), - ), - ), - ], - ), + // Column( + // crossAxisAlignment: CrossAxisAlignment.end, + // children: [ + // if (conversation.unreadCount > 0) + // Container( + // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + // decoration: BoxDecoration( + // color: Theme.of(context).colorScheme.primary, + // borderRadius: BorderRadius.circular(10), + // ), + // child: Text( + // conversation.unreadCount > 99 ? '99+' : conversation.unreadCount.toString(), + // style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), + // ), + // ), + // ], + // ), ], ), ), ); } - String _formatTime(DateTime dateTime) { - final now = DateTime.now(); - final difference = now.difference(dateTime); + // String _formatTime(DateTime dateTime) { + // final now = DateTime.now(); + // final difference = now.difference(dateTime); - if (difference.inDays > 0) { - return '${difference.inDays}d'; - } else if (difference.inHours > 0) { - return '${difference.inHours}h'; - } else if (difference.inMinutes > 0) { - return '${difference.inMinutes}m'; - } else { - return 'now'; - } - } + // if (difference.inDays > 0) { + // return '${difference.inDays}d'; + // } else if (difference.inHours > 0) { + // return '${difference.inHours}h'; + // } else if (difference.inMinutes > 0) { + // return '${difference.inMinutes}m'; + // } else { + // return 'now'; + // } + // } } class ConversationAvatar extends StatelessWidget { - final ChatConversation conversation; - const ConversationAvatar({super.key, required this.conversation}); + final ProfileViewDetailed otherUserProfile; + const ConversationAvatar({super.key, required this.otherUserProfile}); Color _getAvatarColor(int seed) { final colors = [ @@ -136,10 +138,10 @@ class ConversationAvatar extends StatelessWidget { decoration: const BoxDecoration(shape: BoxShape.circle), clipBehavior: Clip.antiAlias, child: UserAvatar( - imageUrl: conversation.otherUserAvatar, - username: conversation.otherUserHandle ?? 'User', + imageUrl: otherUserProfile.avatar.toString(), + username: otherUserProfile.handle, size: 48, - backgroundColor: _getAvatarColor((conversation.otherUserHandle ?? 'User').hashCode), + backgroundColor: _getAvatarColor((otherUserProfile.handle).hashCode), ), ); } diff --git a/lib/src/features/messages/ui/widgets/message_bubble.dart b/lib/src/features/messages/ui/widgets/message_bubble.dart new file mode 100644 index 00000000..6743de46 --- /dev/null +++ b/lib/src/features/messages/ui/widgets/message_bubble.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; +import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:sparksocial/src/features/messages/ui/widgets/sender_avatar.dart'; + +class MessageBubble extends StatelessWidget { + const MessageBubble({ + super.key, + required this.message, + required this.isCurrentUser, + required this.showAvatar, + required this.otherUserAvatar, + required this.otherUserHandle, + }); + + final Message message; + final bool isCurrentUser; + final bool showAvatar; + final String? otherUserAvatar; + final String? otherUserHandle; + String _removeLinksFromText(String text) { + // Regex pattern to match URLs + final urlPattern = RegExp( + r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)', + caseSensitive: false, + ); + + String cleanedText = text.replaceAll(urlPattern, '').trim(); + + // Clean up multiple spaces and newlines that might be left after URL removal + cleanedText = cleanedText.replaceAll(RegExp(r'\s+'), ' ').trim(); + + return cleanedText; + } + + @override + Widget build(BuildContext context) { + final brightness = MediaQuery.of(context).platformBrightness; + final isDarkMode = brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isCurrentUser && showAvatar) ...[ + SenderAvatar(isCurrentUser: false, otherUserAvatar: otherUserAvatar, otherUserHandle: otherUserHandle), + const SizedBox(width: 8), + ] else if (!isCurrentUser) ...[ + const SizedBox(width: 40), + ], + Flexible( + child: () { + final cleanedMessage = _removeLinksFromText(message.message); + // Only show the bubble if there's text content after removing links + if (cleanedMessage.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isCurrentUser + ? AppColors.primary + : isDarkMode + ? Colors.grey.shade800 + : Colors.grey.shade200, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + cleanedMessage, + style: TextStyle( + color: isCurrentUser + ? Colors.white + : isDarkMode + ? Colors.white + : Colors.black, + fontSize: 16, + ), + ), + ); + }(), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/messages/ui/widgets/message_input.dart b/lib/src/features/messages/ui/widgets/message_input.dart new file mode 100644 index 00000000..0577ccc3 --- /dev/null +++ b/lib/src/features/messages/ui/widgets/message_input.dart @@ -0,0 +1,189 @@ + +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:sparksocial/src/core/widgets/user_avatar.dart'; +import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; +import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; + +class MessageInput extends ConsumerWidget { + const MessageInput({super.key, required this.controller, required this.onSend, this.isLoading = false, required this.otherDid, required this.imagePicker}); + + final TextEditingController controller; + final ImagePicker imagePicker; + final VoidCallback onSend; + final bool isLoading; + final String otherDid; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authProvider).session; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outline, width: 0.5)), + ), + child: SafeArea( + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + UserAvatar( + imageUrl: ref + .read(profileNotifierProvider(did: session?.did ?? '')) + .when( + data: (profileData) => profileData.profile?.avatar?.toString(), + error: (error, stackTrace) => null, + loading: () => null, + ), + username: session?.handle ?? '', + size: 28, + borderWidth: 0, + ), + const SizedBox(width: 8), + // _AttachmentButton( + // state: state, + // notifier: notifier, + // context: context, + // borderColor: Theme.of(context).colorScheme.outline, + // textColor: Theme.of(context).colorScheme.onSurface, + // ), + // const SizedBox(width: 5), + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(25), borderSide: BorderSide.none), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + maxLines: null, + textCapitalization: TextCapitalization.sentences, + ), + ), + const SizedBox(width: 8), + Container( + decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle), + child: IconButton( + onPressed: isLoading ? null : onSend, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(FluentIcons.send_24_filled, color: Colors.white), + ), + ), + ], + ), + // if (state.selectedImages.isNotEmpty) + // Padding( + // padding: const EdgeInsets.only(top: 8.0), + // child: _SelectedImagesPreview(state: state, notifier: notifier), + // ), + ], + ), + ), + ); + } +} + +// class _AttachmentButton extends StatelessWidget { +// const _AttachmentButton({ +// required this.state, +// required this.notifier, +// required this.context, +// required this.borderColor, +// required this.textColor, +// }); + +// final EmbedInputState state; +// final EmbedInput notifier; +// final BuildContext context; +// final Color borderColor; +// final Color textColor; + +// @override +// Widget build(BuildContext context) { +// final bool canAddMoreImages = state.selectedImages.length < 4; +// final bool enabled = !state.isPosting && canAddMoreImages; + +// return IconButton( +// padding: EdgeInsets.zero, +// visualDensity: VisualDensity.compact, +// constraints: const BoxConstraints(minWidth: 16, minHeight: 16), +// onPressed: enabled ? () => notifier.pickMedia(context) : null, +// tooltip: enabled ? 'Add media (up to 4)' : (state.isPosting ? 'Posting...' : 'Maximum files reached'), +// icon: Icon(FluentIcons.image_24_regular, size: 24, color: Theme.of(context).colorScheme.primary), +// ); +// } +// } + +// class _SelectedImagesPreview extends StatelessWidget { +// const _SelectedImagesPreview({required this.state, required this.notifier}); + +// final EmbedInputState state; +// final EmbedInput notifier; + +// @override +// Widget build(BuildContext context) { +// return SizedBox( +// height: 72, +// child: ListView.builder( +// scrollDirection: Axis.horizontal, +// itemCount: state.selectedImages.length, +// itemBuilder: (context, index) { +// final imageFile = state.selectedImages[index]; +// return Padding( +// padding: const EdgeInsets.only(right: 8.0), +// child: Stack( +// alignment: Alignment.bottomRight, +// children: [ +// // Image Thumbnail with rounded corners and shadow +// Container( +// width: 72, +// height: 72, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(12), +// border: Border.all(color: Theme.of(context).colorScheme.outline, width: 0.5), +// boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 26), blurRadius: 4, offset: const Offset(0, 2))], +// image: DecorationImage(image: FileImage(File(imageFile.path)), fit: BoxFit.cover), +// ), +// ), +// // Remove Button (top right) +// Positioned( +// top: 4, +// right: 4, +// child: Material( +// color: Colors.black.withValues(alpha: 128), +// shape: const CircleBorder(), +// child: InkWell( +// onTap: () => notifier.removeImage(index), +// customBorder: const CircleBorder(), +// child: Container( +// padding: const EdgeInsets.all(2), +// child: const Icon(FluentIcons.dismiss_16_filled, color: Colors.white, size: 12), +// ), +// ), +// ), +// ), +// ], +// ), +// ); +// }, +// ), +// ); +// } +// } diff --git a/lib/src/features/messages/ui/widgets/messages_list.dart b/lib/src/features/messages/ui/widgets/messages_list.dart new file mode 100644 index 00000000..fdf88d13 --- /dev/null +++ b/lib/src/features/messages/ui/widgets/messages_list.dart @@ -0,0 +1,556 @@ +import 'package:any_link_preview/any_link_preview.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:fluentui_system_icons/fluentui_system_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:sparksocial/src/core/network/messages/data/models/message_models.dart'; +import 'package:sparksocial/src/core/routing/app_router.dart'; +import 'package:sparksocial/src/core/utils/logging/log_service.dart'; +import 'package:sparksocial/src/core/widgets/image_content.dart'; +import 'package:sparksocial/src/core/widgets/video_content.dart'; +import 'package:sparksocial/src/features/messages/ui/widgets/message_bubble.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MessagesList extends StatelessWidget { + const MessagesList({ + super.key, + required this.messages, + required this.scrollController, + required this.currentUserDid, + required this.otherUserHandle, + required this.otherUserAvatar, + }); + + final List messages; + final ScrollController scrollController; + final String? currentUserDid; + final String? otherUserHandle; + final String? otherUserAvatar; + + Future logLinkMetadata(List links) async { + if (links.isEmpty) return; + for (var link in links) { + try { + final metadata = await AnyLinkPreview.getMetadata(link: link); + GetIt.I().getLogger('MessagesList').i('Link metadata for $link: $metadata'); + } catch (e) { + GetIt.I().getLogger('MessagesList').e('Failed to get metadata for link $link: $e'); + } + } + } + + Future validateImage(String imageUrl) async { + http.Response res; + try { + res = await http.get(Uri.parse(imageUrl)); + } catch (e) { + return false; + } + if (res.statusCode != 200) return false; + Map data = res.headers; + return checkIfImage(data['content-type']); + } + + bool checkIfImage(String param) { + if (param == 'image/jpeg' || + param == 'image/png' || + param == 'image/gif' || + param == 'image/webp' || + param == 'image/bmp' || + param == 'image/svg+xml') { + return true; + } + return false; + } + + Future validateVideo(String videoUrl) async { + http.Response res; + try { + res = await http.get(Uri.parse(videoUrl)); + } catch (e) { + return false; + } + if (res.statusCode != 200) return false; + Map data = res.headers; + return checkIfVideo(data['content-type']); + } + + bool checkIfVideo(String param) { + if (param == 'video/mp4' || + param == 'video/webm' || + param == 'video/ogg' || + param == 'video/avi' || + param == 'video/mov' || + param == 'video/quicktime') { + return true; + } + return false; + } + + /// Checks if a URL is a sprk.so watch URL and extracts the post URI + String? extractSprkPostUri(String url) { + try { + final uri = Uri.parse(url); + if (uri.host == 'watch.sprk.so' && uri.queryParameters.containsKey('uri')) { + return uri.queryParameters['uri']; + } + } catch (e) { + // Invalid URL + } + return null; + } + + Future?> validateAndCreateEmbeds(List? embed) async { + List? embeds; + + if (embed?.isNotEmpty ?? false) { + List images = []; + List videos = []; + List links = []; + List sprkPosts = []; + for (final embed in embed!) { + if (embed.type == 'image') { + if (embed.url?.isNotEmpty ?? false) { + images.add(embed.url!); + } + } else if (embed.type == 'video') { + if (embed.url?.isNotEmpty ?? false) { + videos.add(embed.url!); + } + } else if (embed.type == 'link') { + if (embed.url?.isNotEmpty ?? false) { + // Check if this is a sprk.so watch URL + final sprkPostUri = extractSprkPostUri(embed.url!); + if (sprkPostUri != null) { + sprkPosts.add(sprkPostUri); + } else { + links.add(embed.url!); + } + } + } // eventually audios perhaps.. + } + + // Check links for images/videos/sprk posts and reclassify them + List linksToRemove = []; + for (var link in links) { + if (link.isEmpty) continue; + if (Uri.tryParse(link)?.hasScheme != true) continue; // Skip invalid links + + // Check if this is a sprk.so watch URL + final sprkPostUri = extractSprkPostUri(link); + if (sprkPostUri != null) { + sprkPosts.add(sprkPostUri); + linksToRemove.add(link); + } else if (await validateImage(link)) { + // If the link is a valid image, add it to images + images.add(link); + linksToRemove.add(link); // Mark for removal from links + } else if (await validateVideo(link)) { + // If the link is a valid video, add it to videos + videos.add(link); + linksToRemove.add(link); // Mark for removal from links + } + } + + // Remove reclassified links + for (var linkToRemove in linksToRemove) { + links.remove(linkToRemove); + } + + if (images.isNotEmpty) { + embeds ??= []; + embeds.add(ImageContent(imageUrls: images, borderRadius: BorderRadius.circular(12), thumbnailSize: 200)); + } + if (videos.isNotEmpty) { + embeds ??= []; + for (var videoUrl in videos) { + embeds.add(VideoContent(borderRadius: BorderRadius.circular(12), videoUrl: videoUrl)); + } + } + if (sprkPosts.isNotEmpty) { + embeds ??= []; + for (var postUri in sprkPosts) { + embeds.add(_SprkPostThumbnail(postUri: postUri)); + } + } + if (links.isNotEmpty) { + embeds ??= []; + GetIt.I().getLogger('MessagesList').i('Links found in message: $links'); + //logLinkMetadata(links); + embeds.add( + ListView.builder( + shrinkWrap: true, + cacheExtent: 50, + physics: const NeverScrollableScrollPhysics(), + itemCount: links.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _LinkPreview(url: links[index]), + ); + }, + ), + ); + } + } + return embeds; + } + + @override + Widget build(BuildContext context) { + if (messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.chat_24_regular, size: 64, color: Theme.of(context).colorScheme.onSurface), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: TextStyle(fontSize: 18, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Text( + 'Send a message to start the conversation', + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface), + ), + ], + ), + ); + } + + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + final isCurrentUser = message.senderDid == currentUserDid; + final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderDid != message.senderDid); + + return Column( + children: [ + MessageBubble( + message: message, + isCurrentUser: isCurrentUser, + showAvatar: showAvatar, + otherUserAvatar: otherUserAvatar, + otherUserHandle: otherUserHandle, + ), + FutureBuilder?>( + future: validateAndCreateEmbeds(message.embed), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox.shrink(); + } + if (snapshot.hasError) { + GetIt.I().getLogger('MessagesList').e('Error validating embeds: ${snapshot.error}'); + return const SizedBox.shrink(); // Show nothing on error + } + final embeds = snapshot.data; + if (embeds == null || embeds.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: isCurrentUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: embeds + .map( + (embed) => Row( + mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [Flexible(child: embed)], + ), + ) + .toList(), + ), + ); + }, + ), + const SizedBox(height: 8), + ], + ); + }, + ); + } +} + +class _LinkPreview extends StatelessWidget { + const _LinkPreview({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + onTap: () => _launchUrl(url), + child: AnyLinkPreview.builder( + link: url, + placeholderWidget: const _LinkPreviewPlaceholder(), + errorWidget: _LinkPreviewError(url: url), + itemBuilder: (_, metadata, imageProvider, svgPicture) => Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor, width: 0.5), + ), + height: 100, + child: Row( + children: [ + if (imageProvider != null) + ClipRRect( + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(12), topLeft: Radius.circular(12)), + child: Image(width: 100, height: 100, fit: BoxFit.cover, image: imageProvider), + ), + Expanded(child: _LinkPreviewText(metadata: metadata)), + ], + ), + ), + ), + ); + } + + Future _launchUrl(String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (e) { + GetIt.I().getLogger('_LinkPreview').e('Failed to launch URL $url: $e'); + } + } +} + +class _LinkPreviewPlaceholder extends StatelessWidget { + const _LinkPreviewPlaceholder(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + // empty on tap to prevent tap gestures on loading placeholder + onTap: () {}, + child: Container( + height: 100, + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + child: const Center(child: CircularProgressIndicator()), + ), + ); + } +} + +class _LinkPreviewError extends StatelessWidget { + const _LinkPreviewError({required this.url}); + + final String url; + + String get urlStr { + if (url.length > 40) { + return '${url.substring(0, 40)}...'; + } + return url; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + width: 100, + height: 100, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), bottomLeft: Radius.circular(12)), + ), + child: const FittedBox(child: Icon(FluentIcons.link_24_regular)), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FittedBox(child: Text(urlStr, style: theme.textTheme.titleSmall)), + ), + ), + ], + ), + ); + } +} + +class _LinkPreviewText extends StatelessWidget { + const _LinkPreviewText({required this.metadata}); + + final Metadata metadata; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final title = metadata.title?.isNotEmpty == true && metadata.title != 'null' ? metadata.title : null; + final desc = metadata.desc?.isNotEmpty == true && metadata.desc != 'null' ? metadata.desc : null; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title?.isNotEmpty ?? false) ...[ + Text( + _limitLength(title!, 40), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + ], + if (desc?.isNotEmpty ?? false) + Text( + desc!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.15, + fontSize: theme.textTheme.bodyMedium!.fontSize! - 2, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ], + ), + ), + Text( + metadata.url ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: theme.textTheme.bodySmall!.fontSize! - 2, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ], + ), + ); + } + + String _limitLength(String text, int maxLength) { + if (text.length <= maxLength) return text; + return '${text.substring(0, maxLength)}...'; + } +} + +class _SprkPostThumbnail extends StatelessWidget { + const _SprkPostThumbnail({required this.postUri}); + + final String postUri; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GestureDetector( + onTap: () => _navigateToPost(context), + child: Container( + width: double.infinity, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor, width: 0.5), + color: theme.colorScheme.surface, + ), + child: Row( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withAlpha(30), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), bottomLeft: Radius.circular(12)), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(FluentIcons.play_circle_24_filled, size: 32, color: theme.colorScheme.primary), + const SizedBox(height: 4), + Text( + 'SPRK', + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('View Post', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text( + 'Tap to view this post on Spark Social', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withAlpha(150)), + ), + const SizedBox(height: 8), + Text( + postUri, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: theme.textTheme.bodySmall!.fontSize! - 2, + color: theme.colorScheme.onSurface.withAlpha(100), + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _navigateToPost(BuildContext context) { + try { + // Transform the URI format: insert /so.sprk.feed.post before the post ID + String transformedUri = postUri; + + // Find the last slash and insert /so.sprk.feed.post before the post ID + int lastSlashIndex = postUri.lastIndexOf('/'); + if (lastSlashIndex != -1) { + String beforePostId = postUri.substring(0, lastSlashIndex); + String postId = postUri.substring(lastSlashIndex + 1); + transformedUri = '$beforePostId/so.sprk.feed.post/$postId'; + } + + context.router.push(StandalonePostRoute(postUri: transformedUri)); + } catch (e) { + GetIt.I().getLogger('_SprkPostThumbnail').e('Failed to navigate to post $postUri: $e'); + } + } +} diff --git a/lib/src/features/messages/ui/widgets/sender_avatar.dart b/lib/src/features/messages/ui/widgets/sender_avatar.dart new file mode 100644 index 00000000..c72e3000 --- /dev/null +++ b/lib/src/features/messages/ui/widgets/sender_avatar.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:sparksocial/src/core/theme/data/models/colors.dart'; +import 'package:sparksocial/src/core/widgets/user_avatar.dart'; +import 'package:sparksocial/src/features/messages/ui/pages/chat_page.dart'; + +class SenderAvatar extends StatelessWidget { + const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); + + final bool isCurrentUser; + final String? otherUserAvatar; + final String? otherUserHandle; + + @override + Widget build(BuildContext context) { + if (isCurrentUser) { + return UserAvatar( + imageUrl: null, // Current user avatar - can be added later + username: 'You', + size: 32, + backgroundColor: AppColors.primary, + ); + } + + return UserAvatar( + imageUrl: otherUserAvatar, + username: otherUserHandle ?? 'User', + size: 32, + backgroundColor: getAvatarColor((otherUserHandle ?? 'User').hashCode), + ); + } +} diff --git a/lib/src/features/posting/providers/video_upload_provider.dart b/lib/src/features/posting/providers/video_upload_provider.dart index 1434f7b9..1c92d531 100644 --- a/lib/src/features/posting/providers/video_upload_provider.dart +++ b/lib/src/features/posting/providers/video_upload_provider.dart @@ -1,16 +1,11 @@ -import 'dart:convert'; -import 'dart:io'; import 'package:atproto/core.dart'; import 'package:get_it/get_it.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sparksocial/src/core/network/atproto/atproto.dart'; import 'package:sparksocial/src/core/utils/logging/logger.dart'; import '../../../core/auth/data/repositories/auth_repository.dart'; -import '../../../core/config/app_config.dart'; import '../../../core/utils/logging/log_service.dart'; import 'video_upload_state.dart'; @@ -19,11 +14,13 @@ part 'video_upload_provider.g.dart'; @riverpod class VideoUpload extends _$VideoUpload { late final AuthRepository _authRepository; + late final FeedRepository _feedRepository; late final SparkLogger _logger; @override VideoUploadState build(String videoPath) { _authRepository = GetIt.instance().authRepository; + _feedRepository = GetIt.instance().feed; _logger = GetIt.instance().getLogger('VideoService'); return VideoUploadState.initial(videoPath: videoPath); } @@ -34,53 +31,7 @@ class VideoUpload extends _$VideoUpload { state = VideoUploadState.processingVideo(videoPath: videoPath); _logger.i('Starting video processing for: $videoPath'); - final authAtProto = _authRepository.atproto; - if (authAtProto == null || authAtProto.session == null) { - throw Exception('AtProto not initialized'); - } - - // Handle file:// URL scheme - String cleanVideoPath = videoPath; - if (videoPath.startsWith('file://')) { - cleanVideoPath = videoPath.replaceFirst('file://', ''); - } - - // Validate the video file - final file = File(cleanVideoPath); - if (!await file.exists()) { - throw Exception('Video file not found: $cleanVideoPath'); - } - - // Check if the video is in a compatible format - final videoBytes = await file.readAsBytes(); - if (videoBytes.isEmpty) { - throw Exception('Video file is empty'); - } - - _logger.i('Video file size: ${videoBytes.length} bytes'); - - final pdsService = authAtProto.service; - final serviceTokenRes = await authAtProto.server.getServiceAuth( - aud: 'did:web:$pdsService', - lxm: NSID.parse('com.atproto.repo.uploadBlob'), - ); - - final serviceToken = serviceTokenRes.data.token; - final response = await http.post( - Uri.parse('${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo'), - headers: {'Authorization': 'Bearer $serviceToken', 'Content-Type': _getContentType(cleanVideoPath)}, - body: videoBytes, - ); - - if (response.statusCode != 200) { - throw Exception('Failed to upload video: ${response.statusCode} ${response.body}'); - } - - // Parse the response - final responseData = jsonDecode(response.body); - Blob blob; - //{'jobStatus': {'blob': blob}} = responseData; this is how it should work in the lexicon - blob = Blob.fromJson(responseData['blobRef']); + final blob = await _feedRepository.uploadVideo(videoPath); state = VideoUploadState.videoProcessed(videoPath: videoPath, blob: blob); @@ -198,22 +149,4 @@ class VideoUpload extends _$VideoUpload { _logger.i('Successfully crossposted video to Bluesky: ${bskyResult.data.uri}'); } - - /// Helper method to determine content type based on file extension - String _getContentType(String videoPath) { - final extension = path.extension(videoPath).toLowerCase(); - - switch (extension) { - case '.mp4': - return 'video/mp4'; - case '.mov': - return 'video/quicktime'; - case '.avi': - return 'video/x-msvideo'; - case '.webm': - return 'video/webm'; - default: - return 'video/mp4'; // Default to mp4 - } - } } diff --git a/lib/src/features/posting/ui/pages/story_review_page.dart b/lib/src/features/posting/ui/pages/story_review_page.dart index dc0924f5..611408e8 100644 --- a/lib/src/features/posting/ui/pages/story_review_page.dart +++ b/lib/src/features/posting/ui/pages/story_review_page.dart @@ -120,7 +120,10 @@ class _StoryReviewPageState extends ConsumerState { Future _postImageStory() async { final feedRepository = GetIt.I().feed; - final uploadedImageMaps = await feedRepository.uploadImages([widget.imageFile], {widget.imageFile.path: _altText}); + final uploadedImageMaps = await feedRepository.uploadImages( + imageFiles: [widget.imageFile], + altTexts: {widget.imageFile.path: _altText}, + ); if (uploadedImageMaps.isNotEmpty) { ref.read( diff --git a/pubspec.lock b/pubspec.lock index 32448fb4..69d04ce3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + any_link_preview: + dependency: "direct main" + description: + name: any_link_preview + sha256: "0617bd49a58dd0478cd5c4c83bcf7a2d1b7d301aad229817d13693fe86807ea1" + url: "https://pub.dev" + source: hosted + version: "3.0.3" archive: dependency: transitive description: @@ -724,10 +732,10 @@ packages: dependency: transitive description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.6" http: dependency: "direct main" description: @@ -1445,6 +1453,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + string_validator: + dependency: transitive + description: + name: string_validator + sha256: "240f4c98027dfbe8639c8271ef18cc9de735b47067aa15a720cfed9576a512b1" + url: "https://pub.dev" + source: hosted + version: "1.2.0" synchronized: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index c5ebe44c..a122ee3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: imgly_editor: ^1.51.0 socket_io_client: ^3.1.2 web_socket_channel: ^3.0.3 + any_link_preview: ^3.0.3 dev_dependencies: flutter_test: