diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c529cc943..45f310203 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -68,7 +68,7 @@ jobs: - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 with: path: packages/faye_dart/coverage/lcov.info - min_coverage: 49 + min_coverage: 48 - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 with: path: packages/stream_feed_flutter_core/coverage/lcov.info diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 4d1a5c232..bec3e024e 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ @@ -127,7 +127,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cfd..3db53b6e1 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ getUser(String id, {bool withFollowCounts = false}) { assert(_ensureCredentials(), ''); - if (runner == Runner.client) { - _logger.warning('We advice using `client.getUser` only server-side'); - } final token = userToken ?? TokenHelper.buildUsersToken(secret!, TokenAction.read); diff --git a/packages/stream_feed_flutter/example/lib/main.dart b/packages/stream_feed_flutter/example/lib/main.dart index c7741cd5f..fe0d255c9 100644 --- a/packages/stream_feed_flutter/example/lib/main.dart +++ b/packages/stream_feed_flutter/example/lib/main.dart @@ -397,7 +397,7 @@ class _MyHomePageState extends State with StreamFeedMixin { child: const Icon(Icons.edit_outlined), onPressed: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ComposeView( + builder: (_) => ComposeScreen( textEditingController: TextEditingController(), ), fullscreenDialog: true, @@ -552,67 +552,15 @@ class _ProfileScreenState extends State with StreamFeedMixin { style: Theme.of(context).textTheme.headline6, ), const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${widget.user?.followersCount ?? bloc.currentUser!.followersCount}', - style: Theme.of(context).textTheme.headline6, - ), - Text( - 'Followers', - style: Theme.of(context).textTheme.bodyText1, - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${widget.user?.followingCount ?? bloc.currentUser!.followingCount}', - style: Theme.of(context).textTheme.headline6, - ), - Text( - 'Following', - style: Theme.of(context).textTheme.bodyText1, - ), - ], - ), - ], - ), + FollowStatsWidget(user: widget.user), if (widget.user != null && widget.user!.id != bloc.currentUser!.id) ...[ Row( children: [ const SizedBox(width: 16), Expanded( - child: FutureBuilder( - future: bloc.isFollowingFeed( - followerId: widget.user!.id!), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox.shrink(); - } else { - return OutlinedButton( - child: Text( - snapshot.data! ? 'Unfollow' : 'Follow'), - onPressed: () async { - if (snapshot.data!) { - await bloc.unfollowFeed( - unfolloweeId: widget.user!.id!); - setState(() {}); - } else { - await bloc.followFeed( - followeeId: widget.user!.id!); - setState(() {}); - } - }, - ); - } - }, + child: FollowButton( + user: widget.user, ), ), const SizedBox(width: 8), @@ -654,80 +602,3 @@ class _ProfileScreenState extends State with StreamFeedMixin { ); } } - -class FollowingScreen extends StatefulWidget { - const FollowingScreen({Key? key}) : super(key: key); - - @override - State createState() => _FollowingScreenState(); -} - -class _FollowingScreenState extends State - with StreamFeedMixin { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Following'), - ), - body: FutureBuilder>( - future: client.flatFeed('timeline', bloc.currentUser!.id).following(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } else { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - return ListTile( - title: Text(snapshot.data![index].feedId), - ); - }, - ); - } - }, - ), - ); - } -} - -class FollowersScreen extends StatefulWidget { - const FollowersScreen({Key? key}) : super(key: key); - - @override - State createState() => _FollowersScreenState(); -} - -class _FollowersScreenState extends State - with StreamFeedMixin { - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Followers'), - ), - body: FutureBuilder>( - future: client.flatFeed('user', bloc.currentUser!.id).followers(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } else { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final feedName = snapshot.data![index].feedId.split(':').last; - return ListTile( - title: Text(feedName), - ); - }, - ); - } - }, - ), - ); - } -} diff --git a/packages/stream_feed_flutter/lib/src/media/media.dart b/packages/stream_feed_flutter/lib/src/media/media.dart new file mode 100644 index 000000000..a6feb2d54 --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/media/media.dart @@ -0,0 +1,3 @@ +export 'fullscreen_media.dart'; +export 'gallery_header.dart'; +export 'gallery_preview.dart'; diff --git a/packages/stream_feed_flutter/lib/src/widgets/buttons/buttons.dart b/packages/stream_feed_flutter/lib/src/widgets/buttons/buttons.dart index 5fd6d825a..2db3c30cd 100644 --- a/packages/stream_feed_flutter/lib/src/widgets/buttons/buttons.dart +++ b/packages/stream_feed_flutter/lib/src/widgets/buttons/buttons.dart @@ -1,3 +1,5 @@ +export 'follow.dart'; export 'like.dart'; +export 'reaction.dart'; export 'reply_button.dart'; export 'repost.dart'; diff --git a/packages/stream_feed_flutter/lib/src/widgets/buttons/follow.dart b/packages/stream_feed_flutter/lib/src/widgets/buttons/follow.dart new file mode 100644 index 000000000..7af88658e --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/widgets/buttons/follow.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +/// A button to follow or unfollow a user +class FollowButton extends StatefulWidget { + const FollowButton({Key? key, this.user}) : super(key: key); + final User? user; + + @override + _FollowButtonState createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State { + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: FeedProvider.of(context) + .bloc + .isFollowingFeed(followerId: widget.user!.id!), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } else { + return OutlinedButton( + child: Text(snapshot.data! ? 'Unfollow' : 'Follow'), + onPressed: () async { + if (snapshot.data!) { + await FeedProvider.of(context) + .bloc + .unfollowFeed(unfolloweeId: widget.user!.id!); + setState(() {}); + } else { + await FeedProvider.of(context) + .bloc + .followFeed(followeeId: widget.user!.id!); + setState(() {}); + } + }, + ); + } + }, + ); + } +} diff --git a/packages/stream_feed_flutter/lib/src/widgets/buttons/reply_button.dart b/packages/stream_feed_flutter/lib/src/widgets/buttons/reply_button.dart index 3d8f17e2e..20bcbf238 100644 --- a/packages/stream_feed_flutter/lib/src/widgets/buttons/reply_button.dart +++ b/packages/stream_feed_flutter/lib/src/widgets/buttons/reply_button.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed_flutter/src/theme/stream_feed_theme.dart'; import 'package:stream_feed_flutter/src/widgets/icons.dart'; -import 'package:stream_feed_flutter/src/widgets/pages/compose_view.dart'; +import 'package:stream_feed_flutter/src/widgets/pages/compose_screen.dart'; import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; // ignore_for_file: cascade_invocations @@ -58,7 +58,7 @@ class ReplyButton extends StatelessWidget { onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ComposeView( + builder: (_) => ComposeScreen( parentActivity: activity, feedGroup: feedGroup, textEditingController: TextEditingController(), diff --git a/packages/stream_feed_flutter/lib/src/widgets/pages/compose_view.dart b/packages/stream_feed_flutter/lib/src/widgets/pages/compose_screen.dart similarity index 96% rename from packages/stream_feed_flutter/lib/src/widgets/pages/compose_view.dart rename to packages/stream_feed_flutter/lib/src/widgets/pages/compose_screen.dart index 35ca331c5..068a89833 100644 --- a/packages/stream_feed_flutter/lib/src/widgets/pages/compose_view.dart +++ b/packages/stream_feed_flutter/lib/src/widgets/pages/compose_screen.dart @@ -5,8 +5,9 @@ import 'package:stream_feed_flutter/src/widgets/activity/activity.dart'; import 'package:stream_feed_flutter/src/widgets/buttons/reactive_elevated_button.dart'; import 'package:stream_feed_flutter/stream_feed_flutter.dart'; -class ComposeView extends StatefulWidget { - const ComposeView( +/// A widget to react to an activity or compose a new activity. +class ComposeScreen extends StatefulWidget { + const ComposeScreen( {Key? key, this.parentActivity, this.feedGroup = 'user', @@ -36,10 +37,10 @@ class ComposeView extends StatefulWidget { } @override - State createState() => _ComposeViewState(); + State createState() => _ComposeScreenState(); } -class _ComposeViewState extends State { +class _ComposeScreenState extends State { bool get _isReply => widget.parentActivity != null; String get _hintText => diff --git a/packages/stream_feed_flutter/lib/src/widgets/pages/followers_list_view.dart b/packages/stream_feed_flutter/lib/src/widgets/pages/followers_list_view.dart new file mode 100644 index 000000000..4e500bd4d --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/widgets/pages/followers_list_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter/stream_feed_flutter.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +typedef FollowingListViewBuilder = Widget Function(User model); + +/// Displays a list of users the current user is following +class FollowingListView extends StatelessWidget { + final String handleJsonKey; + final String nameJsonKey; + final FollowingListViewBuilder? builder; + const FollowingListView( + {Key? key, + this.handleJsonKey = 'handle', + this.nameJsonKey = 'name', + this.builder}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final bloc = FeedProvider.of(context).bloc; + return FutureBuilder>( + future: bloc.followingUsers(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final user = snapshot.data![index]; + final displayName = user.data?[handleJsonKey] as String? ?? + user.data?[nameJsonKey] as String?; + return builder?.call(user) ?? + ListTile( + leading: Avatar( + user: user, + + // onUserTap: onUserTap, + size: UserBarTheme.of(context).avatarSize, + ), + trailing: FollowButton( + user: user, + ), + title: Text(displayName ?? 'unknown')); + }, + ); + } + }, + ); + } +} + +/// Displays a list of followers for the current user +class FollowersListView extends StatelessWidget { + final String handleJsonKey; + final String nameJsonKey; + final FollowingListViewBuilder? builder; + const FollowersListView( + {Key? key, + this.handleJsonKey = 'handle', + this.nameJsonKey = 'name', + this.builder}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final bloc = FeedProvider.of(context).bloc; + return FutureBuilder>( + future: bloc.followersUsers(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator(), + ); + } else { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final user = snapshot.data![index]; + final displayName = user.data?[handleJsonKey] as String? ?? + user.data?[nameJsonKey] as String?; + return builder?.call(user) ?? + ListTile( + leading: Avatar( + user: user, + + // onUserTap: onUserTap, + size: UserBarTheme.of(context).avatarSize, + ), + trailing: FollowButton( + user: user, + ), + title: Text(displayName ?? 'unknown')); + // ListTile( + // title: Text(displayName ?? 'unknown'), + // ); + }, + ); + } + }, + ); + } +} + +class FollowingScreen extends StatelessWidget { + const FollowingScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Following'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: FollowingListView(), + ), + ); + } +} + +class FollowersScreen extends StatelessWidget { + const FollowersScreen({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Followers'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: const FollowersListView(), + ), + ); + } +} diff --git a/packages/stream_feed_flutter/lib/src/widgets/pages/pages.dart b/packages/stream_feed_flutter/lib/src/widgets/pages/pages.dart new file mode 100644 index 000000000..8c573294a --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/widgets/pages/pages.dart @@ -0,0 +1,4 @@ +export 'compose_screen.dart'; +export 'flat_feed_list_view.dart'; +export 'reaction_list_view.dart'; +export 'followers_list_view.dart'; diff --git a/packages/stream_feed_flutter/lib/src/widgets/stats.dart b/packages/stream_feed_flutter/lib/src/widgets/stats.dart new file mode 100644 index 000000000..fc9e4f76e --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/widgets/stats.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter/stream_feed_flutter.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +class FollowStatsWidget extends StatelessWidget { + const FollowStatsWidget({Key? key, this.user}) : super(key: key); + final User? user; + @override + Widget build(BuildContext context) { + final bloc = FeedProvider.of(context).bloc; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const FollowersScreen(), + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${user?.followersCount ?? bloc.currentUser!.followersCount}', + style: Theme.of(context).textTheme.headline6, + ), + Text( + 'Followers', + style: Theme.of(context).textTheme.bodyText1, + ), + ], + ), + ), + InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const FollowingScreen(), + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${user?.followingCount ?? bloc.currentUser!.followingCount}', + style: Theme.of(context).textTheme.headline6, + ), + Text( + 'Following', + style: Theme.of(context).textTheme.bodyText1, + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/stream_feed_flutter/lib/src/widgets/widgets.dart b/packages/stream_feed_flutter/lib/src/widgets/widgets.dart new file mode 100644 index 000000000..a92510397 --- /dev/null +++ b/packages/stream_feed_flutter/lib/src/widgets/widgets.dart @@ -0,0 +1,7 @@ +export 'buttons/buttons.dart'; +export 'comment/field.dart'; +export 'icons.dart'; +export 'pages/pages.dart'; +export 'stream_feed_app.dart'; +export 'user/avatar.dart'; +export 'stats.dart'; diff --git a/packages/stream_feed_flutter/lib/stream_feed_flutter.dart b/packages/stream_feed_flutter/lib/stream_feed_flutter.dart index 5ad864a99..7060ed464 100644 --- a/packages/stream_feed_flutter/lib/stream_feed_flutter.dart +++ b/packages/stream_feed_flutter/lib/stream_feed_flutter.dart @@ -3,15 +3,7 @@ library stream_feed_flutter; export 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart' hide FlatFeed; -export 'src/media/fullscreen_media.dart'; -export 'src/media/gallery_header.dart'; -export 'src/media/gallery_preview.dart'; +export 'src/media/media.dart'; export 'src/theme/themes.dart'; export 'src/utils/typedefs.dart'; -export 'src/widgets/buttons/reaction.dart'; -export 'src/widgets/comment/field.dart'; -export 'src/widgets/icons.dart'; -export 'src/widgets/pages/compose_view.dart'; -export 'src/widgets/pages/flat_feed_list_view.dart'; -export 'src/widgets/stream_feed_app.dart'; -export 'src/widgets/user/avatar.dart'; +export 'src/widgets/widgets.dart'; diff --git a/packages/stream_feed_flutter/pubspec.yaml b/packages/stream_feed_flutter/pubspec.yaml index de731dd93..e71a06395 100644 --- a/packages/stream_feed_flutter/pubspec.yaml +++ b/packages/stream_feed_flutter/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://getstream.io/ publish_to: none environment: - sdk: '>=2.13.0 <3.0.0' + sdk: '>=2.14.0 <3.0.0' dependencies: animations: ^2.0.0 diff --git a/packages/stream_feed_flutter/test/compose_view_test.dart b/packages/stream_feed_flutter/test/compose_view_test.dart index fe6c77be8..2f9adfbb2 100644 --- a/packages/stream_feed_flutter/test/compose_view_test.dart +++ b/packages/stream_feed_flutter/test/compose_view_test.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_feed_flutter/src/widgets/activity/activity.dart'; -import 'package:stream_feed_flutter/src/widgets/pages/compose_view.dart'; +import 'package:stream_feed_flutter/src/widgets/pages/compose_screen.dart'; import 'package:stream_feed_flutter/stream_feed_flutter.dart'; import 'mock.dart'; @@ -42,7 +42,7 @@ void main() { child: child!, ); }, - home: ComposeView( + home: ComposeScreen( textEditingController: TextEditingController(), feedGroup: 'user', parentActivity: GenericEnrichedActivity( @@ -96,7 +96,7 @@ void main() { testWidgets('debugFillProperties', (tester) async { final builder = DiagnosticPropertiesBuilder(); final now = DateTime.now(); - ComposeView( + ComposeScreen( textEditingController: TextEditingController(), parentActivity: GenericEnrichedActivity( id: '1', diff --git a/packages/stream_feed_flutter/test/follows_test.dart b/packages/stream_feed_flutter/test/follows_test.dart new file mode 100644 index 000000000..7ad1a1e35 --- /dev/null +++ b/packages/stream_feed_flutter/test/follows_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:mocktail_image_network/mocktail_image_network.dart'; +import 'package:stream_feed_flutter/stream_feed_flutter.dart'; + +import 'mock.dart'; + +void main() { + late MockStreamFeedClient mockClient; + late MockFlatFeed timelineFeed; + late MockFlatFeed timelineFeed2; + late MockFlatFeed userFeed; + late MockStreamUser mockUser; + late MockFeedBloc mockFeedBloc; + late String id; + setUp(() { + mockClient = MockStreamFeedClient(); + mockFeedBloc = MockFeedBloc(); + + timelineFeed = MockFlatFeed(); + timelineFeed2 = MockFlatFeed(); + userFeed = MockFlatFeed(); + mockUser = MockStreamUser(); + id = 'sacha'; + when(() => mockFeedBloc.client).thenReturn(mockClient); + when(() => mockClient.currentUser).thenReturn(mockUser); + when(() => mockUser.id).thenReturn(id); + when(() => mockFeedBloc.isFollowingFeed(followerId: '2')) + .thenAnswer((_) async => false); + when(() => mockClient.flatFeed('timeline')).thenReturn(timelineFeed); + when(() => mockClient.flatFeed('timeline', id)).thenReturn(timelineFeed2); + when(() => mockClient.flatFeed('user', id)).thenReturn(userFeed); + when(() => mockFeedBloc.followersUsers()).thenAnswer((_) async => [ + User(id: 'nash', data: { + 'handle': '@Nash', + 'name': 'Nash', + 'profile_image': 'https://randomuser.me/api/portraits/women/1.jpg', + }), + User(id: 'reuben', data: { + 'handle': '@GroovinChip', + 'name': 'Reuben', + 'profile_image': 'https://randomuser.me/api/portraits/women/1.jpg', + }) + ]); + when(() => mockFeedBloc.followingUsers()).thenAnswer((_) async => [ + User(id: 'nash', data: { + 'handle': '@Nash', + 'name': 'Nash', + 'profile_image': 'https://randomuser.me/api/portraits/women/1.jpg', + }), + User(id: 'reuben', data: { + 'handle': '@GroovinChip', + 'name': 'Reuben', + 'profile_image': 'https://randomuser.me/api/portraits/women/1.jpg', + }) + ]); + when(() => userFeed.followers()).thenAnswer((_) async => [ + Follow( + feedId: "user:nash", + targetId: "user:sacha", + createdAt: DateTime.now(), + updatedAt: DateTime.now()), + Follow( + feedId: "user:reuben", + targetId: "user:sacha", + createdAt: DateTime.now(), + updatedAt: DateTime.now()) + ]); + when(() => timelineFeed2.following()).thenAnswer((_) async => [ + Follow( + feedId: + "user:reuben", //TODO(sacha):hmm weird in my mind targetId and feedId are reversed + targetId: "user:sacha", + createdAt: DateTime.now(), + updatedAt: DateTime.now()), + Follow( + feedId: "user:nash", + targetId: "user:sacha", + createdAt: DateTime.now(), + updatedAt: DateTime.now()) + ]); + when(() => timelineFeed.following( + limit: 1, + offset: 0, + filter: [ + FeedId.id('user:2'), + ], + )).thenAnswer((_) async => []); + }); + + testWidgets('FollowingListView', (tester) async { + await tester.pumpWidget(MaterialApp( + builder: (context, child) { + return StreamFeed( + bloc: mockFeedBloc, + child: child!, + ); + }, + home: Scaffold( + body: FollowingListView( + builder: (user) => Text("${user.data!['name']}"))))); + await tester.pumpAndSettle(); + // verify(() => timelineFeed2.following()).called(1); + expect(find.text('Reuben'), findsOneWidget); + expect(find.text('Nash'), findsOneWidget); + }); + + testWidgets('FollowersListView', (tester) async { + await tester.pumpWidget(MaterialApp( + builder: (context, child) { + return StreamFeed( + bloc: mockFeedBloc, + child: child!, + ); + }, + home: Scaffold( + body: FollowersListView( + builder: (user) => Text("${user.data!['name']}"))))); + await tester.pumpAndSettle(); + // verify(() => userFeed.followers()).called(1); + expect(find.text('Reuben'), findsOneWidget); + expect(find.text('Nash'), findsOneWidget); + }); + + testWidgets('FollowStatsWidget', (tester) async { + await tester.pumpWidget(MaterialApp( + builder: (context, child) { + return StreamFeed( + bloc: mockFeedBloc, + child: child!, + ); + }, + home: const Scaffold( + body: FollowStatsWidget( + user: User(followersCount: 1, followingCount: 3), + ), + ))); + final followersCount = find.text('1'); + final followingCount = find.text('3'); + expect(followersCount, findsOneWidget); + expect(followingCount, findsOneWidget); + final followers = find.text('Followers'); + final following = find.text('Following'); + + expect(followers, findsOneWidget); + expect(following, findsOneWidget); + }); + testWidgets('FollowButton', (tester) async { + await tester.pumpWidget(MaterialApp( + builder: (context, child) { + return StreamFeed( + bloc: mockFeedBloc, + child: child!, + ); + }, + home: const Scaffold( + body: FollowButton( + user: User(id: '2', followersCount: 1, followingCount: 3), + )))); + + await tester.pumpAndSettle(); + expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.text('Follow'), findsOneWidget); + // verify(() => mockClient.flatFeed('timeline')).called(1); + // verify(() => timelineFeed.following( + // limit: 1, + // offset: 0, + // filter: [ + // FeedId.id('user:2'), + // ], + // )).called(1); + }); +} diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/feed_bloc.dart b/packages/stream_feed_flutter_core/lib/src/bloc/feed_bloc.dart index fb12e6430..feaf03a42 100644 --- a/packages/stream_feed_flutter_core/lib/src/bloc/feed_bloc.dart +++ b/packages/stream_feed_flutter_core/lib/src/bloc/feed_bloc.dart @@ -8,6 +8,42 @@ import 'package:stream_feed_flutter_core/src/bloc/reactions_controller.dart'; import 'package:stream_feed_flutter_core/src/extensions.dart'; import 'package:stream_feed_flutter_core/src/upload/upload_controller.dart'; +/// {@template feedBloc} +/// Widget dedicated to the state management of an app's Stream feed +/// [FeedBloc] is used to manage a set of operations +/// associated with [EnrichedActivity]s and [Reaction]s. +/// +/// [FeedBloc] can be access at anytime by using the factory [of] method +/// using Flutter's [BuildContext]. +/// +/// Usually what you want is the convenient [FeedBloc] that already +/// has the default parameters defined for you +/// suitable to most use cases. But if you need a +/// more advanced use case use [GenericFeedBloc] instead +/// +/// ## Usage +/// - {@macro queryEnrichedActivities} +/// - {@macro queryReactions} +/// - {@macro onAddActivity} +/// - {@macro deleteActivity} +/// - {@macro onAddReaction} +/// - {@macro onRemoveReaction} +/// - {@macro onAddChildReaction} +/// - {@macro onRemoveChildReaction} +/// {@endtemplate} +/// +/// {@template genericParameters} +/// The generic parameters can be of the following type: +/// - A : [actor] can be an User, or a String +/// - Ob : [object] can a String, or a CollectionEntry +/// - T : [target] can be a String or an Activity +/// - Or : [origin] can be a String or a Reaction or an User +/// +/// To avoid potential runtime errors +/// make sure they are the same across the app if +/// you go the route of using Generic* classes +/// +/// {@endtemplate} class FeedBloc extends GenericFeedBloc { FeedBloc({ required StreamFeedClient client, @@ -104,6 +140,26 @@ class GenericFeedBloc { Stream get queryActivitiesLoading => _queryActivitiesLoadingController.stream; + /// Convenient method to fetch a list of users current user is following + Future> followingUsers({String feedGroup = 'timeline'}) async { + final following = + await client.flatFeed(feedGroup, currentUser!.id).following(); + final users = following + .map((follow) => client.getUser(follow.feedId.split(':').last)) + .toList(); + return Future.wait(users); + } + + /// Convenient method to fetch a list of users following current (followers) + Future> followersUsers({String feedGroup = 'user'}) async { + final followers = + await client.flatFeed(feedGroup, currentUser!.id).followers(); + final users = followers + .map((follow) => client.getUser(follow.feedId.split(':').last)) + .toList(); + return Future.wait(users); + } + /* ACTIVITIES */ /// {@template onAddActivity} diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart b/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart index 0e2f0d5f0..37af37408 100644 --- a/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart +++ b/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart @@ -3,6 +3,15 @@ import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; import 'package:stream_feed_flutter_core/src/bloc/feed_bloc.dart'; +/// {@template feedProvider} +/// Inherited widget providing the [FeedBloc] to the widget tree +/// Usually what you need is the convenient [FeedProvider] that already +/// has the default parameters defined for you +/// suitable to most usecases. But if you need a +/// more advanced use case use [GenericFeedProvider] instead. Make sure you +/// instantiate it only once. +/// {@endtemplate} +/// class FeedProvider extends GenericFeedProvider { const FeedProvider({ Key? key, diff --git a/packages/stream_feed_flutter_core/lib/src/flat_feed_core.dart b/packages/stream_feed_flutter_core/lib/src/flat_feed_core.dart index 9412317c6..65f7f0373 100644 --- a/packages/stream_feed_flutter_core/lib/src/flat_feed_core.dart +++ b/packages/stream_feed_flutter_core/lib/src/flat_feed_core.dart @@ -1,12 +1,45 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'package:stream_feed_flutter_core/src/bloc/bloc.dart'; -import 'package:stream_feed_flutter_core/src/states/empty.dart'; -import 'package:stream_feed_flutter_core/src/states/states.dart'; import 'package:stream_feed_flutter_core/src/typedefs.dart'; import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; +/// {@template flatFeedCore} +/// [FlatFeedCore] is a core class that allows fetching a list of +/// enriched activities (flat) while exposing UI builders. +/// Make sure to have a [FeedProvider] ancestor in order to provide the +/// information about the activities. +/// Usually what you want is the convenient [FlatFeedCore] that already +/// has the default parameters defined for you +/// suitable to most use cases. But if you need a +/// more advanced use case use [GenericFlatFeedCore] instead +/// +/// ## Usage +/// +/// ```dart +/// class ActivityListView extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: FlatFeedCore( +/// onErrorWidget: Center( +/// child: Text('An error has occurred'), +/// ), +/// onEmptyWidget: Center( +/// child: Text('Nothing here...'), +/// ), +/// onProgressWidget: Center( +/// child: CircularProgressIndicator(), +/// ), +/// feedBuilder: (context, activities, idx) { +/// return YourActivityWidget(activity: activities[idx]); +/// } +/// ), +/// ); +/// } +/// } +/// ``` +/// {@endtemplate} class FlatFeedCore extends GenericFlatFeedCore { FlatFeedCore({ required EnrichedFeedBuilder feedBuilder, diff --git a/packages/stream_feed_flutter_core/lib/src/reactions_list_core.dart b/packages/stream_feed_flutter_core/lib/src/reactions_list_core.dart index 76f95e2d8..51fffefe6 100644 --- a/packages/stream_feed_flutter_core/lib/src/reactions_list_core.dart +++ b/packages/stream_feed_flutter_core/lib/src/reactions_list_core.dart @@ -6,6 +6,45 @@ import 'package:stream_feed_flutter_core/src/states/states.dart'; import 'package:stream_feed_flutter_core/src/typedefs.dart'; import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; +/// {@template reactionListCore} +/// [ReactionListCore] is a core class that allows fetching a list of +/// reactions while exposing UI builders. +/// +/// ## Usage +/// +/// ```dart +/// class ReactionListView extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: ReactionListCore( +/// onErrorWidget: Center( +/// child: Text('An error has occurred'), +/// ), +/// onEmptyWidget: Center( +/// child: Text('Nothing here...'), +/// ), +/// onProgressWidget: Center( +/// child: CircularProgressIndicator(), +/// ), +/// feedBuilder: (context, reactions, idx) { +/// return YourReactionWidget(reaction: reactions[idx]); +/// } +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// Make sure to have a [FeedProvider] ancestor in order to provide the +/// information about the reactions. +/// +/// Usually what you want is the convenient [ReactionListCore] that already +/// has the default parameters defined for you +/// suitable to most use cases. But if you need a +/// more advanced use case use [GenericReactionListCore] instead +/// {@endtemplate} +/// class ReactionListCore extends GenericReactionListCore { const ReactionListCore({ diff --git a/packages/stream_feed_flutter_core/lib/src/typedefs.dart b/packages/stream_feed_flutter_core/lib/src/typedefs.dart index aec58d5e1..b501f1095 100644 --- a/packages/stream_feed_flutter_core/lib/src/typedefs.dart +++ b/packages/stream_feed_flutter_core/lib/src/typedefs.dart @@ -17,17 +17,26 @@ typedef EnrichedFeedBuilder = Widget Function( int idx, ); +/// A builder that allows building a widget given a List<[FileUploadState]> typedef UploadsBuilder = Widget Function( BuildContext context, List uploads); +/// A builder that allows to build a widget given the error state of the +/// upload and the file being uploaded typedef UploadsErrorBuilder = Widget Function(Object error); +/// A builder that allows to build a widget given the state of the successful +/// upload and the file being uploaded typedef UploadSuccessBuilder = Widget Function( AttachmentFile file, UploadSuccess success); +/// A builder that allows to build a widget given the state of the in progress +/// upload and the file being uploaded typedef UploadProgressBuilder = Widget Function( AttachmentFile file, UploadProgress progress); +/// A builder that allows to build a widget given the state of the failed upload +/// and the file being uploaded typedef UploadFailedBuilder = Widget Function( AttachmentFile file, UploadFailed progress); @@ -37,146 +46,6 @@ typedef UploadFailedBuilder = Widget Function( typedef ReactionsBuilder = Widget Function( BuildContext context, List reactions, int idx); -/* CONVENIENT TYPEDEFS - for defining default type parameters. - Dart doesn't allow a type parameter to have a default value - so this is a hack until it is supported -*/ - -///Convenient typedef for [GenericFlatFeedCore] with default parameters -/// -/// {@template flatFeedCore} -/// [FlatFeedCore] is a core class that allows fetching a list of -/// enriched activities (flat) while exposing UI builders. -/// Make sure to have a [FeedProvider] ancestor in order to provide the -/// information about the activities. -/// Usually what you want is the convenient [FlatFeedCore] that already -/// has the default parameters defined for you -/// suitable to most use cases. But if you need a -/// more advanced use case use [GenericFlatFeedCore] instead -/// -/// ## Usage -/// -/// ```dart -/// class ActivityListView extends StatelessWidget { -/// @override -/// Widget build(BuildContext context) { -/// return Scaffold( -/// body: FlatFeedCore( -/// onErrorWidget: Center( -/// child: Text('An error has occurred'), -/// ), -/// onEmptyWidget: Center( -/// child: Text('Nothing here...'), -/// ), -/// onProgressWidget: Center( -/// child: CircularProgressIndicator(), -/// ), -/// feedBuilder: (context, activities, idx) { -/// return YourActivityWidget(activity: activities[idx]); -/// } -/// ), -/// ); -/// } -/// } -/// ``` -/// {@endtemplate} -typedef FlatFeedCore = GenericFlatFeedCore; - -///Convenient typedef for [GenericReactionListCore] with default parameters -/// -/// {@template reactionListCore} -/// [ReactionListCore] is a core class that allows fetching a list of -/// reactions while exposing UI builders. -/// -/// ## Usage -/// -/// ```dart -/// class ReactionListView extends StatelessWidget { -/// @override -/// Widget build(BuildContext context) { -/// return Scaffold( -/// body: ReactionListCore( -/// onErrorWidget: Center( -/// child: Text('An error has occurred'), -/// ), -/// onEmptyWidget: Center( -/// child: Text('Nothing here...'), -/// ), -/// onProgressWidget: Center( -/// child: CircularProgressIndicator(), -/// ), -/// feedBuilder: (context, reactions, idx) { -/// return YourReactionWidget(reaction: reactions[idx]); -/// } -/// ), -/// ); -/// } -/// } -/// ``` -/// -/// Make sure to have a [FeedProvider] ancestor in order to provide the -/// information about the reactions. -/// -/// Usually what you want is the convenient [ReactionListCore] that already -/// has the default parameters defined for you -/// suitable to most use cases. But if you need a -/// more advanced use case use [GenericReactionListCore] instead -/// {@endtemplate} -typedef ReactionListCore - = GenericReactionListCore; - -/// Convenient typedef for [GenericFeedProvider] with default parameters -/// -/// {@template feedProvider} -/// Inherited widget providing the [FeedBloc] to the widget tree -/// Usually what you need is the convenient [FeedProvider] that already -/// has the default parameters defined for you -/// suitable to most usecases. But if you need a -/// more advanced use case use [GenericFeedProvider] instead -/// {@endtemplate} -typedef FeedProvider = GenericFeedProvider; - -/// Convenient typedef for [GenericFeedBloc] with default parameters -/// -/// {@template feedBloc} -/// Widget dedicated to the state management of an app's Stream feed -/// [FeedBloc] is used to manage a set of operations -/// associated with [EnrichedActivity]s and [Reaction]s. -/// -/// [FeedBloc] can be access at anytime by using the factory [of] method -/// using Flutter's [BuildContext]. -/// -/// Usually what you want is the convenient [FeedBloc] that already -/// has the default parameters defined for you -/// suitable to most use cases. But if you need a -/// more advanced use case use [GenericFeedBloc] instead -/// -/// ## Usage -/// - {@macro queryEnrichedActivities} -/// - {@macro queryReactions} -/// - {@macro onAddActivity} -/// - {@macro deleteActivity} -/// - {@macro onAddReaction} -/// - {@macro onRemoveReaction} -/// - {@macro onAddChildReaction} -/// - {@macro onRemoveChildReaction} -/// {@endtemplate} -/// -/// {@template genericParameters} -/// The generic parameters can be of the following type: -/// - A : [actor] can be an User, or a String -/// - Ob : [object] can a String, or a CollectionEntry -/// - T : [target] can be a String or an Activity -/// - Or : [origin] can be a String or a Reaction or an User -/// -/// To avoid potential runtime errors -/// make sure they are the same across the app if -/// you go the route of using Generic* classes -/// -/// {@endtemplate} -typedef FeedBloc = GenericFeedBloc; - typedef OnRemoveUpload = void Function(AttachmentFile file); typedef OnCancelUpload = void Function(AttachmentFile file); typedef OnRetryUpload = void Function(AttachmentFile file); diff --git a/packages/stream_feed_flutter_core/lib/src/upload/states.dart b/packages/stream_feed_flutter_core/lib/src/upload/states.dart index 74b4d7f0f..d68ad67f9 100644 --- a/packages/stream_feed_flutter_core/lib/src/upload/states.dart +++ b/packages/stream_feed_flutter_core/lib/src/upload/states.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:stream_feed/stream_feed.dart'; import 'package:stream_feed_flutter_core/src/media.dart'; +/// The state of the file being uploaded class FileUploadState with EquatableMixin { const FileUploadState({required this.file, required this.state}); @@ -16,6 +17,7 @@ class FileUploadState with EquatableMixin { List get props => [file, state]; } +/// The upload state class UploadState with EquatableMixin { final MediaType mediaType; const UploadState({required this.mediaType}); @@ -23,11 +25,13 @@ class UploadState with EquatableMixin { List get props => [mediaType]; } +/// The empty upload state class UploadEmptyState extends UploadState { const UploadEmptyState({required MediaType mediaType}) : super(mediaType: mediaType); } +/// The failed upload state class UploadFailed extends UploadState { const UploadFailed(this.error, {required MediaType mediaType}) : super(mediaType: mediaType); @@ -36,6 +40,7 @@ class UploadFailed extends UploadState { List get props => [...super.props, error]; } +/// The in progress upload state class UploadProgress extends UploadState { const UploadProgress( {this.sentBytes = 0, this.totalBytes = 0, required MediaType mediaType}) @@ -48,10 +53,12 @@ class UploadProgress extends UploadState { List get props => [...super.props, sentBytes, totalBytes]; } +/// The cancelled upload state class UploadCancelled extends UploadState { UploadCancelled({required MediaType mediaType}) : super(mediaType: mediaType); } +/// The sucessful upload state class UploadSuccess extends UploadState { const UploadSuccess._({required this.mediaUri, required MediaType mediaType}) : super(mediaType: mediaType);