diff --git a/docs_code_snippets/01_01_quickstart.dart b/docs_code_snippets/01_01_quickstart.dart index d2ebc369..deb696e8 100644 --- a/docs_code_snippets/01_01_quickstart.dart +++ b/docs_code_snippets/01_01_quickstart.dart @@ -42,10 +42,9 @@ Future socialMediaFeed() async { // Add a comment to activity await timeline.addComment( - request: AddCommentRequest( + request: ActivityAddCommentRequest( comment: 'Great post!', - objectId: 'activity_123', - objectType: 'activity', + activityId: 'activity_123', ), ); @@ -54,6 +53,7 @@ Future socialMediaFeed() async { activityId: 'activity_123', fid: FeedId(group: 'timeline', id: 'john'), ); + await activity.addCommentReaction( commentId: 'commentId', request: AddCommentReactionRequest(type: 'like'), diff --git a/docs_code_snippets/03_01_activities.dart b/docs_code_snippets/03_01_activities.dart index 91cd3db3..35af76aa 100644 --- a/docs_code_snippets/03_01_activities.dart +++ b/docs_code_snippets/03_01_activities.dart @@ -25,7 +25,11 @@ Future imageAndVideo() async { final imageActivity = await feed.addActivity( request: const FeedAddActivityRequest( attachments: [ - Attachment(imageUrl: 'https://example.com/image.jpg', type: 'image'), + Attachment( + imageUrl: 'https://example.com/image.jpg', + type: 'image', + custom: {'width': 600, 'height': 400}, + ), ], text: 'look at NYC', type: 'post', @@ -41,10 +45,12 @@ Future stories() async { const Attachment( imageUrl: 'https://example.com/image1.jpg', type: 'image', + custom: {'width': 600, 'height': 400}, ), const Attachment( assetUrl: 'https://example.com/video1.mp4', type: 'video', + custom: {'width': 1920, 'height': 1080, 'duration': 12}, ), ], expiresAt: tomorrow.toIso8601String(), @@ -69,6 +75,7 @@ Future addManyActivities() async { type: 'post', ), ]; + final upsertedActivities = await client.upsertActivities( activities: activities, ); @@ -83,11 +90,14 @@ Future updatingAndDeletingActivities() async { custom: {'custom': 'custom'}, ), ); + // Delete an activity + await feed.deleteActivity( + id: '123', + // Soft delete sets deleted at but retains the data, hard delete fully removes it + hardDelete: false, + ); - const hardDelete = - false; // Soft delete sets deleted at but retains the data, hard delete fully removes it - await feed.deleteActivity(id: '123', hardDelete: hardDelete); // Batch delete activities await client.deleteActivities( request: const DeleteActivitiesRequest( diff --git a/docs_code_snippets/03_03_file_uploads.dart b/docs_code_snippets/03_03_file_uploads.dart index 2d2f1c74..03984581 100644 --- a/docs_code_snippets/03_03_file_uploads.dart +++ b/docs_code_snippets/03_03_file_uploads.dart @@ -1,3 +1,195 @@ -// ignore_for_file: file_names +// ignore_for_file: unused_local_variable, file_names, avoid_redundant_argument_values -// TODO +import 'package:stream_feeds/stream_feeds.dart'; + +late StreamFeedsClient client; +late Feed feed; +late Activity activity; +late StreamAttachmentUploader attachmentUploader; +late List uploadedAttachments; + +Future howToUploadAFileOrImageStep1() async { + // Create an instance of AttachmentFile with the file path + // + // Note: On web, use `AttachmentFile.fromData`. Or if you are working with + // plugins which provide XFile then use `AttachmentFile.fromXFile`. + final file = AttachmentFile('path/to/file'); + + // Create a StreamAttachment with the file and type (image, video, file) + final streamAttachment = StreamAttachment( + file: file, + type: AttachmentType.image, + custom: {'width': 600, 'height': 400}, + ); + + // Upload the attachment + final result = await attachmentUploader.upload( + streamAttachment, + // Optionally track upload progress + onProgress: (progress) { + // Handle progress updates + }, + ); + + // Map the result to an Attachment model to send with an activity + final uploadedAttachment = result.getOrThrow(); + final attachmentReadyToBeSent = Attachment( + imageUrl: uploadedAttachment.remoteUrl, + assetUrl: uploadedAttachment.remoteUrl, + thumbUrl: uploadedAttachment.thumbnailUrl, + custom: {...?uploadedAttachment.custom}, + ); +} + +Future howToUploadAFileOrImageStep2() async { + // Add an activity with the uploaded attachment + final activity = await feed.addActivity( + request: FeedAddActivityRequest( + attachments: uploadedAttachments, + text: 'look at NYC', + type: 'post', + ), + ); +} + +Future howToUploadAFileOrImageStep3() async { + // Create a list of attachments to upload + final attachmentUploads = []; + + // Add an activity with the attachment needing upload + final activity = await feed.addActivity( + request: FeedAddActivityRequest( + attachmentUploads: attachmentUploads, + text: 'look at NYC', + type: 'post', + ), + ); +} + +Future howToUploadAFileOrImageStep4() async { + // Create a list of attachments to upload + final attachmentUploads = []; + + // Add a comment with the attachment needing upload + final comment = await activity.addComment( + request: ActivityAddCommentRequest( + attachmentUploads: attachmentUploads, + activityId: activity.activityId, + comment: 'look at NYC', + ), + ); +} + +Future howToDeleteAFileOrImage() async { + // Delete an image from the CDN + await client.deleteImage(url: 'https://mycdn.com/image.png'); + + // Delete a file from the CDN + await client.deleteFile(url: 'https://mycdn.com/file.pdf'); +} + +// Your custom implementation of CdnClient +class CustomCDN implements CdnClient { + @override + Future> uploadFile( + AttachmentFile file, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) { + // Use your own CDN upload logic here. + // For example, you might upload the file to AWS S3, Google Cloud Storage, etc. + // After uploading, return a Result with the file URLs. + // + // Note: Make sure to handle progress updates and cancellation if needed. + return uploadToYourOwnCdn( + file, + onProgress: onProgress, + cancelToken: cancelToken, + ); + } + + @override + Future> uploadImage( + AttachmentFile image, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) { + // Use your own CDN upload logic here. + // For example, you might upload the image to AWS S3, Google Cloud Storage, etc. + // After uploading, return a Result with the image URLs. + // + // Note: Make sure to handle progress updates and cancellation if needed. + return uploadToYourOwnCdn( + image, + onProgress: onProgress, + cancelToken: cancelToken, + ); + } + + @override + Future> deleteFile( + String url, { + CancelToken? cancelToken, + }) { + // Use your own CDN deletion logic here. + // For example, you might delete the file from AWS S3, Google Cloud Storage, etc. + // After deleting, return a Result indicating success or failure. + // + // Note: Make sure to handle cancellation if needed. + return deleteFromYourOwnCdn( + url, + cancelToken: cancelToken, + ); + } + + @override + Future> deleteImage( + String url, { + CancelToken? cancelToken, + }) { + // Use your own CDN deletion logic here. + // For example, you might delete the image from AWS S3, Google Cloud Storage, etc. + // After deleting, return a Result indicating success or failure. + // + // Note: Make sure to handle cancellation if needed. + return deleteFromYourOwnCdn( + url, + cancelToken: cancelToken, + ); + } +} + +Future usingYourOwnCdn() async { + // Create a config with your custom CDN client + final config = FeedsConfig(cdnClient: CustomCDN()); + + // Initialize the StreamFeedsClient with the custom config + final client = StreamFeedsClient( + apiKey: 'your_api_key', + user: const User(id: 'user_id'), + config: config, + ); +} + +// region Helper methods to simulate your own CDN logic + +Future> uploadToYourOwnCdn( + AttachmentFile file, { + ProgressCallback? onProgress, + CancelToken? cancelToken, +}) { + // Implement your file upload logic here + // Return a Result indicating success or failure + throw UnimplementedError(); +} + +Future> deleteFromYourOwnCdn( + String url, { + CancelToken? cancelToken, +}) { + // Implement your file deletion logic here + // Return a Result indicating success or failure + throw UnimplementedError(); +} + +// endregion diff --git a/docs_code_snippets/04_01_feeds.dart b/docs_code_snippets/04_01_feeds.dart index 8c43e05e..2274c1e7 100644 --- a/docs_code_snippets/04_01_feeds.dart +++ b/docs_code_snippets/04_01_feeds.dart @@ -19,6 +19,7 @@ Future creatingAFeed() async { visibility: FeedVisibility.public, ), ); + final feed2 = client.feedFromQuery(query); await feed2.getOrCreate(); } @@ -29,31 +30,37 @@ Future readingAFeed() async { final feedData = feed.state.feed; final activities = feed.state.activities; final members = feed.state.members; - // Always dispose the feed when you are done with it + + // Note: Always dispose the feed when you are done with it feed.dispose(); } Future readingAFeedMoreOptions() async { final query = FeedQuery( fid: const FeedId(group: 'user', id: 'john'), - activityFilter: Filter.in_(ActivitiesFilterField.filterTags, const [ - 'green', - ]), // filter activities with filter tag green + // filter activities with filter tag green + activityFilter: Filter.in_( + ActivitiesFilterField.filterTags, + const ['green'], + ), activityLimit: 10, - externalRanking: {'user_score': 0.8}, // additional data used for ranking + // additional data used for ranking + externalRanking: {'user_score': 0.8}, followerLimit: 10, followingLimit: 10, memberLimit: 10, - view: - 'myview', // overwrite the default ranking or aggregation logic for this feed. good for split testing - watch: true, // receive web-socket events with real-time updates + // overwrite the default ranking or aggregation logic for this feed. good for split testing + view: 'myview', + // receive web-socket events with real-time updates + watch: true, ); final feed = client.feedFromQuery(query); await feed.getOrCreate(); final activities = feed.state.activities; final feedData = feed.state.feed; - // Always dispose the feed when you are done with it + + // Note: Always dispose the feed when you are done with it feed.dispose(); } @@ -64,6 +71,7 @@ Future feedPagination() async { activityLimit: 10, ), ); + // Page 1 await feed.getOrCreate(); final activities = feed.state.activities; // First 10 activities @@ -97,6 +105,7 @@ Future filteringExamples() async { type: 'activity', ), ]); + // Now read the feed, this will fetch activity 1 and 2 final query = FeedQuery( fid: feedId, @@ -104,9 +113,11 @@ Future filteringExamples() async { 'blue', ]), ); + final feed = client.feedFromQuery(query); await feed.getOrCreate(); - final activities = feed.state.activities; // contains first and second + // contains first and second + final activities = feed.state.activities; } Future moreComplexFilterExamples() async { @@ -124,6 +135,7 @@ Future moreComplexFilterExamples() async { ]), ]), ); + await feed.getOrCreate(); final activities = feed.state.activities; } @@ -191,6 +203,7 @@ Future queryMyFeeds() async { limit: 10, watch: true, ); + final feedList = client.feedList(query); // Page 1 @@ -217,14 +230,17 @@ Future queryFeedsByNameOrVisibility() async { Filter.query(FeedsFilterField.name, 'Sports'), ]), ); + final sportsFeedList = client.feedList(sportsQuery); final sportsFeeds = await sportsFeedList.get(); + final techQuery = FeedsQuery( filter: Filter.and([ Filter.equal(FeedsFilterField.visibility, 'public'), Filter.autoComplete(FeedsFilterField.description, 'tech'), ]), ); + final techFeedList = client.feedList(techQuery); final techFeeds = await techFeedList.get(); } @@ -236,6 +252,7 @@ Future queryFeedsByCreatorName() async { Filter.query(FeedsFilterField.createdByName, 'Thompson'), ]), ); + final feedList = client.feedList(query); final feeds = await feedList.get(); } diff --git a/melos.yaml b/melos.yaml index 4b01e763..730421e0 100644 --- a/melos.yaml +++ b/melos.yaml @@ -28,10 +28,12 @@ command: freezed_annotation: ^3.0.0 get_it: ^8.0.3 google_fonts: ^6.3.0 + file_picker: ^8.1.4 + flex_grid_view: ^0.1.0 injectable: ^2.5.1 http: ^1.1.0 intl: ">=0.18.1 <=0.21.0" - jiffy: ^6.4.3 + jiffy: ^6.3.2 json_annotation: ^4.9.0 meta: ^1.9.1 retrofit: ^4.6.0 @@ -44,7 +46,7 @@ command: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 47329234bddbc0ccc11f48175a8cd032cf6a2d88 + ref: 5acdcb8e378548372f7dbb6326c518edcf832d10 path: packages/stream_core # List of all the dev_dependencies used in the project. diff --git a/packages/stream_feeds/lib/src/file/cdn_api.dart b/packages/stream_feeds/lib/src/cdn/cdn_api.dart similarity index 56% rename from packages/stream_feeds/lib/src/file/cdn_api.dart rename to packages/stream_feeds/lib/src/cdn/cdn_api.dart index b133941c..3953b005 100644 --- a/packages/stream_feeds/lib/src/file/cdn_api.dart +++ b/packages/stream_feeds/lib/src/cdn/cdn_api.dart @@ -14,18 +14,32 @@ abstract interface class CdnApi { @MultiPart() @POST('/api/v2/uploads/file') - Future> sendFile({ + Future> uploadFile({ // TODO: change to single file upload once upgrade to retrofit ^4.7.0 @Part(name: 'file') required List file, - @SendProgress() ProgressCallback? onSendProgress, + @SendProgress() ProgressCallback? onUploadProgress, + @CancelRequest() CancelToken? cancelToken, }); @MultiPart() @POST('/api/v2/uploads/image') - Future> sendImage({ + Future> uploadImage({ // TODO: change to single file upload once upgrade to retrofit ^4.7.0 @Part(name: 'file') required List file, - @SendProgress() ProgressCallback? onSendProgress, + @SendProgress() ProgressCallback? onUploadProgress, + @CancelRequest() CancelToken? cancelToken, + }); + + @DELETE('/api/v2/uploads/file') + Future> deleteFile({ + @Query('url') String? url, + @CancelRequest() CancelToken? cancelToken, + }); + + @DELETE('/api/v2/uploads/image') + Future> deleteImage({ + @Query('url') String? url, + @CancelRequest() CancelToken? cancelToken, }); } diff --git a/packages/stream_feeds/lib/src/cdn/cdn_api.g.dart b/packages/stream_feeds/lib/src/cdn/cdn_api.g.dart new file mode 100644 index 00000000..0c608113 --- /dev/null +++ b/packages/stream_feeds/lib/src/cdn/cdn_api.g.dart @@ -0,0 +1,236 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cdn_api.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter + +class _CdnApi implements CdnApi { + _CdnApi(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + Future _uploadFile({ + required List file, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.files.addAll(file.map((i) => MapEntry('file', i))); + final _options = _setStreamType>( + Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/api/v2/uploads/file', + queryParameters: queryParameters, + data: _data, + cancelToken: cancelToken, + onSendProgress: onUploadProgress, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late FileUploadResponse _value; + try { + _value = FileUploadResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> uploadFile({ + required List file, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, + }) { + return _ResultCallAdapter().adapt( + () => _uploadFile( + file: file, + onUploadProgress: onUploadProgress, + cancelToken: cancelToken, + ), + ); + } + + Future _uploadImage({ + required List file, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, + }) async { + final _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = FormData(); + _data.files.addAll(file.map((i) => MapEntry('file', i))); + final _options = _setStreamType>( + Options( + method: 'POST', + headers: _headers, + extra: _extra, + contentType: 'multipart/form-data', + ) + .compose( + _dio.options, + '/api/v2/uploads/image', + queryParameters: queryParameters, + data: _data, + cancelToken: cancelToken, + onSendProgress: onUploadProgress, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late ImageUploadResponse _value; + try { + _value = ImageUploadResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> uploadImage({ + required List file, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, + }) { + return _ResultCallAdapter().adapt( + () => _uploadImage( + file: file, + onUploadProgress: onUploadProgress, + cancelToken: cancelToken, + ), + ); + } + + Future _deleteFile({ + String? url, + CancelToken? cancelToken, + }) async { + final _extra = {}; + final queryParameters = {r'url': url}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'DELETE', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v2/uploads/file', + queryParameters: queryParameters, + data: _data, + cancelToken: cancelToken, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late DurationResponse _value; + try { + _value = DurationResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> deleteFile({ + String? url, + CancelToken? cancelToken, + }) { + return _ResultCallAdapter().adapt( + () => _deleteFile(url: url, cancelToken: cancelToken), + ); + } + + Future _deleteImage({ + String? url, + CancelToken? cancelToken, + }) async { + final _extra = {}; + final queryParameters = {r'url': url}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'DELETE', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v2/uploads/image', + queryParameters: queryParameters, + data: _data, + cancelToken: cancelToken, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late DurationResponse _value; + try { + _value = DurationResponse.fromJson(_result.data!); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + @override + Future> deleteImage({ + String? url, + CancelToken? cancelToken, + }) { + return _ResultCallAdapter().adapt( + () => _deleteImage(url: url, cancelToken: cancelToken), + ); + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/packages/stream_feeds/lib/src/cdn/feeds_cdn_client.dart b/packages/stream_feeds/lib/src/cdn/feeds_cdn_client.dart new file mode 100644 index 00000000..1d277f7a --- /dev/null +++ b/packages/stream_feeds/lib/src/cdn/feeds_cdn_client.dart @@ -0,0 +1,79 @@ +import 'package:stream_core/stream_core.dart'; + +import 'cdn_api.dart'; + +class FeedsCdnClient implements CdnClient { + const FeedsCdnClient(this._api); + + final CdnApi _api; + + @override + Future> uploadFile( + AttachmentFile file, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) async { + final multipartFile = await file.toMultipartFile(); + + final result = await _api.uploadFile( + file: [multipartFile], + onUploadProgress: onProgress, + cancelToken: cancelToken, + ); + + return result.map( + (response) => UploadedFile( + fileUrl: response.file, + thumbUrl: response.thumbUrl, + ), + ); + } + + @override + Future> uploadImage( + AttachmentFile image, { + ProgressCallback? onProgress, + CancelToken? cancelToken, + }) async { + final multipartFile = await image.toMultipartFile(); + + final result = await _api.uploadImage( + file: [multipartFile], + onUploadProgress: onProgress, + cancelToken: cancelToken, + ); + + return result.map( + (response) => UploadedFile( + fileUrl: response.file, + thumbUrl: response.thumbUrl, + ), + ); + } + + @override + Future> deleteFile( + String url, { + CancelToken? cancelToken, + }) async { + final result = await _api.deleteFile( + url: url, + cancelToken: cancelToken, + ); + + return result; + } + + @override + Future> deleteImage( + String url, { + CancelToken? cancelToken, + }) async { + final result = await _api.deleteImage( + url: url, + cancelToken: cancelToken, + ); + + return result; + } +} diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index 0ba189b2..9b6bdb9d 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:stream_core/stream_core.dart'; +import '../cdn/cdn_api.dart'; +import '../cdn/feeds_cdn_client.dart'; import '../feeds_client.dart'; import '../generated/api/api.dart' as api; import '../models/activity_data.dart'; @@ -143,20 +145,23 @@ class StreamFeedsClientImpl implements StreamFeedsClient { ]), ); - final apiClient = api.DefaultApi(httpClient); - // endregion // region Initialize repositories - _activitiesRepository = ActivitiesRepository(apiClient); - _appRepository = AppRepository(apiClient); - _bookmarksRepository = BookmarksRepository(apiClient); - _commentsRepository = CommentsRepository(apiClient); - _devicesRepository = DevicesRepository(apiClient); - _feedsRepository = FeedsRepository(apiClient); - _moderationRepository = ModerationRepository(apiClient); - _pollsRepository = PollsRepository(apiClient); + _cdnClient = config.cdnClient ?? FeedsCdnClient(CdnApi(httpClient)); + attachmentUploader = StreamAttachmentUploader(cdn: _cdnClient); + + final feedsApi = api.DefaultApi(httpClient); + + _activitiesRepository = ActivitiesRepository(feedsApi, attachmentUploader); + _appRepository = AppRepository(feedsApi); + _bookmarksRepository = BookmarksRepository(feedsApi); + _commentsRepository = CommentsRepository(feedsApi, attachmentUploader); + _devicesRepository = DevicesRepository(feedsApi); + _feedsRepository = FeedsRepository(feedsApi); + _moderationRepository = ModerationRepository(feedsApi); + _pollsRepository = PollsRepository(feedsApi); moderation = ModerationClient(_moderationRepository); @@ -174,6 +179,11 @@ class StreamFeedsClientImpl implements StreamFeedsClient { late final StreamWebSocketClient _ws; late final ConnectionRecoveryHandler _connectionRecoveryHandler; + late final CdnClient _cdnClient; + + @override + late final StreamAttachmentUploader attachmentUploader; + late final ActivitiesRepository _activitiesRepository; late final AppRepository _appRepository; late final BookmarksRepository _bookmarksRepository; @@ -284,7 +294,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override Activity activity({ required String activityId, - required FeedId fid, + required FeedId fid, ActivityData? initialData, }) { return Activity( @@ -432,14 +442,12 @@ class StreamFeedsClientImpl implements StreamFeedsClient { } @override - Future> deleteFile(String url) { - // TODO: implement deleteFile - throw UnimplementedError(); + Future> deleteFile({required String url}) { + return _cdnClient.deleteFile(url); } @override - Future> deleteImage(String url) { - // TODO: implement deleteImage - throw UnimplementedError(); + Future> deleteImage({required String url}) { + return _cdnClient.deleteImage(url); } } diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 576260c5..38edcdf9 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -667,7 +667,7 @@ abstract interface class StreamFeedsClient { /// ``` /// /// Returns a [Result] indicating success or failure of the deletion operation. - Future> deleteFile(String url); + Future> deleteFile({required String url}); /// Deletes a previously uploaded image from the CDN. /// @@ -689,13 +689,19 @@ abstract interface class StreamFeedsClient { /// ``` /// /// Returns a [Result] indicating success or failure of the deletion operation. - Future> deleteImage(String url); + Future> deleteImage({required String url}); /// The moderation client for managing moderation-related operations. /// /// Provides access to moderation configurations, content moderation, and moderation-related /// queries. ModerationClient get moderation; + + /// The attachment uploader for managing file and image uploads. + /// + /// Provides functionality for uploading files and images to the Stream CDN with + /// support for various file types, progress tracking, and upload configurations. + StreamAttachmentUploader get attachmentUploader; } /// Extension methods for the [StreamFeedsClient] to simplify feed creation. diff --git a/packages/stream_feeds/lib/src/file/cdn_api.g.dart b/packages/stream_feeds/lib/src/file/cdn_api.g.dart deleted file mode 100644 index 0edf9ab2..00000000 --- a/packages/stream_feeds/lib/src/file/cdn_api.g.dart +++ /dev/null @@ -1,140 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'cdn_api.dart'; - -// ************************************************************************** -// RetrofitGenerator -// ************************************************************************** - -// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations,unused_element_parameter - -class _CdnApi implements CdnApi { - _CdnApi(this._dio, {this.baseUrl, this.errorLogger}); - - final Dio _dio; - - String? baseUrl; - - final ParseErrorLogger? errorLogger; - - Future _sendFile({ - required List file, - void Function(int, int)? onSendProgress, - }) async { - final _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _headers = {}; - final _data = FormData(); - _data.files.addAll(file.map((i) => MapEntry('file', i))); - final _options = _setStreamType>( - Options( - method: 'POST', - headers: _headers, - extra: _extra, - contentType: 'multipart/form-data', - ) - .compose( - _dio.options, - '/api/v2/uploads/file', - queryParameters: queryParameters, - data: _data, - onSendProgress: onSendProgress, - ) - .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), - ); - final _result = await _dio.fetch>(_options); - late FileUploadResponse _value; - try { - _value = FileUploadResponse.fromJson(_result.data!); - } on Object catch (e, s) { - errorLogger?.logError(e, s, _options); - rethrow; - } - return _value; - } - - @override - Future> sendFile({ - required List file, - void Function(int, int)? onSendProgress, - }) { - return _ResultCallAdapter().adapt( - () => _sendFile(file: file, onSendProgress: onSendProgress), - ); - } - - Future _sendImage({ - required List file, - void Function(int, int)? onSendProgress, - }) async { - final _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _headers = {}; - final _data = FormData(); - _data.files.addAll(file.map((i) => MapEntry('file', i))); - final _options = _setStreamType>( - Options( - method: 'POST', - headers: _headers, - extra: _extra, - contentType: 'multipart/form-data', - ) - .compose( - _dio.options, - '/api/v2/uploads/image', - queryParameters: queryParameters, - data: _data, - onSendProgress: onSendProgress, - ) - .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), - ); - final _result = await _dio.fetch>(_options); - late FileUploadResponse _value; - try { - _value = FileUploadResponse.fromJson(_result.data!); - } on Object catch (e, s) { - errorLogger?.logError(e, s, _options); - rethrow; - } - return _value; - } - - @override - Future> sendImage({ - required List file, - void Function(int, int)? onSendProgress, - }) { - return _ResultCallAdapter().adapt( - () => _sendImage(file: file, onSendProgress: onSendProgress), - ); - } - - RequestOptions _setStreamType(RequestOptions requestOptions) { - if (T != dynamic && - !(requestOptions.responseType == ResponseType.bytes || - requestOptions.responseType == ResponseType.stream)) { - if (T == String) { - requestOptions.responseType = ResponseType.plain; - } else { - requestOptions.responseType = ResponseType.json; - } - } - return requestOptions; - } - - String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { - if (baseUrl == null || baseUrl.trim().isEmpty) { - return dioBaseUrl; - } - - final url = Uri.parse(baseUrl); - - if (url.isAbsolute) { - return url.toString(); - } - - return Uri.parse(dioBaseUrl).resolveUri(url).toString(); - } -} diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index d77c5672..b6e8746b 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -3,6 +3,7 @@ export 'models/feed_data.dart'; export 'models/feed_id.dart'; export 'models/feed_input_data.dart'; export 'models/feed_member_request_data.dart'; +export 'models/feeds_config.dart'; export 'models/poll_data.dart'; export 'models/request/activity_add_comment_request.dart' show ActivityAddCommentRequest; diff --git a/packages/stream_feeds/lib/src/models/feeds_config.dart b/packages/stream_feeds/lib/src/models/feeds_config.dart index aa0e3f60..4b41d674 100644 --- a/packages/stream_feeds/lib/src/models/feeds_config.dart +++ b/packages/stream_feeds/lib/src/models/feeds_config.dart @@ -1,3 +1,5 @@ +import 'package:stream_core/stream_core.dart'; + import 'push_notifications_config.dart'; /// Configuration settings for the Stream Feeds SDK. @@ -6,11 +8,11 @@ import 'push_notifications_config.dart'; /// and other SDK-wide settings. class FeedsConfig { const FeedsConfig({ + this.cdnClient, this.pushNotificationsConfig, }); - // TODO: Add CDN client - // final FileUploader fileUploader; + final CdnClient? cdnClient; final PushNotificationsConfig? pushNotificationsConfig; } diff --git a/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.dart b/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.dart index fbca12fa..40eda49c 100644 --- a/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.dart +++ b/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.dart @@ -1,6 +1,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart'; +import '../../utils/uploader.dart'; part 'activity_add_comment_request.freezed.dart'; @@ -9,21 +11,39 @@ part 'activity_add_comment_request.freezed.dart'; /// Contains comment content, attachments, mentions, and custom metadata /// needed to create a new comment on an activity. @freezed -class ActivityAddCommentRequest with _$ActivityAddCommentRequest { +class ActivityAddCommentRequest + with _$ActivityAddCommentRequest + implements HasAttachments { /// Creates a new [ActivityAddCommentRequest] instance. const ActivityAddCommentRequest({ + required this.activityId, required this.comment, + this.activityType = 'activity', this.attachments, + this.attachmentUploads, this.createNotificationActivity, this.mentionedUserIds, this.parentId, this.custom, }); + /// The unique identifier of the activity to comment on. + @override + final String activityId; + + /// The type of the activity being commented on. + @override + final String activityType; + /// Optional list of attachments to include with the comment. @override final List? attachments; + /// Optional list of stream attachments to be uploaded before adding the + /// comment to the activity. + @override + final List? attachmentUploads; + /// The content of the comment to be added. @override final String comment; @@ -43,6 +63,18 @@ class ActivityAddCommentRequest with _$ActivityAddCommentRequest { /// Optional custom data to include with the comment. @override final Map? custom; + + /// Creates a copy of this request with updated attachments and uploads. + @override + ActivityAddCommentRequest withAttachments({ + List? attachments, + List? attachmentUploads, + }) { + return copyWith( + attachments: attachments, + attachmentUploads: attachmentUploads, + ); + } } /// Extension function to convert an [ActivityAddCommentRequest] to an API request. @@ -51,10 +83,7 @@ extension ActivityAddCommentRequestMapper on ActivityAddCommentRequest { /// /// Returns an [AddCommentRequest] containing all the necessary /// information to add a comment to an activity. - AddCommentRequest toRequest({ - required String activityId, - String activityType = 'activity', - }) { + AddCommentRequest toRequest() { return AddCommentRequest( comment: comment, attachments: attachments, diff --git a/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.freezed.dart b/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.freezed.dart index 3425058c..2fad987f 100644 --- a/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.freezed.dart +++ b/packages/stream_feeds/lib/src/models/request/activity_add_comment_request.freezed.dart @@ -15,7 +15,10 @@ T _$identity(T value) => value; /// @nodoc mixin _$ActivityAddCommentRequest { + String get activityId; + String get activityType; List? get attachments; + List? get attachmentUploads; String get comment; bool? get createNotificationActivity; List? get mentionedUserIds; @@ -35,8 +38,14 @@ mixin _$ActivityAddCommentRequest { return identical(this, other) || (other.runtimeType == runtimeType && other is ActivityAddCommentRequest && + (identical(other.activityId, activityId) || + other.activityId == activityId) && + (identical(other.activityType, activityType) || + other.activityType == activityType) && const DeepCollectionEquality() .equals(other.attachments, attachments) && + const DeepCollectionEquality() + .equals(other.attachmentUploads, attachmentUploads) && (identical(other.comment, comment) || other.comment == comment) && (identical(other.createNotificationActivity, createNotificationActivity) || @@ -52,7 +61,10 @@ mixin _$ActivityAddCommentRequest { @override int get hashCode => Object.hash( runtimeType, + activityId, + activityType, const DeepCollectionEquality().hash(attachments), + const DeepCollectionEquality().hash(attachmentUploads), comment, createNotificationActivity, const DeepCollectionEquality().hash(mentionedUserIds), @@ -61,7 +73,7 @@ mixin _$ActivityAddCommentRequest { @override String toString() { - return 'ActivityAddCommentRequest(attachments: $attachments, comment: $comment, createNotificationActivity: $createNotificationActivity, mentionedUserIds: $mentionedUserIds, parentId: $parentId, custom: $custom)'; + return 'ActivityAddCommentRequest(activityId: $activityId, activityType: $activityType, attachments: $attachments, attachmentUploads: $attachmentUploads, comment: $comment, createNotificationActivity: $createNotificationActivity, mentionedUserIds: $mentionedUserIds, parentId: $parentId, custom: $custom)'; } } @@ -72,8 +84,11 @@ abstract mixin class $ActivityAddCommentRequestCopyWith<$Res> { _$ActivityAddCommentRequestCopyWithImpl; @useResult $Res call( - {String comment, + {String activityId, + String comment, + String activityType, List? attachments, + List? attachmentUploads, bool? createNotificationActivity, List? mentionedUserIds, String? parentId, @@ -93,22 +108,37 @@ class _$ActivityAddCommentRequestCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ + Object? activityId = null, Object? comment = null, + Object? activityType = null, Object? attachments = freezed, + Object? attachmentUploads = freezed, Object? createNotificationActivity = freezed, Object? mentionedUserIds = freezed, Object? parentId = freezed, Object? custom = freezed, }) { return _then(ActivityAddCommentRequest( + activityId: null == activityId + ? _self.activityId + : activityId // ignore: cast_nullable_to_non_nullable + as String, comment: null == comment ? _self.comment : comment // ignore: cast_nullable_to_non_nullable as String, + activityType: null == activityType + ? _self.activityType + : activityType // ignore: cast_nullable_to_non_nullable + as String, attachments: freezed == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, + attachmentUploads: freezed == attachmentUploads + ? _self.attachmentUploads + : attachmentUploads // ignore: cast_nullable_to_non_nullable + as List?, createNotificationActivity: freezed == createNotificationActivity ? _self.createNotificationActivity : createNotificationActivity // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.dart b/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.dart index 2ef06c7f..ef87c9cf 100644 --- a/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.dart +++ b/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.dart @@ -1,6 +1,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart'; +import '../../utils/uploader.dart'; part 'feed_add_activity_request.freezed.dart'; @@ -10,12 +12,15 @@ part 'feed_add_activity_request.freezed.dart'; /// needed to create a new activity across multiple feeds. Supports advanced /// features like location data, visibility controls, and file attachments. @freezed -class FeedAddActivityRequest with _$FeedAddActivityRequest { +class FeedAddActivityRequest + with _$FeedAddActivityRequest + implements HasAttachments { /// Creates a new [FeedAddActivityRequest] instance. const FeedAddActivityRequest({ required this.type, this.feeds = const [], this.attachments, + this.attachmentUploads, this.custom, this.expiresAt, this.filterTags, @@ -35,6 +40,11 @@ class FeedAddActivityRequest with _$FeedAddActivityRequest { @override final List? attachments; + /// Optional list of stream attachments to be uploaded before adding the + /// activity to the feeds. + @override + final List? attachmentUploads; + /// Custom data associated with the activity. @override final Map? custom; @@ -94,6 +104,18 @@ class FeedAddActivityRequest with _$FeedAddActivityRequest { /// Optional visibility tag for custom visibility rules. @override final String? visibilityTag; + + /// Creates a copy of this request with updated attachments and uploads. + @override + FeedAddActivityRequest withAttachments({ + List? attachments, + List? attachmentUploads, + }) { + return copyWith( + attachments: attachments, + attachmentUploads: attachmentUploads, + ); + } } /// Extension function to convert a [FeedAddActivityRequest] to an API request. diff --git a/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.freezed.dart b/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.freezed.dart index 04adef51..6197e8c9 100644 --- a/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.freezed.dart +++ b/packages/stream_feeds/lib/src/models/request/feed_add_activity_request.freezed.dart @@ -16,6 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$FeedAddActivityRequest { List? get attachments; + List? get attachmentUploads; Map? get custom; String? get expiresAt; List get feeds; @@ -47,6 +48,8 @@ mixin _$FeedAddActivityRequest { other is FeedAddActivityRequest && const DeepCollectionEquality() .equals(other.attachments, attachments) && + const DeepCollectionEquality() + .equals(other.attachmentUploads, attachmentUploads) && const DeepCollectionEquality().equals(other.custom, custom) && (identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt) && @@ -77,6 +80,7 @@ mixin _$FeedAddActivityRequest { int get hashCode => Object.hash( runtimeType, const DeepCollectionEquality().hash(attachments), + const DeepCollectionEquality().hash(attachmentUploads), const DeepCollectionEquality().hash(custom), expiresAt, const DeepCollectionEquality().hash(feeds), @@ -95,7 +99,7 @@ mixin _$FeedAddActivityRequest { @override String toString() { - return 'FeedAddActivityRequest(attachments: $attachments, custom: $custom, expiresAt: $expiresAt, feeds: $feeds, filterTags: $filterTags, id: $id, interestTags: $interestTags, location: $location, mentionedUserIds: $mentionedUserIds, parentId: $parentId, pollId: $pollId, searchData: $searchData, text: $text, type: $type, visibility: $visibility, visibilityTag: $visibilityTag)'; + return 'FeedAddActivityRequest(attachments: $attachments, attachmentUploads: $attachmentUploads, custom: $custom, expiresAt: $expiresAt, feeds: $feeds, filterTags: $filterTags, id: $id, interestTags: $interestTags, location: $location, mentionedUserIds: $mentionedUserIds, parentId: $parentId, pollId: $pollId, searchData: $searchData, text: $text, type: $type, visibility: $visibility, visibilityTag: $visibilityTag)'; } } @@ -109,6 +113,7 @@ abstract mixin class $FeedAddActivityRequestCopyWith<$Res> { {String type, List feeds, List? attachments, + List? attachmentUploads, Map? custom, String? expiresAt, List? filterTags, @@ -140,6 +145,7 @@ class _$FeedAddActivityRequestCopyWithImpl<$Res> Object? type = null, Object? feeds = null, Object? attachments = freezed, + Object? attachmentUploads = freezed, Object? custom = freezed, Object? expiresAt = freezed, Object? filterTags = freezed, @@ -167,6 +173,10 @@ class _$FeedAddActivityRequestCopyWithImpl<$Res> ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, + attachmentUploads: freezed == attachmentUploads + ? _self.attachmentUploads + : attachmentUploads // ignore: cast_nullable_to_non_nullable + as List?, custom: freezed == custom ? _self.custom : custom // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/repository/activities_repository.dart b/packages/stream_feeds/lib/src/repository/activities_repository.dart index b3bee97a..dbd79df8 100644 --- a/packages/stream_feeds/lib/src/repository/activities_repository.dart +++ b/packages/stream_feeds/lib/src/repository/activities_repository.dart @@ -5,7 +5,9 @@ import '../models/activity_data.dart'; import '../models/feed_id.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; +import '../models/request/feed_add_activity_request.dart'; import '../state/query/activities_query.dart'; +import '../utils/uploader.dart'; /// Repository for managing activities and activity-related operations. /// @@ -16,26 +18,31 @@ import '../state/query/activities_query.dart'; /// All methods return [Result] objects for explicit error handling. class ActivitiesRepository { /// Creates a new [ActivitiesRepository] instance. - /// - /// The [api] parameter is required for making API calls to the Stream Feeds service. - const ActivitiesRepository(this._api); + const ActivitiesRepository(this._api, this._uploader); // The API client used for making requests to the Stream Feeds service. final api.DefaultApi _api; + // The attachment uploader for handling file and image uploads. + final StreamAttachmentUploader _uploader; + /// Adds a new activity. /// /// Creates a new activity using the provided [request] data. /// /// Returns a [Result] containing the created [ActivityData] or an error. Future> addActivity( - api.AddActivityRequest request, + FeedAddActivityRequest request, ) async { - final result = await _api.addActivity( - addActivityRequest: request, - ); + final processedRequest = await _uploader.processRequest(request); - return result.map((response) => response.activity.toModel()); + return processedRequest.flatMapAsync((updatedRequest) async { + final result = await _api.addActivity( + addActivityRequest: updatedRequest.toRequest(), + ); + + return result.map((response) => response.activity.toModel()); + }); } /// Deletes an activity. diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index caa80cc7..5ce2c260 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -4,11 +4,13 @@ import '../generated/api/api.dart' as api; import '../models/comment_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; +import '../models/request/activity_add_comment_request.dart'; import '../models/threaded_comment_data.dart'; import '../state/query/activity_comments_query.dart'; import '../state/query/comment_reactions_query.dart'; import '../state/query/comment_replies_query.dart'; import '../state/query/comments_query.dart'; +import '../utils/uploader.dart'; /// Repository for managing comments and comment-related operations. /// @@ -18,13 +20,14 @@ import '../state/query/comments_query.dart'; /// All methods return [Result] objects for explicit error handling. class CommentsRepository { /// Creates a new [CommentsRepository] instance. - /// - /// The [api] parameter is required for making API calls to the Stream Feeds service. - const CommentsRepository(this._api); + const CommentsRepository(this._api, this._uploader); // The API client used for making requests to the Stream Feeds service. final api.DefaultApi _api; + // The attachment uploader for handling file and image uploads. + final StreamAttachmentUploader _uploader; + /// Queries comments. /// /// Searches for comments using the specified [query] filters and pagination. @@ -83,27 +86,42 @@ class CommentsRepository { /// Creates a new comment using the provided [request] data. /// /// Returns a [Result] containing the newly created [CommentData] or an error. - Future> addComment(api.AddCommentRequest request) async { - final result = await _api.addComment(addCommentRequest: request); + Future> addComment( + ActivityAddCommentRequest request, + ) async { + final processedRequest = await _uploader.processRequest(request); - return result.map((response) => response.comment.toModel()); + return processedRequest.flatMapAsync((updatedRequest) async { + final result = await _api.addComment( + addCommentRequest: updatedRequest.toRequest(), + ); + + return result.map((response) => response.comment.toModel()); + }); } /// Adds multiple comments. /// - /// Creates multiple comments in a single batch operation using the provided [request] data. + /// Creates multiple comments in a single batch operation using the provided [requests] data. /// /// Returns a [Result] containing a list of [CommentData] or an error. Future>> addCommentsBatch( - api.AddCommentsBatchRequest request, + List requests, ) async { - final result = await _api.addCommentsBatch( - addCommentsBatchRequest: request, - ); + final processedBatch = await _uploader.processRequestsBatch(requests); - return result.map( - (response) => response.comments.map((c) => c.toModel()).toList(), - ); + return processedBatch.flatMapAsync((batch) async { + final comments = batch.map((r) => r.toRequest()).toList(); + final batchRequest = api.AddCommentsBatchRequest(comments: comments); + + final result = await _api.addCommentsBatch( + addCommentsBatchRequest: batchRequest, + ); + + return result.map( + (response) => response.comments.map((c) => c.toModel()).toList(), + ); + }); } /// Deletes a comment. diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index 8597d60b..fd588d77 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -138,12 +138,10 @@ class Activity with Disposable { /// Adds a comment to this activity. /// /// Returns a [Result] containing the created [CommentData] or an error. - Future> addComment( - ActivityAddCommentRequest request, - ) async { - final result = await commentsRepository.addComment( - request.toRequest(activityId: activityId), - ); + Future> addComment({ + required ActivityAddCommentRequest request, + }) async { + final result = await commentsRepository.addComment(request); result.onSuccess( (comment) => _commentsList.notifier.onCommentAdded( @@ -160,12 +158,7 @@ class Activity with Disposable { Future>> addCommentsBatch( List requests, ) async { - final addCommentRequests = requests.map((request) { - return request.toRequest(activityId: activityId); - }).toList(); - - final request = api.AddCommentsBatchRequest(comments: addCommentRequests); - final result = await commentsRepository.addCommentsBatch(request); + final result = await commentsRepository.addCommentsBatch(requests); result.onSuccess((comments) { final threadedComments = comments.map(ThreadedCommentData.fromComment); diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index eed728f2..0dde1cbb 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -14,6 +14,7 @@ import '../models/feed_member_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/follow_data.dart'; import '../models/model_updates.dart'; +import '../models/request/activity_add_comment_request.dart'; import '../models/request/feed_add_activity_request.dart'; import '../repository/activities_repository.dart'; import '../repository/bookmarks_repository.dart'; @@ -145,15 +146,13 @@ class Feed with Disposable { /// Adds a new activity to the feed. /// /// The [request] contains the activity data to add. - /// The [attachmentUploadProgress] callback provides upload progress updates for any attachments. + /// /// Returns a [Result] containing the added [ActivityData] if successful, or an error if the /// operation fails. Future> addActivity({ required FeedAddActivityRequest request, - // TODO: Implement attachment upload progress - ProgressCallback? attachmentUploadProgress, }) async { - return activitiesRepository.addActivity(request.toRequest()); + return activitiesRepository.addActivity(request); } /// Updates an existing activity in the feed. @@ -291,7 +290,7 @@ class Feed with Disposable { /// Returns a [Result] containing the added [CommentData] if successful, or an error if the /// operation fails. Future> addComment({ - required api.AddCommentRequest request, + required ActivityAddCommentRequest request, }) { return commentsRepository.addComment(request); } @@ -390,7 +389,7 @@ class Feed with Disposable { required String activityId, String? text, }) async { - final request = api.AddActivityRequest( + final request = FeedAddActivityRequest( type: 'post', text: text, feeds: [query.fid.rawValue], @@ -618,7 +617,7 @@ class Feed with Disposable { final result = await pollsRepository.createPoll(request); return result.flatMapAsync((poll) { - final request = api.AddActivityRequest( + final request = FeedAddActivityRequest( feeds: [query.fid.rawValue], pollId: poll.id, type: activityType, diff --git a/packages/stream_feeds/lib/src/utils/uploader.dart b/packages/stream_feeds/lib/src/utils/uploader.dart new file mode 100644 index 00000000..93408cc1 --- /dev/null +++ b/packages/stream_feeds/lib/src/utils/uploader.dart @@ -0,0 +1,143 @@ +import 'package:stream_core/stream_core.dart'; + +import '../generated/api/models.dart' as api; + +/// Interface for requests that support attachment uploads. +abstract interface class HasAttachments { + /// The current attachments in the request. + List? get attachments; + + /// The attachments to be uploaded. + List? get attachmentUploads; + + /// Creates a copy of this request with updated attachments and uploads. + T withAttachments({ + List? attachments, + List? attachmentUploads, + }); +} + +extension HasAttachmentsExtension on StreamAttachmentUploader { + /// Processes a request with attachment uploads by uploading files and merging with existing attachments. + /// + /// Uploads all [StreamAttachment] items from the request and merges them with existing + /// attachments. Returns an updated request with all attachments ready for API submission. + /// + /// Returns a [Result] containing the updated request or an error. + Future> processRequest>( + T request, { + OnBatchUploadProgress? onProgress, + int maxConcurrent = 5, + bool eagerError = true, + }) async { + final attachmentsToUpload = request.attachmentUploads; + // If there are no attachments to upload, return the original request. + if (attachmentsToUpload == null || attachmentsToUpload.isEmpty) { + return Result.success(request); + } + + final uploadResult = await _uploadAll( + attachmentsToUpload, + onProgress: onProgress, + maxConcurrent: maxConcurrent, + eagerError: eagerError, + ); + + return uploadResult.map((attachments) { + final uploadedAttachments = { + for (final uploaded in attachments) + uploaded.id: api.Attachment( + type: uploaded.type, + custom: {...?uploaded.custom}, + assetUrl: uploaded.remoteUrl, + imageUrl: uploaded.remoteUrl, + thumbUrl: uploaded.thumbnailUrl, + ), + }; + + // Merge uploaded attachments with existing ones, avoiding duplicates + final current = request.attachments ?? []; + final updatedAttachments = current.merge( + uploadedAttachments.values, + key: (it) => (it.type, it.assetUrl, it.imageUrl), + ); + + // Remove processed uploads from the upload queue using ID-based filtering + final uploadedIds = uploadedAttachments.keys.toSet(); + final updatedAttachmentUploads = attachmentsToUpload.where( + (upload) => !uploadedIds.contains(upload.id), + ); + + return request.withAttachments( + attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), + attachmentUploads: updatedAttachmentUploads.toList(), + ); + }); + } + + /// Processes multiple requests with attachment uploads in parallel. + /// + /// Processes each request individually using [processRequest] and returns + /// a list of updated requests with all attachments ready for API submission. + /// + /// Returns a [Result] containing the list of updated requests or an error. + Future>> processRequestsBatch>( + List requests, { + OnBatchUploadProgress? onProgress, + int maxConcurrent = 5, + bool eagerError = true, + }) { + return runSafely(() async { + final batch = requests.map( + (request) => processRequest( + request, + onProgress: onProgress, + maxConcurrent: maxConcurrent, + eagerError: eagerError, + ), + ); + + final processed = await batch.wait; + + final successfulRequests = []; + for (final result in processed) { + // If eagerError is enabled, throw on first failure + if (result.exceptionOrNull() case final error? when eagerError) { + final stackTrace = result.stackTraceOrNull(); + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current); + } + + successfulRequests.add(result.getOrNull()); + } + + return successfulRequests.nonNulls.toList(); + }); + } + + // Uploads multiple attachments in parallel with progress tracking. + // + // Processes [attachments] in batches with configurable concurrency and progress + // reporting. Returns a [Result] containing the list of uploaded attachments. + // + // Returns a [Result] containing a list of [UploadedAttachment] or an error. + Future>> _uploadAll( + Iterable attachments, { + OnBatchUploadProgress? onProgress, + int maxConcurrent = 5, + bool eagerError = true, + }) { + return runSafely(() async { + final batch = uploadBatch( + attachments, + onProgress: onProgress, + maxConcurrent: maxConcurrent, + eagerError: eagerError, + ); + + final batchResult = await batch.toList(); + final uploadedAttachments = batchResult.map((it) => it.getOrNull()); + + return uploadedAttachments.nonNulls.toList(); + }); + } +} diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index b7da3df4..11b92ff6 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: stream_core: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 47329234bddbc0ccc11f48175a8cd032cf6a2d88 + ref: 5acdcb8e378548372f7dbb6326c518edcf832d10 path: packages/stream_core uuid: ^4.5.1 diff --git a/pubspec.lock b/pubspec.lock index eba167e8..d899e497 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.3.0" charcode: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: cli_launcher - sha256: "17d2744fb9a254c49ec8eda582536abe714ea0131533e24389843a4256f82eac" + sha256: "67d89e0a1c07b103d1253f6b953a43d3f502ee36805c8cfc21196282c9ddf177" url: "https://pub.dev" source: hosted - version: "0.3.2+1" + version: "0.3.2" cli_util: dependency: transitive description: @@ -93,18 +93,18 @@ packages: dependency: transitive description: name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf url: "https://pub.dev" source: hosted - version: "1.19.1" + version: "1.19.0" conventional_commit: dependency: transitive description: name: conventional_commit - sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 + sha256: fad254feb6fb8eace2be18855176b0a4b97e0d50e416ff0fe590d5ba83735d34 url: "https://pub.dev" source: hosted - version: "0.6.1+1" + version: "0.6.1" convert: dependency: transitive description: @@ -234,10 +234,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.19.0" io: dependency: transitive description: @@ -282,18 +282,18 @@ packages: dependency: "direct dev" description: name: melos - sha256: "4280dc46bd5b741887cce1e67e5c1a6aaf3c22310035cf5bd33dceeeda62ed22" + sha256: "3f3ab3f902843d1e5a1b1a4dd39a4aca8ba1056f2d32fd8995210fa2843f646f" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.2" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.15.0" mime: dependency: transitive description: @@ -354,10 +354,10 @@ packages: dependency: transitive description: name: process - sha256: "44b4226c0afd4bc3b7c7e67d44c4801abd97103cf0c84609e2654b664ca2798c" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.4" + version: "5.0.5" prompts: dependency: transitive description: @@ -386,10 +386,10 @@ packages: dependency: transitive description: name: pub_updater - sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" + sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.4.0" pubspec_parse: dependency: transitive description: @@ -410,10 +410,10 @@ packages: dependency: transitive description: name: retrofit - sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25" + sha256: "84d70114a5b6bae5f4c1302335f9cb610ebeb1b02023d5e7e87697aaff52926a" url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.6.0" rxdart: dependency: transitive description: @@ -471,8 +471,8 @@ packages: dependency: transitive description: path: "packages/stream_core" - ref: "47329234bddbc0ccc11f48175a8cd032cf6a2d88" - resolved-ref: "47329234bddbc0ccc11f48175a8cd032cf6a2d88" + ref: "5acdcb8e378548372f7dbb6326c518edcf832d10" + resolved-ref: "5acdcb8e378548372f7dbb6326c518edcf832d10" url: "https://github.com/GetStream/stream-core-flutter.git" source: git version: "0.0.1" @@ -495,10 +495,10 @@ packages: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -535,10 +535,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" web: dependency: transitive description: @@ -588,5 +588,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.6.2 <4.0.0" flutter: ">=1.16.0" diff --git a/sample_app/android/app/src/main/AndroidManifest.xml b/sample_app/android/app/src/main/AndroidManifest.xml index dd26c94a..6bfce655 100644 --- a/sample_app/android/app/src/main/AndroidManifest.xml +++ b/sample_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + + + /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F629E29423CC7A16D95D0269 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -379,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CA067902D67F59196EF07B5C /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 753210A57FA8F4A5B62062DB /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DC196F3039A2E7EA172EEAF2 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/sample_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/sample_app/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/sample_app/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/sample_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/sample_app/ios/Runner/Info.plist b/sample_app/ios/Runner/Info.plist index ec7e2831..e75f168f 100644 --- a/sample_app/ios/Runner/Info.plist +++ b/sample_app/ios/Runner/Info.plist @@ -45,5 +45,12 @@ UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + This app needs access to camera to take photos and videos for posts. + NSPhotoLibraryUsageDescription + This app needs access to photo library to select images and videos for posts. + NSMicrophoneUsageDescription + This app needs access to microphone to record videos. diff --git a/sample_app/lib/app/app_state.dart b/sample_app/lib/app/app_state.dart index c0ee4598..fc2d169f 100644 --- a/sample_app/lib/app/app_state.dart +++ b/sample_app/lib/app/app_state.dart @@ -18,8 +18,6 @@ class AppStateNotifier extends ValueNotifier { // Initialize the dependency injection system await initDI(); - await Future.delayed(const Duration(seconds: 3)); - // Ensure all fonts are loaded before rendering the app await GoogleFonts.pendingFonts(); diff --git a/sample_app/lib/app/content/app_content.dart b/sample_app/lib/app/content/app_content.dart index 551e477f..361e0cd9 100644 --- a/sample_app/lib/app/content/app_content.dart +++ b/sample_app/lib/app/content/app_content.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../core/di/di_initializer.dart'; @@ -43,6 +44,18 @@ class _StreamFeedsSampleAppContentState debugShowCheckedModeBanner: false, theme: ThemeConfig.fromBrightness(Brightness.light), darkTheme: ThemeConfig.fromBrightness(Brightness.dark), + scrollBehavior: const MaterialScrollBehavior().copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + PointerDeviceKind.trackpad, + PointerDeviceKind.mouse, + // The VoiceAccess sends pointer events with unknown type when scrolling + // scrollables. + PointerDeviceKind.unknown, + }, + ), routerConfig: _appRouter.config( reevaluateListenable: _authController, ), diff --git a/sample_app/lib/core/di/di_initializer.config.dart b/sample_app/lib/core/di/di_initializer.config.dart index fd93423a..33fe398f 100644 --- a/sample_app/lib/core/di/di_initializer.config.dart +++ b/sample_app/lib/core/di/di_initializer.config.dart @@ -14,6 +14,7 @@ import 'package:injectable/injectable.dart' as _i526; import 'package:sample/app/app_state.dart' as _i870; import 'package:sample/app/content/auth_controller.dart' as _i27; import 'package:sample/core/di/di_module.dart' as _i238; +import 'package:sample/core/di/session_module.dart' as _i514; import 'package:sample/core/models/user_credentials.dart' as _i845; import 'package:sample/navigation/app_router.dart' as _i701; import 'package:sample/navigation/guards/auth_guard.dart' as _i1031; @@ -53,6 +54,21 @@ extension GetItInjectableX on _i174.GetIt { () => _i701.AppRouter(gh<_i1031.AuthGuard>())); return this; } + +// initializes the registration of session-scope dependencies inside of GetIt + _i174.GetIt initSessionScope({_i174.ScopeDisposeFunc? dispose}) { + return _i526.GetItHelper(this).initScope( + 'session', + dispose: dispose, + init: (_i526.GetItHelper gh) { + final sessionModule = _$SessionModule(); + gh.singleton<_i250.StreamFeedsClient>(() => + sessionModule.authenticatedFeedsClient(gh<_i27.AuthController>())); + }, + ); + } } class _$AppModule extends _i238.AppModule {} + +class _$SessionModule extends _i514.SessionModule {} diff --git a/sample_app/lib/core/di/session_module.dart b/sample_app/lib/core/di/session_module.dart new file mode 100644 index 00000000..ec6fabe7 --- /dev/null +++ b/sample_app/lib/core/di/session_module.dart @@ -0,0 +1,12 @@ +import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../app/content/auth_controller.dart'; + +@module +abstract class SessionModule { + @Singleton(scope: 'session') + StreamFeedsClient authenticatedFeedsClient(AuthController auth) { + return (auth.value as Authenticated).client; + } +} diff --git a/sample_app/lib/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index 618a39dc..40a24b85 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -3,6 +3,8 @@ import 'package:injectable/injectable.dart'; import '../screens/choose_user/choose_user_screen.dart'; import '../screens/home/home_screen.dart'; + +import '../screens/user_feed/user_feed_screen.dart'; import 'guards/auth_guard.dart'; part 'app_router.gr.dart'; @@ -22,6 +24,17 @@ class AppRouter extends RootStackRouter { initial: true, page: HomeRoute.page, guards: [_authGuard], + children: [ + AutoRoute( + initial: true, + path: 'user_feed', + page: UserFeedRoute.page, + ), + + // Future child routes can be added here: + // AutoRoute(path: 'explore', page: ExploreFeedRoute.page), + // AutoRoute(path: 'notifications', page: NotificationsRoute.page), + ], ), AutoRoute( path: '/choose_user', @@ -31,3 +44,4 @@ class AppRouter extends RootStackRouter { ]; } } + diff --git a/sample_app/lib/navigation/app_router.gr.dart b/sample_app/lib/navigation/app_router.gr.dart index 97209c3a..08d870c2 100644 --- a/sample_app/lib/navigation/app_router.gr.dart +++ b/sample_app/lib/navigation/app_router.gr.dart @@ -14,7 +14,7 @@ part of 'app_router.dart'; /// [ChooseUserScreen] class ChooseUserRoute extends PageRouteInfo { const ChooseUserRoute({List? children}) - : super(ChooseUserRoute.name, initialChildren: children); + : super(ChooseUserRoute.name, initialChildren: children); static const String name = 'ChooseUserRoute'; @@ -30,7 +30,7 @@ class ChooseUserRoute extends PageRouteInfo { /// [HomeScreen] class HomeRoute extends PageRouteInfo { const HomeRoute({List? children}) - : super(HomeRoute.name, initialChildren: children); + : super(HomeRoute.name, initialChildren: children); static const String name = 'HomeRoute'; @@ -41,3 +41,19 @@ class HomeRoute extends PageRouteInfo { }, ); } + +/// generated route for +/// [UserFeedScreen] +class UserFeedRoute extends PageRouteInfo { + const UserFeedRoute({List? children}) + : super(UserFeedRoute.name, initialChildren: children); + + static const String name = 'UserFeedRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const UserFeedScreen(); + }, + ); +} diff --git a/sample_app/lib/navigation/guards/auth_guard.dart b/sample_app/lib/navigation/guards/auth_guard.dart index e170af9d..1a951099 100644 --- a/sample_app/lib/navigation/guards/auth_guard.dart +++ b/sample_app/lib/navigation/guards/auth_guard.dart @@ -14,12 +14,8 @@ class AuthGuard extends AutoRouteGuard { @override void onNavigation(NavigationResolver resolver, StackRouter router) { final isAuthenticated = _authController.value is Authenticated; - debugPrint('AuthGuard: isAuthenticated = $isAuthenticated'); - // If the user is authenticated, allow navigation to the requested route. if (isAuthenticated) return resolver.next(); - - print('AuthGuard: User is not authenticated, redirecting to login.'); // Otherwise, redirect to the Choose user page. resolver.redirectUntil(const ChooseUserRoute(), replace: true); } diff --git a/sample_app/lib/screens/choose_user/choose_user_screen.dart b/sample_app/lib/screens/choose_user/choose_user_screen.dart index ada30fe0..19e7b99b 100644 --- a/sample_app/lib/screens/choose_user/choose_user_screen.dart +++ b/sample_app/lib/screens/choose_user/choose_user_screen.dart @@ -15,40 +15,42 @@ class ChooseUserScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 34), - Center( - child: SvgPicture.asset( - 'assets/images/app_logo.svg', - height: 40, - colorFilter: ColorFilter.mode( - context.appColors.accentPrimary, - BlendMode.srcIn, + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 34), + Center( + child: SvgPicture.asset( + 'assets/images/app_logo.svg', + height: 40, + colorFilter: ColorFilter.mode( + context.appColors.accentPrimary, + BlendMode.srcIn, + ), ), ), - ), - const SizedBox(height: 20), - Text( - 'Welcome to Stream Feeds', - style: context.appTextStyles.title, - ), - const SizedBox(height: 12), - Text( - 'Select a user to try the Flutter SDK:', - style: context.appTextStyles.body, - ), - const SizedBox(height: 32), - Expanded( - child: UserSelectionList( - onUserSelected: (credentials) { - final authController = locator(); - return authController.connect(credentials).ignore(); - }, + const SizedBox(height: 20), + Text( + 'Welcome to Stream Feeds', + style: context.appTextStyles.title, ), - ), - ], + const SizedBox(height: 12), + Text( + 'Select a user to try the Flutter SDK:', + style: context.appTextStyles.body, + ), + const SizedBox(height: 32), + Expanded( + child: UserSelectionList( + onUserSelected: (credentials) { + final authController = locator(); + return authController.connect(credentials).ignore(); + }, + ), + ), + ], + ), ), ); } diff --git a/sample_app/lib/screens/home/home_screen.dart b/sample_app/lib/screens/home/home_screen.dart index 4374336d..1950f12c 100644 --- a/sample_app/lib/screens/home/home_screen.dart +++ b/sample_app/lib/screens/home/home_screen.dart @@ -1,45 +1,30 @@ -import 'dart:ui'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:stream_feeds/stream_feeds.dart'; -import '../../app/content/auth_controller.dart'; +import '../../core/di/di_initializer.config.dart'; import '../../core/di/di_initializer.dart'; -import '../../theme/theme.dart'; -import '../../widgets/user_avatar.dart'; -import '../profile/profile_widget.dart'; -import 'widgets/user_feed_appbar.dart'; -import 'widgets/user_feed_view.dart'; @RoutePage() -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { - final authController = locator(); - final state = authController.value; - final (user, client) = switch (state) { - Authenticated(:final user, :final client) => (user, client), - _ => throw Exception('User not authenticated'), - }; + State createState() => _HomeScreenState(); +} - final wideScreen = MediaQuery.sizeOf(context).width > 600; +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + locator.initSessionScope(); + } - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: UserFeedView( - client: client, - currentUser: user, - wideScreen: wideScreen, - onLogout: authController.disconnect, - ), - ); + @override + void dispose() { + locator.popScope(); + super.dispose(); } + + @override + Widget build(BuildContext context) => const AutoRouter(); } diff --git a/sample_app/lib/screens/home/widgets/user_feed_view.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart similarity index 68% rename from sample_app/lib/screens/home/widgets/user_feed_view.dart rename to sample_app/lib/screens/user_feed/user_feed_screen.dart index f4846bcd..5473ad69 100644 --- a/sample_app/lib/screens/home/widgets/user_feed_view.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -1,39 +1,35 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../widgets/user_avatar.dart'; -import '../../profile/profile_widget.dart'; -import 'activity_comments_view.dart'; -import 'activity_content.dart'; -import 'user_feed_appbar.dart'; +import '../../app/content/auth_controller.dart'; +import '../../core/di/di_initializer.dart'; +import 'widgets/activity_comments_view.dart'; +import 'widgets/activity_content.dart'; +import 'widgets/create_activity_bottom_sheet.dart'; +import 'widgets/user_feed_appbar.dart'; +import 'widgets/user_profile_view.dart'; -class UserFeedView extends StatefulWidget { - const UserFeedView({ - super.key, - required this.client, - required this.currentUser, - required this.wideScreen, - required this.onLogout, - }); - - final User currentUser; - final StreamFeedsClient client; - final bool wideScreen; - final VoidCallback onLogout; +@RoutePage() +class UserFeedScreen extends StatefulWidget { + const UserFeedScreen({super.key}); @override - State createState() => _UserFeedViewState(); + State createState() => _UserFeedScreenState(); } -class _UserFeedViewState extends State { - late final feed = widget.client.feedFromQuery( +class _UserFeedScreenState extends State { + StreamFeedsClient get client => locator(); + + late final feed = client.feedFromQuery( FeedQuery( - fid: FeedId(group: 'user', id: widget.currentUser.id), + fid: FeedId(group: 'user', id: client.user.id), data: FeedInputData( visibility: FeedVisibility.public, - members: [FeedMemberRequestData(userId: widget.currentUser.id)], + members: [FeedMemberRequestData(userId: client.user.id)], ), ), ); @@ -50,8 +46,17 @@ class _UserFeedViewState extends State { super.dispose(); } + Future _onLogout() { + final authController = locator(); + return authController.disconnect(); + } + @override Widget build(BuildContext context) { + final currentUser = client.user; + + final wideScreen = MediaQuery.sizeOf(context).width > 600; + return StateNotifierBuilder( stateNotifier: feed.notifier, builder: (context, state, child) { @@ -98,7 +103,7 @@ class _UserFeedViewState extends State { text: baseActivity.text ?? '', attachments: baseActivity.attachments, data: activity, - currentUserId: widget.client.user.id, + currentUserId: currentUser.id, onCommentClick: () => _onCommentClick(context, activity), onHeartClick: (isAdding) => @@ -117,27 +122,30 @@ class _UserFeedViewState extends State { ), ); - if (!widget.wideScreen) { + if (!wideScreen) { return _buildScaffold( context, feedWidget, + onLogout: _onLogout, onProfileTap: () { - _showProfileBottomSheet(context, widget.client, feed); + _showProfileBottomSheet(context, client, feed); }, ); } + return _buildScaffold( context, Row( children: [ SizedBox( width: 250, - child: ProfileWidget(feedsClient: widget.client, feed: feed), + child: UserProfileView(feedsClient: client, feed: feed), ), const SizedBox(width: 16), Expanded(child: feedWidget), ], ), + onLogout: _onLogout, ); }, ); @@ -146,6 +154,7 @@ class _UserFeedViewState extends State { Widget _buildScaffold( BuildContext context, Widget body, { + VoidCallback? onLogout, VoidCallback? onProfileTap, }) { return Scaffold( @@ -153,7 +162,7 @@ class _UserFeedViewState extends State { leading: GestureDetector( onTap: onProfileTap, child: Center( - child: UserAvatar.appBar(user: widget.currentUser), + child: UserAvatar.appBar(user: client.user), ), ), title: Text( @@ -162,7 +171,7 @@ class _UserFeedViewState extends State { ), actions: [ IconButton( - onPressed: widget.onLogout, + onPressed: onLogout, icon: Icon( Icons.logout, color: context.appColors.textLowEmphasis, @@ -171,6 +180,13 @@ class _UserFeedViewState extends State { ], ), body: body, + floatingActionButton: FloatingActionButton( + elevation: 4, + onPressed: _showCreateActivityBottomSheet, + backgroundColor: context.appColors.accentPrimary, + foregroundColor: context.appColors.appBg, + child: const Icon(Icons.add), + ), ); } @@ -181,7 +197,7 @@ class _UserFeedViewState extends State { ) { showModalBottomSheet( context: context, - builder: (context) => ProfileWidget(feedsClient: client, feed: feed), + builder: (context) => UserProfileView(feedsClient: client, feed: feed), ); } @@ -191,7 +207,7 @@ class _UserFeedViewState extends State { builder: (context) => ActivityCommentsView( activityId: activity.id, feed: feed, - client: widget.client, + client: client, ), ); } @@ -222,14 +238,41 @@ class _UserFeedViewState extends State { feed.addBookmark(activityId: activity.id); } } -} -class _FeedWidget extends StatelessWidget { - const _FeedWidget({super.key}); + Future _showCreateActivityBottomSheet() async { + final request = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => CreateActivityBottomSheet( + currentUser: client.user, + feedId: feed.query.fid, + ), + ); + + if (request == null) return; + final result = await feed.addActivity(request: request); - @override - Widget build(BuildContext context) { - return const Placeholder(); + switch (result) { + case Success(): + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Activity created successfully!'), + backgroundColor: context.appColors.accentPrimary, + ), + ); + } + case Failure(error: final error): + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create activity: $error'), + backgroundColor: context.appColors.accentError, + ), + ); + } + } } } diff --git a/sample_app/lib/screens/home/widgets/activity_comments_view.dart b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart similarity index 91% rename from sample_app/lib/screens/home/widgets/activity_comments_view.dart rename to sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart index e9727870..4107a11e 100644 --- a/sample_app/lib/screens/home/widgets/activity_comments_view.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart @@ -6,8 +6,8 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; +import '../../../widgets/action_button.dart'; import '../../../widgets/user_avatar.dart'; -import 'activity_content.dart'; class ActivityCommentsView extends StatefulWidget { const ActivityCommentsView({ @@ -79,6 +79,8 @@ class _ActivityCommentsViewState extends State { ), floatingActionButton: FloatingActionButton( onPressed: () => _reply(context, null), + backgroundColor: context.appColors.accentPrimary, + foregroundColor: context.appColors.appBg, child: const Icon(Icons.add), ), ); @@ -125,7 +127,11 @@ class _ActivityCommentsViewState extends State { if (text == null) return; await activity.addComment( - ActivityAddCommentRequest(comment: text, parentId: parentComment?.id), + request: ActivityAddCommentRequest( + comment: text, + parentId: parentComment?.id, + activityId: activity.activityId, + ), ); } @@ -333,17 +339,27 @@ class CommentWidget extends StatelessWidget { children: [ TextButton( onPressed: () => onReplyClick(comment), - child: const Text('Reply'), + style: TextButton.styleFrom( + foregroundColor: context.appColors.textLowEmphasis, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Reply', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), ), ActionButton( icon: Icon( hasOwnHeart ? Icons.favorite_rounded : Icons.favorite_border_rounded, - size: 16, - color: hasOwnHeart ? Colors.red : null, ), count: heartsCount, + color: hasOwnHeart ? context.appColors.accentError : null, onTap: () => onHeartClick(comment, !hasOwnHeart), ), ], diff --git a/sample_app/lib/screens/home/widgets/activity_content.dart b/sample_app/lib/screens/user_feed/widgets/activity_content.dart similarity index 70% rename from sample_app/lib/screens/home/widgets/activity_content.dart rename to sample_app/lib/screens/user_feed/widgets/activity_content.dart index eccda909..93d72d46 100644 --- a/sample_app/lib/screens/home/widgets/activity_content.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_content.dart @@ -3,6 +3,8 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; +import '../../../widgets/action_button.dart'; +import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; class ActivityContent extends StatelessWidget { @@ -46,19 +48,14 @@ class ActivityContent extends StatelessWidget { ), const SizedBox(height: 8), Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 500, - ), - child: _UserActions( - user: user, - data: data, - currentUserId: currentUserId, - onCommentClick: onCommentClick, - onHeartClick: onHeartClick, - onRepostClick: onRepostClick, - onBookmarkClick: onBookmarkClick, - ), + child: _UserActions( + user: user, + data: data, + currentUserId: currentUserId, + onCommentClick: onCommentClick, + onHeartClick: onHeartClick, + onRepostClick: onRepostClick, + onBookmarkClick: onBookmarkClick, ), ), ], @@ -82,12 +79,12 @@ class _UserContent extends StatelessWidget { @override Widget build(BuildContext context) { return Row( + spacing: 8, crossAxisAlignment: CrossAxisAlignment.start, children: [ UserAvatar.appBar( user: User(id: user.id, name: user.name, image: user.image), ), - const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -131,28 +128,17 @@ class _ActivityBody extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (text.isNotEmpty) Text(text), if (attachments.isNotEmpty) ...[ - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: attachments.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final attachment = attachments[index]; - return Image.network( - attachment.imageUrl ?? attachment.assetUrl!, - width: 100, - height: 100, - fit: BoxFit.cover, - ); - }, - ), + AttachmentGrid( + attachments: attachments, + onAttachmentTap: (attachment) { + // TODO: Implement fullscreen attachment view + }, ), ], ], @@ -190,10 +176,10 @@ class _UserActions extends StatelessWidget { final hasOwnBookmark = data.ownReactions.any((it) => it.type == 'bookmark'); return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ActionButton( - icon: const Icon(Icons.comment, size: 16), + icon: const Icon(Icons.comment_outlined), count: data.commentCount, onTap: onCommentClick, ), @@ -201,15 +187,14 @@ class _UserActions extends StatelessWidget { icon: Icon( hasOwnHeart ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - size: 16, - color: hasOwnHeart ? Colors.red : null, + : Icons.favorite_outline_rounded, ), count: heartsCount, + color: hasOwnHeart ? context.appColors.accentError : null, onTap: () => onHeartClick?.call(!hasOwnHeart), ), ActionButton( - icon: const Icon(Icons.share_rounded, size: 16), + icon: const Icon(Icons.repeat_rounded), count: data.shareCount, onTap: () => onRepostClick?.call(null), ), @@ -217,39 +202,13 @@ class _UserActions extends StatelessWidget { icon: Icon( hasOwnBookmark ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - size: 16, - color: hasOwnBookmark ? Colors.blue : null, + : Icons.bookmark_outline_rounded, ), count: data.bookmarkCount, + color: hasOwnBookmark ? context.appColors.accentPrimary : null, onTap: onBookmarkClick, ), ], ); } } - -class ActionButton extends StatelessWidget { - const ActionButton({ - super.key, - this.icon, - required this.count, - this.onTap, - }); - - final Widget? icon; - final int count; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return TextButton.icon( - onPressed: onTap, - label: Text( - count > 0 ? count.toString() : '', - style: context.appTextStyles.footnote, - ), - icon: icon, - ); - } -} diff --git a/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart new file mode 100644 index 00000000..97fea273 --- /dev/null +++ b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../widgets/attachments/attachments.dart'; +import '../../../widgets/user_avatar.dart'; + +/// A bottom sheet for creating new activities with text and attachments. +/// +/// Provides a modern compose interface similar to popular social media apps +/// with text input, character counter, attachment selection, and post functionality. +/// Returns a [FeedAddActivityRequest] when the user completes the form. +class CreateActivityBottomSheet extends StatefulWidget { + const CreateActivityBottomSheet({ + super.key, + required this.currentUser, + required this.feedId, + }); + + /// The current user creating the activity. + final User currentUser; + + /// The feed ID where the activity will be posted. + final FeedId feedId; + + @override + State createState() => + _CreateActivityBottomSheetState(); +} + +class _CreateActivityBottomSheetState extends State { + final _textController = TextEditingController(); + final _focusNode = FocusNode(); + final List _attachments = []; + + static const int _maxCharacters = 280; + + @override + void initState() { + super.initState(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: context.appColors.appBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + child: Column( + spacing: 16, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + _buildHeader(context), + + // Content + _buildUserInputSection(context), + + // Attachment preview + AttachmentPreviewList( + attachments: _attachments, + onRemoveAttachment: _removeAttachment, + ), + + // Attachment picker + AttachmentPicker( + onAttachmentsSelected: _addAttachments, + ), + + // Spacing at the bottom + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.appColors.borders, + ), + ), + ), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Cancel', + style: context.appTextStyles.bodyBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ), + const Spacer(), + Text( + 'New Post', + style: context.appTextStyles.headlineBold, + ), + const Spacer(), + FilledButton( + onPressed: _canPost ? _createActivity : null, + style: FilledButton.styleFrom( + backgroundColor: context.appColors.accentPrimary, + foregroundColor: context.appColors.appBg, + ), + child: const Text('Post'), + ), + ], + ), + ); + } + + Widget _buildUserInputSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User avatar + UserAvatar(user: widget.currentUser), + + // Text input area + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Text input + TextFormField( + maxLines: null, + controller: _textController, + focusNode: _focusNode, + maxLength: _maxCharacters, + style: context.appTextStyles.body, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: "What's happening?", + hintStyle: context.appTextStyles.body.copyWith( + color: context.appColors.textLowEmphasis, + ), + border: InputBorder.none, + counterStyle: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + bool get _canPost { + final hasText = _textController.text.trim().isNotEmpty; + final hasAttachments = _attachments.isNotEmpty; + + return hasText || hasAttachments; + } + + void _addAttachments(List newAttachments) { + setState(() => _attachments.addAll(newAttachments)); + } + + void _removeAttachment(StreamAttachment attachment) { + setState(() => _attachments.remove(attachment)); + } + + void _createActivity() { + if (!_canPost) return; + + final text = _textController.text.trim(); + + final request = FeedAddActivityRequest( + type: 'post', + feeds: [widget.feedId.rawValue], + text: text.takeIf((it) => it.isNotEmpty), + attachmentUploads: _attachments.isNotEmpty ? _attachments : null, + ); + + // Return the request to the parent for handling + Navigator.pop(context, request); + } +} diff --git a/sample_app/lib/screens/home/widgets/user_feed_appbar.dart b/sample_app/lib/screens/user_feed/widgets/user_feed_appbar.dart similarity index 100% rename from sample_app/lib/screens/home/widgets/user_feed_appbar.dart rename to sample_app/lib/screens/user_feed/widgets/user_feed_appbar.dart diff --git a/sample_app/lib/screens/profile/profile_widget.dart b/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart similarity index 92% rename from sample_app/lib/screens/profile/profile_widget.dart rename to sample_app/lib/screens/user_feed/widgets/user_profile_view.dart index 6b561cec..4ca1acab 100644 --- a/sample_app/lib/screens/profile/profile_widget.dart +++ b/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:stream_feeds/stream_feeds.dart'; -import '../../theme/extensions/theme_extensions.dart'; -import '../../widgets/user_avatar.dart'; +import '../../../theme/extensions/theme_extensions.dart'; +import '../../../widgets/user_avatar.dart'; -class ProfileWidget extends StatefulWidget { - const ProfileWidget({ +class UserProfileView extends StatefulWidget { + const UserProfileView({ super.key, required this.feedsClient, required this.feed, @@ -16,10 +16,10 @@ class ProfileWidget extends StatefulWidget { final Feed feed; @override - State createState() => _ProfileWidgetState(); + State createState() => _UserProfileViewState(); } -class _ProfileWidgetState extends State { +class _UserProfileViewState extends State { List? followSuggestions; @override @@ -30,7 +30,7 @@ class _ProfileWidgetState extends State { } @override - void didUpdateWidget(covariant ProfileWidget oldWidget) { + void didUpdateWidget(covariant UserProfileView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.feed != widget.feed) { _queryFollowSuggestions(); @@ -59,7 +59,8 @@ class _ProfileWidgetState extends State { Container( alignment: Alignment.center, padding: const EdgeInsets.all(16), - child: Text('Profile', style: context.appTextStyles.headlineBold), + child: Text('User Profile', + style: context.appTextStyles.headlineBold), ), ProfileItem.text( title: 'Feed members', @@ -118,7 +119,7 @@ class _ProfileWidgetState extends State { }); }, ), - ] + ], ], ); }, @@ -128,7 +129,6 @@ class _ProfileWidgetState extends State { class _FollowerItem extends StatelessWidget { const _FollowerItem({ - super.key, required this.follower, required this.buttonText, required this.onButtonPressed, @@ -180,7 +180,6 @@ class _FollowSuggestionsWidget extends StatelessWidget { class _FollowSuggestionWidget extends StatelessWidget { const _FollowSuggestionWidget({ - super.key, required this.owner, required this.followedFeed, required this.onFollowPressed, diff --git a/sample_app/lib/widgets/action_button.dart b/sample_app/lib/widgets/action_button.dart new file mode 100644 index 00000000..bbeba483 --- /dev/null +++ b/sample_app/lib/widgets/action_button.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../theme/extensions/theme_extensions.dart'; + +/// The default size for the icon inside the action button. +const double kDefaultActionButtonIconSize = 16; + +/// The default padding around the action button. +const double kDefaultActionButtonPadding = 8; + +/// {@template streamActionButton} +/// A customized action button for feed interactions. +/// +/// This is used to create like, comment, share, and bookmark buttons +/// in the activity feed with optional count display. +/// {@endtemplate} +class ActionButton extends StatelessWidget { + /// {@macro streamActionButton} + const ActionButton({ + super.key, + required this.icon, + required this.count, + this.onTap, + this.color, + this.disabledColor, + this.iconSize = kDefaultActionButtonIconSize, + this.padding = const EdgeInsets.all(kDefaultActionButtonPadding), + this.showCountWhenZero = false, + }); + + /// The icon to display inside the button. + final Widget icon; + + /// The count to display below the icon. + final int count; + + /// The callback that is called when the button is tapped. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onTap; + + /// The color to use for the icon and text, if the button is enabled. + /// + /// If null, uses the theme's low emphasis text color. + final Color? color; + + /// The color to use for the icon and text, if the button is disabled. + final Color? disabledColor; + + /// The size of the icon inside the button. + /// + /// Defaults to 16.0. + final double iconSize; + + /// The padding around the button content. + /// + /// Defaults to EdgeInsets.all(8.0). + final EdgeInsetsGeometry padding; + + /// Whether to show the count even when it's zero. + /// + /// Defaults to false. + final bool showCountWhenZero; + + @override + Widget build(BuildContext context) { + final shouldShowCount = showCountWhenZero || count > 0; + + if (shouldShowCount) { + return TextButton.icon( + onPressed: onTap, + icon: icon, + label: Text( + count.toString(), + style: context.appTextStyles.footnote, + ), + style: TextButton.styleFrom( + foregroundColor: color, + iconColor: color, + disabledForegroundColor: disabledColor, + disabledIconColor: disabledColor, + iconSize: iconSize, + padding: padding, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + + return IconButton( + onPressed: onTap, + icon: icon, + color: color, + disabledColor: disabledColor, + iconSize: iconSize, + padding: padding, + style: IconButton.styleFrom( + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachments/attachment_grid.dart b/sample_app/lib/widgets/attachments/attachment_grid.dart new file mode 100644 index 00000000..d0599067 --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachment_grid.dart @@ -0,0 +1,249 @@ +import 'package:flex_grid_view/flex_grid_view.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/theme.dart'; +import 'attachment_widget.dart'; + +const _defaultGridConstraints = BoxConstraints( + maxWidth: 300, + maxHeight: 228, +); + +/// A widget that displays attachments in an intelligent grid layout. +/// +/// Uses FlexGrid to create responsive layouts that adapt based on attachment +/// count and aspect ratios, similar to modern social media platforms. +class AttachmentGrid extends StatelessWidget { + const AttachmentGrid({ + super.key, + required this.attachments, + this.constraints = _defaultGridConstraints, + this.onAttachmentTap, + this.spacing = 2.0, + this.runSpacing = 2.0, + }); + + final List attachments; + final BoxConstraints constraints; + final ValueSetter? onAttachmentTap; + final double spacing; + final double runSpacing; + + @override + Widget build(BuildContext context) { + final shape = RoundedRectangleBorder( + side: BorderSide( + color: context.appColors.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(14), + ); + + return Container( + constraints: constraints, + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration(shape: shape), + child: switch (attachments.length) { + 1 => _buildForOne(context, attachments), + 2 => _buildForTwo(context, attachments), + 3 => _buildForThree(context, attachments), + _ => _buildForFourOrMore(context, attachments), + }, + ); + } + + Widget itemBuilder(BuildContext context, int index) { + final attachment = attachments[index]; + return AttachmentWidget( + attachment: attachment, + onTap: switch (onAttachmentTap) { + final onTap? => () => onTap(attachment), + _ => null, + }, + ); + } + + Widget _buildForOne(BuildContext context, List attachments) { + return FlexGrid( + pattern: const [ + [1], + ], + spacing: spacing, + runSpacing: runSpacing, + children: [itemBuilder(context, 0)], + ); + } + + Widget _buildForTwo(BuildContext context, List attachments) { + final aspectRatio1 = attachments[0].originalSize?.aspectRatio; + final aspectRatio2 = attachments[1].originalSize?.aspectRatio; + + // check if one image is landscape and other is portrait or vice versa + final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1; + final isLandscape2 = aspectRatio2 != null && aspectRatio2 > 1; + + // Both the images are landscape. + if (isLandscape1 && isLandscape2) { + // ---------- + // | | + // ---------- + // | | + // ---------- + return FlexGrid( + pattern: const [ + [1], + [1], + ], + spacing: spacing, + runSpacing: runSpacing, + children: [ + itemBuilder(context, 0), + itemBuilder(context, 1), + ], + ); + } + + // Both the images are portrait. + if (!isLandscape1 && !isLandscape2) { + // ----------- + // | | | + // | | | + // | | | + // ----------- + return FlexGrid( + pattern: const [ + [1, 1], + ], + spacing: spacing, + runSpacing: runSpacing, + children: [ + itemBuilder(context, 0), + itemBuilder(context, 1), + ], + ); + } + + // Layout on the basis of isLandscape1. + // 1. True + // ----------- + // | | | + // | | | + // | | | + // ----------- + // + // 2. False + // ----------- + // | | | + // | | | + // | | | + // ----------- + return FlexGrid( + pattern: [ + if (isLandscape1) [2, 1] else [1, 2], + ], + spacing: spacing, + runSpacing: runSpacing, + children: [ + itemBuilder(context, 0), + itemBuilder(context, 1), + ], + ); + } + + Widget _buildForThree(BuildContext context, List attachments) { + final aspectRatio1 = attachments[0].originalSize?.aspectRatio; + final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1; + + // We layout on the basis of isLandscape1. + // 1. True + // ----------- + // | | + // | | + // |---------| + // | | | + // | | | + // ----------- + // + // 2. False + // ----------- + // | | | + // | | | + // | |----| + // | | | + // | | | + // ----------- + return FlexGrid( + pattern: const [ + [1], + [1, 1], + ], + spacing: spacing, + runSpacing: runSpacing, + reverse: !isLandscape1, + children: [ + itemBuilder(context, 0), + itemBuilder(context, 1), + itemBuilder(context, 2), + ], + ); + } + + Widget _buildForFourOrMore( + BuildContext context, + List attachments, + ) { + final pattern = >[]; + final children = []; + + for (var i = 0; i < attachments.length; i++) { + if (i.isEven) { + pattern.add([1]); + } else { + pattern.last.add(1); + } + + children.add(itemBuilder(context, i)); + } + + // ----------- + // | | | + // | | | + // ------------ + // | | | + // | | | + // ------------ + return FlexGrid( + pattern: pattern, + maxChildren: 4, + spacing: spacing, + runSpacing: runSpacing, + children: children, + overlayBuilder: (context, remaining) => IgnorePointer( + child: ColoredBox( + color: AppColorTokens.blackAlpha40, + child: Center( + child: Text( + '+$remaining', + style: const TextStyle( + fontSize: 26, + color: AppColorTokens.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ); + } +} + +extension on Attachment { + Size? get originalSize { + final width = originalWidth; + final height = originalHeight; + + if (width == null || height == null) return null; + return Size(width.toDouble(), height.toDouble()); + } +} diff --git a/sample_app/lib/widgets/attachments/attachment_picker.dart b/sample_app/lib/widgets/attachments/attachment_picker.dart new file mode 100644 index 00000000..e90fdfbf --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachment_picker.dart @@ -0,0 +1,162 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/extensions/theme_extensions.dart'; + +/// A widget that provides attachment selection functionality. +/// +/// Allows users to select images and videos from their device gallery +/// or camera, converting them to StreamAttachment objects for upload. +class AttachmentPicker extends StatelessWidget { + const AttachmentPicker({ + super.key, + required this.onAttachmentsSelected, + }); + + /// Callback when attachments are selected by the user. + final void Function(List attachments) onAttachmentsSelected; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 16, + children: [ + _AttachmentButton( + icon: Icons.photo_library, + label: 'Images', + onTap: () => _pickImages(context), + ), + _AttachmentButton( + icon: Icons.videocam, + label: 'Videos', + onTap: () => _pickVideos(context), + ), + ], + ), + ); + } + + Future _pickImages(BuildContext context) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + allowMultiple: true, + withData: true, // Load bytes for web compatibility + ); + + if (result != null && result.files.isNotEmpty) { + final attachments = _convertToStreamAttachments( + result.files, + type: AttachmentType.image, + ); + + if (attachments.isNotEmpty) onAttachmentsSelected(attachments); + } + } catch (e) { + if (context.mounted) { + _showErrorSnackBar(context, 'Failed to select images: $e'); + } + } + } + + Future _pickVideos(BuildContext context) async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.video, + allowMultiple: true, + withData: true, // Load bytes for web compatibility + ); + + if (result != null && result.files.isNotEmpty) { + final attachments = _convertToStreamAttachments( + result.files, + type: AttachmentType.video, + ); + + if (attachments.isNotEmpty) onAttachmentsSelected(attachments); + } + } catch (e) { + if (context.mounted) { + _showErrorSnackBar(context, 'Failed to select videos: $e'); + } + } + } + + List _convertToStreamAttachments( + List files, { + AttachmentType type = AttachmentType.file, + }) { + return [ + ...files.map( + (file) => StreamAttachment( + type: type, + file: AttachmentFile.fromXFile(file.xFile), + ), + ), + ]; + } + + void _showErrorSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: context.appColors.accentError, + ), + ); + } +} + +class _AttachmentButton extends StatelessWidget { + const _AttachmentButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Material( + color: context.appColors.barsBg, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border.all( + color: context.appColors.borders, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: context.appColors.accentPrimary, + size: 24, + ), + const SizedBox(height: 4), + Text( + label, + style: context.appTextStyles.captionBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachments/attachment_preview_list.dart b/sample_app/lib/widgets/attachments/attachment_preview_list.dart new file mode 100644 index 00000000..50e2b436 --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachment_preview_list.dart @@ -0,0 +1,182 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/extensions/theme_extensions.dart'; + +/// A horizontal list widget that displays attachment previews with remove functionality. +/// +/// Shows selected images and videos in a scrollable horizontal list with +/// remove buttons for each attachment. Supports both file paths and +/// StreamAttachment objects for upload management. +class AttachmentPreviewList extends StatelessWidget { + const AttachmentPreviewList({ + super.key, + required this.attachments, + required this.onRemoveAttachment, + }); + + /// List of StreamAttachment objects to display as previews. + final List attachments; + + /// Callback when user taps remove button on an attachment. + final void Function(StreamAttachment attachment) onRemoveAttachment; + + @override + Widget build(BuildContext context) { + if (attachments.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 80, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: attachments.length, + padding: const EdgeInsets.symmetric(horizontal: 16), + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final attachment = attachments[index]; + return _AttachmentPreviewItem( + attachment: attachment, + onRemove: () => onRemoveAttachment(attachment), + ); + }, + ), + ); + } +} + +class _AttachmentPreviewItem extends StatelessWidget { + const _AttachmentPreviewItem({ + required this.attachment, + required this.onRemove, + }); + + final StreamAttachment attachment; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 80, + height: 80, + child: Stack( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: context.appColors.barsBg, + border: Border.all( + color: context.appColors.borders, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPreview(context), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: onRemove, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: context.appColors.overlayDark, + shape: BoxShape.circle, + ), + child: Icon( + Icons.close, + color: context.appColors.appBg, + size: 16, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildPreview(BuildContext context) { + // Use the attachment type to determine preview + if (attachment.isImage) { + return _buildImagePreview(context); + } else if (attachment.isVideo) { + return _buildVideoPreview(context); + } + + // Fallback for other attachment types + return _buildGenericPreview(context); + } + + Widget _buildImagePreview(BuildContext context) { + // Use the file path for native platforms + if (!kIsWeb && attachment.file.path.isNotEmpty) { + final file = File(attachment.file.path); + return Image.file( + file, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return _buildGenericPreview(context); + }, + ); + } + + return _buildGenericPreview(context); + } + + Widget _buildVideoPreview(BuildContext context) { + return ColoredBox( + color: context.appColors.barsBg, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.play_circle_outline, + color: context.appColors.textHighEmphasis, + size: 32, + ), + const SizedBox(height: 4), + Text( + 'Video', + style: context.appTextStyles.captionBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ), + ); + } + + Widget _buildGenericPreview(BuildContext context) { + return ColoredBox( + color: context.appColors.barsBg, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.attachment, + color: context.appColors.textHighEmphasis, + size: 32, + ), + const SizedBox(height: 4), + Text( + 'File', + style: context.appTextStyles.captionBold.copyWith( + color: context.appColors.textLowEmphasis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachments/attachment_widget.dart b/sample_app/lib/widgets/attachments/attachment_widget.dart new file mode 100644 index 00000000..0e02e4f8 --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachment_widget.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../theme/extensions/theme_extensions.dart'; + +/// A widget that displays an attachment. +/// +/// Supports different attachment types including images, videos, and files +/// with appropriate previews, loading states, and error handling. +class AttachmentWidget extends StatelessWidget { + const AttachmentWidget({ + super.key, + required this.attachment, + this.onTap, + }); + + final Attachment attachment; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: context.appColors.disabled, + child: InkWell( + onTap: onTap, + child: _buildAttachmentPreview(context), + ), + ); + } + + Widget _buildAttachmentPreview(BuildContext context) { + final type = attachment.type?.toLowerCase(); + + // Handle different attachment types + return switch (type) { + AttachmentType.image => _buildImagePreview(context), + AttachmentType.video => _buildVideoPreview(context), + _ => _buildFilePreview(context), + }; + } + + Widget _buildImagePreview(BuildContext context) { + final imageUrl = attachment.imageUrl; + + if (imageUrl == null) { + return _buildErrorPreview(context, 'No image URL'); + } + + return Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return _buildErrorPreview(context, 'Failed to load image'); + }, + ); + } + + Widget _buildVideoPreview(BuildContext context) { + final thumbnailUrl = attachment.thumbUrl; + + return Stack( + fit: StackFit.expand, + children: [ + // Background thumbnail or placeholder + if (thumbnailUrl != null) + Image.network( + thumbnailUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return _buildVideoPlaceholder(context); + }, + ) + else + _buildVideoPlaceholder(context), + + // Play button overlay + ColoredBox( + color: context.appColors.overlay, + child: Center( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: context.appColors.textHighEmphasis, + shape: BoxShape.circle, + ), + child: Icon( + Icons.play_arrow, + color: context.appColors.appBg, + size: 24, + ), + ), + ), + ), + ], + ); + } + + Widget _buildVideoPlaceholder(BuildContext context) { + return ColoredBox( + color: context.appColors.disabled, + child: Center( + child: Icon( + Icons.videocam_rounded, + color: context.appColors.textLowEmphasis, + size: 32, + ), + ), + ); + } + + Widget _buildFilePreview(BuildContext context) { + final title = attachment.title ?? attachment.text ?? 'File'; + final fileType = attachment.type?.toUpperCase() ?? 'FILE'; + + return ColoredBox( + color: context.appColors.disabled, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.insert_drive_file, + color: context.appColors.textHighEmphasis, + size: 32, + ), + const SizedBox(height: 8), + Text( + fileType, + style: context.appTextStyles.captionBold.copyWith( + color: context.appColors.accentPrimary, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.textLowEmphasis, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPreview(BuildContext context, String message) { + return ColoredBox( + color: context.appColors.disabled, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: context.appColors.accentError, + size: 24, + ), + const SizedBox(height: 4), + Text( + 'Error', + style: context.appTextStyles.footnote.copyWith( + color: context.appColors.accentError, + ), + ), + ], + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/attachments/attachments.dart b/sample_app/lib/widgets/attachments/attachments.dart new file mode 100644 index 00000000..6e9e8902 --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachments.dart @@ -0,0 +1,4 @@ +export 'attachment_grid.dart'; +export 'attachment_picker.dart'; +export 'attachment_preview_list.dart'; +export 'attachment_widget.dart'; diff --git a/sample_app/macos/Runner/DebugProfile.entitlements b/sample_app/macos/Runner/DebugProfile.entitlements index c946719a..9153e9d8 100644 --- a/sample_app/macos/Runner/DebugProfile.entitlements +++ b/sample_app/macos/Runner/DebugProfile.entitlements @@ -3,12 +3,24 @@ com.apple.security.app-sandbox - + com.apple.security.cs.allow-jit com.apple.security.network.server com.apple.security.network.client + + com.apple.security.device.camera + + com.apple.security.personal-information.photos-library + + com.apple.security.device.microphone + + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.downloads.read-only + diff --git a/sample_app/macos/Runner/Info.plist b/sample_app/macos/Runner/Info.plist index 4789daa6..dd4bc6ee 100644 --- a/sample_app/macos/Runner/Info.plist +++ b/sample_app/macos/Runner/Info.plist @@ -28,5 +28,12 @@ MainMenu NSPrincipalClass NSApplication + + NSCameraUsageDescription + This app needs access to camera to take photos and videos for posts. + NSPhotoLibraryUsageDescription + This app needs access to photo library to select images and videos for posts. + NSMicrophoneUsageDescription + This app needs access to microphone to record videos. diff --git a/sample_app/macos/Runner/Release.entitlements b/sample_app/macos/Runner/Release.entitlements index 48271acc..381cc506 100644 --- a/sample_app/macos/Runner/Release.entitlements +++ b/sample_app/macos/Runner/Release.entitlements @@ -6,5 +6,17 @@ com.apple.security.network.client + + com.apple.security.device.camera + + com.apple.security.personal-information.photos-library + + com.apple.security.device.microphone + + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.downloads.read-only + diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 31a2e568..5ea78434 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -18,8 +18,10 @@ dependencies: flutter_svg: ^2.2.0 get_it: ^8.0.3 google_fonts: ^6.3.0 + file_picker: ^8.1.4 + flex_grid_view: ^0.1.0 injectable: ^2.5.1 - jiffy: ^6.4.3 + jiffy: ^6.3.2 shared_preferences: ^2.5.3 stream_feeds: path: ../packages/stream_feeds