From 03414b5d483aef6c675a4b95d1fb285d47294d42 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 03:21:17 +0200 Subject: [PATCH 01/19] feat(llc): implement attachment uploads This commit introduces the `StreamAttachmentUploader` for handling file and image uploads to the Stream CDN. Key changes: - Added `CdnApi` with methods for uploading and deleting files and images. - Implemented `FeedsCdnClient` to wrap `CdnApi` and conform to the `CdnClient` interface from `stream_core`. - Integrated `StreamAttachmentUploader` into `FeedsClientImpl`. - Modified `ActivitiesRepository` and `CommentsRepository` to use `StreamAttachmentUploader` for uploading attachments before creating activities or comments. - Updated `FeedAddActivityRequest` and `ActivityAddCommentRequest` to include `attachmentUploads` (list of `StreamAttachment`). - Renamed `onSendProgress` to `onUploadProgress` in `CdnApi` for clarity. - Updated relevant public API surfaces and internal logic to support attachment uploads. - Regenerated `cdn_api.g.dart`. --- .../lib/src/client/feeds_client_impl.dart | 40 +++--- .../stream_feeds/lib/src/feeds_client.dart | 6 + .../stream_feeds/lib/src/file/cdn_api.dart | 22 ++- .../stream_feeds/lib/src/file/cdn_api.g.dart | 128 +++++++++++++++--- .../lib/src/file/feeds_cdn_client.dart | 79 +++++++++++ .../lib/src/models/feeds_config.dart | 6 +- .../request/activity_add_comment_request.dart | 22 ++- .../activity_add_comment_request.freezed.dart | 34 ++++- .../request/feed_add_activity_request.dart | 7 + .../feed_add_activity_request.freezed.dart | 12 +- .../src/repository/activities_repository.dart | 56 +++++++- .../src/repository/comments_repository.dart | 86 +++++++++++- .../stream_feeds/lib/src/state/activity.dart | 11 +- packages/stream_feeds/lib/src/state/feed.dart | 13 +- packages/stream_feeds/lib/stream_feeds.dart | 2 + 15 files changed, 449 insertions(+), 75 deletions(-) create mode 100644 packages/stream_feeds/lib/src/file/feeds_cdn_client.dart 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 ea75496d..cd9bc1e9 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:stream_core/stream_core.dart'; import '../feeds_client.dart'; +import '../file/cdn_api.dart'; +import '../file/feeds_cdn_client.dart'; import '../generated/api/api.dart' as api; import '../models/activity_data.dart'; import '../models/app_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; @@ -432,14 +442,8 @@ class StreamFeedsClientImpl implements StreamFeedsClient { } @override - Future> deleteFile(String url) { - // TODO: implement deleteFile - throw UnimplementedError(); - } + Future> deleteFile(String url) => _cdnClient.deleteFile(url); @override - Future> deleteImage(String url) { - // TODO: implement deleteImage - throw UnimplementedError(); - } + Future> deleteImage(String url) => _cdnClient.deleteImage(url); } diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 74f3c5bb..8d38f58d 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -696,6 +696,12 @@ abstract interface class StreamFeedsClient { /// 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.dart b/packages/stream_feeds/lib/src/file/cdn_api.dart index b133941c..3953b005 100644 --- a/packages/stream_feeds/lib/src/file/cdn_api.dart +++ b/packages/stream_feeds/lib/src/file/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/file/cdn_api.g.dart b/packages/stream_feeds/lib/src/file/cdn_api.g.dart index 0edf9ab2..0c608113 100644 --- a/packages/stream_feeds/lib/src/file/cdn_api.g.dart +++ b/packages/stream_feeds/lib/src/file/cdn_api.g.dart @@ -17,9 +17,10 @@ class _CdnApi implements CdnApi { final ParseErrorLogger? errorLogger; - Future _sendFile({ + Future _uploadFile({ required List file, - void Function(int, int)? onSendProgress, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, }) async { final _extra = {}; final queryParameters = {}; @@ -39,7 +40,8 @@ class _CdnApi implements CdnApi { '/api/v2/uploads/file', queryParameters: queryParameters, data: _data, - onSendProgress: onSendProgress, + cancelToken: cancelToken, + onSendProgress: onUploadProgress, ) .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), ); @@ -55,18 +57,24 @@ class _CdnApi implements CdnApi { } @override - Future> sendFile({ + Future> uploadFile({ required List file, - void Function(int, int)? onSendProgress, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, }) { return _ResultCallAdapter().adapt( - () => _sendFile(file: file, onSendProgress: onSendProgress), + () => _uploadFile( + file: file, + onUploadProgress: onUploadProgress, + cancelToken: cancelToken, + ), ); } - Future _sendImage({ + Future _uploadImage({ required List file, - void Function(int, int)? onSendProgress, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, }) async { final _extra = {}; final queryParameters = {}; @@ -74,7 +82,7 @@ class _CdnApi implements CdnApi { final _headers = {}; final _data = FormData(); _data.files.addAll(file.map((i) => MapEntry('file', i))); - final _options = _setStreamType>( + final _options = _setStreamType>( Options( method: 'POST', headers: _headers, @@ -86,14 +94,15 @@ class _CdnApi implements CdnApi { '/api/v2/uploads/image', queryParameters: queryParameters, data: _data, - onSendProgress: onSendProgress, + cancelToken: cancelToken, + onSendProgress: onUploadProgress, ) .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), ); final _result = await _dio.fetch>(_options); - late FileUploadResponse _value; + late ImageUploadResponse _value; try { - _value = FileUploadResponse.fromJson(_result.data!); + _value = ImageUploadResponse.fromJson(_result.data!); } on Object catch (e, s) { errorLogger?.logError(e, s, _options); rethrow; @@ -102,12 +111,99 @@ class _CdnApi implements CdnApi { } @override - Future> sendImage({ + Future> uploadImage({ required List file, - void Function(int, int)? onSendProgress, + void Function(int, int)? onUploadProgress, + CancelToken? cancelToken, }) { - return _ResultCallAdapter().adapt( - () => _sendImage(file: file, onSendProgress: onSendProgress), + 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), ); } diff --git a/packages/stream_feeds/lib/src/file/feeds_cdn_client.dart b/packages/stream_feeds/lib/src/file/feeds_cdn_client.dart new file mode 100644 index 00000000..1d277f7a --- /dev/null +++ b/packages/stream_feeds/lib/src/file/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/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..33aeb584 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,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart'; @@ -12,18 +13,34 @@ part 'activity_add_comment_request.freezed.dart'; class ActivityAddCommentRequest with _$ActivityAddCommentRequest { /// Creates a new [ActivityAddCommentRequest] instance. const ActivityAddCommentRequest({ + required this.activityId, required this.comment, + this.activityType = 'activity', this.attachments, + this.attachmentUploads = const [], 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; @@ -51,10 +68,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..542ad085 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 = null, 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: null == 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..cb7389f1 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,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart'; @@ -16,6 +17,7 @@ class FeedAddActivityRequest with _$FeedAddActivityRequest { required this.type, this.feeds = const [], this.attachments, + this.attachmentUploads = const [], this.custom, this.expiresAt, this.filterTags, @@ -35,6 +37,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; 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..8390f0b6 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 = null, 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: null == 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..3b21d121 100644 --- a/packages/stream_feeds/lib/src/repository/activities_repository.dart +++ b/packages/stream_feeds/lib/src/repository/activities_repository.dart @@ -5,6 +5,7 @@ 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'; /// Repository for managing activities and activity-related operations. @@ -16,28 +17,73 @@ 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 uploadedAttachments = await _uploadStreamAttachments( + request.attachmentUploads, + ); + + final currentAttachments = request.attachments ?? []; + final updatedAttachments = currentAttachments.merge( + uploadedAttachments, + key: (it) => (it.type, it.assetUrl, it.imageUrl), + ); + + final updatedRequest = request.copyWith( + attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), + ); + final result = await _api.addActivity( - addActivityRequest: request, + addActivityRequest: updatedRequest.toRequest(), ); return result.map((response) => response.activity.toModel()); } + // Uploads stream attachments and converts them to API attachment format. + // + // Processes the provided attachments by uploading them via the uploader + // and converting successful uploads to API attachment objects. + Future> _uploadStreamAttachments( + List attachments, + ) async { + if (attachments.isEmpty) return []; + + final batch = _uploader.uploadBatch(attachments); + final results = await batch.toList(); + + final successfulUploads = results.map( + (result) { + final uploaded = result.getOrNull(); + if (uploaded == null) return null; + + return api.Attachment( + custom: const {}, + type: uploaded.type, + assetUrl: uploaded.remoteUrl, + imageUrl: uploaded.remoteUrl, + thumbUrl: uploaded.thumbnailUrl, + ); + }, + ).nonNulls; + + return successfulUploads.toList(); + } + /// Deletes an activity. /// /// Removes the activity with the specified [activityId]. When [hardDelete] diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index caa80cc7..908cca4b 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -4,6 +4,7 @@ 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'; @@ -18,13 +19,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,8 +85,26 @@ 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 uploadedAttachments = await _uploadStreamAttachments( + request.attachmentUploads, + ); + + final currentAttachments = request.attachments ?? []; + final updatedAttachments = currentAttachments.merge( + uploadedAttachments, + key: (it) => (it.type, it.assetUrl, it.imageUrl), + ); + + final updatedRequest = request.copyWith( + attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), + ); + + final result = await _api.addComment( + addCommentRequest: updatedRequest.toRequest(), + ); return result.map((response) => response.comment.toModel()); } @@ -95,10 +115,32 @@ class CommentsRepository { /// /// Returns a [Result] containing a list of [CommentData] or an error. Future>> addCommentsBatch( - api.AddCommentsBatchRequest request, + List requests, ) async { + final batch = await requests.map( + (request) async { + final uploadedAttachments = await _uploadStreamAttachments( + request.attachmentUploads, + ); + + final currentAttachments = request.attachments ?? []; + final updatedAttachments = currentAttachments.merge( + uploadedAttachments, + key: (it) => (it.type, it.assetUrl, it.imageUrl), + ); + + return request.copyWith( + attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), + ); + }, + ).wait; + + final batchRequest = api.AddCommentsBatchRequest( + comments: batch.map((r) => r.toRequest()).toList(), + ); + final result = await _api.addCommentsBatch( - addCommentsBatchRequest: request, + addCommentsBatchRequest: batchRequest, ); return result.map( @@ -106,6 +148,36 @@ class CommentsRepository { ); } + // Uploads stream attachments and converts them to API attachment format. + // + // Processes the provided attachments by uploading them via the uploader + // and converting successful uploads to API attachment objects. + Future> _uploadStreamAttachments( + List attachments, + ) async { + if (attachments.isEmpty) return []; + + final batch = _uploader.uploadBatch(attachments); + final results = await batch.toList(); + + final successfulUploads = results.map( + (result) { + final uploaded = result.getOrNull(); + if (uploaded == null) return null; + + return api.Attachment( + custom: const {}, + type: uploaded.type, + assetUrl: uploaded.remoteUrl, + imageUrl: uploaded.remoteUrl, + thumbUrl: uploaded.thumbnailUrl, + ); + }, + ).nonNulls; + + return successfulUploads.toList(); + } + /// Deletes a comment. /// /// Removes the comment with the specified [commentId] from the system. diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index 5046fcfd..26ffb8ac 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -138,9 +138,7 @@ class Activity with Disposable { Future> addComment( ActivityAddCommentRequest request, ) async { - final result = await commentsRepository.addComment( - request.toRequest(activityId: activityId), - ); + final result = await commentsRepository.addComment(request); result.onSuccess( (comment) => _commentsList.notifier.onCommentAdded( @@ -157,12 +155,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 b958f676..bec22cb9 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'; @@ -142,15 +143,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. @@ -288,7 +287,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); } @@ -387,7 +386,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], @@ -615,7 +614,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/stream_feeds.dart b/packages/stream_feeds/lib/stream_feeds.dart index 12228e2b..7eeb4545 100644 --- a/packages/stream_feeds/lib/stream_feeds.dart +++ b/packages/stream_feeds/lib/stream_feeds.dart @@ -7,6 +7,8 @@ export 'src/models/feed_id.dart'; export 'src/models/feed_input_data.dart'; export 'src/models/feed_member_request_data.dart'; export 'src/models/poll_data.dart'; +export 'src/models/request/activity_add_comment_request.dart'; +export 'src/models/request/feed_add_activity_request.dart'; export 'src/models/user_data.dart'; export 'src/state/feed.dart'; export 'src/state/query/feed_query.dart'; From c2cc816613250551caba1700b32ebd9682f0ba3c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 03:44:48 +0200 Subject: [PATCH 02/19] feat: include custom data in attachment uploads --- .../stream_feeds/lib/src/repository/activities_repository.dart | 2 +- .../stream_feeds/lib/src/repository/comments_repository.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_feeds/lib/src/repository/activities_repository.dart b/packages/stream_feeds/lib/src/repository/activities_repository.dart index 3b21d121..f6447314 100644 --- a/packages/stream_feeds/lib/src/repository/activities_repository.dart +++ b/packages/stream_feeds/lib/src/repository/activities_repository.dart @@ -72,8 +72,8 @@ class ActivitiesRepository { if (uploaded == null) return null; return api.Attachment( - custom: const {}, type: uploaded.type, + custom: {...?uploaded.custom}, assetUrl: uploaded.remoteUrl, imageUrl: uploaded.remoteUrl, thumbUrl: uploaded.thumbnailUrl, diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index 908cca4b..a3c2de66 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -166,8 +166,8 @@ class CommentsRepository { if (uploaded == null) return null; return api.Attachment( - custom: const {}, type: uploaded.type, + custom: {...?uploaded.custom}, assetUrl: uploaded.remoteUrl, imageUrl: uploaded.remoteUrl, thumbUrl: uploaded.thumbnailUrl, From 06bb0f21308df1e3bbc94ab46513c0c890c47af6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 04:13:46 +0200 Subject: [PATCH 03/19] chore: fix typo in `addCommentsBatch` method doc --- .../stream_feeds/lib/src/repository/comments_repository.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index a3c2de66..4db757e0 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -111,7 +111,7 @@ class CommentsRepository { /// 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( From ca57f6cc84f3e66328a6386d84b70f6ccf4cca4c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 12:02:01 +0200 Subject: [PATCH 04/19] refactor: move CDN files to cdn directory Moves CDN-related files from the `file` directory to a new `cdn` directory. --- packages/stream_feeds/lib/src/{file => cdn}/cdn_api.dart | 0 packages/stream_feeds/lib/src/{file => cdn}/cdn_api.g.dart | 0 .../stream_feeds/lib/src/{file => cdn}/feeds_cdn_client.dart | 0 packages/stream_feeds/lib/src/client/feeds_client_impl.dart | 4 ++-- 4 files changed, 2 insertions(+), 2 deletions(-) rename packages/stream_feeds/lib/src/{file => cdn}/cdn_api.dart (100%) rename packages/stream_feeds/lib/src/{file => cdn}/cdn_api.g.dart (100%) rename packages/stream_feeds/lib/src/{file => cdn}/feeds_cdn_client.dart (100%) diff --git a/packages/stream_feeds/lib/src/file/cdn_api.dart b/packages/stream_feeds/lib/src/cdn/cdn_api.dart similarity index 100% rename from packages/stream_feeds/lib/src/file/cdn_api.dart rename to packages/stream_feeds/lib/src/cdn/cdn_api.dart diff --git a/packages/stream_feeds/lib/src/file/cdn_api.g.dart b/packages/stream_feeds/lib/src/cdn/cdn_api.g.dart similarity index 100% rename from packages/stream_feeds/lib/src/file/cdn_api.g.dart rename to packages/stream_feeds/lib/src/cdn/cdn_api.g.dart diff --git a/packages/stream_feeds/lib/src/file/feeds_cdn_client.dart b/packages/stream_feeds/lib/src/cdn/feeds_cdn_client.dart similarity index 100% rename from packages/stream_feeds/lib/src/file/feeds_cdn_client.dart rename to packages/stream_feeds/lib/src/cdn/feeds_cdn_client.dart 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 cd9bc1e9..55cd2dee 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -2,9 +2,9 @@ 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 '../file/cdn_api.dart'; -import '../file/feeds_cdn_client.dart'; import '../generated/api/api.dart' as api; import '../models/activity_data.dart'; import '../models/app_data.dart'; From 085953ebd880e107f925df8e7fcf68334475fbfb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 12:29:53 +0200 Subject: [PATCH 05/19] temp --- melos.yaml | 5 +- packages/stream_feeds/pubspec.yaml | 5 +- sample_app/lib/home_screen/home_screen.dart | 463 ++++++++++++++++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../macos/Runner/DebugProfile.entitlements | 6 + sample_app/macos/Runner/Info.plist | 2 + sample_app/macos/Runner/Release.entitlements | 6 + sample_app/pubspec.yaml | 1 + .../windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 447 insertions(+), 47 deletions(-) diff --git a/melos.yaml b/melos.yaml index b79c3c38..091c6d7f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -37,10 +37,7 @@ command: uuid: ^4.5.1 stream_core: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 3b59846b89070fdd542945dbc1228a0a472e9b37 - path: packages/stream_core + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core # List of all the dev_dependencies used in the project. dev_dependencies: diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 5a35ccaa..01819e8e 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -35,10 +35,7 @@ dependencies: state_notifier: ^1.0.0 # TODO Replace with hosted version when published stream_core: - git: - url: https://github.com/GetStream/stream-core-flutter.git - ref: 3b59846b89070fdd542945dbc1228a0a472e9b37 - path: packages/stream_core + path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core uuid: ^4.5.1 dev_dependencies: diff --git a/sample_app/lib/home_screen/home_screen.dart b/sample_app/lib/home_screen/home_screen.dart index 71d288e9..a76f79c8 100644 --- a/sample_app/lib/home_screen/home_screen.dart +++ b/sample_app/lib/home_screen/home_screen.dart @@ -1,10 +1,14 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:stream_feeds/stream_feeds.dart'; +import 'package:stream_core/stream_core.dart'; import '../navigation/app_state.dart'; @@ -99,48 +103,429 @@ class _FeedListState extends State<_FeedList> { Widget build(BuildContext context) { return StateNotifierBuilder( stateNotifier: feed.state, - builder: (context, state, child) => RefreshIndicator( - onRefresh: () => feed.getOrCreate(), - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: ListView.builder( - itemCount: state.activities.length, - itemBuilder: (context, index) { - final activity = state.activities[index]; - - return ListTile( - leading: CircleAvatar( - backgroundImage: switch (activity.user.image) { - final String imageUrl => - CachedNetworkImageProvider(imageUrl), - _ => null, - }, - child: switch (activity.user.image) { - String _ => null, - _ => Text( - activity.user.name?.substring(0, 1).toUpperCase() ?? - '?', + builder: (context, state, child) { + return Scaffold( + body: RefreshIndicator( + onRefresh: () => feed.getOrCreate(), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: ListView.builder( + itemCount: state.activities.length, + itemBuilder: (context, index) { + final activity = state.activities[index]; + + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User info row + Row( + children: [ + CircleAvatar( + backgroundImage: switch (activity.user.image) { + final String imageUrl => + CachedNetworkImageProvider(imageUrl), + _ => null, + }, + child: switch (activity.user.image) { + String _ => null, + _ => Text( + activity.user.name + ?.substring(0, 1) + .toUpperCase() ?? + '?', + ), + }, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.user.name ?? 'unknown user', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Text( + activity.createdAt.toString(), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + + // Activity text + if (activity.text?.isNotEmpty == true) ...[ + const SizedBox(height: 12), + Text( + activity.text!, + style: const TextStyle(fontSize: 16), + ), + ], + + // Horizontal image list + if (activity.attachments.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: activity.attachments.length, + itemBuilder: (context, attachmentIndex) { + final attachment = + activity.attachments[attachmentIndex]; + final imageUrl = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl; + + if (imageUrl == null) + return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.only(right: 8), + width: 120, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + Container( + color: Colors.grey.shade200, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => + Container( + color: Colors.grey.shade200, + child: const Icon( + Icons.image_not_supported, + color: Colors.grey, + ), + ), + ), + ), + ); + }, + ), + ), + ], + + // Reaction count + const SizedBox(height: 12), + Text( + '${activity.reactionCount} reactions', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + ], ), - }, - ), - title: Text(activity.user.name ?? 'unknown user'), - subtitle: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(activity.text ?? 'empty message'), - Text('${activity.reactionCount} reactions'), - ], + ), + ); + }, + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showCreateActivityBottomSheet(context), + tooltip: 'Create Activity', + child: const Icon(Icons.add), + ), + ); + }, + ); + } + + /// Shows the bottom sheet for creating a new activity. + void _showCreateActivityBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return _CreateActivityBottomSheet(feed: feed); + }, + ); + } +} + +class _CreateActivityBottomSheet extends StatefulWidget { + const _CreateActivityBottomSheet({required this.feed}); + + final Feed feed; + + @override + State<_CreateActivityBottomSheet> createState() => + _CreateActivityBottomSheetState(); +} + +class _CreateActivityBottomSheetState + extends State<_CreateActivityBottomSheet> { + final TextEditingController _textController = TextEditingController(); + final ImagePicker _picker = ImagePicker(); + List _selectedImages = []; + bool _isCreating = false; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _pickImage() async { + print('Picking image...'); + try { + XFile? image; + + // Try gallery first, fallback to camera if gallery fails on macOS + try { + image = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + } catch (galleryError) { + print('Gallery picker failed, trying camera: $galleryError'); + // On some platforms/configurations, try camera as fallback + image = await _picker.pickImage( + source: ImageSource.camera, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + } + + if (image case final image?) { + setState(() { + _selectedImages.add(image); + }); + } + } catch (e) { + print('Error picking image: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pick image: $e')), + ); + } + } + } + + Future _createActivity() async { + final text = _textController.text.trim(); + + if (text.isEmpty && _selectedImages.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please add some text or an image')), + ); + return; + } + + setState(() { + _isCreating = true; + }); + + try { + // Create attachments for all selected images + final attachmentUploads = []; + for (final image in _selectedImages) { + final attachmentFile = AttachmentFile(image.path); + + attachmentUploads.add( + StreamAttachment( + file: attachmentFile, + type: AttachmentType.image, + ), + ); + } + + // Create the activity request + final request = FeedAddActivityRequest( + type: 'activity', + text: text.isNotEmpty ? text : null, + feeds: [widget.feed.query.fid.rawValue], + attachmentUploads: attachmentUploads, + ); + + // Add the activity using the existing feed instance + final result = await widget.feed.addActivity(request: request); + + if (mounted) { + switch (result) { + case Success(): + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Activity created successfully!')), + ); + case Failure(error: final error): + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to create activity: $error')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error creating activity: $e')), + ); + } + } finally { + if (mounted) { + setState(() { + _isCreating = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + const Text( + 'Create Activity', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, ), - ); - }, + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], ), - ), + const SizedBox(height: 16), + + // Text field + TextField( + controller: _textController, + decoration: const InputDecoration( + labelText: "What's on your mind?", + border: OutlineInputBorder(), + hintText: 'Share your thoughts...', + ), + maxLines: 3, + enabled: !_isCreating, + ), + const SizedBox(height: 16), + + // Horizontal image preview list + if (_selectedImages.isNotEmpty) ...[ + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _selectedImages.length, + itemBuilder: (context, index) { + final image = _selectedImages[index]; + + return Container( + margin: const EdgeInsets.only(right: 8), + width: 120, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(image.path), + width: 120, + height: 120, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 4, + right: 4, + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: () { + setState(() { + _selectedImages.removeAt(index); + }); + }, + icon: const Icon( + Icons.close, + color: Colors.white, + size: 16, + ), + iconSize: 16, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + + // Action buttons + Row( + children: [ + IconButton( + onPressed: _isCreating ? null : _pickImage, + icon: const Icon(Icons.image), + tooltip: 'Add Image', + ), + const Spacer(), + ElevatedButton( + onPressed: _isCreating ? null : _createActivity, + child: _isCreating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Post'), + ), + ], + ), + ], ), ); } diff --git a/sample_app/linux/flutter/generated_plugin_registrant.cc b/sample_app/linux/flutter/generated_plugin_registrant.cc index e71a16d2..64a0ecea 100644 --- a/sample_app/linux/flutter/generated_plugin_registrant.cc +++ b/sample_app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/sample_app/linux/flutter/generated_plugins.cmake b/sample_app/linux/flutter/generated_plugins.cmake index 2e1de87a..2db3c22a 100644 --- a/sample_app/linux/flutter/generated_plugins.cmake +++ b/sample_app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/sample_app/macos/Runner/DebugProfile.entitlements b/sample_app/macos/Runner/DebugProfile.entitlements index c946719a..3518f845 100644 --- a/sample_app/macos/Runner/DebugProfile.entitlements +++ b/sample_app/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,11 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + + com.apple.security.files.user-selected.read-write + + com.apple.security.assets.pictures.read-only + diff --git a/sample_app/macos/Runner/Info.plist b/sample_app/macos/Runner/Info.plist index 4789daa6..5d972275 100644 --- a/sample_app/macos/Runner/Info.plist +++ b/sample_app/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + NSPhotoLibraryUsageDescription + This app needs access to your photo library to select images for activities. diff --git a/sample_app/macos/Runner/Release.entitlements b/sample_app/macos/Runner/Release.entitlements index 48271acc..4a32fa89 100644 --- a/sample_app/macos/Runner/Release.entitlements +++ b/sample_app/macos/Runner/Release.entitlements @@ -6,5 +6,11 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + + com.apple.security.files.user-selected.read-write + + com.apple.security.assets.pictures.read-only + diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index bde81518..a6dacf9d 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: sdk: flutter flutter_state_notifier: ^1.0.0 get_it: ^8.0.3 + image_picker: ^1.1.2 shared_preferences: ^2.5.3 stream_feeds: path: ../packages/stream_feeds diff --git a/sample_app/windows/flutter/generated_plugins.cmake b/sample_app/windows/flutter/generated_plugins.cmake index b93c4c30..a423a024 100644 --- a/sample_app/windows/flutter/generated_plugins.cmake +++ b/sample_app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 8c222e44e9bfe7abbba00ff6098e19b980b8cd76 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:41:43 +0200 Subject: [PATCH 06/19] chore: update stream_core dependency and fix comment request - Updated stream_core dependency to use a git reference. - Added activityId to ActivityAddCommentRequest in sample_app. --- melos.yaml | 5 ++++- packages/stream_feeds/pubspec.yaml | 5 ++++- .../lib/screens/home/widgets/activity_comments_view.dart | 6 +++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/melos.yaml b/melos.yaml index 243e578c..646ecf1c 100644 --- a/melos.yaml +++ b/melos.yaml @@ -42,7 +42,10 @@ command: # TODO Replace with hosted version when published stream_core: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 944f677e72e30f148f5f7c251419f3ad1dac2ffa + path: packages/stream_core # List of all the dev_dependencies used in the project. dev_dependencies: diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index db1489ad..00034f86 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -34,7 +34,10 @@ dependencies: rxdart: ^0.28.0 state_notifier: ^1.0.0 stream_core: - path: /Users/xsahil03x/StudioProjects/stream-core-flutter/packages/stream_core + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 944f677e72e30f148f5f7c251419f3ad1dac2ffa + path: packages/stream_core uuid: ^4.5.1 dev_dependencies: diff --git a/sample_app/lib/screens/home/widgets/activity_comments_view.dart b/sample_app/lib/screens/home/widgets/activity_comments_view.dart index 052ebad0..549fb316 100644 --- a/sample_app/lib/screens/home/widgets/activity_comments_view.dart +++ b/sample_app/lib/screens/home/widgets/activity_comments_view.dart @@ -122,7 +122,11 @@ class _ActivityCommentsViewState extends State { if (text == null) return; await activity.addComment( - ActivityAddCommentRequest(comment: text, parentId: parentComment?.id), + ActivityAddCommentRequest( + comment: text, + parentId: parentComment?.id, + activityId: activity.activityId, + ), ); } From 92b2aa4d99163cddfae9ded1e183d90d727163bb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:47:40 +0200 Subject: [PATCH 07/19] Discard changes to sample_app/linux/flutter/generated_plugin_registrant.cc --- sample_app/linux/flutter/generated_plugin_registrant.cc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sample_app/linux/flutter/generated_plugin_registrant.cc b/sample_app/linux/flutter/generated_plugin_registrant.cc index 64a0ecea..e71a16d2 100644 --- a/sample_app/linux/flutter/generated_plugin_registrant.cc +++ b/sample_app/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,6 @@ #include "generated_plugin_registrant.h" -#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } From 3dffebcb5b5c2c316715301fd9eef5fe1b65715e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:47:49 +0200 Subject: [PATCH 08/19] Discard changes to sample_app/linux/flutter/generated_plugins.cmake --- sample_app/linux/flutter/generated_plugins.cmake | 1 - 1 file changed, 1 deletion(-) diff --git a/sample_app/linux/flutter/generated_plugins.cmake b/sample_app/linux/flutter/generated_plugins.cmake index 2db3c22a..2e1de87a 100644 --- a/sample_app/linux/flutter/generated_plugins.cmake +++ b/sample_app/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From b62ebb52cf14637e336832094795e43eccce6eaa Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:47:55 +0200 Subject: [PATCH 09/19] Discard changes to sample_app/macos/Runner/DebugProfile.entitlements --- sample_app/macos/Runner/DebugProfile.entitlements | 6 ------ 1 file changed, 6 deletions(-) diff --git a/sample_app/macos/Runner/DebugProfile.entitlements b/sample_app/macos/Runner/DebugProfile.entitlements index 3518f845..c946719a 100644 --- a/sample_app/macos/Runner/DebugProfile.entitlements +++ b/sample_app/macos/Runner/DebugProfile.entitlements @@ -10,11 +10,5 @@ com.apple.security.network.client - com.apple.security.files.user-selected.read-only - - com.apple.security.files.user-selected.read-write - - com.apple.security.assets.pictures.read-only - From 5d538e6f28ac5fec380538c8d25d93ea16e319cd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:48:02 +0200 Subject: [PATCH 10/19] Discard changes to sample_app/macos/Runner/Info.plist --- sample_app/macos/Runner/Info.plist | 2 -- 1 file changed, 2 deletions(-) diff --git a/sample_app/macos/Runner/Info.plist b/sample_app/macos/Runner/Info.plist index 5d972275..4789daa6 100644 --- a/sample_app/macos/Runner/Info.plist +++ b/sample_app/macos/Runner/Info.plist @@ -28,7 +28,5 @@ MainMenu NSPrincipalClass NSApplication - NSPhotoLibraryUsageDescription - This app needs access to your photo library to select images for activities. From cc8eb645ef855970eeb996de47d71a1b707dc041 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:48:12 +0200 Subject: [PATCH 11/19] Discard changes to sample_app/macos/Runner/Release.entitlements --- sample_app/macos/Runner/Release.entitlements | 6 ------ 1 file changed, 6 deletions(-) diff --git a/sample_app/macos/Runner/Release.entitlements b/sample_app/macos/Runner/Release.entitlements index 4a32fa89..48271acc 100644 --- a/sample_app/macos/Runner/Release.entitlements +++ b/sample_app/macos/Runner/Release.entitlements @@ -6,11 +6,5 @@ com.apple.security.network.client - com.apple.security.files.user-selected.read-only - - com.apple.security.files.user-selected.read-write - - com.apple.security.assets.pictures.read-only - From 7e5e44d37c238f388336beaf784f97df9753bf83 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 2 Sep 2025 15:48:20 +0200 Subject: [PATCH 12/19] Discard changes to sample_app/windows/flutter/generated_plugins.cmake --- sample_app/windows/flutter/generated_plugins.cmake | 1 - 1 file changed, 1 deletion(-) diff --git a/sample_app/windows/flutter/generated_plugins.cmake b/sample_app/windows/flutter/generated_plugins.cmake index a423a024..b93c4c30 100644 --- a/sample_app/windows/flutter/generated_plugins.cmake +++ b/sample_app/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From cf78da37071773a727da7916f4f56b754360e6fc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 Sep 2025 05:00:55 +0200 Subject: [PATCH 13/19] feat(llc): add uploader utility for processing attachments This commit introduces a new `StreamAttachmentUploader` extension with methods to handle attachment uploads for requests that implement the `HasAttachments` interface. The key changes include: - **`HasAttachments` interface:** Defines a contract for requests that support attachment uploads, ensuring they provide a way to access and update attachments. - **`processRequest` method:** Uploads `StreamAttachment` items from a single request, merges them with existing attachments, and returns an updated request. - **`processRequestsBatch` method:** Processes multiple requests with attachment uploads in parallel, leveraging `processRequest`. - **Integration with `FeedAddActivityRequest` and `ActivityAddCommentRequest`:** These request models now implement `HasAttachments` and utilize the new uploader utility in `ActivitiesRepository` and `CommentsRepository` respectively. This simplifies attachment handling in these repositories. - **Nullable `attachmentUploads`:** The `attachmentUploads` field in `FeedAddActivityRequest` and `ActivityAddCommentRequest` is now nullable to align with the uploader's behavior, where an empty or null list signifies no new uploads. This change streamlines the process of uploading attachments before sending API requests, making the code more robust and easier to maintain. --- .../request/activity_add_comment_request.dart | 21 ++- .../activity_add_comment_request.freezed.dart | 10 +- .../request/feed_add_activity_request.dart | 19 ++- .../feed_add_activity_request.freezed.dart | 10 +- .../src/repository/activities_repository.dart | 55 +------ .../src/repository/comments_repository.dart | 93 +++--------- .../stream_feeds/lib/src/utils/uploader.dart | 143 ++++++++++++++++++ 7 files changed, 215 insertions(+), 136 deletions(-) create mode 100644 packages/stream_feeds/lib/src/utils/uploader.dart 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 33aeb584..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 @@ -2,6 +2,7 @@ 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'; @@ -10,14 +11,16 @@ 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 = const [], + this.attachmentUploads, this.createNotificationActivity, this.mentionedUserIds, this.parentId, @@ -39,7 +42,7 @@ class ActivityAddCommentRequest with _$ActivityAddCommentRequest { /// Optional list of stream attachments to be uploaded before adding the /// comment to the activity. @override - final List attachmentUploads; + final List? attachmentUploads; /// The content of the comment to be added. @override @@ -60,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. 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 542ad085..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 @@ -18,7 +18,7 @@ mixin _$ActivityAddCommentRequest { String get activityId; String get activityType; List? get attachments; - List get attachmentUploads; + List? get attachmentUploads; String get comment; bool? get createNotificationActivity; List? get mentionedUserIds; @@ -88,7 +88,7 @@ abstract mixin class $ActivityAddCommentRequestCopyWith<$Res> { String comment, String activityType, List? attachments, - List attachmentUploads, + List? attachmentUploads, bool? createNotificationActivity, List? mentionedUserIds, String? parentId, @@ -112,7 +112,7 @@ class _$ActivityAddCommentRequestCopyWithImpl<$Res> Object? comment = null, Object? activityType = null, Object? attachments = freezed, - Object? attachmentUploads = null, + Object? attachmentUploads = freezed, Object? createNotificationActivity = freezed, Object? mentionedUserIds = freezed, Object? parentId = freezed, @@ -135,10 +135,10 @@ class _$ActivityAddCommentRequestCopyWithImpl<$Res> ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, - attachmentUploads: null == attachmentUploads + attachmentUploads: freezed == attachmentUploads ? _self.attachmentUploads : attachmentUploads // ignore: cast_nullable_to_non_nullable - as List, + 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 cb7389f1..fdfb1c67 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 @@ -2,6 +2,7 @@ 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'; @@ -11,13 +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 = const [], + this.attachmentUploads, this.custom, this.expiresAt, this.filterTags, @@ -40,7 +43,7 @@ class FeedAddActivityRequest with _$FeedAddActivityRequest { /// Optional list of stream attachments to be uploaded before adding the /// activity to the feeds. @override - final List attachmentUploads; + final List? attachmentUploads; /// Custom data associated with the activity. @override @@ -101,6 +104,16 @@ 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 8390f0b6..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,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$FeedAddActivityRequest { List? get attachments; - List get attachmentUploads; + List? get attachmentUploads; Map? get custom; String? get expiresAt; List get feeds; @@ -113,7 +113,7 @@ abstract mixin class $FeedAddActivityRequestCopyWith<$Res> { {String type, List feeds, List? attachments, - List attachmentUploads, + List? attachmentUploads, Map? custom, String? expiresAt, List? filterTags, @@ -145,7 +145,7 @@ class _$FeedAddActivityRequestCopyWithImpl<$Res> Object? type = null, Object? feeds = null, Object? attachments = freezed, - Object? attachmentUploads = null, + Object? attachmentUploads = freezed, Object? custom = freezed, Object? expiresAt = freezed, Object? filterTags = freezed, @@ -173,10 +173,10 @@ class _$FeedAddActivityRequestCopyWithImpl<$Res> ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as List?, - attachmentUploads: null == attachmentUploads + attachmentUploads: freezed == attachmentUploads ? _self.attachmentUploads : attachmentUploads // ignore: cast_nullable_to_non_nullable - as List, + 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 f6447314..dbd79df8 100644 --- a/packages/stream_feeds/lib/src/repository/activities_repository.dart +++ b/packages/stream_feeds/lib/src/repository/activities_repository.dart @@ -7,6 +7,7 @@ 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. /// @@ -33,55 +34,15 @@ class ActivitiesRepository { Future> addActivity( FeedAddActivityRequest request, ) async { - final uploadedAttachments = await _uploadStreamAttachments( - request.attachmentUploads, - ); - - final currentAttachments = request.attachments ?? []; - final updatedAttachments = currentAttachments.merge( - uploadedAttachments, - key: (it) => (it.type, it.assetUrl, it.imageUrl), - ); - - final updatedRequest = request.copyWith( - attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), - ); - - final result = await _api.addActivity( - addActivityRequest: updatedRequest.toRequest(), - ); - - return result.map((response) => response.activity.toModel()); - } - - // Uploads stream attachments and converts them to API attachment format. - // - // Processes the provided attachments by uploading them via the uploader - // and converting successful uploads to API attachment objects. - Future> _uploadStreamAttachments( - List attachments, - ) async { - if (attachments.isEmpty) return []; + final processedRequest = await _uploader.processRequest(request); - final batch = _uploader.uploadBatch(attachments); - final results = await batch.toList(); - - final successfulUploads = results.map( - (result) { - final uploaded = result.getOrNull(); - if (uploaded == null) return null; - - return api.Attachment( - type: uploaded.type, - custom: {...?uploaded.custom}, - assetUrl: uploaded.remoteUrl, - imageUrl: uploaded.remoteUrl, - thumbUrl: uploaded.thumbnailUrl, - ); - }, - ).nonNulls; + return processedRequest.flatMapAsync((updatedRequest) async { + final result = await _api.addActivity( + addActivityRequest: updatedRequest.toRequest(), + ); - return successfulUploads.toList(); + 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 4db757e0..8f038d4f 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:stream_core/stream_core.dart'; import '../generated/api/api.dart' as api; @@ -10,6 +11,7 @@ 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. /// @@ -88,25 +90,15 @@ class CommentsRepository { Future> addComment( ActivityAddCommentRequest request, ) async { - final uploadedAttachments = await _uploadStreamAttachments( - request.attachmentUploads, - ); - - final currentAttachments = request.attachments ?? []; - final updatedAttachments = currentAttachments.merge( - uploadedAttachments, - key: (it) => (it.type, it.assetUrl, it.imageUrl), - ); - - final updatedRequest = request.copyWith( - attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), - ); + final processedRequest = await _uploader.processRequest(request); - final result = await _api.addComment( - addCommentRequest: updatedRequest.toRequest(), - ); + return processedRequest.flatMapAsync((updatedRequest) async { + final result = await _api.addComment( + addCommentRequest: updatedRequest.toRequest(), + ); - return result.map((response) => response.comment.toModel()); + return result.map((response) => response.comment.toModel()); + }); } /// Adds multiple comments. @@ -117,65 +109,20 @@ class CommentsRepository { Future>> addCommentsBatch( List requests, ) async { - final batch = await requests.map( - (request) async { - final uploadedAttachments = await _uploadStreamAttachments( - request.attachmentUploads, - ); - - final currentAttachments = request.attachments ?? []; - final updatedAttachments = currentAttachments.merge( - uploadedAttachments, - key: (it) => (it.type, it.assetUrl, it.imageUrl), - ); - - return request.copyWith( - attachments: updatedAttachments.takeIf((it) => it.isNotEmpty), - ); - }, - ).wait; - - final batchRequest = api.AddCommentsBatchRequest( - comments: batch.map((r) => r.toRequest()).toList(), - ); + final processedBatch = await _uploader.processRequestsBatch(requests); - final result = await _api.addCommentsBatch( - addCommentsBatchRequest: batchRequest, - ); + return processedBatch.flatMapAsync((batch) async { + final comments = batch.map((r) => r.toRequest()).toList(); + final batchRequest = api.AddCommentsBatchRequest(comments: comments); - return result.map( - (response) => response.comments.map((c) => c.toModel()).toList(), - ); - } + final result = await _api.addCommentsBatch( + addCommentsBatchRequest: batchRequest, + ); - // Uploads stream attachments and converts them to API attachment format. - // - // Processes the provided attachments by uploading them via the uploader - // and converting successful uploads to API attachment objects. - Future> _uploadStreamAttachments( - List attachments, - ) async { - if (attachments.isEmpty) return []; - - final batch = _uploader.uploadBatch(attachments); - final results = await batch.toList(); - - final successfulUploads = results.map( - (result) { - final uploaded = result.getOrNull(); - if (uploaded == null) return null; - - return api.Attachment( - type: uploaded.type, - custom: {...?uploaded.custom}, - assetUrl: uploaded.remoteUrl, - imageUrl: uploaded.remoteUrl, - thumbUrl: uploaded.thumbnailUrl, - ); - }, - ).nonNulls; - - return successfulUploads.toList(); + return result.map( + (response) => response.comments.map((c) => c.toModel()).toList(), + ); + }); } /// Deletes a comment. 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(); + }); + } +} From 696e148c36387b5018fb7c619b2f5fffeda3d626 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 Sep 2025 15:39:02 +0200 Subject: [PATCH 14/19] feat(sample): add support for creating activities, with attachments --- melos.yaml | 6 +- packages/stream_feeds/lib/stream_feeds.dart | 2 + packages/stream_feeds/pubspec.yaml | 2 +- .../android/app/src/main/AndroidManifest.xml | 5 + .../ios/Runner.xcodeproj/project.pbxproj | 112 +++++++ .../contents.xcworkspacedata | 3 + sample_app/ios/Runner/Info.plist | 7 + sample_app/lib/app/app_state.dart | 2 - sample_app/lib/app/content/app_content.dart | 13 + sample_app/lib/navigation/app_router.dart | 15 + sample_app/lib/navigation/app_router.gr.dart | 20 +- .../choose_user/choose_user_screen.dart | 64 ++-- sample_app/lib/screens/home/home_screen.dart | 28 +- .../lib/screens/home/session_scope.dart | 31 ++ .../user_feed_screen.dart} | 154 ++++++---- .../widgets/activity_comments_view.dart | 19 +- .../widgets/activity_content.dart | 85 ++---- .../widgets/create_activity_bottom_sheet.dart | 200 ++++++++++++ .../widgets/user_feed_appbar.dart | 0 .../widgets/user_profile_view.dart} | 16 +- sample_app/lib/widgets/action_button.dart | 103 +++++++ .../widgets/attachments/attachment_grid.dart | 289 ++++++++++++++++++ .../attachments/attachment_picker.dart | 162 ++++++++++ .../attachments/attachment_preview_list.dart | 182 +++++++++++ .../attachments/attachment_widget.dart | 178 +++++++++++ .../lib/widgets/attachments/attachments.dart | 4 + .../macos/Runner/DebugProfile.entitlements | 14 +- sample_app/macos/Runner/Info.plist | 7 + sample_app/macos/Runner/Release.entitlements | 12 + sample_app/pubspec.yaml | 4 +- 30 files changed, 1546 insertions(+), 193 deletions(-) create mode 100644 sample_app/lib/screens/home/session_scope.dart rename sample_app/lib/screens/{home/widgets/user_feed_view.dart => user_feed/user_feed_screen.dart} (61%) rename sample_app/lib/screens/{home => user_feed}/widgets/activity_comments_view.dart (93%) rename sample_app/lib/screens/{home => user_feed}/widgets/activity_content.dart (72%) create mode 100644 sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart rename sample_app/lib/screens/{home => user_feed}/widgets/user_feed_appbar.dart (100%) rename sample_app/lib/screens/{profile/profile_widget.dart => user_feed/widgets/user_profile_view.dart} (93%) create mode 100644 sample_app/lib/widgets/action_button.dart create mode 100644 sample_app/lib/widgets/attachments/attachment_grid.dart create mode 100644 sample_app/lib/widgets/attachments/attachment_picker.dart create mode 100644 sample_app/lib/widgets/attachments/attachment_preview_list.dart create mode 100644 sample_app/lib/widgets/attachments/attachment_widget.dart create mode 100644 sample_app/lib/widgets/attachments/attachments.dart diff --git a/melos.yaml b/melos.yaml index 646ecf1c..44f6f054 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 + flex_grid_view: ^0.1.0 + file_picker: ^8.1.4 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: 944f677e72e30f148f5f7c251419f3ad1dac2ffa + ref: 5acdcb8e378548372f7dbb6326c518edcf832d10 path: packages/stream_core # List of all the dev_dependencies used in the project. diff --git a/packages/stream_feeds/lib/stream_feeds.dart b/packages/stream_feeds/lib/stream_feeds.dart index 090ebcdb..35baf24a 100644 --- a/packages/stream_feeds/lib/stream_feeds.dart +++ b/packages/stream_feeds/lib/stream_feeds.dart @@ -12,6 +12,8 @@ export 'src/models/request/activity_add_comment_request.dart' show ActivityAddCommentRequest; export 'src/models/request/activity_update_comment_request.dart' show ActivityUpdateCommentRequest; +export 'src/models/request/feed_add_activity_request.dart' + show FeedAddActivityRequest; export 'src/models/threaded_comment_data.dart'; export 'src/models/user_data.dart'; export 'src/state/activity.dart'; diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 00034f86..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: 944f677e72e30f148f5f7c251419f3ad1dac2ffa + ref: 5acdcb8e378548372f7dbb6326c518edcf832d10 path: packages/stream_core uuid: ^4.5.1 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/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index 618a39dc..03c2aba8 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -1,8 +1,12 @@ import 'package:auto_route/auto_route.dart'; +import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; +import 'package:stream_feeds/stream_feeds.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 +26,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', 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/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..22ae25ec 100644 --- a/sample_app/lib/screens/home/home_screen.dart +++ b/sample_app/lib/screens/home/home_screen.dart @@ -1,16 +1,9 @@ -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.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'; +import 'session_scope.dart'; @RoutePage() class HomeScreen extends StatelessWidget { @@ -25,21 +18,10 @@ class HomeScreen extends StatelessWidget { _ => throw Exception('User not authenticated'), }; - final wideScreen = MediaQuery.sizeOf(context).width > 600; - - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: UserFeedView( - client: client, - currentUser: user, - wideScreen: wideScreen, - onLogout: authController.disconnect, - ), + return SessionScope( + user: user, + client: client, + child: const AutoRouter(), ); } } diff --git a/sample_app/lib/screens/home/session_scope.dart b/sample_app/lib/screens/home/session_scope.dart new file mode 100644 index 00000000..79316d72 --- /dev/null +++ b/sample_app/lib/screens/home/session_scope.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +class SessionScope extends InheritedWidget { + const SessionScope({ + super.key, + required this.user, + required this.client, + required super.child, + }); + + final User user; + final StreamFeedsClient client; + + static SessionScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'No SessionScope found in context'); + return scope!; + } + + @override + bool updateShouldNotify(SessionScope oldWidget) { + // only rebuild dependents if values actually change + return user != oldWidget.user || client != oldWidget.client; + } +} + +extension SessionScopeExtension on BuildContext { + User get currentUser => SessionScope.of(this).user; + StreamFeedsClient get client => SessionScope.of(this).client; +} 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 61% 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 65626664..b547fb43 100644 --- a/sample_app/lib/screens/home/widgets/user_feed_view.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -1,46 +1,41 @@ +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 '../home/session_scope.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 { + late final feed = context.client.feedFromQuery( FeedQuery( - fid: FeedId(group: 'user', id: widget.currentUser.id), + fid: FeedId(group: 'user', id: context.currentUser.id), data: FeedInputData( visibility: FeedVisibility.public, - members: [FeedMemberRequestData(userId: widget.currentUser.id)], + members: [FeedMemberRequestData(userId: context.currentUser.id)], ), ), ); @override - void initState() { - super.initState(); + void didChangeDependencies() { + super.didChangeDependencies(); feed.getOrCreate(); } @@ -50,8 +45,15 @@ class _UserFeedViewState extends State { super.dispose(); } + Future _onLogout() { + final authController = locator(); + return authController.disconnect(); + } + @override Widget build(BuildContext context) { + final wideScreen = MediaQuery.sizeOf(context).width > 600; + return StateNotifierBuilder( stateNotifier: feed.state, builder: (context, state, child) { @@ -98,7 +100,7 @@ class _UserFeedViewState extends State { text: baseActivity.text ?? '', attachments: baseActivity.attachments, data: activity, - currentUserId: widget.client.user.id, + currentUserId: context.currentUser.id, onCommentClick: () => _onCommentClick(context, activity), onHeartClick: (isAdding) => @@ -115,28 +117,31 @@ class _UserFeedViewState extends State { ), ); - if (!widget.wideScreen) { - return _buildScaffold( - context, - feedWidget, - onProfileTap: () { - _showProfileBottomSheet(context, widget.client, feed); - }, - ); - } - return _buildScaffold( - context, - Row( - children: [ - SizedBox( - width: 250, - child: ProfileWidget(feedsClient: widget.client, feed: feed), - ), - const SizedBox(width: 16), - Expanded(child: feedWidget), - ], - ), - ); + if (!wideScreen) { + return _buildScaffold( + context, + feedWidget, + onLogout: _onLogout, + onProfileTap: () { + _showProfileBottomSheet(context, context.client, feed); + }, + ); + } + + return _buildScaffold( + context, + Row( + children: [ + SizedBox( + width: 250, + child: UserProfileView(feedsClient: context.client, feed: feed), + ), + const SizedBox(width: 16), + Expanded(child: feedWidget), + ], + ), + onLogout: _onLogout, + ); }, ); } @@ -144,6 +149,7 @@ class _UserFeedViewState extends State { Widget _buildScaffold( BuildContext context, Widget body, { + VoidCallback? onLogout, VoidCallback? onProfileTap, }) { return Scaffold( @@ -151,7 +157,7 @@ class _UserFeedViewState extends State { leading: GestureDetector( onTap: onProfileTap, child: Center( - child: UserAvatar.appBar(user: widget.currentUser), + child: UserAvatar.appBar(user: context.currentUser), ), ), title: Text( @@ -160,7 +166,7 @@ class _UserFeedViewState extends State { ), actions: [ IconButton( - onPressed: widget.onLogout, + onPressed: onLogout, icon: Icon( Icons.logout, color: context.appColors.textLowEmphasis, @@ -169,6 +175,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), + ), ); } @@ -179,7 +192,7 @@ class _UserFeedViewState extends State { ) { showModalBottomSheet( context: context, - builder: (context) => ProfileWidget(feedsClient: client, feed: feed), + builder: (context) => UserProfileView(feedsClient: client, feed: feed), ); } @@ -189,7 +202,7 @@ class _UserFeedViewState extends State { builder: (context) => ActivityCommentsView( activityId: activity.id, feed: feed, - client: widget.client, + client: context.client, ), ); } @@ -207,14 +220,41 @@ class _UserFeedViewState extends State { ); } } -} -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: context.currentUser, + feedId: feed.query.fid, + ), + ); - @override - Widget build(BuildContext context) { - return const Placeholder(); + if (request == null) return; + final result = await feed.addActivity(request: request); + + 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 93% 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 549fb316..81b833db 100644 --- a/sample_app/lib/screens/home/widgets/activity_comments_view.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart @@ -7,6 +7,7 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; import '../../../widgets/user_avatar.dart'; +import '../../../widgets/action_button.dart'; import 'activity_content.dart'; class ActivityCommentsView extends StatefulWidget { @@ -79,6 +80,8 @@ class _ActivityCommentsViewState extends State { ), floatingActionButton: FloatingActionButton( onPressed: () => _reply(context, null), + backgroundColor: context.appColors.accentPrimary, + foregroundColor: context.appColors.appBg, child: const Icon(Icons.add), ), ); @@ -334,17 +337,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 72% rename from sample_app/lib/screens/home/widgets/activity_content.dart rename to sample_app/lib/screens/user_feed/widgets/activity_content.dart index f35a90f5..e023a71e 100644 --- a/sample_app/lib/screens/home/widgets/activity_content.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_content.dart @@ -4,6 +4,8 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; import '../../../widgets/user_avatar.dart'; +import '../../../widgets/attachments/attachments.dart'; +import '../../../widgets/action_button.dart'; class ActivityContent extends StatelessWidget { const ActivityContent({ @@ -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, ), ), ], @@ -83,12 +80,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, @@ -133,28 +130,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 + }, ), ], ], @@ -193,10 +179,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), count: data.commentCount, onTap: onCommentClick, ), @@ -205,14 +191,13 @@ class _UserActions extends StatelessWidget { 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?.call(!hasOwnHeart), ), ActionButton( - icon: const Icon(Icons.share_rounded, size: 16), + icon: const Icon(Icons.share_rounded), count: data.shareCount, onTap: () => onRepostClick?.call(null), ), @@ -221,10 +206,9 @@ class _UserActions extends StatelessWidget { hasOwnBookmark ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - size: 16, - color: hasOwnBookmark ? Colors.blue : null, ), count: data.bookmarkCount, + color: hasOwnBookmark ? context.appColors.accentPrimary : null, onTap: onBookmarkClick, ), ], @@ -232,27 +216,4 @@ class _UserActions extends StatelessWidget { } } -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..7c83ec0b --- /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/user_avatar.dart'; +import '../../../widgets/attachments/attachments.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: 'activity', + 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 93% rename from sample_app/lib/screens/profile/profile_widget.dart rename to sample_app/lib/screens/user_feed/widgets/user_profile_view.dart index 0573a564..c4b59916 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,7 @@ 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', 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..98e226c0 --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachment_grid.dart @@ -0,0 +1,289 @@ +import 'dart:ui'; + +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, + ), + ), + ), + ), + ), + ); + } + + double _getAspectRatio(Attachment attachment) { + final width = attachment.originalWidth?.toDouble(); + final height = attachment.originalHeight?.toDouble(); + + if (width != null && height != null && height > 0) { + return width / height; + } + + // Default to square aspect ratio if dimensions are unknown + return 1; + } + + double _calculateConstrainedAspectRatio( + double originalAspectRatio, + BoxConstraints constraints, + ) { + // Calculate the aspect ratio range based on constraints + final minAspectRatio = constraints.minWidth / constraints.maxHeight; + final maxAspectRatio = constraints.maxWidth / constraints.minHeight; + + // Clamp the original aspect ratio within the constraint bounds + return originalAspectRatio.clamp(minAspectRatio, maxAspectRatio); + } + + void _onAttachmentTap(BuildContext context, Attachment attachment) { + onAttachmentTap?.call(attachment); + } +} + +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()); + } +} + +extension on BoxConstraints { + /// Returns new box constraints that tightens the max width and max height + /// to the given [size]. + BoxConstraints tightenMaxSize(Size? size) { + if (size == null) return this; + return copyWith( + maxWidth: clampDouble(size.width, minWidth, maxWidth), + maxHeight: clampDouble(size.height, minHeight, maxHeight), + ); + } +} 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..98a630da --- /dev/null +++ b/sample_app/lib/widgets/attachments/attachments.dart @@ -0,0 +1,4 @@ +export 'attachment_widget.dart'; +export 'attachment_grid.dart'; +export 'attachment_picker.dart'; +export 'attachment_preview_list.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..5c5b5478 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 + flex_grid_view: ^0.1.0 + file_picker: ^8.1.4 injectable: ^2.5.1 - jiffy: ^6.4.3 + jiffy: ^6.3.2 shared_preferences: ^2.5.3 stream_feeds: path: ../packages/stream_feeds From e30613b535510921d0e424607192ddbc0e23a664 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 Sep 2025 15:42:27 +0200 Subject: [PATCH 15/19] feat: add support for creating activities, with attachments --- melos.yaml | 2 +- .../request/feed_add_activity_request.dart | 4 +- .../src/repository/comments_repository.dart | 1 - sample_app/lib/navigation/app_router.dart | 2 - sample_app/lib/navigation/app_router.gr.dart | 6 +-- .../screens/user_feed/user_feed_screen.dart | 48 +++++++++---------- .../widgets/activity_comments_view.dart | 3 +- .../user_feed/widgets/activity_content.dart | 9 +--- .../widgets/create_activity_bottom_sheet.dart | 2 +- .../user_feed/widgets/user_profile_view.dart | 7 ++- .../lib/theme/schemes/app_color_scheme.dart | 2 + sample_app/lib/theme/schemes/app_effects.dart | 2 +- .../lib/theme/schemes/app_text_theme.dart | 2 + .../widgets/attachments/attachment_grid.dart | 46 ++---------------- .../lib/widgets/attachments/attachments.dart | 2 +- sample_app/pubspec.yaml | 2 +- 16 files changed, 48 insertions(+), 92 deletions(-) diff --git a/melos.yaml b/melos.yaml index 44f6f054..730421e0 100644 --- a/melos.yaml +++ b/melos.yaml @@ -28,8 +28,8 @@ command: freezed_annotation: ^3.0.0 get_it: ^8.0.3 google_fonts: ^6.3.0 - flex_grid_view: ^0.1.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" 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 fdfb1c67..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 @@ -112,7 +112,9 @@ class FeedAddActivityRequest List? attachmentUploads, }) { return copyWith( - attachments: attachments, attachmentUploads: attachmentUploads); + attachments: attachments, + attachmentUploads: attachmentUploads, + ); } } diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index 8f038d4f..5ce2c260 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:stream_core/stream_core.dart'; import '../generated/api/api.dart' as api; diff --git a/sample_app/lib/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index 03c2aba8..6243e872 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -1,7 +1,5 @@ import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; import 'package:injectable/injectable.dart'; -import 'package:stream_feeds/stream_feeds.dart'; import '../screens/choose_user/choose_user_screen.dart'; import '../screens/home/home_screen.dart'; diff --git a/sample_app/lib/navigation/app_router.gr.dart b/sample_app/lib/navigation/app_router.gr.dart index 08d870c2..15d3e3e9 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'; @@ -46,7 +46,7 @@ class HomeRoute extends PageRouteInfo { /// [UserFeedScreen] class UserFeedRoute extends PageRouteInfo { const UserFeedRoute({List? children}) - : super(UserFeedRoute.name, initialChildren: children); + : super(UserFeedRoute.name, initialChildren: children); static const String name = 'UserFeedRoute'; diff --git a/sample_app/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index b547fb43..87039f46 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -117,31 +117,31 @@ class _UserFeedScreenState extends State { ), ); - if (!wideScreen) { - return _buildScaffold( - context, - feedWidget, - onLogout: _onLogout, - onProfileTap: () { - _showProfileBottomSheet(context, context.client, feed); - }, - ); - } + if (!wideScreen) { + return _buildScaffold( + context, + feedWidget, + onLogout: _onLogout, + onProfileTap: () { + _showProfileBottomSheet(context, context.client, feed); + }, + ); + } - return _buildScaffold( - context, - Row( - children: [ - SizedBox( - width: 250, - child: UserProfileView(feedsClient: context.client, feed: feed), - ), - const SizedBox(width: 16), - Expanded(child: feedWidget), - ], - ), - onLogout: _onLogout, - ); + return _buildScaffold( + context, + Row( + children: [ + SizedBox( + width: 250, + child: UserProfileView(feedsClient: context.client, feed: feed), + ), + const SizedBox(width: 16), + Expanded(child: feedWidget), + ], + ), + onLogout: _onLogout, + ); }, ); } diff --git a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart index 81b833db..86a0ce02 100644 --- a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart @@ -6,9 +6,8 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; -import '../../../widgets/user_avatar.dart'; import '../../../widgets/action_button.dart'; -import 'activity_content.dart'; +import '../../../widgets/user_avatar.dart'; class ActivityCommentsView extends StatefulWidget { const ActivityCommentsView({ diff --git a/sample_app/lib/screens/user_feed/widgets/activity_content.dart b/sample_app/lib/screens/user_feed/widgets/activity_content.dart index e023a71e..c3f3496a 100644 --- a/sample_app/lib/screens/user_feed/widgets/activity_content.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_content.dart @@ -3,9 +3,9 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; -import '../../../widgets/user_avatar.dart'; -import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/action_button.dart'; +import '../../../widgets/attachments/attachments.dart'; +import '../../../widgets/user_avatar.dart'; class ActivityContent extends StatelessWidget { const ActivityContent({ @@ -65,7 +65,6 @@ class ActivityContent extends StatelessWidget { class _UserContent extends StatelessWidget { const _UserContent({ - super.key, required this.user, required this.data, required this.text, @@ -115,7 +114,6 @@ class _UserContent extends StatelessWidget { class _ActivityBody extends StatelessWidget { const _ActivityBody({ - super.key, required this.user, required this.text, required this.attachments, @@ -150,7 +148,6 @@ class _ActivityBody extends StatelessWidget { class _UserActions extends StatelessWidget { const _UserActions({ - super.key, required this.user, required this.data, required this.currentUserId, @@ -215,5 +212,3 @@ class _UserActions extends StatelessWidget { ); } } - - 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 index 7c83ec0b..02658038 100644 --- 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 @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; -import '../../../widgets/user_avatar.dart'; import '../../../widgets/attachments/attachments.dart'; +import '../../../widgets/user_avatar.dart'; /// A bottom sheet for creating new activities with text and attachments. /// diff --git a/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart b/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart index c4b59916..d0c67b54 100644 --- a/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart +++ b/sample_app/lib/screens/user_feed/widgets/user_profile_view.dart @@ -59,7 +59,8 @@ class _UserProfileViewState extends State { Container( alignment: Alignment.center, padding: const EdgeInsets.all(16), - child: Text('User Profile', style: context.appTextStyles.headlineBold), + child: Text('User Profile', + style: context.appTextStyles.headlineBold), ), ProfileItem.text( title: 'Feed members', @@ -118,7 +119,7 @@ class _UserProfileViewState extends State { }); }, ), - ] + ], ], ); }, @@ -128,7 +129,6 @@ class _UserProfileViewState 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/theme/schemes/app_color_scheme.dart b/sample_app/lib/theme/schemes/app_color_scheme.dart index 4ecaefa2..61c7d5c8 100644 --- a/sample_app/lib/theme/schemes/app_color_scheme.dart +++ b/sample_app/lib/theme/schemes/app_color_scheme.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer_constructors_over_static_methods + import 'package:flutter/material.dart'; import '../tokens/color_tokens.dart'; diff --git a/sample_app/lib/theme/schemes/app_effects.dart b/sample_app/lib/theme/schemes/app_effects.dart index 5e68fdcc..d290e4f9 100644 --- a/sample_app/lib/theme/schemes/app_effects.dart +++ b/sample_app/lib/theme/schemes/app_effects.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_redundant_argument_values, prefer_constructors_over_static_methods import 'package:flutter/material.dart'; diff --git a/sample_app/lib/theme/schemes/app_text_theme.dart b/sample_app/lib/theme/schemes/app_text_theme.dart index e30081b3..b2baf1b8 100644 --- a/sample_app/lib/theme/schemes/app_text_theme.dart +++ b/sample_app/lib/theme/schemes/app_text_theme.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer_constructors_over_static_methods + import 'package:flutter/material.dart'; import '../tokens/color_tokens.dart'; diff --git a/sample_app/lib/widgets/attachments/attachment_grid.dart b/sample_app/lib/widgets/attachments/attachment_grid.dart index 98e226c0..d0599067 100644 --- a/sample_app/lib/widgets/attachments/attachment_grid.dart +++ b/sample_app/lib/widgets/attachments/attachment_grid.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flex_grid_view/flex_grid_view.dart'; import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; @@ -192,7 +190,9 @@ class AttachmentGrid extends StatelessWidget { } Widget _buildForFourOrMore( - BuildContext context, List attachments) { + BuildContext context, + List attachments, + ) { final pattern = >[]; final children = []; @@ -236,34 +236,6 @@ class AttachmentGrid extends StatelessWidget { ), ); } - - double _getAspectRatio(Attachment attachment) { - final width = attachment.originalWidth?.toDouble(); - final height = attachment.originalHeight?.toDouble(); - - if (width != null && height != null && height > 0) { - return width / height; - } - - // Default to square aspect ratio if dimensions are unknown - return 1; - } - - double _calculateConstrainedAspectRatio( - double originalAspectRatio, - BoxConstraints constraints, - ) { - // Calculate the aspect ratio range based on constraints - final minAspectRatio = constraints.minWidth / constraints.maxHeight; - final maxAspectRatio = constraints.maxWidth / constraints.minHeight; - - // Clamp the original aspect ratio within the constraint bounds - return originalAspectRatio.clamp(minAspectRatio, maxAspectRatio); - } - - void _onAttachmentTap(BuildContext context, Attachment attachment) { - onAttachmentTap?.call(attachment); - } } extension on Attachment { @@ -275,15 +247,3 @@ extension on Attachment { return Size(width.toDouble(), height.toDouble()); } } - -extension on BoxConstraints { - /// Returns new box constraints that tightens the max width and max height - /// to the given [size]. - BoxConstraints tightenMaxSize(Size? size) { - if (size == null) return this; - return copyWith( - maxWidth: clampDouble(size.width, minWidth, maxWidth), - maxHeight: clampDouble(size.height, minHeight, maxHeight), - ); - } -} diff --git a/sample_app/lib/widgets/attachments/attachments.dart b/sample_app/lib/widgets/attachments/attachments.dart index 98a630da..6e9e8902 100644 --- a/sample_app/lib/widgets/attachments/attachments.dart +++ b/sample_app/lib/widgets/attachments/attachments.dart @@ -1,4 +1,4 @@ -export 'attachment_widget.dart'; export 'attachment_grid.dart'; export 'attachment_picker.dart'; export 'attachment_preview_list.dart'; +export 'attachment_widget.dart'; diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 5c5b5478..5ea78434 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -18,8 +18,8 @@ dependencies: flutter_svg: ^2.2.0 get_it: ^8.0.3 google_fonts: ^6.3.0 - flex_grid_view: ^0.1.0 file_picker: ^8.1.4 + flex_grid_view: ^0.1.0 injectable: ^2.5.1 jiffy: ^6.3.2 shared_preferences: ^2.5.3 From 44093f5a33e49acb490cd6e791b910140e11ea95 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 4 Sep 2025 16:09:10 +0200 Subject: [PATCH 16/19] refactor: Introduce session scope for DI and remove SessionScope widget This commit introduces a dedicated session scope for dependency injection using `injectable`. This replaces the previous `SessionScope` InheritedWidget. Key changes: - **`SessionModule`:** A new module is added to provide `StreamFeedsClient` within the session scope. - **`HomeScreen`:** Now manages the session scope lifecycle. It initializes the scope in `initState` and disposes of it in `dispose`. - **Removed `SessionScope` widget:** The `SessionScope` InheritedWidget and its associated extension have been removed. - **Updated `UserFeedScreen`:** Now retrieves `StreamFeedsClient` and the current user directly from the DI container within the session scope. - **`AuthGuard`:** Simplified logging. This change centralizes session-specific dependency management and aligns with standard DI practices. --- sample_app/lib/core/di/auth_module.dart | 12 +++++++ .../lib/core/di/di_initializer.config.dart | 16 +++++++++ sample_app/lib/navigation/app_router.dart | 1 + sample_app/lib/navigation/app_router.gr.dart | 6 ++-- .../lib/navigation/guards/auth_guard.dart | 4 --- sample_app/lib/screens/home/home_screen.dart | 33 ++++++++++--------- .../lib/screens/home/session_scope.dart | 31 ----------------- .../screens/user_feed/user_feed_screen.dart | 27 ++++++++------- 8 files changed, 65 insertions(+), 65 deletions(-) create mode 100644 sample_app/lib/core/di/auth_module.dart delete mode 100644 sample_app/lib/screens/home/session_scope.dart diff --git a/sample_app/lib/core/di/auth_module.dart b/sample_app/lib/core/di/auth_module.dart new file mode 100644 index 00000000..ede82a73 --- /dev/null +++ b/sample_app/lib/core/di/auth_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 authenticatedFeeds(AuthController auth) { + return (auth.value as Authenticated).client; + } +} diff --git a/sample_app/lib/core/di/di_initializer.config.dart b/sample_app/lib/core/di/di_initializer.config.dart index fd93423a..d96969d9 100644 --- a/sample_app/lib/core/di/di_initializer.config.dart +++ b/sample_app/lib/core/di/di_initializer.config.dart @@ -13,6 +13,7 @@ import 'package:get_it/get_it.dart' as _i174; 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/auth_module.dart' as _i209; import 'package:sample/core/di/di_module.dart' as _i238; import 'package:sample/core/models/user_credentials.dart' as _i845; import 'package:sample/navigation/app_router.dart' as _i701; @@ -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.authenticatedFeeds(gh<_i27.AuthController>())); + }, + ); + } } class _$AppModule extends _i238.AppModule {} + +class _$SessionModule extends _i209.SessionModule {} diff --git a/sample_app/lib/navigation/app_router.dart b/sample_app/lib/navigation/app_router.dart index 6243e872..40a24b85 100644 --- a/sample_app/lib/navigation/app_router.dart +++ b/sample_app/lib/navigation/app_router.dart @@ -44,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 15d3e3e9..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'; @@ -46,7 +46,7 @@ class HomeRoute extends PageRouteInfo { /// [UserFeedScreen] class UserFeedRoute extends PageRouteInfo { const UserFeedRoute({List? children}) - : super(UserFeedRoute.name, initialChildren: children); + : super(UserFeedRoute.name, initialChildren: children); static const String name = 'UserFeedRoute'; 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/home/home_screen.dart b/sample_app/lib/screens/home/home_screen.dart index 22ae25ec..1950f12c 100644 --- a/sample_app/lib/screens/home/home_screen.dart +++ b/sample_app/lib/screens/home/home_screen.dart @@ -1,27 +1,30 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import '../../app/content/auth_controller.dart'; +import '../../core/di/di_initializer.config.dart'; import '../../core/di/di_initializer.dart'; -import 'session_scope.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(); +} - return SessionScope( - user: user, - client: client, - child: const AutoRouter(), - ); +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + locator.initSessionScope(); } + + @override + void dispose() { + locator.popScope(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const AutoRouter(); } diff --git a/sample_app/lib/screens/home/session_scope.dart b/sample_app/lib/screens/home/session_scope.dart deleted file mode 100644 index 79316d72..00000000 --- a/sample_app/lib/screens/home/session_scope.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_feeds/stream_feeds.dart'; - -class SessionScope extends InheritedWidget { - const SessionScope({ - super.key, - required this.user, - required this.client, - required super.child, - }); - - final User user; - final StreamFeedsClient client; - - static SessionScope of(BuildContext context) { - final scope = context.dependOnInheritedWidgetOfExactType(); - assert(scope != null, 'No SessionScope found in context'); - return scope!; - } - - @override - bool updateShouldNotify(SessionScope oldWidget) { - // only rebuild dependents if values actually change - return user != oldWidget.user || client != oldWidget.client; - } -} - -extension SessionScopeExtension on BuildContext { - User get currentUser => SessionScope.of(this).user; - StreamFeedsClient get client => SessionScope.of(this).client; -} diff --git a/sample_app/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index 87039f46..a55d301b 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -7,7 +7,6 @@ import '../../../theme/extensions/theme_extensions.dart'; import '../../../widgets/user_avatar.dart'; import '../../app/content/auth_controller.dart'; import '../../core/di/di_initializer.dart'; -import '../home/session_scope.dart'; import 'widgets/activity_comments_view.dart'; import 'widgets/activity_content.dart'; import 'widgets/create_activity_bottom_sheet.dart'; @@ -23,19 +22,21 @@ class UserFeedScreen extends StatefulWidget { } class _UserFeedScreenState extends State { - late final feed = context.client.feedFromQuery( + StreamFeedsClient get client => locator(); + + late final feed = client.feedFromQuery( FeedQuery( - fid: FeedId(group: 'user', id: context.currentUser.id), + fid: FeedId(group: 'user', id: client.user.id), data: FeedInputData( visibility: FeedVisibility.public, - members: [FeedMemberRequestData(userId: context.currentUser.id)], + members: [FeedMemberRequestData(userId: client.user.id)], ), ), ); @override - void didChangeDependencies() { - super.didChangeDependencies(); + void initState() { + super.initState(); feed.getOrCreate(); } @@ -52,6 +53,8 @@ class _UserFeedScreenState extends State { @override Widget build(BuildContext context) { + final currentUser = client.user; + final wideScreen = MediaQuery.sizeOf(context).width > 600; return StateNotifierBuilder( @@ -100,7 +103,7 @@ class _UserFeedScreenState extends State { text: baseActivity.text ?? '', attachments: baseActivity.attachments, data: activity, - currentUserId: context.currentUser.id, + currentUserId: currentUser.id, onCommentClick: () => _onCommentClick(context, activity), onHeartClick: (isAdding) => @@ -123,7 +126,7 @@ class _UserFeedScreenState extends State { feedWidget, onLogout: _onLogout, onProfileTap: () { - _showProfileBottomSheet(context, context.client, feed); + _showProfileBottomSheet(context, client, feed); }, ); } @@ -134,7 +137,7 @@ class _UserFeedScreenState extends State { children: [ SizedBox( width: 250, - child: UserProfileView(feedsClient: context.client, feed: feed), + child: UserProfileView(feedsClient: client, feed: feed), ), const SizedBox(width: 16), Expanded(child: feedWidget), @@ -157,7 +160,7 @@ class _UserFeedScreenState extends State { leading: GestureDetector( onTap: onProfileTap, child: Center( - child: UserAvatar.appBar(user: context.currentUser), + child: UserAvatar.appBar(user: client.user), ), ), title: Text( @@ -202,7 +205,7 @@ class _UserFeedScreenState extends State { builder: (context) => ActivityCommentsView( activityId: activity.id, feed: feed, - client: context.client, + client: client, ), ); } @@ -227,7 +230,7 @@ class _UserFeedScreenState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => CreateActivityBottomSheet( - currentUser: context.currentUser, + currentUser: client.user, feedId: feed.query.fid, ), ); From ca4d5b401a7ab80d33f367762c546e946ac70206 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 4 Sep 2025 16:11:06 +0200 Subject: [PATCH 17/19] Refactor: Rename AuthModule to SessionModule and update client getter This commit renames `AuthModule` to `SessionModule` to better reflect its purpose. Additionally, the method `authenticatedFeeds` within the module has been renamed to `authenticatedFeedsClient` for clarity. This change has been propagated to the dependency injection configuration. --- sample_app/lib/core/di/di_initializer.config.dart | 8 ++++---- .../lib/core/di/{auth_module.dart => session_module.dart} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename sample_app/lib/core/di/{auth_module.dart => session_module.dart} (79%) diff --git a/sample_app/lib/core/di/di_initializer.config.dart b/sample_app/lib/core/di/di_initializer.config.dart index d96969d9..33fe398f 100644 --- a/sample_app/lib/core/di/di_initializer.config.dart +++ b/sample_app/lib/core/di/di_initializer.config.dart @@ -13,8 +13,8 @@ import 'package:get_it/get_it.dart' as _i174; 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/auth_module.dart' as _i209; 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; @@ -62,8 +62,8 @@ extension GetItInjectableX on _i174.GetIt { dispose: dispose, init: (_i526.GetItHelper gh) { final sessionModule = _$SessionModule(); - gh.singleton<_i250.StreamFeedsClient>( - () => sessionModule.authenticatedFeeds(gh<_i27.AuthController>())); + gh.singleton<_i250.StreamFeedsClient>(() => + sessionModule.authenticatedFeedsClient(gh<_i27.AuthController>())); }, ); } @@ -71,4 +71,4 @@ extension GetItInjectableX on _i174.GetIt { class _$AppModule extends _i238.AppModule {} -class _$SessionModule extends _i209.SessionModule {} +class _$SessionModule extends _i514.SessionModule {} diff --git a/sample_app/lib/core/di/auth_module.dart b/sample_app/lib/core/di/session_module.dart similarity index 79% rename from sample_app/lib/core/di/auth_module.dart rename to sample_app/lib/core/di/session_module.dart index ede82a73..ec6fabe7 100644 --- a/sample_app/lib/core/di/auth_module.dart +++ b/sample_app/lib/core/di/session_module.dart @@ -6,7 +6,7 @@ import '../../app/content/auth_controller.dart'; @module abstract class SessionModule { @Singleton(scope: 'session') - StreamFeedsClient authenticatedFeeds(AuthController auth) { + StreamFeedsClient authenticatedFeedsClient(AuthController auth) { return (auth.value as Authenticated).client; } } From 4cdeeb411d4af9290ee4321f1f6a5230831eeff0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 4 Sep 2025 17:41:36 +0200 Subject: [PATCH 18/19] docs: update file upload and activity snippets This commit updates the documentation snippets for file uploads and activities to reflect recent changes and best practices. **Key changes:** - **`03_03_file_uploads.dart`:** - Added comprehensive examples for uploading files and images, including: - Step-by-step instructions for uploading individual files. - How to include `StreamAttachment` objects directly in `FeedAddActivityRequest` and `ActivityAddCommentRequest` for automatic uploading. - Guidance on implementing a custom `CdnClient` for users who want to use their own CDN. - **`01_01_quickstart.dart` and `sample_app`:** - Updated `addComment` calls to use the named `request` parameter and `ActivityAddCommentRequest`. - **`04_01_feeds.dart`:** - Improved formatting and added comments for clarity in feed reading and filtering examples. - **`03_01_activities.dart`:** - Added `custom` data examples to image and video `Attachment` objects. - Simplified the delete activity example. - **`sample_app` UI:** - Updated icons in `activity_content.dart` for comments, hearts, and reposts to use outlined variants for inactive states and filled variants for active states. - Changed the `type` of new activities created in `create_activity_bottom_sheet.dart` from 'activity' to 'post'. - **`models.dart`:** - Exported `FeedsConfig`. - **`pubspec.lock`:** - Updated various dependency versions. --- docs_code_snippets/01_01_quickstart.dart | 6 +- docs_code_snippets/03_01_activities.dart | 18 +- docs_code_snippets/03_03_file_uploads.dart | 187 +++++++++++++++++- docs_code_snippets/04_01_feeds.dart | 37 +++- packages/stream_feeds/lib/src/models.dart | 1 + .../stream_feeds/lib/src/state/activity.dart | 6 +- pubspec.lock | 54 ++--- .../screens/user_feed/user_feed_screen.dart | 1 - .../widgets/activity_comments_view.dart | 2 +- .../user_feed/widgets/activity_content.dart | 8 +- .../widgets/create_activity_bottom_sheet.dart | 2 +- 11 files changed, 266 insertions(+), 56 deletions(-) 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..7b6723cc 100644 --- a/docs_code_snippets/03_03_file_uploads.dart +++ b/docs_code_snippets/03_03_file_uploads.dart @@ -1,3 +1,186 @@ -// 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 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', + ), + ); +} + +// 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/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/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index f54e619e..fd588d77 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -138,9 +138,9 @@ 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 { + Future> addComment({ + required ActivityAddCommentRequest request, + }) async { final result = await commentsRepository.addComment(request); result.onSuccess( 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/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index ae13c85c..5473ad69 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -238,7 +238,6 @@ class _UserFeedScreenState extends State { feed.addBookmark(activityId: activity.id); } } -} Future _showCreateActivityBottomSheet() async { final request = await showModalBottomSheet( diff --git a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart index f443a373..4107a11e 100644 --- a/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_comments_view.dart @@ -127,7 +127,7 @@ class _ActivityCommentsViewState extends State { if (text == null) return; await activity.addComment( - ActivityAddCommentRequest( + request: ActivityAddCommentRequest( comment: text, parentId: parentComment?.id, activityId: activity.activityId, diff --git a/sample_app/lib/screens/user_feed/widgets/activity_content.dart b/sample_app/lib/screens/user_feed/widgets/activity_content.dart index c3f3496a..93d72d46 100644 --- a/sample_app/lib/screens/user_feed/widgets/activity_content.dart +++ b/sample_app/lib/screens/user_feed/widgets/activity_content.dart @@ -179,7 +179,7 @@ class _UserActions extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ActionButton( - icon: const Icon(Icons.comment), + icon: const Icon(Icons.comment_outlined), count: data.commentCount, onTap: onCommentClick, ), @@ -187,14 +187,14 @@ class _UserActions extends StatelessWidget { icon: Icon( hasOwnHeart ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + : Icons.favorite_outline_rounded, ), count: heartsCount, color: hasOwnHeart ? context.appColors.accentError : null, onTap: () => onHeartClick?.call(!hasOwnHeart), ), ActionButton( - icon: const Icon(Icons.share_rounded), + icon: const Icon(Icons.repeat_rounded), count: data.shareCount, onTap: () => onRepostClick?.call(null), ), @@ -202,7 +202,7 @@ class _UserActions extends StatelessWidget { icon: Icon( hasOwnBookmark ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, + : Icons.bookmark_outline_rounded, ), count: data.bookmarkCount, color: hasOwnBookmark ? context.appColors.accentPrimary : null, 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 index 02658038..97fea273 100644 --- 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 @@ -188,7 +188,7 @@ class _CreateActivityBottomSheetState extends State { final text = _textController.text.trim(); final request = FeedAddActivityRequest( - type: 'activity', + type: 'post', feeds: [widget.feedId.rawValue], text: text.takeIf((it) => it.isNotEmpty), attachmentUploads: _attachments.isNotEmpty ? _attachments : null, From bda59a6050fa50688e04385e90b506a31645099b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 4 Sep 2025 17:46:00 +0200 Subject: [PATCH 19/19] docs(uploads): update deleteFile and deleteImage methods This commit updates the `deleteFile` and `deleteImage` methods in `FeedsClient` and `FeedsClientImpl` to accept the `url` parameter as a named, required argument. Additionally, it adds a new code snippet to the documentation demonstrating how to use these methods to delete files and images from the CDN. --- docs_code_snippets/03_03_file_uploads.dart | 9 +++++++++ .../stream_feeds/lib/src/client/feeds_client_impl.dart | 10 +++++++--- packages/stream_feeds/lib/src/feeds_client.dart | 4 ++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs_code_snippets/03_03_file_uploads.dart b/docs_code_snippets/03_03_file_uploads.dart index 7b6723cc..03984581 100644 --- a/docs_code_snippets/03_03_file_uploads.dart +++ b/docs_code_snippets/03_03_file_uploads.dart @@ -2,6 +2,7 @@ import 'package:stream_feeds/stream_feeds.dart'; +late StreamFeedsClient client; late Feed feed; late Activity activity; late StreamAttachmentUploader attachmentUploader; @@ -79,6 +80,14 @@ Future howToUploadAFileOrImageStep4() async { ); } +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 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 f974d484..9b6bdb9d 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -294,7 +294,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { @override Activity activity({ required String activityId, - required FeedId fid, + required FeedId fid, ActivityData? initialData, }) { return Activity( @@ -442,8 +442,12 @@ class StreamFeedsClientImpl implements StreamFeedsClient { } @override - Future> deleteFile(String url) => _cdnClient.deleteFile(url); + Future> deleteFile({required String url}) { + return _cdnClient.deleteFile(url); + } @override - Future> deleteImage(String url) => _cdnClient.deleteImage(url); + 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 6cc9ba4b..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,7 +689,7 @@ 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. ///