diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5aef10673..3f1e6d94d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -57,12 +57,16 @@ jobs: - name: Post Codecov report run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} - - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + + - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 with: path: packages/stream_feed/coverage/lcov.info - min_coverage: 82 - - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 + min_coverage: 81 + - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 with: path: packages/faye_dart/coverage/lcov.info min_coverage: 49 + - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 + with: + path: packages/stream_feed_flutter_core/coverage/lcov.info + min_coverage: 65 diff --git a/.gitignore b/.gitignore index 3129bbac3..a3139b8a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ build/ example/pubspec.lock coverage.lcov -coverage/ \ No newline at end of file +coverage/ +packages/stream_feed_flutter/example/.flutter-plugins-dependencies +packages/stream_feed_flutter/example/.flutter-plugins-dependencies diff --git a/README.md b/README.md index df3034471..4d878a099 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ const apiKey = 'my-API-key'; const secret = 'my-API-secret'; // Instantiate a new client (server side) -var client = StreamFeedClient.connect(apiKey, secret: secret); +var client = StreamFeedClient(apiKey, secret: secret); // Optionally supply the app identifier and an options object specifying the data center to use and timeout for requests (15s) -client = StreamFeedClient.connect(apiKey, +client = StreamFeedClient(apiKey, secret: secret, appId: 'yourappid', options: StreamHttpClientOptions( @@ -70,7 +70,7 @@ final userToken = client.frontendToken('the-user-id'); ```dart // Instantiate new client with a user token -var client = StreamFeedClient.connect(apiKey, token: Token('userToken')); +var client = StreamFeedClient(apiKey, token: Token('userToken')); ``` ### 🔮 Examples @@ -235,7 +235,7 @@ Stream uses [Faye](https://faye.jcoglan.com) for realtime notifications. Below i ```dart // ⚠️ userToken is generated server-side (see previous section) -final client = StreamFeedClient.connect('YOUR_API_KEY', token: userToken,appId: 'APP_ID'); +final client = StreamFeedClient('YOUR_API_KEY', token: userToken,appId: 'APP_ID'); final user1 = client.flatFeed('user', '1'); // subscribe to the changes diff --git a/analysis_options.yaml b/analysis_options.yaml index ecef0ee36..c835c25eb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -41,7 +41,6 @@ linter: - use_key_in_widget_constructors - valid_regexps - always_declare_return_types - - always_put_required_named_parameters_first - always_require_non_null_named_parameters - annotate_overrides - avoid_bool_literals_in_conditional_expressions @@ -96,7 +95,6 @@ linter: - prefer_constructors_over_static_methods - prefer_contains - prefer_equal_for_default_values - - prefer_expression_function_bodies - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals @@ -116,7 +114,7 @@ linter: - prefer_spread_collections - prefer_typing_uninitialized_variables - provide_deprecation_message - - public_member_api_docs + # - public_member_api_docs - recursive_getters - sized_box_for_whitespace - slash_for_doc_comments diff --git a/example/lib/activity_item.dart b/example/lib/activity_item.dart index c96400dbe..075a3456a 100644 --- a/example/lib/activity_item.dart +++ b/example/lib/activity_item.dart @@ -1,20 +1,26 @@ +import 'package:example/app_user.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; import 'package:timeago/timeago.dart' as timeago; -import 'app_user.dart'; - +//ignore: public_member_api_docs class ActivityCard extends StatelessWidget { - final Activity activity; + //ignore: public_member_api_docs + const ActivityCard({ + required this.activity, + Key? key, + }) : super(key: key); - const ActivityCard({Key? key, required this.activity}) : super(key: key); + //ignore: public_member_api_docs + final Activity activity; @override Widget build(BuildContext context) { final user = appUsers .firstWhere((it) => createUserReference(it.id) == activity.actor); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -23,14 +29,14 @@ class ActivityCard extends StatelessWidget { CircleAvatar( child: Text(user.name[0]), ), - SizedBox(width: 16), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( user.name, - style: TextStyle( + style: const TextStyle( fontSize: 18, ), ), @@ -39,7 +45,7 @@ class ActivityCard extends StatelessWidget { activity.time!, allowFromNow: true, )}', - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.w300, ), ), @@ -48,10 +54,10 @@ class ActivityCard extends StatelessWidget { ) ], ), - SizedBox(height: 16), + const SizedBox(height: 16), Text( - activity.extraData!['tweet'] as String, - style: TextStyle( + activity.extraData!['tweet'].toString(), + style: const TextStyle( fontSize: 24, ), ), @@ -59,4 +65,10 @@ class ActivityCard extends StatelessWidget { ), ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('activity', activity)); + } } diff --git a/example/lib/add_activity_dialog.dart b/example/lib/add_activity_dialog.dart index 37507178a..303e45ea8 100644 --- a/example/lib/add_activity_dialog.dart +++ b/example/lib/add_activity_dialog.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +//ignore: public_member_api_docs class AddActivityDialog extends StatefulWidget { + //ignore: public_member_api_docs + const AddActivityDialog({Key? key}) : super(key: key); + @override _AddActivityDialogState createState() => _AddActivityDialogState(); } @@ -9,10 +13,11 @@ class _AddActivityDialogState extends State { final _activityController = TextEditingController(); @override + //ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Dialog( child: Padding( - padding: const EdgeInsets.all(24.0), + padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -20,17 +25,19 @@ class _AddActivityDialogState extends State { controller: _activityController, decoration: const InputDecoration( hintText: "What's happening?", - border: const OutlineInputBorder(), + border: OutlineInputBorder(), ), ), - SizedBox(height: 12), - RaisedButton( + const SizedBox(height: 12), + ElevatedButton( onPressed: () { final message = _activityController.text; Navigator.pop(context, message); }, - color: Colors.blue, - child: Text( + style: ElevatedButton.styleFrom( + primary: Colors.blue, + ), + child: const Text( 'POST ACTIVITY', style: TextStyle( color: Colors.white, diff --git a/example/lib/app_user.dart b/example/lib/app_user.dart index 835de2211..a75b8a262 100644 --- a/example/lib/app_user.dart +++ b/example/lib/app_user.dart @@ -1,26 +1,43 @@ +//ignore: public_member_api_docs class AppUser { - final String id; - final String name; - final String token; - + //ignore: public_member_api_docs const AppUser.sahil() : id = 'sahil-kumar', name = 'Sahil Kumar', token = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwta3VtYXIifQ.d6RW5eZedEl949w-IeZ40Ukji3yXfFnMw3baLsow028'; + '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwta3VtYXIifQ.d6RW5eZedEl949w-IeZ40Ukji3yXfFnMw3baLsow028'''; + //ignore: public_member_api_docs const AppUser.sacha() : id = 'sacha-arbonel', name = 'Sacha Arbonel', token = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FjaGEtYXJib25lbCJ9.fzDKEyiQ40J4YYgtZxpeQhn6ajX-GEnKZOOmcb-xa7M'; + '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FjaGEtYXJib25lbCJ9.fzDKEyiQ40J4YYgtZxpeQhn6ajX-GEnKZOOmcb-xa7M'''; + //ignore: public_member_api_docs const AppUser.nash() : id = 'neevash-ramdial', name = 'Neevash Ramdial', token = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaC1yYW1kaWFsIn0.yKqSehu_O5WJGh3-aa5qipnBRs7Qtue-1T9TZhT2ejw'; + '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaC1yYW1kaWFsIn0.yKqSehu_O5WJGh3-aa5qipnBRs7Qtue-1T9TZhT2ejw'''; //ignore: public_member_api_docs + + //ignore: public_member_api_docs + const AppUser.reuben() + : id = 'groovin', + name = 'Reuben Turner', + token = + '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Jvb3ZpbiJ9.6uMlHDLHpiHubWbcTztZO7NbFFZozuhwgNdrGgObgTE'''; + + //ignore: public_member_api_docs + final String id; + + //ignore: public_member_api_docs + final String name; + + //ignore: public_member_api_docs + final String token; + //ignore: public_member_api_docs Map get data { final parts = name.split(' '); return { @@ -31,8 +48,10 @@ class AppUser { } } +//ignore: public_member_api_docs const appUsers = [ AppUser.sahil(), AppUser.sacha(), AppUser.nash(), + AppUser.reuben(), ]; diff --git a/example/lib/extension.dart b/example/lib/extension.dart index e8e4daa39..966b76d4c 100644 --- a/example/lib/extension.dart +++ b/example/lib/extension.dart @@ -1,13 +1,16 @@ +import 'package:example/client_provider.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'client_provider.dart'; - +//ignore: public_member_api_docs extension ProviderX on BuildContext { + //ignore: public_member_api_docs StreamFeedClient get client => ClientProvider.of(this).client; } +//ignore: public_member_api_docs extension Snackbar on BuildContext { + //ignore: public_member_api_docs void showSnackBar(final String message) { ScaffoldMessenger.of(this).showSnackBar(SnackBar(content: Text(message))); } diff --git a/example/lib/home.dart b/example/lib/home.dart index 26b3e408b..5c47b4474 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -1,27 +1,41 @@ import 'package:example/people_screen.dart'; import 'package:example/profile_screen.dart'; import 'package:example/timeline_screen.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; +//ignore: public_member_api_docs class HomeScreen extends StatefulWidget { - final StreamUser currentUser; + //ignore: public_member_api_docs + const HomeScreen({ + required this.currentUser, + Key? key, + }) : super(key: key); - const HomeScreen({Key? key, required this.currentUser}) : super(key: key); + //ignore: public_member_api_docs + final StreamUser currentUser; @override _HomeScreenState createState() => _HomeScreenState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('currentUser', currentUser)); + } } class _HomeScreenState extends State { int _currentIndex = 0; @override + //ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( - children: [ + children: const [ Icon(Icons.bike_scooter_rounded), SizedBox(width: 16), Text('Tweet It!'), @@ -38,14 +52,12 @@ class _HomeScreenState extends State { ), bottomNavigationBar: BottomNavigationBar( selectedItemColor: Colors.blue, - elevation: 16.0, + elevation: 16, type: BottomNavigationBarType.fixed, iconSize: 22, - selectedFontSize: 14, - unselectedFontSize: 12, currentIndex: _currentIndex, onTap: (index) => setState(() => _currentIndex = index), - items: [ + items: const [ BottomNavigationBarItem( backgroundColor: Colors.black, icon: Icon(Icons.timeline), diff --git a/example/lib/main.dart b/example/lib/main.dart index bbba5f529..5c8598249 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,18 +1,22 @@ import 'package:example/app_user.dart'; import 'package:example/client_provider.dart'; import 'package:example/extension.dart'; +import 'package:example/home.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'client_provider.dart'; -import 'home.dart'; +//ignore_for_file: public_member_api_docs void main() { WidgetsFlutterBinding.ensureInitialized(); - final _key = String.fromEnvironment('key'); - final _user_token = String.fromEnvironment('user_token'); + const _key = String.fromEnvironment('key'); + const _userToken = String.fromEnvironment('user_token'); + + final client = StreamFeedClient( + _key, + token: const Token(_userToken), + ); - final client = StreamFeedClient.connect(_key, token: Token(_user_token)); runApp( MyApp( client: client, @@ -21,21 +25,32 @@ void main() { } class MyApp extends StatelessWidget { - const MyApp({Key? key, required this.client}) : super(key: key); + const MyApp({ + required this.client, + Key? key, + }) : super(key: key); + + // ignore: diagnostic_describe_all_properties final StreamFeedClient client; @override Widget build(BuildContext context) { return MaterialApp( title: 'Stream Feed Demo', - home: LoginScreen(), - builder: (context, child) => - ClientProvider(client: client, child: child!), + home: const LoginScreen(), + builder: (context, child) => ClientProvider( + client: client, + child: child!, + ), ); } } class LoginScreen extends StatefulWidget { + const LoginScreen({ + Key? key, + }) : super(key: key); + @override _LoginScreenState createState() => _LoginScreenState(); } @@ -46,30 +61,29 @@ class _LoginScreenState extends State { final size = MediaQuery.of(context).size; final _client = context.client; return Scaffold( - // backgroundColor: Colors.grey.shade100, body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SizedBox( width: size.width, height: size.height, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( + const Text( 'Login with a User', style: TextStyle( fontSize: 42, fontWeight: FontWeight.w500, ), ), - SizedBox(height: 42), + const SizedBox(height: 42), for (final user in appUsers) Padding( - padding: const EdgeInsets.all(8.0), - child: RaisedButton( + padding: const EdgeInsets.all(8), + child: ElevatedButton( onPressed: () async { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text('Loading User'), ), ); @@ -78,7 +92,7 @@ class _LoginScreenState extends State { getOrCreate: true, ); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text('User Loaded'), ), ); @@ -90,30 +104,35 @@ class _LoginScreenState extends State { ), ); }, - padding: const EdgeInsets.symmetric(horizontal: 4.0), - color: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24.0), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + primary: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), child: Padding( padding: const EdgeInsets.symmetric( - vertical: 36.0, horizontal: 24.0), + vertical: 36, horizontal: 24), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( user.name, - style: TextStyle( + style: const TextStyle( fontSize: 24, color: Colors.blue, ), ), - Icon(Icons.arrow_forward_ios) + Icon( + Icons.arrow_forward_ios, + color: Theme.of(context).colorScheme.onSurface, + ) ], ), ), ), - ) + ), ], ), ), diff --git a/example/lib/people_screen.dart b/example/lib/people_screen.dart index d40f1a7d1..d140b6e39 100644 --- a/example/lib/people_screen.dart +++ b/example/lib/people_screen.dart @@ -1,80 +1,90 @@ +import 'package:example/app_user.dart'; +import 'package:example/extension.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'app_user.dart'; -import 'extension.dart'; + +// ignore_for_file: public_member_api_docs class PeopleScreen extends StatefulWidget { - final StreamUser currentUser; + const PeopleScreen({ + required this.currentUser, + Key? key, + }) : super(key: key); - const PeopleScreen({Key? key, required this.currentUser}) : super(key: key); + final StreamUser currentUser; @override _PeopleScreenState createState() => _PeopleScreenState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('currentUser', currentUser)); + } } class _PeopleScreenState extends State { @override Widget build(BuildContext context) { - final users = List.from(appUsers) + final users = List.from(appUsers) ..removeWhere((it) => it.id == widget.currentUser.id); final _client = context.client; final followDialog = CupertinoAlertDialog( - title: Text('Follow User?'), + title: const Text('Follow User?'), actions: [ CupertinoDialogAction( onPressed: () => Navigator.pop(context, true), - child: Text("Yes"), + child: const Text('Yes'), ), CupertinoDialogAction( isDefaultAction: true, onPressed: Navigator.of(context).pop, - child: Text("No"), + child: const Text('No'), ), ], ); - return Container( - child: Center( - child: ListView.separated( - itemCount: users.length, - padding: const EdgeInsets.symmetric(vertical: 8), - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (_, index) { - final user = users[index]; - return InkWell( - onTap: () async { - final result = await showDialog( - context: context, - builder: (_) => followDialog, - ); - if (result != null) { - context.showSnackBar('Following User...'); + return Center( + child: ListView.separated( + itemCount: users.length, + padding: const EdgeInsets.symmetric(vertical: 8), + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (_, index) { + final user = users[index]; + return InkWell( + onTap: () async { + final result = await showDialog( + context: context, + builder: (_) => followDialog, + ); + if (result != null) { + context.showSnackBar('Following User...'); - final currentUserFeed = - _client.flatFeed('timeline', widget.currentUser.id); - final selectedUserFeed = _client.flatFeed('user', user.id); - await currentUserFeed.follow(selectedUserFeed); + final currentUserFeed = + _client.flatFeed('timeline', widget.currentUser.id); + final selectedUserFeed = _client.flatFeed('user', user.id); + await currentUserFeed.follow(selectedUserFeed); - context.showSnackBar('Followed User'); - } - }, - child: ListTile( - leading: CircleAvatar( - child: Text(user.name[0]), - ), - title: Text( - user.name, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w300, - ), + context.showSnackBar('Followed User'); + } + }, + child: ListTile( + leading: CircleAvatar( + child: Text(user.name[0]), + ), + title: Text( + user.name, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w300, ), ), - ); - }, - ), + ), + ); + }, ), ); } diff --git a/example/lib/profile_screen.dart b/example/lib/profile_screen.dart index cd8c3b2db..47bb1bc0e 100644 --- a/example/lib/profile_screen.dart +++ b/example/lib/profile_screen.dart @@ -1,20 +1,29 @@ -import 'extension.dart'; +import 'package:example/activity_item.dart'; +import 'package:example/add_activity_dialog.dart'; +import 'package:example/extension.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'activity_item.dart'; -import 'add_activity_dialog.dart'; - +//ignore: public_member_api_docs class ProfileScreen extends StatefulWidget { + //ignore: public_member_api_docs const ProfileScreen({ - Key? key, required this.currentUser, + Key? key, }) : super(key: key); + //ignore: public_member_api_docs final StreamUser currentUser; @override _ProfileScreenState createState() => _ProfileScreenState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('currentUser', currentUser)); + } } class _ProfileScreenState extends State { @@ -47,7 +56,7 @@ class _ProfileScreenState extends State { onPressed: () async { final message = await showDialog( context: context, - builder: (_) => AddActivityDialog(), + builder: (_) => const AddActivityDialog(), ); if (message != null) { context.showSnackBar('Posting Activity...'); @@ -66,15 +75,15 @@ class _ProfileScreenState extends State { _loadActivities(); } }, - child: Icon(Icons.add), + child: const Icon(Icons.add), ), body: Center( child: RefreshIndicator( onRefresh: () => _loadActivities(pullToRefresh: true), child: _isLoading - ? CircularProgressIndicator() + ? const CircularProgressIndicator() : activities.isEmpty - ? Text('No activities yet!') + ? const Text('No activities yet!') : ListView.separated( itemCount: activities.length, padding: const EdgeInsets.symmetric(vertical: 16), @@ -88,5 +97,11 @@ class _ProfileScreenState extends State { ), ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('activities', activities)); + } } //Shared an update Just now diff --git a/example/lib/timeline_screen.dart b/example/lib/timeline_screen.dart index 4ee27989b..e533a75e9 100644 --- a/example/lib/timeline_screen.dart +++ b/example/lib/timeline_screen.dart @@ -1,16 +1,27 @@ -import 'extension.dart'; +import 'package:example/activity_item.dart'; +import 'package:example/extension.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_feed/stream_feed.dart'; -import 'activity_item.dart'; +// ignore_for_file: public_member_api_docs class TimelineScreen extends StatefulWidget { - final StreamUser currentUser; + const TimelineScreen({ + required this.currentUser, + Key? key, + }) : super(key: key); - const TimelineScreen({Key? key, required this.currentUser}) : super(key: key); + final StreamUser currentUser; @override _TimelineScreenState createState() => _TimelineScreenState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('currentUser', currentUser)); + } } class _TimelineScreenState extends State { @@ -23,9 +34,7 @@ class _TimelineScreenState extends State { Future _listenToFeed() async { _feedSubscription = await _client .flatFeed('timeline', widget.currentUser.id) - .subscribe((message) { - print(message); - }); + .subscribe(print); } Future _loadActivities({bool pullToRefresh = false}) async { @@ -57,14 +66,14 @@ class _TimelineScreenState extends State { body: RefreshIndicator( onRefresh: () => _loadActivities(pullToRefresh: true), child: _isLoading - ? Center(child: CircularProgressIndicator()) + ? const Center(child: CircularProgressIndicator()) : activities.isEmpty ? Column( children: [ - Text('No activities yet!'), - RaisedButton( + const Text('No activities yet!'), + ElevatedButton( onPressed: _loadActivities, - child: Text('Reload'), + child: const Text('Reload'), ) ], ) @@ -80,4 +89,10 @@ class _TimelineScreenState extends State { ), ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IterableProperty('activities', activities)); + } } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 75f4bca92..4d7c002cc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -8,9 +8,9 @@ environment: sdk: '>=2.12.0 <3.0.0' dependencies: + cupertino_icons: ^1.0.2 flutter: sdk: flutter - cupertino_icons: ^1.0.2 timeago: ^3.0.2 stream_feed: path: ../packages/stream_feed diff --git a/packages/faye_dart/lib/faye_dart.dart b/packages/faye_dart/lib/faye_dart.dart index c9f5fbf62..0a8021362 100644 --- a/packages/faye_dart/lib/faye_dart.dart +++ b/packages/faye_dart/lib/faye_dart.dart @@ -1,6 +1,6 @@ library faye_dart; export 'src/client.dart' show FayeClient, FayeClientState; -export 'src/subscription.dart'; -export 'src/extensible.dart' show MessageHandler; export 'src/error.dart'; +export 'src/extensible.dart' show MessageHandler; +export 'src/subscription.dart'; diff --git a/packages/faye_dart/lib/src/channel.dart b/packages/faye_dart/lib/src/channel.dart index 956c17f02..541ad772c 100644 --- a/packages/faye_dart/lib/src/channel.dart +++ b/packages/faye_dart/lib/src/channel.dart @@ -1,17 +1,16 @@ import 'package:equatable/equatable.dart'; -import 'subscription.dart'; import 'package:faye_dart/src/event_emitter.dart'; - -import 'message.dart'; -import 'grammar.dart' as grammar; +import 'package:faye_dart/src/grammar.dart' as grammar; +import 'package:faye_dart/src/message.dart'; +import 'package:faye_dart/src/subscription.dart'; const event_message = 'message'; class Channel with EquatableMixin, EventEmitter { - final String name; - Channel(this.name); + final String name; + static const String handshake = '/meta/handshake'; static const String connect = '/meta/connect'; static const String disconnect = '/meta/disconnect'; diff --git a/packages/faye_dart/lib/src/client.dart b/packages/faye_dart/lib/src/client.dart index 1f383bcd8..84a204780 100644 --- a/packages/faye_dart/lib/src/client.dart +++ b/packages/faye_dart/lib/src/client.dart @@ -1,19 +1,18 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:faye_dart/faye_dart.dart'; import 'package:faye_dart/src/channel.dart'; import 'package:faye_dart/src/message.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:web_socket_channel/status.dart' as status; -import 'package:logging/logging.dart'; -import 'extensible.dart'; -import 'dart:math' as math; - import 'package:faye_dart/src/subscription.dart'; - -import 'timeout_helper.dart'; +import 'package:faye_dart/src/timeout_helper.dart'; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:web_socket_channel/status.dart' as status; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'package:faye_dart/src/extensible.dart'; enum FayeClientState { unconnected, diff --git a/packages/faye_dart/pubspec.yaml b/packages/faye_dart/pubspec.yaml index f956038e5..61d5fb10a 100644 --- a/packages/faye_dart/pubspec.yaml +++ b/packages/faye_dart/pubspec.yaml @@ -9,15 +9,15 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: - web_socket_channel: ^2.1.0 equatable: ^2.0.0 logging: ^1.0.1 - uuid: ^3.0.4 meta: ^1.3.0 + uuid: ^3.0.4 + web_socket_channel: ^2.1.0 dev_dependencies: - test: ^1.17.3 mocktail: ^0.1.2 + test: ^1.17.3 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec \ No newline at end of file diff --git a/packages/faye_dart/test/channel_test.dart b/packages/faye_dart/test/channel_test.dart index 1e49c2ebb..ba24aba4e 100644 --- a/packages/faye_dart/test/channel_test.dart +++ b/packages/faye_dart/test/channel_test.dart @@ -5,7 +5,7 @@ import 'package:test/test.dart'; import 'mock.dart'; -main() { +void main() { group('channel', () { test('channels should be equal if name is same', () { final channelOne = Channel('id'); @@ -15,31 +15,31 @@ main() { test('expand returns all patterns that match a channel",', () { expect( - ["/**", "/foo", "/*"], - Channel.expand("/foo"), + ['/**', '/foo', '/*'], + Channel.expand('/foo'), ); expect( - ["/**", "/foo/bar", "/foo/*", "/foo/**"], - Channel.expand("/foo/bar"), + ['/**', '/foo/bar', '/foo/*', '/foo/**'], + Channel.expand('/foo/bar'), ); expect( - ["/**", "/foo/bar/qux", "/foo/bar/*", "/foo/**", "/foo/bar/**"], - Channel.expand("/foo/bar/qux"), + ['/**', '/foo/bar/qux', '/foo/bar/*', '/foo/**', '/foo/bar/**'], + Channel.expand('/foo/bar/qux'), ); }); test('channel should be subscribable', () { - const channelName = "/fo_o/\$@()bar"; + const channelName = '/fo_o/\$@()bar'; expect(Channel.isSubscribable(channelName), isTrue); }); test('meta channels should not be subscribable', () { - const channelName = "/meta/fo_o/\$@()bar"; + const channelName = '/meta/fo_o/\$@()bar'; expect(Channel.isSubscribable(channelName), isFalse); }); test('service channels should not be subscribable', () { - const channelName = "/service/fo_o/\$@()bar"; + const channelName = '/service/fo_o/\$@()bar'; expect(Channel.isSubscribable(channelName), isFalse); }); }); @@ -48,7 +48,7 @@ main() { test('subscribes and un-subscribes', () { final client = MockClient(); final channels = {}; - final channel = "/foo/**"; + const channel = '/foo/**'; final subscription = Subscription(client, channel); channels.subscribe(channel, subscription); @@ -61,7 +61,7 @@ main() { () { final client = MockClient(); final channels = {}; - final channel = "/foo/**"; + const channel = '/foo/**'; var callbackInvoked = false; final subscription = Subscription(client, channel, callback: (_) { diff --git a/packages/faye_dart/test/error_test.dart b/packages/faye_dart/test/error_test.dart index 5585f80be..b0e6c67d9 100644 --- a/packages/faye_dart/test/error_test.dart +++ b/packages/faye_dart/test/error_test.dart @@ -3,7 +3,7 @@ import 'package:test/test.dart'; void main() { test('should successfully parse error if it matches error grammar', () { - final errorMessage = '405:bayeuxChannel:Invalid channel'; + const errorMessage = '405:bayeuxChannel:Invalid channel'; final error = FayeClientError.parse(errorMessage); expect(error.code, 405); expect(error.params, ['bayeuxChannel']); @@ -13,7 +13,7 @@ void main() { test( 'should return same errorMessage if it does not matches error grammar', () { - final errorMessage = 'dummy error message'; + const errorMessage = 'dummy error message'; final error = FayeClientError.parse(errorMessage); expect(error.code, isNull); expect(error.params, isEmpty); @@ -22,7 +22,7 @@ void main() { ); test('should print error as a String', () { - final errorMessage = '405:bayeuxChannel:Invalid channel'; + const errorMessage = '405:bayeuxChannel:Invalid channel'; final error = FayeClientError.parse(errorMessage); expect( error.toString(), diff --git a/packages/faye_dart/test/event_emitter_test.dart b/packages/faye_dart/test/event_emitter_test.dart index 9c5ecb1ee..1de04fd98 100644 --- a/packages/faye_dart/test/event_emitter_test.dart +++ b/packages/faye_dart/test/event_emitter_test.dart @@ -1,10 +1,12 @@ +// ignore_for_file: cascade_invocations + import 'dart:async'; import 'package:faye_dart/src/event_emitter.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; -main() { +void main() { test('`on` should emit until removed', () { final eventEmitter = EventEmitter(); const event = 'click'; diff --git a/packages/faye_dart/test/extensible_test.dart b/packages/faye_dart/test/extensible_test.dart index 1db0b7a30..2a8f8bd4c 100644 --- a/packages/faye_dart/test/extensible_test.dart +++ b/packages/faye_dart/test/extensible_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: cascade_invocations + import 'package:faye_dart/src/extensible.dart'; import 'package:faye_dart/src/message.dart'; import 'package:test/test.dart'; diff --git a/packages/faye_dart/test/grammar_test.dart b/packages/faye_dart/test/grammar_test.dart index 732f67454..1f0bbda57 100644 --- a/packages/faye_dart/test/grammar_test.dart +++ b/packages/faye_dart/test/grammar_test.dart @@ -1,77 +1,77 @@ import 'package:faye_dart/src/grammar.dart' as grammar; import 'package:test/test.dart'; -main() { +void main() { group('grammar', () { group('channelName', () { final channelName = RegExp(grammar.channelName); test('matches valid channel names', () { - expect(channelName.hasMatch("/fo_o/\$@()bar"), isTrue); + expect(channelName.hasMatch('/fo_o/\$@()bar'), isTrue); }); test('does not match channel patterns', () { - expect(channelName.hasMatch("/foo/**"), isFalse); + expect(channelName.hasMatch('/foo/**'), isFalse); }); test('does not match invalid channel names', () { - expect(channelName.hasMatch("foo/\$@()bar"), isFalse); - expect(channelName.hasMatch("/foo/\$@()bar/"), isFalse); - expect(channelName.hasMatch("/fo o/\$@()bar"), isFalse); + expect(channelName.hasMatch('foo/\$@()bar'), isFalse); + expect(channelName.hasMatch('/foo/\$@()bar/'), isFalse); + expect(channelName.hasMatch('/fo o/\$@()bar'), isFalse); }); }); group('channelPattern', () { final channelPattern = RegExp(grammar.channelPattern); test('does not match channel names', () { - expect(channelPattern.hasMatch("/fo_o/\$@()bar"), isFalse); + expect(channelPattern.hasMatch('/fo_o/\$@()bar'), isFalse); }); test('matches valid channel patterns', () { - expect(channelPattern.hasMatch("/foo/**"), isTrue); - expect(channelPattern.hasMatch("/foo/*"), isTrue); + expect(channelPattern.hasMatch('/foo/**'), isTrue); + expect(channelPattern.hasMatch('/foo/*'), isTrue); }); test('does not match invalid channel patterns', () { - expect(channelPattern.hasMatch("/foo/**/*"), isFalse); + expect(channelPattern.hasMatch('/foo/**/*'), isFalse); }); }); group('error', () { final error = RegExp(grammar.error); - test("matches an error with an argument", () { - expect(error.hasMatch("402:bayeuxChannel:Unknown Client ID"), isTrue); + test('matches an error with an argument', () { + expect(error.hasMatch('402:bayeuxChannel:Unknown Client ID'), isTrue); }); test('matches an error with many arguments', () { expect( - error.hasMatch("403:bayeuxChannel,/foo/bar:Subscription denied"), + error.hasMatch('403:bayeuxChannel,/foo/bar:Subscription denied'), isTrue, ); }); test('matches an error with no arguments', () { - expect(error.hasMatch("402::Unknown Client ID"), isTrue); + expect(error.hasMatch('402::Unknown Client ID'), isTrue); }); test('does not match an error with no code', () { - expect(error.hasMatch(":bayeuxChannel:Unknown Client ID"), isFalse); + expect(error.hasMatch(':bayeuxChannel:Unknown Client ID'), isFalse); }); test('does not match an error with an invalid code', () { - expect(error.hasMatch("40:bayeuxChannel:Unknown Client ID"), isFalse); + expect(error.hasMatch('40:bayeuxChannel:Unknown Client ID'), isFalse); }); }); group('version', () { final version = RegExp(grammar.version); test('matches a version number', () { - expect(version.hasMatch("9"), isTrue); - expect(version.hasMatch("9.0.a-delta1"), isTrue); + expect(version.hasMatch('9'), isTrue); + expect(version.hasMatch('9.0.a-delta1'), isTrue); }); test('does not match invalid version numbers', () { - expect(version.hasMatch("9.0.a-delta1."), isFalse); - expect(version.hasMatch(""), isFalse); + expect(version.hasMatch('9.0.a-delta1.'), isFalse); + expect(version.hasMatch(''), isFalse); }); }); }); diff --git a/packages/faye_dart/test/models_test.dart b/packages/faye_dart/test/models_test.dart index 51efdedc7..24fb5c2fb 100644 --- a/packages/faye_dart/test/models_test.dart +++ b/packages/faye_dart/test/models_test.dart @@ -1,10 +1,10 @@ import 'package:faye_dart/src/message.dart'; import 'package:test/test.dart'; -main() { +void main() { group('Advice', () { final json = {'interval': 2, 'reconnect': '', 'timeout': 2}; - final advice = Advice(interval: 2, reconnect: '', timeout: 2); + const advice = Advice(interval: 2, reconnect: '', timeout: 2); test('fromJson', () { final adviceFromJson = Advice.fromJson(json); expect(adviceFromJson, advice); @@ -17,28 +17,28 @@ main() { group('Message', () { final data = { - "id": "test", - "group": "test", - "activities": [ + 'id': 'test', + 'group': 'test', + 'activities': [ { - "id": "test", - "actor": "test", - "verb": "test", - "object": "test", - "foreign_id": "test", - "target": "test", - "time": "2001-09-11T00:01:02.000", - "origin": "test", - "to": ["slug:id"], - "score": 1.0, - "analytics": {"test": "test"}, - "extra_context": {"test": "test"}, - "test": "test" + 'id': 'test', + 'actor': 'test', + 'verb': 'test', + 'object': 'test', + 'foreign_id': 'test', + 'target': 'test', + 'time': '2001-09-11T00:01:02.000', + 'origin': 'test', + 'to': ['slug:id'], + 'score': 1.0, + 'analytics': {'test': 'test'}, + 'extra_context': {'test': 'test'}, + 'test': 'test' } ], - "actor_count": 1, - "created_at": "2001-09-11T00:01:02.000", - "updated_at": "2001-09-11T00:01:02.000" + 'actor_count': 1, + 'created_at': '2001-09-11T00:01:02.000', + 'updated_at': '2001-09-11T00:01:02.000' }; final json = { 'channel': 'bayeuxChannel', @@ -47,7 +47,7 @@ main() { }; final message = Message( - "bayeuxChannel", + 'bayeuxChannel', data: data, supportedConnectionTypes: ['websocket'], ); diff --git a/packages/faye_dart/test/subscription_test.dart b/packages/faye_dart/test/subscription_test.dart index 482f13a0f..513f059e4 100644 --- a/packages/faye_dart/test/subscription_test.dart +++ b/packages/faye_dart/test/subscription_test.dart @@ -9,8 +9,8 @@ void main() { group('subscription', () { test('successfully invokes the callback', () { final client = MockClient(); - const channel = "/foo/**"; - bool callbackInvoked = false; + const channel = '/foo/**'; + var callbackInvoked = false; final subscription = Subscription( client, channel, @@ -25,15 +25,16 @@ void main() { test('successfully invokes withChannel callback if provided', () { final client = MockClient(); - const channel = "/foo/**"; - bool callbackInvoked = false; - bool withChannelCallbackInvoked = false; + const channel = '/foo/**'; + var callbackInvoked = false; + var withChannelCallbackInvoked = false; final subscription = Subscription( client, channel, callback: (data) => callbackInvoked = true, ); + // ignore: cascade_invocations subscription.withChannel( (channel, data) => withChannelCallbackInvoked = true, ); @@ -47,7 +48,7 @@ void main() { test('successfully cancels the subscription', () { final client = MockClient(); - const channel = "/foo/**"; + const channel = '/foo/**'; final subscription = Subscription(client, channel, callback: (data) {}); diff --git a/packages/faye_dart/test/timeout_helper_test.dart b/packages/faye_dart/test/timeout_helper_test.dart index 8d39d9178..565b294b8 100644 --- a/packages/faye_dart/test/timeout_helper_test.dart +++ b/packages/faye_dart/test/timeout_helper_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: cascade_invocations + import 'package:faye_dart/src/timeout_helper.dart'; import 'package:test/test.dart'; @@ -8,7 +10,7 @@ void main() { timeoutHelper.setTimeout(const Duration(seconds: 3), () {}); expect(timeoutHelper.hasTimeouts, isTrue); - addTearDown(() => timeoutHelper.cancelAllTimeout()); + addTearDown(timeoutHelper.cancelAllTimeout); }); test('cancelTimeout', () { diff --git a/packages/stream_feed/CHANGELOG.md b/packages/stream_feed/CHANGELOG.md index 7b5551a17..3fbc57456 100644 --- a/packages/stream_feed/CHANGELOG.md +++ b/packages/stream_feed/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.4.0: 29/10/2021 + +- breaking: `StreamFeedClient.connect` is now `StreamFeedClient` for better user session handling. +The connect verb was confusing, and made you think that it will perform the connection immediately. Also it doesn't infer the id anymore from the token anymore. You can now have to call `setUser` down the tree or before `runApp` +- breaking: `setUser` now takes a `User` (must contain id) and a token. Passing the userToken in client constructor was making the whole instance depend on a single user. +- new: we support generics +`EnrichedActivity` is now `GenericEnrichedActivity` in order to have a more flexible API surface. Those generic parameters can be as follows: +A = [actor]: can be an User, 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 +- breaking: along with these changes we removed `EnrichableField` field from `EnrichedActivity` +- new: there is a type definition `EnrichedActivity` to handle most use cases of `GenericEnrichedActivity` (User,String,String,String) +- fix: a time drift issue in token generation when using the low level client sever-side +- bump: dart sdk package constraints to 2.14 to make use of typedefs for non function types + + ## 0.3.0: 06/09/2021 - improvements: diff --git a/packages/stream_feed/README.md b/packages/stream_feed/README.md index a953cd4a2..577a612d4 100644 --- a/packages/stream_feed/README.md +++ b/packages/stream_feed/README.md @@ -42,10 +42,10 @@ const apiKey = 'my-API-key'; const secret = 'my-API-secret'; // Instantiate a new client (server side) -var client = StreamFeedClient.connect(apiKey, secret: secret, runner: Runner.server); +var client = StreamFeedClient(apiKey, secret: secret, runner: Runner.server); // Optionally supply the app identifier and an options object specifying the data center to use and timeout for requests (15s) -client = StreamFeedClient.connect(apiKey, +client = StreamFeedClient(apiKey, secret: secret, runner: Runner.server, appId: 'yourappid', @@ -67,7 +67,7 @@ final userToken = client.frontendToken('the-user-id'); ```dart // Instantiate new client with a user token -var client = StreamFeedClient.connect(apiKey, token: Token('userToken')); +var client = StreamFeedClient(apiKey, token: Token('userToken')); ``` ### 🔮 Examples @@ -232,7 +232,7 @@ Stream uses [Faye](http://faye.jcoglan.com) for realtime notifications. Below is ```dart // ⚠️ userToken is generated server-side (see previous section) -final client = StreamFeedClient.connect('YOUR_API_KEY', token: userToken,appId: 'APP_ID'); +final client = StreamFeedClient('YOUR_API_KEY', token: userToken,appId: 'APP_ID'); final user1 = client.flatFeed('user', '1'); // subscribe to the changes diff --git a/packages/stream_feed/example/main.dart b/packages/stream_feed/example/main.dart index 17742e878..f156c2c8d 100644 --- a/packages/stream_feed/example/main.dart +++ b/packages/stream_feed/example/main.dart @@ -7,7 +7,7 @@ Future main() async { final secret = env['secret']; final apiKey = env['apiKey']; final appId = env['appId']; - final clientWithSecret = StreamFeedClient.connect( + final clientWithSecret = StreamFeedClient( apiKey!, secret: secret, runner: Runner.server, @@ -72,7 +72,7 @@ Future main() async { // response = await userFeed.getActivities(limit: 5, ranking: "popularity");//must be enabled // Server-side - var client = StreamFeedClient.connect( + var client = StreamFeedClient( apiKey, secret: secret, appId: appId, @@ -83,7 +83,7 @@ Future main() async { final userToken = client.frontendToken('user.id'); // Client-side - client = StreamFeedClient.connect( + client = StreamFeedClient( apiKey, token: userToken, appId: appId, @@ -338,13 +338,18 @@ Future main() async { object: cheeseBurgerRef, )); - client = StreamFeedClient.connect(apiKey, token: frontendToken); + client = StreamFeedClient(apiKey, token: frontendToken); // ensure the user data is stored on Stream - await client.setUser({ - 'name': 'John Doe', - 'occupation': 'Software Engineer', - 'gender': 'male' - }); + await client.setUser( + const User( + data: { + 'name': 'John Doe', + 'occupation': 'Software Engineer', + 'gender': 'male' + }, + ), + frontendToken, + ); // create a new user, if the user already exist an error is returned // await client.user('john-doe').create({ diff --git a/packages/stream_feed/lib/src/client/aggregated_feed.dart b/packages/stream_feed/lib/src/client/aggregated_feed.dart index 963109aad..eb6625c41 100644 --- a/packages/stream_feed/lib/src/client/aggregated_feed.dart +++ b/packages/stream_feed/lib/src/client/aggregated_feed.dart @@ -1,10 +1,9 @@ import 'package:stream_feed/src/client/feed.dart'; import 'package:stream_feed/src/core/api/feed_api.dart'; import 'package:stream_feed/src/core/http/token.dart'; +import 'package:stream_feed/src/core/index.dart'; import 'package:stream_feed/src/core/models/activity.dart'; import 'package:stream_feed/src/core/models/activity_marker.dart'; -import 'package:stream_feed/src/core/models/enriched_activity.dart'; -import 'package:stream_feed/src/core/models/enrichment_flags.dart'; import 'package:stream_feed/src/core/models/feed_id.dart'; import 'package:stream_feed/src/core/models/filter.dart'; import 'package:stream_feed/src/core/models/group.dart'; @@ -74,7 +73,8 @@ class AggregatedFeed extends Feed { /// Retrieve activities with reaction enrichment /// /// {@macro filter} - Future>> getEnrichedActivities({ + Future>>> + getEnrichedActivities({ int? limit, int? offset, String? session, @@ -93,8 +93,12 @@ class AggregatedFeed extends Feed { TokenHelper.buildFeedToken(secret!, TokenAction.read, feedId); final result = await feed.getEnrichedActivities(token, feedId, options); final data = (result.data['results'] as List) - .map((e) => Group.fromJson(e, - (json) => EnrichedActivity.fromJson(json as Map?))) + .map((e) => Group.fromJson( + e, + (json) => GenericEnrichedActivity.fromJson( + json! as Map?, + ), + )) .toList(growable: false); return data; } diff --git a/packages/stream_feed/lib/src/client/batch_operations_client.dart b/packages/stream_feed/lib/src/client/batch_operations_client.dart index f54c599e6..db37abd2b 100644 --- a/packages/stream_feed/lib/src/client/batch_operations_client.dart +++ b/packages/stream_feed/lib/src/client/batch_operations_client.dart @@ -73,10 +73,10 @@ class BatchOperationsClient { return _batch.getActivitiesById(token, ids); } - Future> getEnrichedActivitiesById( - Iterable ids) { + Future>> + getEnrichedActivitiesById(Iterable ids) { final token = TokenHelper.buildActivityToken(secret, TokenAction.read); - return _batch.getEnrichedActivitiesById(token, ids); + return _batch.getEnrichedActivitiesById(token, ids); } /// Retrieve a batch of activities by a list of foreign ids. @@ -86,10 +86,11 @@ class BatchOperationsClient { return _batch.getActivitiesByForeignId(token, pairs); } - Future> getEnrichedActivitiesByForeignId( - Iterable pairs) { + Future>> + getEnrichedActivitiesByForeignId( + Iterable pairs) { final token = TokenHelper.buildActivityToken(secret, TokenAction.read); - return _batch.getEnrichedActivitiesByForeignId(token, pairs); + return _batch.getEnrichedActivitiesByForeignId(token, pairs); } /// Update a single activity diff --git a/packages/stream_feed/lib/src/client/feed.dart b/packages/stream_feed/lib/src/client/feed.dart index 2ce877c95..5bcc6e570 100644 --- a/packages/stream_feed/lib/src/client/feed.dart +++ b/packages/stream_feed/lib/src/client/feed.dart @@ -42,6 +42,7 @@ class Feed { 'At least a secret or userToken must be provided', ); + /// TODO: document me final FeedSubscriber? subscriber; /// Your API secret @@ -63,8 +64,8 @@ class Feed { /// Subscribes to any changes in the feed, return a [Subscription] @experimental - Future subscribe( - void Function(RealtimeMessage? message) callback, + Future subscribe( + void Function(RealtimeMessage? message) callback, ) { checkNotNull( subscriber, @@ -74,7 +75,9 @@ class Feed { TokenHelper.buildFeedToken(secret!, TokenAction.read, feedId); return subscriber!(token, feedId, (data) { - final realtimeMessage = RealtimeMessage.fromJson(data!); + final realtimeMessage = RealtimeMessage.fromJson( + data!, + ); callback(realtimeMessage); }); } diff --git a/packages/stream_feed/lib/src/client/file_storage_client.dart b/packages/stream_feed/lib/src/client/file_storage_client.dart index 38595d88f..07b380f1d 100644 --- a/packages/stream_feed/lib/src/client/file_storage_client.dart +++ b/packages/stream_feed/lib/src/client/file_storage_client.dart @@ -4,7 +4,6 @@ import 'package:stream_feed/src/core/http/token.dart'; import 'package:stream_feed/src/core/http/typedefs.dart'; import 'package:stream_feed/src/core/models/attachment_file.dart'; import 'package:stream_feed/src/core/util/token_helper.dart'; -import 'package:stream_feed/stream_feed.dart'; /// {@template files} /// This API endpoint allows the uploading of files to, and deleting files diff --git a/packages/stream_feed/lib/src/client/flat_feed.dart b/packages/stream_feed/lib/src/client/flat_feed.dart index 978ea79e3..d2b2acdbd 100644 --- a/packages/stream_feed/lib/src/client/flat_feed.dart +++ b/packages/stream_feed/lib/src/client/flat_feed.dart @@ -45,8 +45,9 @@ class FlatFeed extends Feed { } /// Retrieves one enriched activity from a feed - Future getEnrichedActivityDetail(String activityId) async { - final activities = await getEnrichedActivities( + Future> + getEnrichedActivityDetail(String activityId) async { + final activities = await getEnrichedActivities( limit: 1, filter: Filter() .idLessThanOrEqual(activityId) @@ -109,7 +110,8 @@ class FlatFeed extends Feed { /// ``` /// /// {@macro filter} - Future> getEnrichedActivities({ + Future>> + getEnrichedActivities({ int? limit, int? offset, String? session, @@ -130,7 +132,7 @@ class FlatFeed extends Feed { TokenHelper.buildFeedToken(secret!, TokenAction.read, feedId); final result = await feed.getEnrichedActivities(token, feedId, options); final data = (result.data['results'] as List) - .map((e) => EnrichedActivity.fromJson(e)) + .map((e) => GenericEnrichedActivity.fromJson(e)) .toList(growable: false); return data; } diff --git a/packages/stream_feed/lib/src/client/image_storage_client.dart b/packages/stream_feed/lib/src/client/image_storage_client.dart index 1524d5976..d12886b3f 100644 --- a/packages/stream_feed/lib/src/client/image_storage_client.dart +++ b/packages/stream_feed/lib/src/client/image_storage_client.dart @@ -98,6 +98,12 @@ class ImageStorageClient { return _images.get(token, url); } + Future _process(String url, Map params) { + final token = + userToken ?? TokenHelper.buildFilesToken(secret!, TokenAction.read); + return _images.get(token, url, options: params); + } + /// Crop an image using its URL. A new URL is then returned by the API. /// # Examples: /// @@ -123,12 +129,6 @@ class ImageStorageClient { Future getResized(String url, Resize resize) => _process(url, resize.params); - Future _process(String url, Map params) { - final token = - userToken ?? TokenHelper.buildFilesToken(secret!, TokenAction.read); - return _images.get(token, url, options: params); - } - ///Generate a thumbnail for a given image url Future thumbnail(String url, Thumbnail thumbnail) => _process(url, thumbnail.params); diff --git a/packages/stream_feed/lib/src/client/notification_feed.dart b/packages/stream_feed/lib/src/client/notification_feed.dart index cd3a02a3f..972598a15 100644 --- a/packages/stream_feed/lib/src/client/notification_feed.dart +++ b/packages/stream_feed/lib/src/client/notification_feed.dart @@ -102,7 +102,8 @@ class NotificationFeed extends AggregatedFeed { /// /// {@macro filter} @override - Future>> getEnrichedActivities({ + Future>>> + getEnrichedActivities({ int? limit, int? offset, String? session, @@ -121,8 +122,12 @@ class NotificationFeed extends AggregatedFeed { TokenHelper.buildFeedToken(secret!, TokenAction.read, feedId); final result = await feed.getEnrichedActivities(token, feedId, options); final data = (result.data['results'] as List) - .map((e) => NotificationGroup.fromJson(e, - (json) => EnrichedActivity.fromJson(json as Map?))) + .map((e) => NotificationGroup.fromJson( + e, + (json) => GenericEnrichedActivity.fromJson( + json! as Map?, + ), + )) .toList(growable: false); return data; } diff --git a/packages/stream_feed/lib/src/client/personalization_client.dart b/packages/stream_feed/lib/src/client/personalization_client.dart index b9c63ff95..929395dd1 100644 --- a/packages/stream_feed/lib/src/client/personalization_client.dart +++ b/packages/stream_feed/lib/src/client/personalization_client.dart @@ -52,8 +52,13 @@ class PersonalizationClient { 'At least a secret or userToken must be provided', ); + /// TODO: document me final Token? userToken; + + /// TODO: document me final String? secret; + + /// TODO: document me final PersonalizationAPI _personalization; /// {@template personalizationGet} @@ -72,6 +77,7 @@ class PersonalizationClient { } //------------------------- Server side methods ----------------------------// + /// TODO: document me Future post( String resource, Map params, { @@ -83,6 +89,7 @@ class PersonalizationClient { return _personalization.post(token, resource, params, payload); } + /// TODO: document me Future delete( String resource, { Map? params, diff --git a/packages/stream_feed/lib/src/client/reactions_client.dart b/packages/stream_feed/lib/src/client/reactions_client.dart index f156219ad..9e6428d00 100644 --- a/packages/stream_feed/lib/src/client/reactions_client.dart +++ b/packages/stream_feed/lib/src/client/reactions_client.dart @@ -237,7 +237,7 @@ class ReactionsClient { /// Paginated reactions and filter them /// /// {@macro filter} - Future paginatedFilter( + Future> paginatedFilter( LookupAttribute lookupAttr, String lookupValue, { Filter? filter, @@ -246,7 +246,13 @@ class ReactionsClient { }) { final token = userToken ?? TokenHelper.buildReactionToken(secret!, TokenAction.read); - return _reactions.paginatedFilter(token, lookupAttr, lookupValue, - filter ?? Default.filter, limit ?? Default.limit, kind ?? ''); + return _reactions.paginatedFilter( + token, + lookupAttr, + lookupValue, + filter ?? Default.filter, + limit ?? Default.limit, + kind ?? '', + ); } } diff --git a/packages/stream_feed/lib/src/client/stream_feed_client.dart b/packages/stream_feed/lib/src/client/stream_feed_client.dart index d1a7bd096..763a250be 100644 --- a/packages/stream_feed/lib/src/client/stream_feed_client.dart +++ b/packages/stream_feed/lib/src/client/stream_feed_client.dart @@ -42,20 +42,20 @@ abstract class StreamFeedClient { /// - Instantiate a new client (server side) with [StreamFeedClient.connect] /// using your api [secret] parameter and [apiKey] /// ```dart - /// var client = connect('YOUR_API_KEY',secret: 'API_KEY_SECRET'); + /// var client = StreamFeedClient('YOUR_API_KEY',secret: 'API_KEY_SECRET'); /// ``` /// - Create a token for user with id "the-user-id" /// ```dart /// var userToken = client.frontendToken('the-user-id'); /// ``` /// - if you are using the SDK client side, get a userToken in your dashboard - /// and pass it to [StreamFeedClient.connect] using the [token] parameter + /// and pass it to [StreamFeedClient] using the [token] parameter /// and [apiKey] /// ```dart - /// var client = connect('YOUR_API_KEY',token: Token('userToken')); + /// var client = StreamFeedClient('YOUR_API_KEY',token: Token('userToken')); /// ``` /// {@endtemplate} - factory StreamFeedClient.connect( + factory StreamFeedClient( String apiKey, { Token? token, String? secret, @@ -84,7 +84,11 @@ abstract class StreamFeedClient { StreamUser? get currentUser; /// Set data for the [currentUser] assigned to [StreamFeedClient] - Future setUser(Map data); + Future setUser( + User user, + Token userToken, { + Map? extraData, + }); /// Convenient getter for [BatchOperationsClient] BatchOperationsClient get batch; diff --git a/packages/stream_feed/lib/src/client/stream_feed_client_impl.dart b/packages/stream_feed/lib/src/client/stream_feed_client_impl.dart index 854218a1d..ec2463687 100644 --- a/packages/stream_feed/lib/src/client/stream_feed_client_impl.dart +++ b/packages/stream_feed/lib/src/client/stream_feed_client_impl.dart @@ -53,16 +53,6 @@ class StreamFeedClientImpl implements StreamFeedClient { _logger.info('instantiating new client'); _api = api ?? StreamApiImpl(apiKey, logger: _logger, options: options); - - if (userToken != null) { - final jwtBody = jwtDecode(userToken!); - final userId = jwtBody.claims.getTyped('user_id'); - assert( - userId != null, - 'Invalid `userToken`, It should contain `user_id`', - ); - _currentUser = user(userId); - } } bool _ensureCredentials() { @@ -100,11 +90,11 @@ class StreamFeedClientImpl implements StreamFeedClient { final String apiKey; final String? appId; - final Token? userToken; + Token? userToken; final String? secret; final String fayeUrl; final Runner runner; - late final StreamAPI _api; + late StreamAPI _api; late final Logger _logger; void _defaultLogHandler(LogRecord record) { @@ -143,13 +133,14 @@ class StreamFeedClientImpl implements StreamFeedClient { StreamUser? get currentUser => _currentUser; @override - Future setUser(Map data) async { - assert( - runner == Runner.client, - 'This method can only be used client-side using a user token', - ); - final body = {...data}..remove('id'); - return _currentUser!.getOrCreate(body); + Future setUser( + User user, + Token userToken, { + Map? extraData, + }) async { + this.userToken = userToken; + return _currentUser = + await this.user(user.id!).getOrCreate(extraData ?? {}); } @override diff --git a/packages/stream_feed/lib/src/client/stream_user.dart b/packages/stream_feed/lib/src/client/stream_user.dart index 38b350272..f3e15136b 100644 --- a/packages/stream_feed/lib/src/client/stream_user.dart +++ b/packages/stream_feed/lib/src/client/stream_user.dart @@ -2,8 +2,8 @@ import 'package:equatable/equatable.dart'; import 'package:stream_feed/src/core/api/users_api.dart'; import 'package:stream_feed/src/core/http/token.dart'; import 'package:stream_feed/src/core/index.dart' show User; +import 'package:stream_feed/src/core/util/enrichment.dart'; import 'package:stream_feed/src/core/util/token_helper.dart'; -import 'package:stream_feed/stream_feed.dart'; /// {@template user} /// Stream allows you to store user information diff --git a/packages/stream_feed/lib/src/core/api/batch_api.dart b/packages/stream_feed/lib/src/core/api/batch_api.dart index f854ebd92..3c3b04eb1 100644 --- a/packages/stream_feed/lib/src/core/api/batch_api.dart +++ b/packages/stream_feed/lib/src/core/api/batch_api.dart @@ -10,6 +10,7 @@ import 'package:stream_feed/src/core/models/follow_relation.dart'; import 'package:stream_feed/src/core/models/foreign_id_time_pair.dart'; import 'package:stream_feed/src/core/util/extension.dart'; import 'package:stream_feed/src/core/util/routes.dart'; +import 'package:stream_feed/stream_feed.dart'; /// The http layer api for for anything related to Batch operations class BatchAPI { @@ -93,8 +94,9 @@ class BatchAPI { } /// Retrieve multiple enriched activities by a single id - Future> getEnrichedActivitiesById( - Token token, Iterable ids) async { + Future>> + getEnrichedActivitiesById( + Token token, Iterable ids) async { checkArgument(ids.isNotEmpty, 'No activities to get'); final result = await _client.get( Routes.enrichedActivitiesUrl, @@ -102,14 +104,17 @@ class BatchAPI { queryParameters: {'ids': ids.join(',')}, ); final data = (result.data['results'] as List) - .map((e) => EnrichedActivity.fromJson(e)) + .map((e) => GenericEnrichedActivity.fromJson(e)) .toList(growable: false); return data; } /// Retrieve multiple enriched activities by a single foreign id - Future> getEnrichedActivitiesByForeignId( - Token token, Iterable pairs) async { + Future>> + getEnrichedActivitiesByForeignId( + Token token, + Iterable pairs, + ) async { checkArgument(pairs.isNotEmpty, 'No activities to get'); final result = await _client.get( Routes.enrichedActivitiesUrl, @@ -121,7 +126,7 @@ class BatchAPI { }, ); final data = (result.data['results'] as List) - .map((e) => EnrichedActivity.fromJson(e)) + .map((e) => GenericEnrichedActivity.fromJson(e)) .toList(growable: false); return data; } diff --git a/packages/stream_feed/lib/src/core/api/feed_api.dart b/packages/stream_feed/lib/src/core/api/feed_api.dart index da61238de..4c71bbb23 100644 --- a/packages/stream_feed/lib/src/core/api/feed_api.dart +++ b/packages/stream_feed/lib/src/core/api/feed_api.dart @@ -330,7 +330,7 @@ class FeedAPI { } /// {@macro personalizedFeed} - Future personalizedFeed( + Future> personalizedFeed( Token token, Map options, ) async { @@ -339,6 +339,6 @@ class FeedAPI { headers: {'Authorization': '$token'}, queryParameters: options, ); - return PersonalizedFeed.fromJson(response.data!); + return PersonalizedFeed.fromJson(response.data!); } } diff --git a/packages/stream_feed/lib/src/core/api/reactions_api.dart b/packages/stream_feed/lib/src/core/api/reactions_api.dart index af0dccc0f..dc5d4bcf8 100644 --- a/packages/stream_feed/lib/src/core/api/reactions_api.dart +++ b/packages/stream_feed/lib/src/core/api/reactions_api.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; import 'package:stream_feed/src/core/http/stream_http_client.dart'; import 'package:stream_feed/src/core/http/token.dart'; -import 'package:stream_feed/src/core/lookup_attribute.dart'; import 'package:stream_feed/src/core/models/filter.dart'; +import 'package:stream_feed/src/core/models/lookup_attribute.dart'; import 'package:stream_feed/src/core/models/paginated_reactions.dart'; import 'package:stream_feed/src/core/models/reaction.dart'; import 'package:stream_feed/src/core/util/extension.dart'; import 'package:stream_feed/src/core/util/routes.dart'; +import 'package:stream_feed/stream_feed.dart'; /// The http layer api for CRUD operations on Reactions class ReactionsAPI { @@ -89,7 +90,7 @@ class ReactionsAPI { } /// Paginated reactions and filter them - Future paginatedFilter( + Future> paginatedFilter( Token token, LookupAttribute lookupAttr, String lookupValue, @@ -108,18 +109,20 @@ class ReactionsAPI { 'with_activity_data': lookupAttr == LookupAttribute.activityId, }, ); - return PaginatedReactions.fromJson(result.data); + return PaginatedReactions.fromJson(result.data); } /// Next reaction pagination returned by [PaginatedReactions].next - Future nextPaginatedFilter( - Token token, String next) async { + Future> nextPaginatedFilter( + Token token, + String next, + ) async { checkArgument(next.isNotEmpty, "Next url can't be empty"); final result = await _client.get( next, headers: {'Authorization': '$token'}, ); - return PaginatedReactions.fromJson(result.data); + return PaginatedReactions.fromJson(result.data); } /// Update a reaction diff --git a/packages/stream_feed/lib/src/core/exceptions.dart b/packages/stream_feed/lib/src/core/error/exceptions.dart similarity index 100% rename from packages/stream_feed/lib/src/core/exceptions.dart rename to packages/stream_feed/lib/src/core/error/exceptions.dart diff --git a/packages/stream_feed/lib/src/core/error/index.dart b/packages/stream_feed/lib/src/core/error/index.dart index 5ce386c39..f1247f5b3 100644 --- a/packages/stream_feed/lib/src/core/error/index.dart +++ b/packages/stream_feed/lib/src/core/error/index.dart @@ -1,2 +1,3 @@ +export 'exceptions.dart'; export 'feeds_error_code.dart'; export 'stream_feeds_error.dart'; diff --git a/packages/stream_feed/lib/src/core/http/index.dart b/packages/stream_feed/lib/src/core/http/index.dart new file mode 100644 index 000000000..68740fec5 --- /dev/null +++ b/packages/stream_feed/lib/src/core/http/index.dart @@ -0,0 +1,3 @@ +export 'location.dart'; +export 'stream_http_client.dart' show StreamHttpClientOptions; +export 'token.dart'; diff --git a/packages/stream_feed/lib/src/core/location.dart b/packages/stream_feed/lib/src/core/http/location.dart similarity index 100% rename from packages/stream_feed/lib/src/core/location.dart rename to packages/stream_feed/lib/src/core/http/location.dart diff --git a/packages/stream_feed/lib/src/core/http/stream_http_client.dart b/packages/stream_feed/lib/src/core/http/stream_http_client.dart index 3917e68e8..1c0c0221c 100644 --- a/packages/stream_feed/lib/src/core/http/stream_http_client.dart +++ b/packages/stream_feed/lib/src/core/http/stream_http_client.dart @@ -4,8 +4,8 @@ import 'package:meta/meta.dart'; import 'package:stream_feed/src/core/error/feeds_error_code.dart'; import 'package:stream_feed/src/core/error/stream_feeds_error.dart'; import 'package:stream_feed/src/core/http/interceptor/logging_interceptor.dart'; +import 'package:stream_feed/src/core/http/location.dart'; import 'package:stream_feed/src/core/http/typedefs.dart'; -import 'package:stream_feed/src/core/location.dart'; import 'package:stream_feed/src/core/platform_detector/platform_detector.dart'; import 'package:stream_feed/src/core/util/extension.dart'; import 'package:stream_feed/version.dart'; @@ -52,7 +52,7 @@ class StreamHttpClient { ]); } - /// Your project Stream Chat api key. + /// Your project Stream Feed api key. /// /// Find your API keys here https://getstream.io/dashboard/. /// @@ -77,7 +77,6 @@ class StreamHttpClient { } else { feedsError = StreamFeedsNetworkError.fromDioError(dioError); } - print(feedsError.errorCode); return feedsError..stackTrace = dioError.stackTrace; } @@ -104,7 +103,7 @@ class StreamHttpClient { } } - /// Handy method to make http POST request with error parsing. + /// Handy method to make an http POST request with error parsing. Future> post( String path, { String serviceName = 'api', diff --git a/packages/stream_feed/lib/src/core/http/typedefs.dart b/packages/stream_feed/lib/src/core/http/typedefs.dart index 62c868f9b..cc4e6b1ba 100644 --- a/packages/stream_feed/lib/src/core/http/typedefs.dart +++ b/packages/stream_feed/lib/src/core/http/typedefs.dart @@ -1,2 +1,5 @@ -typedef void OnSendProgress(int sentBytes, int totalBytes); -typedef void OnReceiveProgress(int receivedBytes, int totalBytes); +/// TODO: document me +typedef OnSendProgress = void Function(int sentBytes, int totalBytes); + +/// TODO: document me +typedef OnReceiveProgress = void Function(int receivedBytes, int totalBytes); diff --git a/packages/stream_feed/lib/src/core/index.dart b/packages/stream_feed/lib/src/core/index.dart index 9d014022f..b7fb3072b 100644 --- a/packages/stream_feed/lib/src/core/index.dart +++ b/packages/stream_feed/lib/src/core/index.dart @@ -1,8 +1,4 @@ export 'error/index.dart'; -export 'http/stream_http_client.dart' show StreamHttpClientOptions; -export 'http/token.dart'; -export 'location.dart'; -export 'lookup_attribute.dart'; +export 'http/index.dart'; export 'models/index.dart'; -export 'util/enrichment.dart'; -export 'util/extension.dart'; +export 'util/index.dart'; diff --git a/packages/stream_feed/lib/src/core/models/enriched_activity.dart b/packages/stream_feed/lib/src/core/models/enriched_activity.dart index 41bcbd422..1f045c23b 100644 --- a/packages/stream_feed/lib/src/core/models/enriched_activity.dart +++ b/packages/stream_feed/lib/src/core/models/enriched_activity.dart @@ -1,10 +1,14 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_feed/src/core/models/activity.dart'; +import 'package:stream_feed/src/core/models/collection_entry.dart'; import 'package:stream_feed/src/core/models/reaction.dart'; +import 'package:stream_feed/src/core/models/user.dart'; import 'package:stream_feed/src/core/util/serializer.dart'; part 'enriched_activity.g.dart'; +//TODO: mark this as immutable maybe? /// Enrichment is a concept in Stream that enables our API to work quickly /// and efficiently. /// @@ -15,34 +19,23 @@ part 'enriched_activity.g.dart'; /// /// The same rule applies to users and reactions. They are stored only once, /// but references are used elsewhere. -class EnrichableField extends Equatable { - /// Builds a [EnrichableField]. - const EnrichableField(this.data); - - /// Underlying [EnrichableField] data - final Object? data; - - /// Deserializes an [EnrichableField]. - static EnrichableField deserialize(Object? obj) { - if (obj is String) { - return EnrichableField(obj); - } - return EnrichableField(obj as Map?); - } - - /// Serializes an [EnrichableField]. - static Object? serialize(EnrichableField? field) => field?.data; - - @override - List get props => [data]; -} - +/// /// An Enriched Activity is an Activity with additional fields /// that are derived from the Activity's -@JsonSerializable() -class EnrichedActivity extends Equatable { - /// [EnrichedActivity] constructor - const EnrichedActivity({ +/// +/// This class makes use of generics in order to have a more flexible API +/// surface. Here is a legend of what each generic is for: +/// * A = [actor] +/// * Ob = [object] +/// * T = [target] +/// * Or = [origin] +@JsonSerializable(genericArgumentFactories: true) +class GenericEnrichedActivity extends Equatable { + //TODO: improve this + // when type parameter to can a default type in Dart + //i.e. https://github.com/dart-lang/language/issues/283#issuecomment-839603127 + /// Builds an [GenericEnrichedActivity]. + const GenericEnrichedActivity({ this.id, this.actor, this.verb, @@ -62,30 +55,59 @@ class EnrichedActivity extends Equatable { }); /// Create a new instance from a JSON object - factory EnrichedActivity.fromJson(Map? json) => - _$EnrichedActivityFromJson( - Serializer.moveKeysToRoot(json, topLevelFields)!); + factory GenericEnrichedActivity.fromJson( + Map? json, [ + A Function(Object? json)? fromJsonA, + Ob Function(Object? json)? fromJsonOb, + T Function(Object? json)? fromJsonT, + Or Function(Object? json)? fromJsonOr, + ]) => + _$EnrichedActivityFromJson( + Serializer.moveKeysToRoot(json, topLevelFields)!, + fromJsonA ?? + (jsonA) => (A == User) + ? User.fromJson(jsonA! as Map) as A + : jsonA as A, + fromJsonOb ?? + (jsonOb) => (Ob == + CollectionEntry) //TODO: can be a list of collection entry and a list of activities + ? CollectionEntry.fromJson(jsonOb! as Map) + as Ob + : jsonOb as Ob, + fromJsonT ?? + (jsonT) => (T == Activity) + ? Activity.fromJson(jsonT! as Map) as T + : jsonT as T, + fromJsonOr ?? + (jsonOr) { + if (Or == User) { + return User.fromJson(jsonOr! as Map) as Or; + } else if (Or == Reaction) { + return Reaction.fromJson(jsonOr! as Map) as Or; + } else { + return jsonOr as Or; + } + }, + ); /// The Stream id of the activity. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? id; /// The actor performing the activity. - @JsonKey( - fromJson: EnrichableField.deserialize, - toJson: EnrichableField.serialize, - ) - final EnrichableField? actor; + /// + /// The type of this field can be either a `String` or a [User]. + @JsonKey(includeIfNull: false) + final A? actor; /// The verb of the activity. final String? verb; - /// object of the activity. - @JsonKey( - fromJson: EnrichableField.deserialize, - toJson: EnrichableField.serialize, - ) - final EnrichableField? object; + /// The object of the activity. + /// + /// Can be a String or a [CollectionEntry]. + @JsonKey(includeIfNull: false) + final Ob? object; /// A unique ID from your application for this activity. /// @@ -93,13 +115,21 @@ class EnrichedActivity extends Equatable { @JsonKey(includeIfNull: false) final String? foreignId; + /// Describes the target of the activity. + /// + /// The precise meaning of the activity's target is dependent on the + /// activities verb, but will often be the object the English preposition + /// "to". + /// + /// For instance, in the activity, "John saved a movie to his wishlist", + /// the target of the activity is "wishlist". The activity target MUST NOT + /// be used to identity an indirect object that is not a target of the + /// activity. An activity MAY contain a target property whose value is a + /// single Object. /// - @JsonKey( - includeIfNull: false, - fromJson: EnrichableField.deserialize, - toJson: Serializer.readOnly, - ) - final EnrichableField? target; + /// The type of this field can be either [Activity] or `String`. + @JsonKey(includeIfNull: false) + final T? target; /// The optional time of the activity in iso format. /// @@ -108,12 +138,10 @@ class EnrichedActivity extends Equatable { final DateTime? time; /// The feed id where the activity was posted. - @JsonKey( - includeIfNull: false, - fromJson: EnrichableField.deserialize, - toJson: Serializer.readOnly, - ) - final EnrichableField? origin; + /// + /// Can be of type User, Reaction, or String + @JsonKey(includeIfNull: false) + final Or? origin; /// An array allows you to specify a list of feeds to which the activity /// should be copied. @@ -150,6 +178,43 @@ class EnrichedActivity extends Equatable { @JsonKey(includeIfNull: false) final Map? extraData; + GenericEnrichedActivity copyWith({ + A? actor, + Ob? object, + String? verb, + T? target, + List? to, + String? foreignId, + String? id, + DateTime? time, + Map? analytics, + Map? extraContext, + Or? origin, + double? score, + Map? extraData, + Map? reactionCounts, + Map>? ownReactions, + Map>? latestReactions, + }) => + GenericEnrichedActivity( + actor: actor ?? this.actor, + object: object ?? this.object, + verb: verb ?? this.verb, + target: target ?? this.target, + to: to ?? this.to, + foreignId: foreignId ?? this.foreignId, + id: id ?? this.id, + time: time ?? this.time, + analytics: analytics ?? this.analytics, + extraContext: extraContext ?? this.extraContext, + origin: origin ?? this.origin, + score: score ?? this.score, + extraData: extraData ?? this.extraData, + reactionCounts: reactionCounts ?? this.reactionCounts, + ownReactions: ownReactions ?? this.ownReactions, + latestReactions: latestReactions ?? this.latestReactions, + ); + /// Known top level fields. /// Useful for [Serializer] methods. static const topLevelFields = [ @@ -191,6 +256,13 @@ class EnrichedActivity extends Equatable { ]; /// Serialize to JSON - Map toJson() => Serializer.moveKeysToMapInPlace( - _$EnrichedActivityToJson(this), topLevelFields); + Map toJson( + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, + ) => + Serializer.moveKeysToMapInPlace( + _$EnrichedActivityToJson(this, toJsonA, toJsonOb, toJsonT, toJsonOr), + topLevelFields); } diff --git a/packages/stream_feed/lib/src/core/models/enriched_activity.g.dart b/packages/stream_feed/lib/src/core/models/enriched_activity.g.dart index 243bfafb3..9ce7086be 100644 --- a/packages/stream_feed/lib/src/core/models/enriched_activity.g.dart +++ b/packages/stream_feed/lib/src/core/models/enriched_activity.g.dart @@ -6,16 +6,22 @@ part of 'enriched_activity.dart'; // JsonSerializableGenerator // ************************************************************************** -EnrichedActivity _$EnrichedActivityFromJson(Map json) { - return EnrichedActivity( +GenericEnrichedActivity _$EnrichedActivityFromJson( + Map json, + A Function(Object? json) fromJsonA, + Ob Function(Object? json) fromJsonOb, + T Function(Object? json) fromJsonT, + Or Function(Object? json) fromJsonOr, +) { + return GenericEnrichedActivity( id: json['id'] as String?, - actor: EnrichableField.deserialize(json['actor']), + actor: _$nullableGenericFromJson(json['actor'], fromJsonA), verb: json['verb'] as String?, - object: EnrichableField.deserialize(json['object']), + object: _$nullableGenericFromJson(json['object'], fromJsonOb), foreignId: json['foreign_id'] as String?, - target: EnrichableField.deserialize(json['target']), + target: _$nullableGenericFromJson(json['target'], fromJsonT), time: json['time'] == null ? null : DateTime.parse(json['time'] as String), - origin: EnrichableField.deserialize(json['origin']), + origin: _$nullableGenericFromJson(json['origin'], fromJsonOr), to: (json['to'] as List?)?.map((e) => e as String).toList(), score: (json['score'] as num?)?.toDouble(), analytics: (json['analytics'] as Map?)?.map( @@ -49,7 +55,13 @@ EnrichedActivity _$EnrichedActivityFromJson(Map json) { ); } -Map _$EnrichedActivityToJson(EnrichedActivity instance) { +Map _$EnrichedActivityToJson( + GenericEnrichedActivity instance, + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, +) { final val = {}; void writeNotNull(String key, dynamic value) { @@ -59,13 +71,13 @@ Map _$EnrichedActivityToJson(EnrichedActivity instance) { } writeNotNull('id', readonly(instance.id)); - val['actor'] = EnrichableField.serialize(instance.actor); + writeNotNull('actor', _$nullableGenericToJson(instance.actor, toJsonA)); val['verb'] = instance.verb; - val['object'] = EnrichableField.serialize(instance.object); + writeNotNull('object', _$nullableGenericToJson(instance.object, toJsonOb)); writeNotNull('foreign_id', instance.foreignId); - writeNotNull('target', readonly(instance.target)); + writeNotNull('target', _$nullableGenericToJson(instance.target, toJsonT)); writeNotNull('time', instance.time?.toIso8601String()); - writeNotNull('origin', readonly(instance.origin)); + writeNotNull('origin', _$nullableGenericToJson(instance.origin, toJsonOr)); writeNotNull('to', readonly(instance.to)); writeNotNull('score', readonly(instance.score)); writeNotNull('analytics', readonly(instance.analytics)); @@ -76,3 +88,15 @@ Map _$EnrichedActivityToJson(EnrichedActivity instance) { writeNotNull('extra_data', instance.extraData); return val; } + +T? _$nullableGenericFromJson( + Object? input, + T Function(Object? json) fromJson, +) => + input == null ? null : fromJson(input); + +Object? _$nullableGenericToJson( + T? input, + Object? Function(T value) toJson, +) => + input == null ? null : toJson(input); diff --git a/packages/stream_feed/lib/src/core/models/enrichment_flags.dart b/packages/stream_feed/lib/src/core/models/enrichment_flags.dart index 82cd92d7c..a64e40691 100644 --- a/packages/stream_feed/lib/src/core/models/enrichment_flags.dart +++ b/packages/stream_feed/lib/src/core/models/enrichment_flags.dart @@ -29,8 +29,10 @@ extension _EnrichmentTypeX on _EnrichmentType { }[this]!; } +/// {@template enrichmentFlags} /// Flags to indicate the API to enrich activities with additional info like /// user reactions and count +/// {@endtemplate} class EnrichmentFlags { String? _userId; final Map<_EnrichmentType, Object> _flags = {}; diff --git a/packages/stream_feed/lib/src/core/models/filter.dart b/packages/stream_feed/lib/src/core/models/filter.dart index c5b52622e..34132830c 100644 --- a/packages/stream_feed/lib/src/core/models/filter.dart +++ b/packages/stream_feed/lib/src/core/models/filter.dart @@ -58,4 +58,7 @@ class Filter { _filters[_Filter.idLessThan] = id; return this; } + + @override + String toString() => _filters.toString(); } diff --git a/packages/stream_feed/lib/src/core/models/index.dart b/packages/stream_feed/lib/src/core/models/index.dart index 2311be2a9..71ffd2274 100644 --- a/packages/stream_feed/lib/src/core/models/index.dart +++ b/packages/stream_feed/lib/src/core/models/index.dart @@ -15,6 +15,7 @@ export 'followers.dart'; export 'following.dart'; export 'foreign_id_time_pair.dart'; export 'group.dart'; +export 'lookup_attribute.dart'; export 'open_graph_data.dart'; export 'reaction.dart'; export 'realtime_message.dart'; diff --git a/packages/stream_feed/lib/src/core/lookup_attribute.dart b/packages/stream_feed/lib/src/core/models/lookup_attribute.dart similarity index 92% rename from packages/stream_feed/lib/src/core/lookup_attribute.dart rename to packages/stream_feed/lib/src/core/models/lookup_attribute.dart index a15a16f2d..a9c617573 100644 --- a/packages/stream_feed/lib/src/core/lookup_attribute.dart +++ b/packages/stream_feed/lib/src/core/models/lookup_attribute.dart @@ -1,4 +1,6 @@ +/// {@template lookupAttribute} /// Lookup objects based on attributes +/// {@endtemplate} enum LookupAttribute { /// The id of the activity you want to lookup activityId, diff --git a/packages/stream_feed/lib/src/core/models/paginated_reactions.dart b/packages/stream_feed/lib/src/core/models/paginated_reactions.dart index d97872df3..26e303801 100644 --- a/packages/stream_feed/lib/src/core/models/paginated_reactions.dart +++ b/packages/stream_feed/lib/src/core/models/paginated_reactions.dart @@ -1,21 +1,55 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_feed/src/core/models/activity.dart'; +import 'package:stream_feed/src/core/models/collection_entry.dart'; import 'package:stream_feed/src/core/models/enriched_activity.dart'; import 'package:stream_feed/src/core/models/paginated.dart'; import 'package:stream_feed/src/core/models/reaction.dart'; +import 'package:stream_feed/src/core/models/user.dart'; part 'paginated_reactions.g.dart'; /// Paginated [Reaction] -@JsonSerializable(createToJson: true) -class PaginatedReactions extends Paginated { +@JsonSerializable(createToJson: true, genericArgumentFactories: true) +class PaginatedReactions extends Paginated { /// Builds a [PaginatedReactions]. const PaginatedReactions( String? next, List? results, this.activity, String? duration) : super(next, results, duration); /// Deserialize json to [PaginatedReactions] - factory PaginatedReactions.fromJson(Map json) => - _$PaginatedReactionsFromJson(json); + factory PaginatedReactions.fromJson( + Map json, [ + A Function(Object? json)? fromJsonA, + Ob Function(Object? json)? fromJsonOb, + T Function(Object? json)? fromJsonT, + Or Function(Object? json)? fromJsonOr, + ]) => + _$PaginatedReactionsFromJson( + json, + fromJsonA ?? + (jsonA) => (A == User) + ? User.fromJson(jsonA! as Map) as A + : jsonA as A, + fromJsonOb ?? + (jsonOb) => (Ob == CollectionEntry) + ? CollectionEntry.fromJson(jsonOb! as Map) + as Ob + : jsonOb as Ob, + fromJsonT ?? + (jsonT) => (T == Activity) + ? Activity.fromJson(jsonT! as Map) as T + : jsonT as T, + fromJsonOr ?? + (jsonOr) { + if (Or == User) { + return User.fromJson(jsonOr! as Map) as Or; + } else if (Or == Reaction) { + return Reaction.fromJson(jsonOr! as Map) as Or; + } else { + return jsonOr as Or; + } + }, + ); @override List get props => [...super.props, activity]; @@ -23,8 +57,14 @@ class PaginatedReactions extends Paginated { /// The activity data. /// /// This field is returned only when with_activity_data is sent. - final EnrichedActivity? activity; + final GenericEnrichedActivity? activity; /// Serialize to json - Map toJson() => _$PaginatedReactionsToJson(this); + Map toJson( + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, + ) => + _$PaginatedReactionsToJson(this, toJsonA, toJsonOb, toJsonT, toJsonOr); } diff --git a/packages/stream_feed/lib/src/core/models/paginated_reactions.g.dart b/packages/stream_feed/lib/src/core/models/paginated_reactions.g.dart index db3ee4e67..c5ba3defb 100644 --- a/packages/stream_feed/lib/src/core/models/paginated_reactions.g.dart +++ b/packages/stream_feed/lib/src/core/models/paginated_reactions.g.dart @@ -6,25 +6,47 @@ part of 'paginated_reactions.dart'; // JsonSerializableGenerator // ************************************************************************** -PaginatedReactions _$PaginatedReactionsFromJson(Map json) { - return PaginatedReactions( +PaginatedReactions _$PaginatedReactionsFromJson( + Map json, + A Function(Object? json) fromJsonA, + Ob Function(Object? json) fromJsonOb, + T Function(Object? json) fromJsonT, + Or Function(Object? json) fromJsonOr, +) { + return PaginatedReactions( json['next'] as String?, (json['results'] as List?) ?.map((e) => Reaction.fromJson(Map.from(e as Map))) .toList(), json['activity'] == null ? null - : EnrichedActivity.fromJson((json['activity'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), + : GenericEnrichedActivity.fromJson( + (json['activity'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ), + (value) => fromJsonA(value), + (value) => fromJsonOb(value), + (value) => fromJsonT(value), + (value) => fromJsonOr(value)), json['duration'] as String?, ); } -Map _$PaginatedReactionsToJson(PaginatedReactions instance) => +Map _$PaginatedReactionsToJson( + PaginatedReactions instance, + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, +) => { 'next': instance.next, 'results': instance.results?.map((e) => e.toJson()).toList(), 'duration': instance.duration, - 'activity': instance.activity?.toJson(), + 'activity': instance.activity?.toJson( + (value) => toJsonA(value), + (value) => toJsonOb(value), + (value) => toJsonT(value), + (value) => toJsonOr(value), + ), }; diff --git a/packages/stream_feed/lib/src/core/models/personalized_feed.dart b/packages/stream_feed/lib/src/core/models/personalized_feed.dart index 4163a9d14..b81bdb7fb 100644 --- a/packages/stream_feed/lib/src/core/models/personalized_feed.dart +++ b/packages/stream_feed/lib/src/core/models/personalized_feed.dart @@ -7,25 +7,58 @@ part 'personalized_feed.g.dart'; /// A personalized feed for a single user. /// /// In other words, a feed of based on user's activities. -@JsonSerializable(createToJson: true) -class PersonalizedFeed extends Paginated { +@JsonSerializable(createToJson: true, genericArgumentFactories: true) +class PersonalizedFeed + extends Paginated> { /// Builds a [PaginatedReactions]. const PersonalizedFeed({ required this.version, required this.offset, required this.limit, String? next, - List? results, + List>? results, String? duration, }) : super(next, results, duration); /// Deserialize json to [PaginatedReactions] - factory PersonalizedFeed.fromJson(Map json) => - _$PersonalizedFeedFromJson(json); + factory PersonalizedFeed.fromJson( + Map json, [ + A Function(Object? json)? fromJsonA, + Ob Function(Object? json)? fromJsonOb, + T Function(Object? json)? fromJsonT, + Or Function(Object? json)? fromJsonOr, + ]) => + _$PersonalizedFeedFromJson( + json, + fromJsonA ?? + (jsonA) => (A == User) + ? User.fromJson(jsonA! as Map) as A + : jsonA as A, + fromJsonOb ?? + (jsonOb) => (Ob == CollectionEntry) + ? CollectionEntry.fromJson(jsonOb! as Map) + as Ob + : jsonOb as Ob, + fromJsonT ?? + (jsonT) => (T == Activity) + ? Activity.fromJson(jsonT! as Map) as T + : jsonT as T, + fromJsonOr ?? + (jsonOr) { + if (Or == User) { + return User.fromJson(jsonOr! as Map) as Or; + } else if (Or == Reaction) { + return Reaction.fromJson(jsonOr! as Map) as Or; + } else { + return jsonOr as Or; + } + }, + ); @override List get props => [...super.props, version, offset, limit]; + /// TODO: document me final String version; /// The offset of the first result in the current page. @@ -35,5 +68,11 @@ class PersonalizedFeed extends Paginated { final int limit; /// Serialize to json - Map toJson() => _$PersonalizedFeedToJson(this); + Map toJson( + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, + ) => + _$PersonalizedFeedToJson(this, toJsonA, toJsonOb, toJsonT, toJsonOr); } diff --git a/packages/stream_feed/lib/src/core/models/personalized_feed.g.dart b/packages/stream_feed/lib/src/core/models/personalized_feed.g.dart index 6fac8efde..dd9511a53 100644 --- a/packages/stream_feed/lib/src/core/models/personalized_feed.g.dart +++ b/packages/stream_feed/lib/src/core/models/personalized_feed.g.dart @@ -6,25 +6,49 @@ part of 'personalized_feed.dart'; // JsonSerializableGenerator // ************************************************************************** -PersonalizedFeed _$PersonalizedFeedFromJson(Map json) { - return PersonalizedFeed( +PersonalizedFeed _$PersonalizedFeedFromJson( + Map json, + A Function(Object? json) fromJsonA, + Ob Function(Object? json) fromJsonOb, + T Function(Object? json) fromJsonT, + Or Function(Object? json) fromJsonOr, +) { + return PersonalizedFeed( version: json['version'] as String, offset: json['offset'] as int, limit: json['limit'] as int, next: json['next'] as String?, results: (json['results'] as List?) - ?.map((e) => EnrichedActivity.fromJson((e as Map?)?.map( + ?.map((e) => GenericEnrichedActivity.fromJson( + (e as Map?)?.map( (k, e) => MapEntry(k as String, e), - ))) + ), + (value) => fromJsonA(value), + (value) => fromJsonOb(value), + (value) => fromJsonT(value), + (value) => fromJsonOr(value))) .toList(), duration: json['duration'] as String?, ); } -Map _$PersonalizedFeedToJson(PersonalizedFeed instance) => +Map _$PersonalizedFeedToJson( + PersonalizedFeed instance, + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, +) => { 'next': instance.next, - 'results': instance.results?.map((e) => e.toJson()).toList(), + 'results': instance.results + ?.map((e) => e.toJson( + (value) => toJsonA(value), + (value) => toJsonOb(value), + (value) => toJsonT(value), + (value) => toJsonOr(value), + )) + .toList(), 'duration': instance.duration, 'version': instance.version, 'offset': instance.offset, diff --git a/packages/stream_feed/lib/src/core/models/reaction.dart b/packages/stream_feed/lib/src/core/models/reaction.dart index 087a7a340..a5c6b4a78 100644 --- a/packages/stream_feed/lib/src/core/models/reaction.dart +++ b/packages/stream_feed/lib/src/core/models/reaction.dart @@ -119,21 +119,21 @@ class Reaction extends Equatable { ]; /// Copies this [Reaction] to a new instance. - Reaction copyWith({ - String? id, - String? kind, - String? activityId, - String? userId, - String? parent, - DateTime? createdAt, - DateTime? updatedAt, - List? targetFeeds, - User? user, - Map? targetFeedsExtraData, - Map? data, - Map>? latestChildren, - Map? childrenCounts, - }) => + Reaction copyWith( + {String? id, + String? kind, + String? activityId, + String? userId, + String? parent, + DateTime? createdAt, + DateTime? updatedAt, + List? targetFeeds, + User? user, + Map? targetFeedsExtraData, + Map? data, + Map>? latestChildren, + Map? childrenCounts, + Map>? ownChildren}) => Reaction( id: id ?? this.id, kind: kind ?? this.kind, @@ -148,6 +148,7 @@ class Reaction extends Equatable { data: data ?? this.data, latestChildren: latestChildren ?? this.latestChildren, childrenCounts: childrenCounts ?? this.childrenCounts, + ownChildren: ownChildren ?? this.ownChildren, ); @override @@ -165,6 +166,7 @@ class Reaction extends Equatable { data, latestChildren, childrenCounts, + ownChildren ]; /// Serialize to json diff --git a/packages/stream_feed/lib/src/core/models/realtime_message.dart b/packages/stream_feed/lib/src/core/models/realtime_message.dart index bd1528102..96c255cb4 100644 --- a/packages/stream_feed/lib/src/core/models/realtime_message.dart +++ b/packages/stream_feed/lib/src/core/models/realtime_message.dart @@ -22,21 +22,52 @@ part 'realtime_message.g.dart'; /// /// The only thing you don’t get is the enriched reactions like `own_reaction` /// or `latest_reactions` -@JsonSerializable() -class RealtimeMessage extends Equatable { +@JsonSerializable(genericArgumentFactories: true) +class RealtimeMessage extends Equatable { /// Builds a [RealtimeMessage]. const RealtimeMessage({ required this.feed, this.deleted = const [], this.deletedForeignIds = const [], - this.newActivities = const [], + this.newActivities, this.appId, this.publishedAt, }); /// Create a new instance from a JSON object - factory RealtimeMessage.fromJson(Map json) => - _$RealtimeMessageFromJson(json); + factory RealtimeMessage.fromJson( + Map json, [ + A Function(Object? json)? fromJsonA, + Ob Function(Object? json)? fromJsonOb, + T Function(Object? json)? fromJsonT, + Or Function(Object? json)? fromJsonOr, + ]) => + _$RealtimeMessageFromJson( + json, + fromJsonA ?? + (jsonA) => (A == User) + ? User.fromJson(jsonA! as Map) as A + : jsonA as A, + fromJsonOb ?? + (jsonOb) => (Ob == CollectionEntry) + ? CollectionEntry.fromJson(jsonOb! as Map) + as Ob + : jsonOb as Ob, + fromJsonT ?? + (jsonT) => (T == Activity) + ? Activity.fromJson(jsonT! as Map) as T + : jsonT as T, + fromJsonOr ?? + (jsonOr) { + if (Or == User) { + return User.fromJson(jsonOr! as Map) as Or; + } else if (Or == Reaction) { + return Reaction.fromJson(jsonOr! as Map) as Or; + } else { + return jsonOr as Or; + } + }, + ); /// Name of the feed this update was published on @JsonKey(toJson: FeedId.toId, fromJson: FeedId.fromId) @@ -68,7 +99,7 @@ class RealtimeMessage extends Equatable { /// the only thing you don’t get is the enriched reactions like `own_reaction` /// or `latest_reactions` @JsonKey(name: 'new') - final List newActivities; + final List>? newActivities; /// Time of the update in ISO format @JsonKey(includeIfNull: false) @@ -85,5 +116,11 @@ class RealtimeMessage extends Equatable { ]; /// Serialize to json - Map toJson() => _$RealtimeMessageToJson(this); + Map toJson( + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, + ) => + _$RealtimeMessageToJson(this, toJsonA, toJsonOb, toJsonT, toJsonOr); } diff --git a/packages/stream_feed/lib/src/core/models/realtime_message.g.dart b/packages/stream_feed/lib/src/core/models/realtime_message.g.dart index a82f325dc..664952a19 100644 --- a/packages/stream_feed/lib/src/core/models/realtime_message.g.dart +++ b/packages/stream_feed/lib/src/core/models/realtime_message.g.dart @@ -6,17 +6,28 @@ part of 'realtime_message.dart'; // JsonSerializableGenerator // ************************************************************************** -RealtimeMessage _$RealtimeMessageFromJson(Map json) { - return RealtimeMessage( +RealtimeMessage _$RealtimeMessageFromJson( + Map json, + A Function(Object? json) fromJsonA, + Ob Function(Object? json) fromJsonOb, + T Function(Object? json) fromJsonT, + Or Function(Object? json) fromJsonOr, +) { + return RealtimeMessage( feed: FeedId.fromId(json['feed'] as String?), deleted: (json['deleted'] as List).map((e) => e as String).toList(), deletedForeignIds: ForeignIdTimePair.fromList(json['deleted_foreign_ids'] as List?), - newActivities: (json['new'] as List) - .map((e) => EnrichedActivity.fromJson((e as Map?)?.map( + newActivities: (json['new'] as List?) + ?.map((e) => GenericEnrichedActivity.fromJson( + (e as Map?)?.map( (k, e) => MapEntry(k as String, e), - ))) + ), + (value) => fromJsonA(value), + (value) => fromJsonOb(value), + (value) => fromJsonT(value), + (value) => fromJsonOr(value))) .toList(), appId: json['app_id'] as String?, publishedAt: json['published_at'] == null @@ -25,7 +36,13 @@ RealtimeMessage _$RealtimeMessageFromJson(Map json) { ); } -Map _$RealtimeMessageToJson(RealtimeMessage instance) { +Map _$RealtimeMessageToJson( + RealtimeMessage instance, + Object? Function(A value) toJsonA, + Object? Function(Ob value) toJsonOb, + Object? Function(T value) toJsonT, + Object? Function(Or value) toJsonOr, +) { final val = { 'feed': FeedId.toId(instance.feed), }; @@ -40,7 +57,14 @@ Map _$RealtimeMessageToJson(RealtimeMessage instance) { val['deleted'] = instance.deleted; val['deleted_foreign_ids'] = ForeignIdTimePair.toList(instance.deletedForeignIds); - val['new'] = instance.newActivities.map((e) => e.toJson()).toList(); + val['new'] = instance.newActivities + ?.map((e) => e.toJson( + (value) => toJsonA(value), + (value) => toJsonOb(value), + (value) => toJsonT(value), + (value) => toJsonOr(value), + )) + .toList(); writeNotNull('published_at', instance.publishedAt?.toIso8601String()); return val; } diff --git a/packages/stream_feed/lib/src/core/util/index.dart b/packages/stream_feed/lib/src/core/util/index.dart new file mode 100644 index 000000000..62cad4488 --- /dev/null +++ b/packages/stream_feed/lib/src/core/util/index.dart @@ -0,0 +1,3 @@ +export 'enrichment.dart'; +export 'extension.dart'; +export 'typedefs.dart'; diff --git a/packages/stream_feed/lib/src/core/util/routes.dart b/packages/stream_feed/lib/src/core/util/routes.dart index 9e83d5cef..079c428d4 100644 --- a/packages/stream_feed/lib/src/core/util/routes.dart +++ b/packages/stream_feed/lib/src/core/util/routes.dart @@ -1,5 +1,6 @@ import 'package:stream_feed/src/core/models/feed_id.dart'; +/// TODO: document me class Routes { static const _addToManyPath = 'feed/add_to_many'; static const _followManyPath = 'follow_many'; @@ -37,6 +38,7 @@ class Routes { static String buildEnrichedFeedUrl(FeedId feed, [String path = '']) => '$_enrichedFeedPath/${feed.slug}/${feed.userId}/$path'; + /// TODO: document me static String get enrichedActivitiesUrl => _enrichActivitiesPath; /// Handy method to build a url for a Collection resource for a given path diff --git a/packages/stream_feed/lib/src/core/util/token_helper.dart b/packages/stream_feed/lib/src/core/util/token_helper.dart index 9b85dd7b1..eca026ed6 100644 --- a/packages/stream_feed/lib/src/core/util/token_helper.dart +++ b/packages/stream_feed/lib/src/core/util/token_helper.dart @@ -220,12 +220,11 @@ String issueJwtHS256({ required Map? claims, DateTime? expiresAt, }) { + final now = DateTime.now(); final claimSet = JsonWebTokenClaims.fromJson({ - 'exp': DateTime.now() - .add(const Duration(seconds: 1200)) - .millisecondsSinceEpoch ~/ - 1000, - 'iat': DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000, + 'exp': + now.add(const Duration(seconds: 1200)).millisecondsSinceEpoch ~/ 1000, + //'iat': now.toUtc().millisecondsSinceEpoch ~/ 1000, if (claims != null) ...claims, }); diff --git a/packages/stream_feed/lib/src/core/util/typedefs.dart b/packages/stream_feed/lib/src/core/util/typedefs.dart new file mode 100644 index 000000000..73cfb81fc --- /dev/null +++ b/packages/stream_feed/lib/src/core/util/typedefs.dart @@ -0,0 +1,7 @@ +import 'package:stream_feed/src/core/models/enriched_activity.dart'; +import 'package:stream_feed/src/core/models/user.dart'; + +/// Convenient typedef for [GenericEnrichedActivity] with default parameters +/// ready to use and suitable for most use cases +typedef EnrichedActivity + = GenericEnrichedActivity; diff --git a/packages/stream_feed/lib/version.dart b/packages/stream_feed/lib/version.dart index 046d71955..ba28d4f30 100644 --- a/packages/stream_feed/lib/version.dart +++ b/packages/stream_feed/lib/version.dart @@ -1,3 +1,3 @@ /// Current package version /// Used in [HttpClient] to build the `x-stream-client` header -const String packageVersion = '0.3.0'; +const String packageVersion = '0.4.0'; diff --git a/packages/stream_feed/pubspec.yaml b/packages/stream_feed/pubspec.yaml index b52123605..f075659f5 100644 --- a/packages/stream_feed/pubspec.yaml +++ b/packages/stream_feed/pubspec.yaml @@ -1,11 +1,11 @@ name: stream_feed description: Stream Feed official Dart SDK. Build your own feed experience using Dart and Flutter. -version: 0.3.0 +version: 0.4.0 repository: https://github.com/GetStream/stream-feed-flutter issue_tracker: https://github.com/GetStream/stream-feed-flutter/issues homepage: https://getstream.io/ environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.14.0 <3.0.0' dependencies: collection: ^1.15.0 diff --git a/packages/stream_feed/test/aggregated_client_test.dart b/packages/stream_feed/test/aggregated_client_test.dart index 0a1189700..648ee30a2 100644 --- a/packages/stream_feed/test/aggregated_client_test.dart +++ b/packages/stream_feed/test/aggregated_client_test.dart @@ -110,20 +110,22 @@ void main() { path: '', ), statusCode: 200)); - final activities = await client.getEnrichedActivities( - limit: limit, - offset: offset, - filter: filter, - marker: marker, - flags: flags); + final activities = + await client.getEnrichedActivities( + limit: limit, + offset: offset, + filter: filter, + marker: marker, + flags: flags); expect( activities, rawActivities .map((e) => Group.fromJson( - e, - (json) => - EnrichedActivity.fromJson(json as Map?))) + e, + (json) => GenericEnrichedActivity.fromJson(json as Map?), + )) .toList(growable: false)); verify(() => api.getEnrichedActivities(token, feedId, options)).called(1); }); diff --git a/packages/stream_feed/test/analytics_api_test.dart b/packages/stream_feed/test/analytics_api_test.dart index d35485c58..363e4a946 100644 --- a/packages/stream_feed/test/analytics_api_test.dart +++ b/packages/stream_feed/test/analytics_api_test.dart @@ -18,11 +18,14 @@ void main() { final api = AnalyticsAPI(apiKey, client: httpClient); final impression = Impression( contentList: [ - Content(foreignId: FeedId.fromId('post:42'), data: { - 'actor': {'id': 'user:2353540'}, - 'verb': 'share', - 'object': {'id': 'song:34349698'}, - }) + Content( + foreignId: FeedId.fromId('post:42'), + data: const { + 'actor': {'id': 'user:2353540'}, + 'verb': 'share', + 'object': {'id': 'song:34349698'}, + }, + ), ], feedId: FeedId('timeline', 'tom'), ); diff --git a/packages/stream_feed/test/analytics_client_test.dart b/packages/stream_feed/test/analytics_client_test.dart index ca8be1702..9b4df97bd 100644 --- a/packages/stream_feed/test/analytics_client_test.dart +++ b/packages/stream_feed/test/analytics_client_test.dart @@ -26,7 +26,7 @@ void main() { final client = StreamAnalytics(apiKey, userToken: token, analytics: api); final impression = Impression( contentList: [ - Content(foreignId: FeedId.id('post:42'), data: { + Content(foreignId: FeedId.id('post:42'), data: const { 'actor': {'id': 'user:2353540'}, 'verb': 'share', 'object': {'id': 'song:34349698'}, diff --git a/packages/stream_feed/test/execeptions_test.dart b/packages/stream_feed/test/execeptions_test.dart index 688d1b6de..9c5d99993 100644 --- a/packages/stream_feed/test/execeptions_test.dart +++ b/packages/stream_feed/test/execeptions_test.dart @@ -1,4 +1,4 @@ -import 'package:stream_feed/src/core/exceptions.dart'; +import 'package:stream_feed/src/core/error/exceptions.dart'; import 'package:test/test.dart'; void main() { diff --git a/packages/stream_feed/test/faye_client_test.dart b/packages/stream_feed/test/faye_client_test.dart index 0b3790e66..c04a07bb6 100644 --- a/packages/stream_feed/test/faye_client_test.dart +++ b/packages/stream_feed/test/faye_client_test.dart @@ -13,7 +13,7 @@ void main() async { final appId = env['appId']; final apiKey = env['apiKey']; - final client = StreamFeedClient.connect( + final client = StreamFeedClient( apiKey!, secret: secret, appId: appId, @@ -42,7 +42,7 @@ void main() async { await Future.delayed(const Duration(seconds: 3)); expect(realTimeMessage, isNotNull); - expect(realTimeMessage!.newActivities.first.id, + expect(realTimeMessage!.newActivities!.first.id, activity.id); //TODO: this test is flaky addTearDown(subscription.cancel); diff --git a/packages/stream_feed/test/feed_client_test.dart b/packages/stream_feed/test/feed_client_test.dart index cdd9caaf3..ea92285dc 100644 --- a/packages/stream_feed/test/feed_client_test.dart +++ b/packages/stream_feed/test/feed_client_test.dart @@ -100,7 +100,7 @@ void main() { const offset = 0; final feed = FeedId('slug', 'userId'); final feedIds = [FeedId('slug', 'userId')]; - final date = DateTime.parse("2021-05-14T19:58:27.274792063Z"); + final date = DateTime.parse('2021-05-14T19:58:27.274792063Z'); final follows = [ Follow( feedId: 'timeline:1', @@ -125,7 +125,7 @@ void main() { const offset = 0; final feed = FeedId('slug', 'userId'); final feedIds = [FeedId('slug', 'userId')]; - final date = DateTime.parse("2021-05-14T19:58:27.274792063Z"); + final date = DateTime.parse('2021-05-14T19:58:27.274792063Z'); final follows = [ Follow( feedId: 'timeline:1', diff --git a/packages/stream_feed/test/fixtures/enriched_activity_collection_entry.json b/packages/stream_feed/test/fixtures/enriched_activity_collection_entry.json new file mode 100644 index 000000000..c19f454f1 --- /dev/null +++ b/packages/stream_feed/test/fixtures/enriched_activity_collection_entry.json @@ -0,0 +1,95 @@ +{ + "id": "test", + "actor": "test", + "object": { + "created_at": "2001-09-11T00:01:02.000", + "collection": "test", + "id": "test", + "data": { + "test": "test" + }, + "foreign_id": "test", + "updated_at": "2001-09-11T00:01:03.000" + }, + "verb": "test", + "target": "test", + "to": [ + "test" + ], + "foreign_id": "test", + "time": "2001-09-11T00:01:02.000", + "analytics": { + "test": "test" + }, + "extra_context": { + "test": "test" + }, + "origin": "test", + "score": 1.0, + "reaction_counts": { + "test": 1 + }, + "own_reactions": { + "test": [ + { + "id": "test", + "kind": "test", + "activity_id": "test", + "user_id": "test", + "parent": "test", + "updated_at": "2001-09-11T00:01:02.000", + "created_at": "2001-09-11T00:01:02.000", + "target_feeds": [ + "slug:userId" + ], + "user": { + "id": "test", + "data": { + "test": "test" + } + }, + "target_feeds_extra_data": { + "test": "test" + }, + "data": { + "test": "test" + }, + "children_counts": { + "test": 1 + } + } + ] + }, + "latest_reactions": { + "test": [ + { + "id": "test", + "kind": "test", + "activity_id": "test", + "user_id": "test", + "parent": "test", + "updated_at": "2001-09-11T00:01:02.000", + "created_at": "2001-09-11T00:01:02.000", + "target_feeds": [ + "slug:userId" + ], + "user": { + "id": "test", + "data": { + "test": "test" + } + }, + "target_feeds_extra_data": { + "test": "test" + }, + "data": { + "test": "test" + }, + "children_counts": { + "test": 1 + } + } + ] + }, + "test": "test" +} \ No newline at end of file diff --git a/packages/stream_feed/test/fixtures/realtime_message.json b/packages/stream_feed/test/fixtures/realtime_message.json index afc00f245..9c4c9a9e0 100644 --- a/packages/stream_feed/test/fixtures/realtime_message.json +++ b/packages/stream_feed/test/fixtures/realtime_message.json @@ -9,8 +9,8 @@ "id": "f3de8328-be2d-11eb-bb18-128a130028af", "message": "@Jessica check out getstream.io it's so dang awesome.", "object": "tweet:id", - "origin": null, - "target": null, + "origin": "test", + "target": "test", "time": "2021-05-26T14:23:33.918391", "to": ["notification:jessica"], "verb": "tweet" diff --git a/packages/stream_feed/test/fixtures/realtime_message_issue89.json b/packages/stream_feed/test/fixtures/realtime_message_issue89.json index 708dfa25e..1186a7eda 100644 --- a/packages/stream_feed/test/fixtures/realtime_message_issue89.json +++ b/packages/stream_feed/test/fixtures/realtime_message_issue89.json @@ -1,7 +1,7 @@ { "deleted": [], "deleted_foreign_ids": [], - "feed": "task: 32db0f46-3593-4e14-aa57-f05af4887260", + "feed": "task:32db0f46-3593-4e14-aa57-f05af4887260", "new": [ { "actor": { diff --git a/packages/stream_feed/test/flat_client_test.dart b/packages/stream_feed/test/flat_client_test.dart index 606c69b52..a6044dd98 100644 --- a/packages/stream_feed/test/flat_client_test.dart +++ b/packages/stream_feed/test/flat_client_test.dart @@ -71,12 +71,14 @@ void main() { path: '', ), statusCode: 200)); - final activities = await client.getEnrichedActivityDetail(activityId); + final activities = await client.getEnrichedActivityDetail(activityId); expect( activities, rawActivities - .map((e) => EnrichedActivity.fromJson(e)) + .map((e) => GenericEnrichedActivity.fromJson(e)) .toList(growable: false) .first); verify(() => api.getEnrichedActivities(token, feedId, options)).called(1); @@ -170,17 +172,20 @@ void main() { path: '', ), statusCode: 200)); - final activities = await client.getEnrichedActivities( - limit: limit, - offset: offset, - filter: filter, - flags: flags, - ranking: ranking); + final activities = + await client.getEnrichedActivities( + limit: limit, + offset: offset, + filter: filter, + flags: flags, + ranking: ranking, + ); expect( activities, rawActivities - .map((e) => EnrichedActivity.fromJson(e)) + .map((e) => GenericEnrichedActivity.fromJson(e)) .toList(growable: false)); verify(() => api.getEnrichedActivities(token, feedId, options)).called(1); }); diff --git a/packages/stream_feed/test/images_client_test.dart b/packages/stream_feed/test/images_client_test.dart index dbf9b68cb..26e1afb69 100644 --- a/packages/stream_feed/test/images_client_test.dart +++ b/packages/stream_feed/test/images_client_test.dart @@ -26,7 +26,7 @@ void main() { test('getCropped', () async { const url = 'url'; - final crop = const Crop(50, 50); + const crop = Crop(50, 50); when(() => api.get(token, url, options: crop.params)) .thenAnswer((invocation) async => 'whatever'); @@ -36,7 +36,7 @@ void main() { test('getResized', () async { const url = 'url'; - final resize = const Resize(50, 50); + const resize = Resize(50, 50); when(() => api.get(token, url, options: resize.params)) .thenAnswer((invocation) async => 'whatever'); diff --git a/packages/stream_feed/test/models_test.dart b/packages/stream_feed/test/models_test.dart index bed2d038c..aab9e15f3 100644 --- a/packages/stream_feed/test/models_test.dart +++ b/packages/stream_feed/test/models_test.dart @@ -30,6 +30,7 @@ void main() { 'with_recent_reactions': true }); }); + test('withOwnChildren', () { final withOwnChildren = EnrichmentFlags().withOwnChildren(); expect(withOwnChildren.params, {'with_own_children': true}); @@ -69,6 +70,7 @@ void main() { {'with_own_children': true, 'user_id': 'userId'}); }); }); + group('FeedId', () { test('claim', () { final feedId = FeedId('slug', 'userId'); @@ -90,10 +92,13 @@ void main() { final followers = Following(feed: FeedId.id('user:jessica')); expect(Following.fromJson(const {'feed': 'user:jessica'}), followers); }); + group('FollowStats', () { final followStats = FollowStats( - following: Following(feed: FeedId.id('user:jessica'), count: 0), - followers: Followers(feed: FeedId.id('user:jessica'), count: 1)); + following: Following(feed: FeedId.id('user:jessica'), count: 0), + followers: Followers(feed: FeedId.id('user:jessica'), count: 1), + ); + test('fromJson', () { final followStatsJson = json.decode(fixture('follow_stats.json')); final followStatsFromJson = FollowStats.fromJson(followStatsJson); @@ -108,14 +113,15 @@ void main() { test('toJson slugs', () { final followStatsSlugs = FollowStats( - following: Following( - feed: FeedId.id('user:jessica'), - slugs: const ['user', 'news'], - ), - followers: Followers( - feed: FeedId.id('user:jessica'), - slugs: const ['timeline'], - )); + following: Following( + feed: FeedId.id('user:jessica'), + slugs: const ['user', 'news'], + ), + followers: Followers( + feed: FeedId.id('user:jessica'), + slugs: const ['timeline'], + ), + ); final toJson = followStatsSlugs.toJson(); expect(toJson, { @@ -126,74 +132,118 @@ void main() { }); }); }); + group('RealtimeMessage', () { test('fromJson', () { - final fromJson = - RealtimeMessage.fromJson(jsonFixture('realtime_message.json')); + final fromJson = RealtimeMessage.fromJson( + jsonFixture('realtime_message.json')); expect( - fromJson, - RealtimeMessage( - deleted: [], - deletedForeignIds: [], - feed: FeedId.fromId('reward:1'), - newActivities: [ - EnrichedActivity( - actor: const EnrichableField('reward:1'), - id: 'f3de8328-be2d-11eb-bb18-128a130028af', - extraData: { - 'message': - "@Jessica check out getstream.io it's so dang awesome.", - }, - origin: const EnrichableField(null), - target: const EnrichableField(null), - object: const EnrichableField('tweet:id'), - time: DateTime.parse('2021-05-26T14:23:33.918391'), - to: ['notification:jessica'], - verb: 'tweet') - ])); + fromJson, + RealtimeMessage( + feed: FeedId.fromId('reward:1'), + newActivities: [ + GenericEnrichedActivity( + actor: 'reward:1', + id: 'f3de8328-be2d-11eb-bb18-128a130028af', + extraData: const { + 'message': + "@Jessica check out getstream.io it's so dang awesome.", + }, + target: 'test', + origin: 'test', + object: 'tweet:id', + time: DateTime.parse('2021-05-26T14:23:33.918391'), + to: const ['notification:jessica'], + verb: 'tweet', + ), + ], + ), + ); }); test('issue-89', () { - RealtimeMessage.fromJson(jsonFixture('realtime_message_issue89.json')); + final fixture = RealtimeMessage.fromJson( + jsonFixture('realtime_message_issue89.json'), + ); + expect( + fixture, + RealtimeMessage( + feed: FeedId.fromId('task:32db0f46-3593-4e14-aa57-f05af4887260'), + newActivities: [ + GenericEnrichedActivity( + foreignId: null, + id: 'cff95542-c979-11eb-8080-80005abdd229', + object: 'task_situation_updated to true', + time: DateTime.parse('2021-06-09T23:24:18.238189'), + verb: 'updated', + actor: User( + createdAt: DateTime.parse('2021-04-13T22:53:19.670051Z'), + updatedAt: DateTime.parse('2021-04-13T22:53:19.670051Z'), + id: 'eTHVBnEm0FQB2HeaRKVlEfVf58B3personal', + data: const { + 'gender': 'Male', + 'name': 'Rickey Lee', + 'photo': + 'https://firebasestorage.googleapis.com/v0/b/fire-snab.appspot.com/o/profile-image-placeholder.png?alt=media&token=b17598bb-a510-4167-8354-ab75642ba89e' + }, + ), + extraData: const { + 'createdTask': { + 'id': '32db0f46-3593-4e14-aa57-f05af4887260', + 'title': 'KeyPack', + 'isFinished': true + }, + 'group': 'updated_2021-06-09', + }, + ) + ], + ), + ); }); }); + test('EnrichedActivity issue 61', () { - final enrichedActivity = EnrichedActivity.fromJson( - jsonFixture('enriched_activity_issue61.json')); + final enrichedActivity = + GenericEnrichedActivity.fromJson( + jsonFixture('enriched_activity_issue61.json'), + ); expect(enrichedActivity.latestReactions, isNotNull); expect(enrichedActivity.ownReactions, isNotNull); expect(enrichedActivity.reactionCounts, isNotNull); }); + test('EnrichedActivity', () { final reaction1 = Reaction( - id: 'test', - kind: 'test', - activityId: 'test', - userId: 'test', - parent: 'test', - createdAt: DateTime.parse('2001-09-11T00:01:02.000'), - updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), - targetFeeds: [FeedId('slug', 'userId')], - user: const User(id: 'test', data: {'test': 'test'}), - targetFeedsExtraData: const {'test': 'test'}, - data: const {'test': 'test'}, - // latestChildren: { - // "test": [reaction2] - // }, - childrenCounts: const {'test': 1}); - final enrichedActivity = EnrichedActivity( id: 'test', - actor: const EnrichableField('test'), - object: const EnrichableField('test'), + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + // latestChildren: { + // "test": [reaction2] + // }, + childrenCounts: const {'test': 1}, + ); + + final enrichedActivity = GenericEnrichedActivity( + id: 'test', + actor: 'test', + object: 'test', verb: 'test', - target: const EnrichableField('test'), + target: 'test', to: const ['test'], foreignId: 'test', time: DateTime.parse('2001-09-11T00:01:02.000'), analytics: const {'test': 'test'}, extraContext: const {'test': 'test'}, - origin: const EnrichableField('test'), + origin: 'test', score: 1, extraData: const {'test': 'test'}, reactionCounts: const {'test': 1}, @@ -204,9 +254,74 @@ void main() { 'test': [reaction1] }, ); + final enrichedActivityJson = json.decode(fixture('enriched_activity.json')); final enrichedActivityFromJson = - EnrichedActivity.fromJson(enrichedActivityJson); + GenericEnrichedActivity.fromJson( + enrichedActivityJson); + expect(enrichedActivityFromJson, enrichedActivity); + // we will never get “extra_data” from the api + //that's why it's not explicit in the json fixture + // all the extra data other than the default fields in json will ultimately + // gets collected as a field extra_data of type Map + expect(enrichedActivityFromJson.extraData, {'test': 'test'}); + }); + + test('EnrichedActivity with CollectionEntry object', () { + final reaction1 = Reaction( + id: 'test', + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + // latestChildren: { + // "test": [reaction2] + // }, + childrenCounts: const {'test': 1}, + ); + + final enrichedActivity = GenericEnrichedActivity( + id: 'test', + actor: 'test', + object: CollectionEntry( + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + collection: 'test', + id: 'test', + data: const {'test': 'test'}, + updatedAt: DateTime.parse('2001-09-11T00:01:03.000'), + foreignId: 'test', + ), + verb: 'test', + target: 'test', + to: const ['test'], + foreignId: 'test', + time: DateTime.parse('2001-09-11T00:01:02.000'), + analytics: const {'test': 'test'}, + extraContext: const {'test': 'test'}, + origin: 'test', + score: 1, + extraData: const {'test': 'test'}, + reactionCounts: const {'test': 1}, + ownReactions: { + 'test': [reaction1] + }, + latestReactions: { + 'test': [reaction1] + }, + ); + + final enrichedActivityJson = + json.decode(fixture('enriched_activity_collection_entry.json')); + final enrichedActivityFromJson = GenericEnrichedActivity.fromJson( + enrichedActivityJson, + ); expect(enrichedActivityFromJson, enrichedActivity); // we will never get “extra_data” from the api //that's why it's not explicit in the json fixture @@ -214,22 +329,25 @@ void main() { // gets collected as a field extra_data of type Map expect(enrichedActivityFromJson.extraData, {'test': 'test'}); }); + group('Activity', () { final activity = Activity( - target: 'test', - foreignId: 'test', - id: 'test', - analytics: const {'test': 'test'}, - extraContext: const {'test': 'test'}, - origin: 'test', - score: 1, - extraData: const {'test': 'test'}, - actor: 'test', - verb: 'test', - object: 'test', - to: [FeedId('slug', 'id')], - time: DateTime.parse('2001-09-11T00:01:02.000')); + target: 'test', + foreignId: 'test', + id: 'test', + analytics: const {'test': 'test'}, + extraContext: const {'test': 'test'}, + origin: 'test', + score: 1, + extraData: const {'test': 'test'}, + actor: 'test', + verb: 'test', + object: 'test', + to: [FeedId('slug', 'id')], + time: DateTime.parse('2001-09-11T00:01:02.000'), + ); final r = json.decode(fixture('activity.json')); + test('fromJson', () { final activityJson = Activity.fromJson(r); expect(activityJson, activity); @@ -248,28 +366,31 @@ void main() { group: 'test', activities: [ Activity( - target: 'test', - foreignId: 'test', - id: 'test', - analytics: const {'test': 'test'}, - extraContext: const {'test': 'test'}, - origin: 'test', - score: 1, - extraData: const {'test': 'test'}, - actor: 'test', - verb: 'test', - object: 'test', - to: [FeedId('slug', 'id')], - time: DateTime.parse('2001-09-11T00:01:02.000')) + target: 'test', + foreignId: 'test', + id: 'test', + analytics: const {'test': 'test'}, + extraContext: const {'test': 'test'}, + origin: 'test', + score: 1, + extraData: const {'test': 'test'}, + actor: 'test', + verb: 'test', + object: 'test', + to: [FeedId('slug', 'id')], + time: DateTime.parse('2001-09-11T00:01:02.000'), + ), ], actorCount: 1, createdAt: DateTime.parse('2001-09-11T00:01:02.000'), updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), ); + final groupJson = json.decode(fixture('group.json')); // expect(groupJson, matcher) final groupFromJson = Group.fromJson( groupJson, (e) => Activity.fromJson(e as Map?)); + expect(groupFromJson, group); expect(group.toJson((activity) => activity.toJson()), { 'id': 'test', @@ -304,25 +425,27 @@ void main() { ForeignIdTimePair('foreignID', DateTime(2021, 04, 03)); expect(foreignIdTimePair, otherForeignIdTimePair); }); + group('NotificationGroup', () { final notificationGroup = NotificationGroup( id: 'test', group: 'test', activities: [ Activity( - target: 'test', - foreignId: 'test', - id: 'test', - analytics: const {'test': 'test'}, - extraContext: const {'test': 'test'}, - origin: 'test', - score: 1, - extraData: const {'test': 'test'}, - actor: 'test', - verb: 'test', - object: 'test', - to: [FeedId('slug', 'id')], - time: DateTime.parse('2001-09-11T00:01:02.000')) + target: 'test', + foreignId: 'test', + id: 'test', + analytics: const {'test': 'test'}, + extraContext: const {'test': 'test'}, + origin: 'test', + score: 1, + extraData: const {'test': 'test'}, + actor: 'test', + verb: 'test', + object: 'test', + to: [FeedId('slug', 'id')], + time: DateTime.parse('2001-09-11T00:01:02.000'), + ), ], actorCount: 1, createdAt: DateTime.parse('2001-09-11T00:01:02.000'), @@ -330,12 +453,14 @@ void main() { isRead: true, isSeen: true, ); + test('fromJson', () { final notificationGroupJson = json.decode(fixture('notification_group.json')); final notificationGroupFromJson = NotificationGroup.fromJson( notificationGroupJson, (e) => Activity.fromJson(e as Map?)); + expect(notificationGroupFromJson, notificationGroup); }); @@ -368,6 +493,7 @@ void main() { }); }); }); + group('CollectionEntry', () { final entry = CollectionEntry( id: 'test', @@ -380,6 +506,7 @@ void main() { test('ref', () { expect(entry.ref, 'SO:test:test'); }); + test('fromJson', () { final entryJson = json.decode(fixture('collection_entry.json')); final entryFromJson = CollectionEntry.fromJson(entryJson); @@ -391,6 +518,7 @@ void main() { 'name': 'Cheese Burger', 'rating': '4 stars', }); + expect(entryCopiedWith.data, { 'name': 'Cheese Burger', 'rating': '4 stars', @@ -408,6 +536,7 @@ void main() { }); }); }); + test('Content', () { final content = Content(foreignId: FeedId.fromId('tweet:34349698')); expect(content.toJson(), {'foreign_id': 'tweet:34349698'}); @@ -415,10 +544,12 @@ void main() { group('Engagement', () { final engagement = Engagement( - content: Content(foreignId: FeedId.id('tweet:34349698')), - label: 'click', - userData: const UserData('test', 'test'), - feedId: FeedId('user', 'thierry')); + content: Content(foreignId: FeedId.id('tweet:34349698')), + label: 'click', + userData: const UserData('test', 'test'), + feedId: FeedId('user', 'thierry'), + ); + final json = { 'user_data': {'id': 'test', 'alias': 'test'}, 'feed_id': 'user:thierry', @@ -426,6 +557,7 @@ void main() { 'label': 'click', 'score': null }; + test('fromJson', () { final engagementFromJson = Engagement.fromJson(json); expect(engagementFromJson, engagement); @@ -438,14 +570,16 @@ void main() { group('Impression', () { final impression = Impression( - contentList: [ - Content( - foreignId: FeedId.fromId('tweet:34349698'), - ) - ], - userData: const UserData('test', 'test'), - feedId: FeedId('flat', 'tommaso'), - location: 'profile_page'); + contentList: [ + Content( + foreignId: FeedId.fromId('tweet:34349698'), + ) + ], + userData: const UserData('test', 'test'), + feedId: FeedId('flat', 'tommaso'), + location: 'profile_page', + ); + final json = { 'user_data': {'id': 'test', 'alias': 'test'}, 'feed_id': 'flat:tommaso', @@ -473,46 +607,52 @@ void main() { 'results': [], 'duration': '419.81ms' }; - final personalizedFeed = PersonalizedFeed.fromJson(json); + final personalizedFeed = + PersonalizedFeed.fromJson(json); + expect( - personalizedFeed, - const PersonalizedFeed( - limit: 25, - offset: 0, - version: 'user_1_1619210635', - next: '', - results: [], - duration: '419.81ms')); + personalizedFeed, + const PersonalizedFeed( + limit: 25, + offset: 0, + version: 'user_1_1619210635', + next: '', + results: [], + duration: '419.81ms', + ), + ); }); + test('PaginatedReactions', () { final reaction1 = Reaction( - id: 'test', - kind: 'test', - activityId: 'test', - userId: 'test', - parent: 'test', - createdAt: DateTime.parse('2001-09-11T00:01:02.000'), - updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), - targetFeeds: [FeedId('slug', 'userId')], - user: const User(id: 'test', data: {'test': 'test'}), - targetFeedsExtraData: const {'test': 'test'}, - data: const {'test': 'test'}, - // latestChildren: { - // "test": [reaction2] - // },//TODO: test this - childrenCounts: const {'test': 1}); - final enrichedActivity = EnrichedActivity( id: 'test', - actor: const EnrichableField('test'), - object: const EnrichableField('test'), + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + // latestChildren: { + // "test": [reaction2] + // },//TODO: test this + childrenCounts: const {'test': 1}, + ); + final enrichedActivity = GenericEnrichedActivity( + id: 'test', + actor: 'test', + object: 'test', verb: 'test', - target: const EnrichableField('test'), + target: 'test', to: const ['test'], foreignId: 'test', time: DateTime.parse('2001-09-11T00:01:02.000'), analytics: const {'test': 'test'}, extraContext: const {'test': 'test'}, - origin: const EnrichableField('test'), + origin: 'test', score: 1, extraData: const {'test': 'test'}, reactionCounts: const {'test': 1}, @@ -524,57 +664,68 @@ void main() { }, ); final reaction = Reaction( - id: 'test', - kind: 'test', - activityId: 'test', - userId: 'test', - parent: 'test', - createdAt: DateTime.parse('2001-09-11T00:01:02.000'), - updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), - targetFeeds: [FeedId('slug', 'userId')], - user: const User(id: 'test', data: {'test': 'test'}), - targetFeedsExtraData: const {'test': 'test'}, - data: const {'test': 'test'}, - // latestChildren: { - // "test": [reaction2] - // },//TODO: test this - childrenCounts: const {'test': 1}); + id: 'test', + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + // latestChildren: { + // "test": [reaction2] + // },//TODO: test this + childrenCounts: const {'test': 1}, + ); final paginatedReactions = PaginatedReactions('test', [reaction], enrichedActivity, 'duration'); final paginatedReactionsJson = json.decode(fixture('paginated_reactions.json')); final paginatedReactionsFromJson = - PaginatedReactions.fromJson(paginatedReactionsJson); + PaginatedReactions.fromJson( + paginatedReactionsJson); expect(paginatedReactionsFromJson, paginatedReactions); - expect(paginatedReactions.toJson(), { - 'next': 'test', - 'results': [ + expect( + paginatedReactions.toJson( + (json) => json, + (json) => json, + (json) => json, + (json) => json, + ), { - 'kind': 'test', - 'activity_id': 'test', - 'user_id': 'test', - 'parent': 'test', - 'created_at': '2001-09-11T00:01:02.000', - 'target_feeds': ['slug:userId'], - 'user': { - 'id': 'test', - 'data': {'test': 'test'} - }, - 'target_feeds_extra_data': {'test': 'test'}, - 'data': {'test': 'test'} - } - ], - 'duration': 'duration', - 'activity': { - 'actor': 'test', - 'verb': 'test', - 'object': 'test', - 'foreign_id': 'test', - 'time': '2001-09-11T00:01:02.000', - 'test': 'test' - } - }); + 'next': 'test', + 'results': [ + { + 'kind': 'test', + 'activity_id': 'test', + 'user_id': 'test', + 'parent': 'test', + 'created_at': '2001-09-11T00:01:02.000', + 'target_feeds': ['slug:userId'], + 'user': { + 'id': 'test', + 'data': {'test': 'test'} + }, + 'target_feeds_extra_data': {'test': 'test'}, + 'data': {'test': 'test'} + } + ], + 'duration': 'duration', + 'activity': { + 'actor': 'test', + 'verb': 'test', + 'target': 'test', + 'object': 'test', + 'origin': 'test', + 'foreign_id': 'test', + 'time': '2001-09-11T00:01:02.000', + 'test': 'test' + } + }); }); group('Filter', () { @@ -582,6 +733,7 @@ void main() { final filter = Filter().idGreaterThanOrEqual('id'); expect(filter.params, {'id_gte': 'id'}); }); + test('idGreaterThan', () { final filter = Filter().idGreaterThan('id'); expect(filter.params, {'id_gt': 'id'}); @@ -597,6 +749,7 @@ void main() { expect(filter.params, {'id_lte': 'id'}); }); }); + test('User', () { final user = User( id: 'test', @@ -642,16 +795,16 @@ void main() { test('Follow', () { final followJson = { - "feed_id": "timeline:feedId", - "target_id": "user:userId", - "created_at": "2021-05-14T19:58:27.274792063Z", - "updated_at": "2021-05-14T19:58:27.274792063Z" + 'feed_id': 'timeline:feedId', + 'target_id': 'user:userId', + 'created_at': '2021-05-14T19:58:27.274792063Z', + 'updated_at': '2021-05-14T19:58:27.274792063Z' }; final follow = Follow( feedId: 'timeline:feedId', targetId: 'user:userId', - createdAt: DateTime.parse("2021-05-14T19:58:27.274792063Z"), - updatedAt: DateTime.parse("2021-05-14T19:58:27.274792063Z")); + createdAt: DateTime.parse('2021-05-14T19:58:27.274792063Z'), + updatedAt: DateTime.parse('2021-05-14T19:58:27.274792063Z')); expect(follow, Follow.fromJson(followJson)); expect(follow.toJson(), { @@ -670,6 +823,7 @@ void main() { expect(follow, FollowRelation.fromJson(followJson)); expect(follow.toJson(), {'source': 'feedId', 'target': 'targetId'}); }); + group('Unfollow', () { const unfollow = UnFollowRelation( source: 'feedId', target: 'targetId', keepHistory: true); @@ -679,6 +833,7 @@ void main() { final unfollowFromFollow = UnFollowRelation.fromFollow(follow, true); expect(unfollowFromFollow, unfollow); }); + test('fromJson', () { final unfollowJson = json.decode(fixture('unfollow_relation.json')); expect(unfollow, UnFollowRelation.fromJson(unfollowJson)); @@ -757,6 +912,7 @@ void main() { 'unset': ['test'] }); }); + test('equality', () { final activityUpdateWithId = ActivityUpdate.withId( id: 'id', @@ -786,6 +942,7 @@ void main() { const crop = Crop(10, 10); expect(crop.params, {'crop': 'center', 'w': 10, 'h': 10}); }); + test('Width should be a positive number', () { expect( () => Crop(-1, 10), @@ -806,6 +963,7 @@ void main() { const resize = Resize(10, 10); expect(resize.params, {'resize': 'clip', 'w': 10, 'h': 10}); }); + test('Width should be a positive number', () { expect( () => Resize(-1, 10), @@ -827,6 +985,7 @@ void main() { expect(resize.params, {'resize': 'clip', 'crop': 'center', 'w': 10, 'h': 10}); }); + test('Width should be a positive number', () { expect( () => Thumbnail(-1, 10), @@ -844,40 +1003,43 @@ void main() { group('Reaction', () { final reaction2 = Reaction( - id: 'test', - kind: 'test', - activityId: 'test', - userId: 'test', - parent: 'test', - createdAt: DateTime.parse('2001-09-11T00:01:02.000'), - updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), - targetFeeds: [FeedId('slug', 'userId')], - user: const User(id: 'test', data: {'test': 'test'}), - targetFeedsExtraData: const {'test': 'test'}, - data: const {'test': 'test'}, - childrenCounts: const {'test': 1}); + id: 'test', + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + childrenCounts: const {'test': 1}, + ); final reaction = Reaction( - id: 'test', - kind: 'test', - activityId: 'test', - userId: 'test', - parent: 'test', - createdAt: DateTime.parse('2001-09-11T00:01:02.000'), - updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), - targetFeeds: [FeedId('slug', 'userId')], - user: const User(id: 'test', data: {'test': 'test'}), - targetFeedsExtraData: const {'test': 'test'}, - data: const {'test': 'test'}, - // latestChildren: { - // "test": [reaction2] - // },//TODO: test this - childrenCounts: const {'test': 1}); + id: 'test', + kind: 'test', + activityId: 'test', + userId: 'test', + parent: 'test', + createdAt: DateTime.parse('2001-09-11T00:01:02.000'), + updatedAt: DateTime.parse('2001-09-11T00:01:02.000'), + targetFeeds: [FeedId('slug', 'userId')], + user: const User(id: 'test', data: {'test': 'test'}), + targetFeedsExtraData: const {'test': 'test'}, + data: const {'test': 'test'}, + // latestChildren: { + // "test": [reaction2] + // },//TODO: test this + childrenCounts: const {'test': 1}, + ); test('copyWith', () { final reactionCopiedWith = reaction.copyWith(data: {'text': 'awesome post!'}); expect(reactionCopiedWith.data, {'text': 'awesome post!'}); }); + test('fromJson', () { final reactionJson = json.decode(fixture('reaction.json')); final reactionFromJson = Reaction.fromJson(reactionJson); @@ -916,181 +1078,205 @@ void main() { expect(imageFromJson, image); }); - test('Video', () { - const video = OgVideo( - image: 'test', - url: 'test', - secureUrl: 'test', - width: 'test', - height: 'test', - type: 'test', - alt: 'test', - ); - - final videoJson = json.decode(fixture('video.json')); - final videoFromJson = OgVideo.fromJson(videoJson); - expect(videoFromJson, video); - }); - test('Audio', () { - const audio = OgAudio( - audio: 'test', - url: 'test', - secureUrl: 'test', - type: 'test', - ); - final audioJson = json.decode(fixture('audio.json')); - final audioFromJson = OgAudio.fromJson(audioJson); - expect(audioFromJson, audio); - }); - - test('OpenGraphData', () { - const openGraphData = OpenGraphData( - title: 'test', - type: 'test', - url: 'test', - site: 'test', - siteName: 'test', - description: 'test', - determiner: 'test', - locale: 'test', - images: [ - OgImage( - image: 'test', - url: 'test', - secureUrl: 'test', - width: 'test', - height: 'test', - type: 'test', - alt: 'test') - ], - videos: [ - OgVideo( + group('OG', () { + test('Image', () { + const image = OgImage( image: 'test', url: 'test', secureUrl: 'test', width: 'test', height: 'test', type: 'test', - alt: 'test', - ) - ], - audios: [ - OgAudio( - audio: 'test', - url: 'test', - secureUrl: 'test', - type: 'test', - ) - ], - ); - final openGraphDataJson = json.decode(fixture('open_graph_data.json')); - final openGraphDataFromJson = OpenGraphData.fromJson(openGraphDataJson); - expect(openGraphDataFromJson, openGraphData); - expect(openGraphData.toJson(), { - 'title': 'test', - 'type': 'test', - 'url': 'test', - 'site': 'test', - 'site_name': 'test', - 'description': 'test', - 'determiner': 'test', - 'locale': 'test', - 'images': [ - { - 'image': 'test', - 'url': 'test', - 'secure_url': 'test', - 'width': 'test', - 'height': 'test', - 'type': 'test', - 'alt': 'test' - } - ], - 'videos': [ - { - 'image': 'test', - 'url': 'test', - 'secure_url': 'test', - 'width': 'test', - 'height': 'test', - 'type': 'test', - 'alt': 'test' - } - ], - 'audios': [ - {'audio': 'test', 'url': 'test', 'secure_url': 'test', 'type': 'test'} - ] + alt: 'test'); + final imageJson = json.decode(fixture('image.json')); + final imageFromJson = OgImage.fromJson(imageJson); + expect(imageFromJson, image); + }); + + test('Video', () { + const video = OgVideo( + image: 'test', + url: 'test', + secureUrl: 'test', + width: 'test', + height: 'test', + type: 'test', + alt: 'test', + ); + + final videoJson = json.decode(fixture('video.json')); + final videoFromJson = OgVideo.fromJson(videoJson); + expect(videoFromJson, video); }); - }); - test('activity acttachment', () { - const openGraph = OpenGraphData( - title: - "'Queen' rapper rescheduling dates to 2019 after deciding to “reevaluate elements of production on the 'NickiHndrxx Tour'", - url: - 'https://www.rollingstone.com/music/music-news/nicki-minaj-cancels-north-american-tour-with-future-714315/', - description: - 'Why choose one when you can wear both? These energizing pairings stand out from the crowd', + test('Audio', () { + const audio = OgAudio( + audio: 'test', + url: 'test', + secureUrl: 'test', + type: 'test', + ); + final audioJson = json.decode(fixture('audio.json')); + final audioFromJson = OgAudio.fromJson(audioJson); + expect(audioFromJson, audio); + }); + + test('OpenGraphData', () { + const openGraphData = OpenGraphData( + title: 'test', + type: 'test', + url: 'test', + site: 'test', + siteName: 'test', + description: 'test', + determiner: 'test', + locale: 'test', images: [ OgImage( - image: - 'https://www.rollingstone.com/wp-content/uploads/2018/08/GettyImages-1020376858.jpg', + image: 'test', + url: 'test', + secureUrl: 'test', + width: 'test', + height: 'test', + type: 'test', + alt: 'test') + ], + videos: [ + OgVideo( + image: 'test', + url: 'test', + secureUrl: 'test', + width: 'test', + height: 'test', + type: 'test', + alt: 'test', + ) + ], + audios: [ + OgAudio( + audio: 'test', + url: 'test', + secureUrl: 'test', + type: 'test', ) - ]); + ], + ); + final openGraphDataJson = json.decode(fixture('open_graph_data.json')); + final openGraphDataFromJson = OpenGraphData.fromJson(openGraphDataJson); + expect(openGraphDataFromJson, openGraphData); + expect(openGraphData.toJson(), { + 'title': 'test', + 'type': 'test', + 'url': 'test', + 'site': 'test', + 'site_name': 'test', + 'description': 'test', + 'determiner': 'test', + 'locale': 'test', + 'images': [ + { + 'image': 'test', + 'url': 'test', + 'secure_url': 'test', + 'width': 'test', + 'height': 'test', + 'type': 'test', + 'alt': 'test' + } + ], + 'videos': [ + { + 'image': 'test', + 'url': 'test', + 'secure_url': 'test', + 'width': 'test', + 'height': 'test', + 'type': 'test', + 'alt': 'test' + } + ], + 'audios': [ + { + 'audio': 'test', + 'url': 'test', + 'secure_url': 'test', + 'type': 'test' + } + ] + }); + }); - expect( - openGraph, - OpenGraphData.fromJson({ - 'description': - 'Why choose one when you can wear both? These energizing pairings stand out from the crowd', - 'title': + test('activity attachment', () { + const openGraph = OpenGraphData( + title: "'Queen' rapper rescheduling dates to 2019 after deciding to “reevaluate elements of production on the 'NickiHndrxx Tour'", - 'url': + url: 'https://www.rollingstone.com/music/music-news/nicki-minaj-cancels-north-american-tour-with-future-714315/', - 'images': [ - { - 'image': + description: + 'Why choose one when you can wear both? These energizing pairings stand out from the crowd', + images: [ + OgImage( + image: 'https://www.rollingstone.com/wp-content/uploads/2018/08/GettyImages-1020376858.jpg', + ) + ]); + + expect( + openGraph, + OpenGraphData.fromJson( + const { + 'description': + 'Why choose one when you can wear both? These energizing pairings stand out from the crowd', + 'title': + "'Queen' rapper rescheduling dates to 2019 after deciding to “reevaluate elements of production on the 'NickiHndrxx Tour'", + 'url': + 'https://www.rollingstone.com/music/music-news/nicki-minaj-cancels-north-american-tour-with-future-714315/', + 'images': [ + { + 'image': + 'https://www.rollingstone.com/wp-content/uploads/2018/08/GettyImages-1020376858.jpg', + }, + ], }, - ], - })); + )); + }); }); - }); - group('AttachmentFile', () { - const path = 'testPath'; - const name = 'testFile'; - final bytes = Uint8List.fromList([]); - const size = 0; + group('AttachmentFile', () { + const path = 'testPath'; + const name = 'testFile'; + final bytes = Uint8List.fromList([]); + const size = 0; - test('should throw if `path` or `bytes` is not provided', () { - expect(() => AttachmentFile(), throwsA(isA())); - }); + test('should throw if `path` or `bytes` is not provided', () { + expect(() => AttachmentFile(), throwsA(isA())); + }); - test('toJson', () { - final attachmentFile = AttachmentFile( - path: path, - name: name, - bytes: bytes, - size: size, - ); + test('toJson', () { + final attachmentFile = AttachmentFile( + path: path, + name: name, + bytes: bytes, + size: size, + ); - expect(attachmentFile.toJson(), { - 'path': 'testPath', - 'name': 'testFile', - 'bytes': '', - 'size': 0, + expect(attachmentFile.toJson(), { + 'path': 'testPath', + 'name': 'testFile', + 'bytes': '', + 'size': 0, + }); }); - }); - test('fromJson', () { - final file = json.decode(fixture('attachment_file.json')); - final attachmentFile = AttachmentFile.fromJson(file); + test('fromJson', () { + final file = json.decode(fixture('attachment_file.json')); + final attachmentFile = AttachmentFile.fromJson(file); - expect(attachmentFile.path, path); - expect(attachmentFile.name, name); - expect(attachmentFile.bytes, bytes); - expect(attachmentFile.size, size); + expect(attachmentFile.path, path); + expect(attachmentFile.name, name); + expect(attachmentFile.bytes, bytes); + expect(attachmentFile.size, size); + }); }); }); } diff --git a/packages/stream_feed/test/notification_client_test.dart b/packages/stream_feed/test/notification_client_test.dart index 25e4a9d2e..08009e5f5 100644 --- a/packages/stream_feed/test/notification_client_test.dart +++ b/packages/stream_feed/test/notification_client_test.dart @@ -110,20 +110,21 @@ void main() { path: '', ), statusCode: 200)); - final activities = await client.getEnrichedActivities( - limit: limit, - offset: offset, - filter: filter, - marker: marker, - flags: flags); + final activities = + await client.getEnrichedActivities( + limit: limit, + offset: offset, + filter: filter, + marker: marker, + flags: flags); expect( activities, rawActivities .map((e) => NotificationGroup.fromJson( e, - (json) => - EnrichedActivity.fromJson(json as Map?))) + (json) => GenericEnrichedActivity.fromJson(json as Map?))) .toList(growable: false)); verify(() => api.getEnrichedActivities(token, feedId, options)).called(1); }); diff --git a/packages/stream_feed/test/reactions_client_test.dart b/packages/stream_feed/test/reactions_client_test.dart index c0ee0e5e8..aedd20b7f 100644 --- a/packages/stream_feed/test/reactions_client_test.dart +++ b/packages/stream_feed/test/reactions_client_test.dart @@ -2,8 +2,8 @@ import 'package:dio/dio.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_feed/src/client/reactions_client.dart'; import 'package:stream_feed/src/core/http/token.dart'; -import 'package:stream_feed/src/core/lookup_attribute.dart'; import 'package:stream_feed/src/core/models/feed_id.dart'; +import 'package:stream_feed/src/core/models/lookup_attribute.dart'; import 'package:stream_feed/src/core/models/paginated_reactions.dart'; import 'package:stream_feed/src/core/models/reaction.dart'; import 'package:stream_feed/stream_feed.dart'; @@ -168,7 +168,7 @@ void main() { ]; final duration = const Duration(minutes: 2).toString(); final paginatedReactions = PaginatedReactions( - 'next', reactions, const EnrichedActivity(), duration); + 'next', reactions, const GenericEnrichedActivity(), duration); when(() => api.paginatedFilter( token, lookupAttr, diff --git a/packages/stream_feed/test/stream_feed_client_test.dart b/packages/stream_feed/test/stream_feed_client_test.dart index 52971f3ff..4964662a9 100644 --- a/packages/stream_feed/test/stream_feed_client_test.dart +++ b/packages/stream_feed/test/stream_feed_client_test.dart @@ -13,7 +13,7 @@ void main() { group('throw', () { test('throws an AssertionError when no secret or token provided', () { expect( - () => StreamFeedClient.connect('apiKey'), + () => StreamFeedClient('apiKey'), throwsA( predicate((e) => e.message == 'At least a secret or userToken must be provided'), @@ -26,7 +26,7 @@ void main() { 'while running on client-side', () { expect( - () => StreamFeedClient.connect('apiKey', secret: 'secret'), + () => StreamFeedClient('apiKey', secret: 'secret'), throwsA( predicate( (e) => @@ -43,7 +43,7 @@ void main() { 'while running on server-side', () { expect( - () => StreamFeedClient.connect( + () => StreamFeedClient( 'apiKey', token: TokenHelper.buildFrontendToken('secret', 'userId'), runner: Runner.server, @@ -64,7 +64,7 @@ void main() { 'while running on client-side', () { expect( - () => StreamFeedClient.connect( + () => StreamFeedClient( 'apiKey', secret: 'secret', token: TokenHelper.buildFrontendToken('secret', 'userId'), @@ -83,12 +83,11 @@ void main() { ); test("don't throw if secret provided while running on server-side", () { - StreamFeedClient.connect('apiKey', - secret: 'secret', runner: Runner.server); + StreamFeedClient('apiKey', secret: 'secret', runner: Runner.server); }); test("don't throw if token provided while running on client-side", () { - StreamFeedClient.connect( + StreamFeedClient( 'apiKey', token: TokenHelper.buildFrontendToken('secret', 'userId'), ); diff --git a/packages/stream_feed/test/stream_feeds_error_test.dart b/packages/stream_feed/test/stream_feeds_error_test.dart index 698eb1fa6..ffdd275ca 100644 --- a/packages/stream_feed/test/stream_feeds_error_test.dart +++ b/packages/stream_feed/test/stream_feeds_error_test.dart @@ -5,7 +5,7 @@ import 'package:stream_feed/src/core/error/stream_feeds_error.dart'; import 'package:test/test.dart'; void main() { - group('StreamChatNetworkError', () { + group('StreamFeedNetworkError', () { test('.raw', () { const code = 400; const message = 'test-error-message'; @@ -20,8 +20,11 @@ void main() { const statusCode = 666; const message = 'test-error-message'; final options = RequestOptions(path: 'test-path'); - final data = - ErrorResponse(code: code, message: message, statusCode: statusCode); + const data = ErrorResponse( + code: code, + message: message, + statusCode: statusCode, + ); final dioError = DioError( requestOptions: options, diff --git a/packages/stream_feed/test/token_helper_test.dart b/packages/stream_feed/test/token_helper_test.dart index fae2c6427..ef3444924 100644 --- a/packages/stream_feed/test/token_helper_test.dart +++ b/packages/stream_feed/test/token_helper_test.dart @@ -33,8 +33,14 @@ void main() { final payloadStr = b64urlEncRfc7515Decode(payload); final payloadJson = json.decode(payloadStr); - expect(payloadJson, - {'exp': isA(), 'iat': isA(), 'user_id': 'userId'}); + expect( + payloadJson, + { + 'exp': isA(), + // 'iat': isA(), + 'user_id': 'userId' + }, + ); expect(payloadJson['user_id'], 'userId'); }); @@ -53,7 +59,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + //'iat': isA(), 'action': '*', 'resource': 'feed', 'feed_id': '*', @@ -75,7 +81,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'follower', 'feed_id': '*', @@ -98,7 +104,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'reactions', 'feed_id': '*', @@ -107,7 +113,7 @@ void main() { test('buildActivityToken', () async { final activityToken = - TokenHelper.buildActivityToken(secret, TokenAction.any); + TokenHelper.buildActivityToken(secret, TokenAction.write); final jwt = JsonWebToken.unverified(activityToken.token); final verified = await jwt.verify(keyStore); expect(verified, true); @@ -121,8 +127,8 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), - 'action': '*', + // 'iat': isA(), + 'action': 'write', 'resource': 'activities', 'feed_id': '*', }); @@ -143,7 +149,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'users', 'feed_id': '*', @@ -166,7 +172,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'collections', 'feed_id': '*', @@ -190,7 +196,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': 'read', 'resource': 'url', 'feed_id': '*', @@ -213,7 +219,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'feed_targets', 'feed_id': '*', @@ -235,7 +241,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'analytics', 'feed_id': '*', @@ -259,7 +265,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'personalization', 'feed_id': '*', @@ -283,7 +289,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'redirect_and_track', 'feed_id': '*', @@ -306,7 +312,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': 'write', 'resource': 'analytics', 'feed_id': '*', @@ -327,7 +333,7 @@ void main() { final payloadJson = json.decode(payloadStr); expect(payloadJson, { 'exp': isA(), - 'iat': isA(), + // 'iat': isA(), 'action': '*', 'resource': 'files', 'feed_id': '*', diff --git a/packages/stream_feed_flutter_core/.gitignore b/packages/stream_feed_flutter_core/.gitignore new file mode 100644 index 000000000..0c44ab06c --- /dev/null +++ b/packages/stream_feed_flutter_core/.gitignore @@ -0,0 +1,13 @@ +# Files and directories created by pub +.dart_tool/ +.packages + +# Omit commiting pubspec.lock for library packages: +# https://dart.dev/guides/libraries/private-files#pubspeclock +pubspec.lock + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ diff --git a/packages/stream_feed_flutter_core/CHANGELOG.md b/packages/stream_feed_flutter_core/CHANGELOG.md new file mode 100644 index 000000000..c367a1762 --- /dev/null +++ b/packages/stream_feed_flutter_core/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.4.0: 29/10/2021 + +- first release diff --git a/packages/stream_feed_flutter_core/LICENSE b/packages/stream_feed_flutter_core/LICENSE new file mode 100644 index 000000000..4dcdfd417 --- /dev/null +++ b/packages/stream_feed_flutter_core/LICENSE @@ -0,0 +1,219 @@ +SOURCE CODE LICENSE AGREEMENT + +IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR +ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. + +THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE +BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE +LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN +INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN +EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE +OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN +AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO +THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND +CUSTOMER TO THIS AGREEMENT. + +STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING +CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A +COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS +AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE +USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU +REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF +STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE +READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE +BOUND BY ALL THE TERMS OF THIS AGREEMENT. + +IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, +STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO +NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND +CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE +SOFTWARE. + +1. SOFTWARE. The Stream.io software accompanying this Agreement, may include +Source Code, Executable Object Code, associated media, printed materials and +documentation (collectively, the “Software”). The Software also includes any +updates or upgrades to or new versions of the original Software, if and when +made available to you by Stream.io. “Source Code” means computer programming +code in human readable form that is not suitable for machine execution without +the intervening steps of interpretation or compilation. “Executable Object +Code" means the computer programming code in any other form than Source Code +that is not readily perceivable by humans and suitable for machine execution +without the intervening steps of interpretation or compilation. “Site” means a +Customer location controlled by Customer. “Authorized User” means any employee +or contractor of Customer working at the Site, who has signed a written +confidentiality agreement with Customer or is otherwise bound in writing by +confidentiality and use obligations at least as restrictive as those imposed +under this Agreement. + +2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in +consideration for the representations, warranties, and covenants made by +Customer in this Agreement, Stream.io grants to Customer, during the term of +this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable +license to: + +a. install and use Software Source Code on password protected computers at a Site, +restricted to Authorized Users; + +b. create derivative works, improvements (whether or not patentable), extensions +and other modifications to the Software Source Code (“Modifications”) to build +unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s +application program interface (“API”); + +c. compile the Software Source Code to create Executable Object Code versions of +the Software Source Code and Modifications to build such newsfeeds, activity +streams, and in-app messaging via the API; + +d. install, execute and use such Executable Object Code versions solely for +Customer’s internal business use (including development of websites through +which data generated by Stream services will be streamed (“Apps”)); + +e. use and distribute such Executable Object Code as part of Customer’s Apps; and + +f. make electronic copies of the Software and Modifications as required for backup +or archival purposes. + +3. RESTRICTIONS. Customer is responsible for all activities that occur in +connection with the Software. Customer will not, and will not attempt to: (a) +sublicense or transfer the Software or any Source Code related to the Software +or any of Customer’s rights under this Agreement, except as otherwise provided +in this Agreement, (b) use the Software Source Code for the benefit of a third +party or to operate a service; (c) allow any third party to access or use the +Software Source Code; (d) sublicense or distribute the Software Source Code or +any Modifications in Source Code or other derivative works based on any part of +the Software Source Code; (e) use the Software in any manner that competes with +Stream.io or its business; or (e) otherwise use the Software in any manner that +exceeds the scope of use permitted in this Agreement. Customer shall use the +Software in compliance with any accompanying documentation any laws applicable +to Customer. + +4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or +software components that are open source in conjunction with the Software +Source Code or any Modifications in Source Code or in any way that could +subject the Software to any open source licenses. + +5. CONTRACTORS. Under the rights granted to Customer under this Agreement, +Customer may permit its employees, contractors, and agencies of Customer to +become Authorized Users to exercise the rights to the Software granted to +Customer in accordance with this Agreement solely on behalf of Customer to +provide services to Customer; provided that Customer shall be liable for the +acts and omissions of all Authorized Users to the extent any of such acts or +omissions, if performed by Customer, would constitute a breach of, or otherwise +give rise to liability to Customer under, this Agreement. Customer shall not +and shall not permit any Authorized User to use the Software except as +expressly permitted in this Agreement. + +6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way +to engage in the development of products or services which could be reasonably +construed to provide a complete or partial functional or commercial alternative +to Stream.io’s products or services (a “Competitive Product”). Customer shall +ensure that there is no direct or indirect use of, or sharing of, Software +source code, or other information based upon or derived from the Software to +develop such products or services. Without derogating from the generality of +the foregoing, development of Competitive Products shall include having direct +or indirect access to, supervising, consulting or assisting in the development +of, or producing any specifications, documentation, object code or source code +for, all or part of a Competitive Product. + +7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, +Modifications may only be created and used by Customer as permitted by this +Agreement and Modification Source Code may not be distributed to third parties. +Customer will not assert against Stream.io, its affiliates, or their customers, +direct or indirect, agents and contractors, in any way, any patent rights that +Customer may obtain relating to any Modifications for Stream.io, its +affiliates’, or their customers’, direct or indirect, agents’ and contractors’ +manufacture, use, import, offer for sale or sale of any Stream.io products or +services. + +8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant +to Stream.io standard download procedures. The Software is deemed accepted upon +delivery. + +9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to +provide any support or consultation concerning the Software. + +10. TERM AND TERMINATION. The term of this Agreement begins when the Software is +downloaded or accessed and shall continue until terminated. Either party may +terminate this Agreement upon written notice. This Agreement shall +automatically terminate if Customer is or becomes a competitor of Stream.io or +makes or sells any Competitive Products. Upon termination of this Agreement for +any reason, (a) all rights granted to Customer in this Agreement immediately +cease to exist, (b) Customer must promptly discontinue all use of the Software +and return to Stream.io or destroy all copies of the Software in Customer’s +possession or control. Any continued use of the Software by Customer or attempt +by Customer to exercise any rights under this Agreement after this Agreement +has terminated shall be considered copyright infringement and subject Customer +to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 +shall survive expiration or termination of this Agreement for any reason. + +11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual +property rights and proprietary rights relating thereto or embodied therein, +are the exclusive property of Stream.io and its suppliers. Stream.io and its +suppliers reserve all rights in and to the Software not expressly granted to +Customer in this Agreement, and no other licenses or rights are granted by +implication, estoppel or otherwise. + +12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S +OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND +WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT +LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, +QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS +ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED +THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS +SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO +MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND +DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. +CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE +EXPRESS WARRANTIES IN THIS AGREEMENT. + +13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S +TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR +THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, +SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, +CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND +WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING +TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON +ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO +THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. + +14. General. Customer may not assign or transfer this Agreement, by operation of +law or otherwise, or any of its rights under this Agreement (including the +license rights granted to Customer) to any third party without Stream.io’s +prior written consent, which consent will not be unreasonably withheld or +delayed. Stream.io may assign this Agreement, without consent, including, but +limited to, affiliate or any successor to all or substantially all its business +or assets to which this Agreement relates, whether by merger, sale of assets, +sale of stock, reorganization or otherwise. Any attempted assignment or +transfer in violation of the foregoing will be null and void. Stream.io shall +not be liable hereunder by reason of any failure or delay in the performance of +its obligations hereunder for any cause which is beyond the reasonable control. +All notices, consents, and approvals under this Agreement must be delivered in +writing by courier, by electronic mail, or by certified or registered mail, +(postage prepaid and return receipt requested) to the other party at the +address set forth in the customer agreement between Stream.io and Customer and +will be effective upon receipt or when delivery is refused. This Agreement will +be governed by and interpreted in accordance with the laws of the State of +Colorado, without reference to its choice of laws rules. The United Nations +Convention on Contracts for the International Sale of Goods does not apply to +this Agreement. Any action or proceeding arising from or relating to this +Agreement shall be brought in a federal or state court in Denver, Colorado, and +each party irrevocably submits to the jurisdiction and venue of any such court +in any such action or proceeding. All waivers must be in writing. Any waiver or +failure to enforce any provision of this Agreement on one occasion will not be +deemed a waiver of any other provision or of such provision on any other +occasion. If any provision of this Agreement is unenforceable, such provision +will be changed and interpreted to accomplish the objectives of such provision +to the greatest extent possible under applicable law and the remaining +provisions will continue in full force and effect. Customer shall not violate +any applicable law, rule or regulation, including those regarding the export of +technical data. The headings of Sections of this Agreement are for convenience +and are not to be used in interpreting this Agreement. As used in this +Agreement, the word “including” means “including but not limited to.” This +Agreement (including all exhibits and attachments) constitutes the entire +agreement between the parties regarding the subject hereof and supersedes all +prior or contemporaneous agreements, understandings and communication, whether +written or oral. This Agreement may be amended only by a written document +signed by both parties. The terms of any purchase order or similar document +submitted by Customer to Stream.io will have no effect. \ No newline at end of file diff --git a/packages/stream_feed_flutter_core/README.md b/packages/stream_feed_flutter_core/README.md new file mode 100644 index 000000000..aa9a2cc81 --- /dev/null +++ b/packages/stream_feed_flutter_core/README.md @@ -0,0 +1,128 @@ +# Official Core [Flutter SDK](https://getstream.io/activity-feeds/sdk/flutter/tutorial/) for [Stream Feeds](https://getstream.io/activity-feeds/) + +> The official Flutter core components for Stream Feeds, a service for +> building activity feeds. + +**🔗 Quick Links** + +- [Register](https://getstream.io/activity-feeds/try-for-free) to get an API key for Stream Activity Feeds +- [Tutorial](https://getstream.io/activity-feeds/sdk/flutter/tutorial/) to learn how to setup a timeline feed, follow other feeds and post new activities. +- [Stream Activity Feeds UI Kit](https://getstream.io/activity-feeds/ui-kit/) to jumpstart your design with notifications and social feeds + +#### Install from pub Pub + +Next step is to add `stream_feed_flutter_core` to your dependencies, to do that just open pubspec.yaml and add it inside the dependencies section. + +```yaml +dependencies: + flutter: + sdk: flutter + + stream_feed_flutter_core: ^[latest-version] +``` + +Then run `flutter packages get` + +This package requires no custom setup on any platform since it does not depend on any platform-specific dependency. + + +### Changelog + +Check out the [changelog on pub.dev](https://pub.dev/packages/stream_feed_flutter_core/changelog) to see the latest changes in the package. + +## Usage + +This package provides business logic to fetch common things required for integrating Stream Feeds into your application. +The core package allows more customisation and hence provides business logic but no UI components. + + +A simple example: + +```dart +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +void main() { + const apiKey = 'API-KEY'; + const userToken = 'USER-TOKEN'; + final client = StreamFeedClient( + apiKey, + token: const Token(userToken), + ); + + runApp( + MaterialApp( + /// Wrap your application in a `FeedProvider`. This requires a `FeedBloc`. + /// The `FeedBloc` is used to perform various Stream Feed operations. + builder: (context, child) => FeedProvider( + bloc: FeedBloc(client: client), + child: child!, + ), + home: Scaffold( + /// Returns `Activities`s for the given `feedGroup` in the `feedBuilder`. + body: FlatFeedCore( + feedGroup: 'user', + feedBuilder: (BuildContext context, activities, int index) { + return InkWell( + child: Column(children: [ + Text("${activities[index].actor}"), + Text("${activities[index].object}"), + ]), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => Scaffold( + /// Returns `Reaction`s for the given + /// `lookupValue` in the `reactionsBuilder`. + body: ReactionListCore( + lookupValue: activities[index].id!, + reactionsBuilder: (context, reactions, idx) => + Text("${reactions[index].data?["text"]}"), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); +} +``` + +## Docs + +This package provides business logic to fetch common things required for integrating Stream Feed into your application. +The core package allows more customisation and hence provides business logic but no UI components. +Use stream_feed for the low-level client. + +The package primarily contains three types of classes: + +1) Business Logic Components +2) Core Components + +### Business Logic Components + +These components allow you to have the maximum and lower-level control of the queries being executed. +The BLoCs we provide are: + +1) FeedBloc + +### Core Components + +Core components usually are an easy way to fetch data associated with Stream Feed which are decoupled from UI and often expose UI builders. +Data fetching can be controlled with the controllers of the respective core components. + +1) FlatFeedCore (Fetch a list of activities) +2) ReactionListCore (Fetch a list of reactions) +3) FeedProvider (Inherited widget providing bloc to the widget tree) + +## Contributing + +We welcome code changes that improve this library or fix a problem, +please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. +We are pleased to merge your code into the official repository. +Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. +See our license file for more details. \ No newline at end of file diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/activities_controller.dart b/packages/stream_feed_flutter_core/lib/src/bloc/activities_controller.dart new file mode 100644 index 000000000..9e9eda5dd --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/bloc/activities_controller.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_feed/stream_feed.dart'; + +@visibleForTesting +class ActivitiesController { + final Map>>> + _controllers = {}; + + /// Init controller for given feedGroup. + void init(String feedGroup) => _controllers[feedGroup] = + BehaviorSubject>>(); + + /// Retrieve with feedGroup the corresponding StreamController from the map + /// of controllers. + BehaviorSubject>>? _getController( + String feedGroup) => + _controllers[feedGroup]; + + /// Convert the Stream of activities to a List of activities. + List>? getActivities( + String feedGroup) => + _getController(feedGroup)?.valueOrNull; + + ///Retrieve Stream of activities with feedGroup + Stream>>? getStream( + String feedGroup) => + _getController(feedGroup)?.stream; + + /// Clear activities for a given feedGroup + void clearActivities(String feedGroup) { + _getController(feedGroup)!.value = []; + } + + /// Clear all controllers + void clearAllActivities(List feedGroups) { + feedGroups.forEach(init); + } + + /// Close all the controllers + void close() { + _controllers.forEach((key, value) { + value.close(); + }); + } + + /// Check if controller is not empty for given feedGroup + bool hasValue(String feedGroup) => + _getController(feedGroup)?.hasValue != null; + + /// Add a list of activities to the correct controller based on feedGroup + void add(String feedGroup, + List> activities) { + if (hasValue(feedGroup)) { + _getController(feedGroup)!.add(activities); + } + } + + /// Update the correct controller with given activities based on feedGroup + void update(String feedGroup, + List> activities) { + if (hasValue(feedGroup)) { + _getController(feedGroup)!.value = activities; + } + } + + /// Add an error event to the Stream based on feedGroup + void addError(String feedGroup, Object e, StackTrace stk) { + if (hasValue(feedGroup)) { + _getController(feedGroup)!.addError(e, stk); + } + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/bloc.dart b/packages/stream_feed_flutter_core/lib/src/bloc/bloc.dart new file mode 100644 index 000000000..47f1eddef --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/bloc/bloc.dart @@ -0,0 +1,4 @@ +export 'activities_controller.dart'; +export 'feed_bloc.dart'; +export 'provider.dart'; +export 'reactions_controller.dart'; 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 new file mode 100644 index 000000000..ab0601d4c --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/bloc/feed_bloc.dart @@ -0,0 +1,483 @@ +import 'package:rxdart/rxdart.dart'; +import 'package:stream_feed/stream_feed.dart'; +import 'package:stream_feed_flutter_core/src/bloc/activities_controller.dart'; +import 'package:stream_feed_flutter_core/src/bloc/reactions_controller.dart'; +import 'package:stream_feed_flutter_core/src/extensions.dart'; + +/// The generic version of feedBloc +/// +/// {@macro feedBloc} +/// {@macro genericParameters} +class GenericFeedBloc { + /// {@macro feedBloc} + GenericFeedBloc({required this.client, this.analyticsClient}); + + /// The underlying client instance + final StreamFeedClient client; + + /// The current User + StreamUser? get currentUser => client.currentUser; + + /// The underlying analytics client + final StreamAnalytics? analyticsClient; + + late ReactionsController reactionsController = ReactionsController(); + + late ActivitiesController activitiesController = + ActivitiesController(); + + /// The current activities list. + List>? getActivities( + String feedGroup) => + activitiesController.getActivities(feedGroup); + + /// The current reactions list. + List getReactions(String activityId, [Reaction? reaction]) => + reactionsController.getReactions(activityId, reaction); + + /// The current activities list as a stream. + Stream>>? getActivitiesStream( + String feedGroup) => + activitiesController.getStream(feedGroup); + + /// The current reactions list as a stream. + Stream>? getReactionsStream(String activityId, + [String? kind]) { + return reactionsController.getStream(activityId, kind); + } + + /// Clear activities for a given feedGroup + void clearActivities(String feedGroup) => + activitiesController.clearActivities(feedGroup); + + /// Clear all activities for a given feedGroups + void clearAllActivities(List feedGroups) => + activitiesController.clearAllActivities(feedGroups); + + final _queryActivitiesLoadingController = BehaviorSubject.seeded(false); + + final Map> _queryReactionsLoadingControllers = + {}; + + /// The stream notifying the state of queryReactions call. + Stream queryReactionsLoadingFor(String activityId) => + _queryReactionsLoadingControllers[activityId]!; + + /// The stream notifying the state of queryActivities call. + Stream get queryActivitiesLoading => + _queryActivitiesLoadingController.stream; + + /* ACTIVITIES */ + + /// {@template onAddActivity} + /// Add an activity to the feed in a reactive way + /// + /// For example a tweet + /// ```dart + /// FeedProvider.of(context).bloc.onAddActivity() + /// ``` + /// {@endtemplate} + + Future onAddActivity({ + required String feedGroup, + Map? data, + required String verb, + required String object, + String? userId, + List? to, + }) async { + final activity = Activity( + actor: client.currentUser?.ref, + verb: verb, + object: object, + extraData: data, + to: to, + ); + + final flatFeed = client.flatFeed(feedGroup, userId); + + final addedActivity = await flatFeed.addActivity(activity); + + // TODO(Sacha): this is a hack. Merge activity and enriched activity classes together + final enrichedActivity = await flatFeed + .getEnrichedActivityDetail(addedActivity.id!); + + final _activities = getActivities(feedGroup) ?? []; + + // ignore: cascade_invocations + _activities.insert(0, enrichedActivity); + + activitiesController.add(feedGroup, _activities); + + await trackAnalytics( + label: verb, + foreignId: activity.foreignId, + feedGroup: feedGroup, + ); //TODO: remove hardcoded value + return addedActivity; + } + + /// {@template onRemoveActivity} + /// Remove an Activity from the feed in a reactive way + /// + /// For example delete a tweet + /// ```dart + /// FeedProvider.of(context).bloc.onRemoveActivity() + /// ``` + /// {@endtemplate} + Future onRemoveActivity({ + required String feedGroup, + required String activityId, + }) async { + await client.flatFeed(feedGroup).removeActivityById(activityId); + final _activities = getActivities(feedGroup) ?? []; + // ignore: cascade_invocations + _activities.removeWhere((element) => element.id == activityId); + activitiesController.add(feedGroup, _activities); + } + + /* CHILD REACTIONS */ + + /// {@template onAddChildReaction} + /// Add child reaction to the feed in a reactive way + /// + /// For example to add a like to a comment + /// ```dart + /// FeedProvider.of(context).bloc.onAddReaction() + /// ``` + /// {@endtemplate} + Future onAddChildReaction({ + required String kind, + required Reaction reaction, + required GenericEnrichedActivity activity, + Map? data, + String? userId, + List? targetFeeds, + }) async { + final childReaction = await client.reactions.addChild(kind, reaction.id!, + data: data, userId: userId, targetFeeds: targetFeeds); + final _reactions = getReactions(activity.id!, reaction); + final reactionPath = _reactions.getReactionPath(reaction); + final indexPath = _reactions + .indexWhere((r) => r.id! == reaction.id); //TODO: handle null safety + + final childrenCounts = reactionPath.childrenCounts.unshiftByKind(kind); + final latestChildren = + reactionPath.latestChildren.unshiftByKind(kind, childReaction); + final ownChildren = + reactionPath.ownChildren.unshiftByKind(kind, childReaction); + + final updatedReaction = reactionPath.copyWith( + ownChildren: ownChildren, + latestChildren: latestChildren, + childrenCounts: childrenCounts, + ); + + // adds reaction to the rxstream + reactionsController + ..unshiftById(activity.id!, childReaction) + ..update(activity.id!, _reactions.updateIn(updatedReaction, indexPath)); + + return childReaction; + } + + /// {@template onRemoveChildReaction} + /// Remove child reactions from the feed in a reactive way + /// + /// For example to unlike a comment + /// ```dart + /// FeedProvider.of(context).bloc.onRemoveChildReaction() + /// ``` + /// {@endtemplate} + Future onRemoveChildReaction({ + required String kind, + required GenericEnrichedActivity activity, + required Reaction childReaction, + required Reaction parentReaction, + }) async { + await client.reactions.delete(childReaction.id!); + final _reactions = getReactions(activity.id!, parentReaction); + + final reactionPath = _reactions.getReactionPath(parentReaction); + + final indexPath = _reactions.indexWhere( + (r) => r.id! == parentReaction.id); //TODO: handle null safety + + final childrenCounts = + reactionPath.childrenCounts.unshiftByKind(kind, ShiftType.decrement); + final latestChildren = reactionPath.latestChildren + .unshiftByKind(kind, childReaction, ShiftType.decrement); + final ownChildren = reactionPath.ownChildren + .unshiftByKind(kind, childReaction, ShiftType.decrement); + + final updatedReaction = reactionPath.copyWith( + ownChildren: ownChildren, + latestChildren: latestChildren, + childrenCounts: childrenCounts, + ); + + // remove reaction from rxstream + reactionsController + ..unshiftById(activity.id!, childReaction, ShiftType.decrement) + ..update(activity.id!, _reactions.updateIn(updatedReaction, indexPath)); + } + + /// {@template onRemoveReaction} + /// Remove reaction from the feed in a reactive way + /// + /// For example to delete a comment under a tweet + /// ```dart + /// FeedProvider.of(context).bloc.onRemoveReaction() + /// ``` + /// {@endtemplate} + Future onRemoveReaction({ + required String kind, + required GenericEnrichedActivity activity, + required Reaction reaction, + required String feedGroup, + }) async { + await client.reactions.delete(reaction.id!); + await trackAnalytics( + label: 'un$kind', foreignId: activity.foreignId, feedGroup: feedGroup); + final _activities = getActivities(feedGroup) ?? [activity]; + final activityPath = _activities.getEnrichedActivityPath(activity); + + final indexPath = _activities + .indexWhere((a) => a.id! == activity.id); //TODO: handle null safety + + final reactionCounts = + activityPath.reactionCounts.unshiftByKind(kind, ShiftType.decrement); + + final latestReactions = activityPath.latestReactions + .unshiftByKind(kind, reaction, ShiftType.decrement); + + final ownReactions = activityPath.ownReactions + .unshiftByKind(kind, reaction, ShiftType.decrement); + + final updatedActivity = activityPath.copyWith( + ownReactions: ownReactions, + latestReactions: latestReactions, + reactionCounts: reactionCounts, + ); + + // remove reaction from the stream + reactionsController.unshiftById( + activity.id!, reaction, ShiftType.decrement); + + activitiesController.update( + feedGroup, _activities.updateIn(updatedActivity, indexPath)); + } + + /* REACTIONS */ + + /// {@template onAddReaction} + /// Add a new reaction to the feed + /// in a reactive way. + /// For example to add a comment under a tweet: + /// ```dart + /// FeedProvider.of(context).bloc.onAddReaction(kind:'comment', + /// activity: activities[idx],feedGroup:'user'), + /// data: {'text': trimmedText} + /// ``` + /// {@endtemplate} + + Future onAddReaction({ + required String kind, + required GenericEnrichedActivity activity, + required String feedGroup, + List? targetFeeds, + Map? data, + }) async { + final reaction = await client.reactions + .add(kind, activity.id!, targetFeeds: targetFeeds, data: data); + await trackAnalytics( + label: kind, foreignId: activity.foreignId, feedGroup: feedGroup); + final _activities = getActivities(feedGroup) ?? [activity]; + final activityPath = _activities.getEnrichedActivityPath(activity); + final indexPath = _activities + .indexWhere((a) => a.id! == activity.id); //TODO: handle null safety + + final reactionCounts = activityPath.reactionCounts.unshiftByKind(kind); + final latestReactions = + activityPath.latestReactions.unshiftByKind(kind, reaction); + final ownReactions = + activityPath.ownReactions.unshiftByKind(kind, reaction); + + final updatedActivity = activityPath.copyWith( + ownReactions: ownReactions, + latestReactions: latestReactions, + reactionCounts: reactionCounts, + ); + + // adds reaction to the stream + reactionsController.unshiftById(activity.id!, reaction); + + activitiesController.update( + feedGroup, + _activities //TODO: handle null safety + .updateIn(updatedActivity, indexPath)); + return reaction; + } + + /// Track analytics. + Future trackAnalytics({ + required String label, + String? foreignId, + required String feedGroup, + }) async { + analyticsClient != null + ? await analyticsClient!.trackEngagement(Engagement( + content: Content(foreignId: FeedId.fromId(foreignId)), + label: label, + feedId: FeedId.fromId(feedGroup), + )) + : print('warning: analytics: not enabled'); //TODO:logger + } + + /// {@template queryReactions} + /// Query the reactions stream (like, retweet, claps). + /// + /// Checkout our convenient core widget + /// [ReactionListCore] for displaying reactions easily + /// {@endtemplate} + Future queryReactions( + LookupAttribute lookupAttr, + String lookupValue, { + Filter? filter, + int? limit, + String? kind, + EnrichmentFlags? flags, + }) async { + reactionsController.init(lookupValue); + _queryReactionsLoadingControllers[lookupValue] = + BehaviorSubject.seeded(false); + if (_queryReactionsLoadingControllers[lookupValue]?.value == true) return; + + if (reactionsController.hasValue(lookupValue)) { + _queryReactionsLoadingControllers[lookupValue]!.add(true); + } + + try { + final oldReactions = List.from(getReactions(lookupValue)); + final reactionsResponse = await client.reactions.filter( + lookupAttr, + lookupValue, + filter: filter, + flags: flags, + limit: limit, + kind: kind, + ); + final temp = oldReactions + reactionsResponse; + reactionsController.add(lookupValue, temp); + } catch (e, stk) { + // reset loading controller + _queryReactionsLoadingControllers[lookupValue]?.add(false); + if (reactionsController.hasValue(lookupValue)) { + _queryReactionsLoadingControllers[lookupValue]?.addError(e, stk); + } else { + reactionsController.addError(lookupValue, e, stk); + } + } + } + + /// {@template queryEnrichedActivities} + /// Query the activities stream + /// + /// Checkout our convenient core widget [FlatFeedCore] + /// to display activities easily + /// {@endtemplate} + Future queryEnrichedActivities({ + required String feedGroup, + int? limit, + int? offset, + String? session, + Filter? filter, + EnrichmentFlags? flags, + String? ranking, + String? userId, + + //TODO: no way to parameterized marker? + }) async { + activitiesController.init(feedGroup); + if (_queryActivitiesLoadingController.value == true) return; + + if (activitiesController.hasValue(feedGroup)) { + _queryActivitiesLoadingController.add(true); + } + + try { + final activitiesResponse = await client + .flatFeed(feedGroup, userId) + .getEnrichedActivities( + limit: limit, + offset: offset, + session: session, + filter: filter, + flags: flags, + ranking: ranking, + ); + + activitiesController.add(feedGroup, activitiesResponse); + if (activitiesController.hasValue(feedGroup) && + _queryActivitiesLoadingController.value) { + _queryActivitiesLoadingController.sink.add(false); + } + } catch (e, stk) { + // reset loading controller + _queryActivitiesLoadingController.add(false); + if (activitiesController.hasValue(feedGroup)) { + _queryActivitiesLoadingController.addError(e, stk); + } else { + activitiesController.addError(feedGroup, e, stk); + } + } + } + + /* FOLLOW */ + + /// Follows the given [otherUser] id. + Future followFlatFeed( + String otherUser, + ) async { + final timeline = client.flatFeed('timeline'); + final user = client.flatFeed('user', otherUser); + await timeline.follow(user); + } + + /// Unfollows the given [otherUser] id. + Future unfollowFlatFeed( + String otherUser, + ) async { + final timeline = client.flatFeed('timeline'); + final user = client.flatFeed('user', otherUser); + await timeline.unfollow(user); + } + + /// Checks whether the current user is following a feed with the given + /// [userId]. + /// + /// It filters the request such that if the current user is in fact + /// following the given user, one user will be returned that matches the + /// current user, thus indicating that the current user does follow the given + /// user. If no results are found, this means that the current user is not + /// following the given user. + Future isFollowingUser(String userId) async { + final following = await client.flatFeed('timeline').following( + limit: 1, + offset: 0, + filter: [ + FeedId.id('user:$userId'), + ], + ); + return following.isNotEmpty; + } + + void dispose() { + activitiesController.close(); + reactionsController.close(); + _queryActivitiesLoadingController.close(); + _queryReactionsLoadingControllers.forEach((key, value) { + value.close(); + }); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart b/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart new file mode 100644 index 000000000..0b1f23836 --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/bloc/provider.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter_core/src/bloc/feed_bloc.dart'; + +/// The generic version of [FeedProvider] +/// +/// {@macro feedProvider} +/// {@macro genericParameters} +class GenericFeedProvider extends InheritedWidget { + /// {@macro feedProvider} + const GenericFeedProvider({ + Key? key, + required this.bloc, + required Widget child, + }) : super(key: key, child: child); + + /// Obtains the nearest widget of type [GenericFeedProvider] + factory GenericFeedProvider.of(BuildContext context) { + final result = context.dependOnInheritedWidgetOfExactType< + GenericFeedProvider>(); + assert(result != null, + 'No GenericFeedProvider<$A, $Ob, $T, $Or> found in context'); + return result!; + } + final GenericFeedBloc bloc; + + @override + bool updateShouldNotify(GenericFeedProvider old) => bloc != old.bloc; // + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty>('bloc', bloc)); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/bloc/reactions_controller.dart b/packages/stream_feed_flutter_core/lib/src/bloc/reactions_controller.dart new file mode 100644 index 000000000..3423e3405 --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/bloc/reactions_controller.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_feed/stream_feed.dart'; +import 'package:stream_feed_flutter_core/src/extensions.dart'; + +@visibleForTesting +class ReactionsController { + final Map>> _controller = {}; + + /// Init controller for given lookupValue. + void init(String lookupValue) => + _controller[lookupValue] = BehaviorSubject>(); + + /// Retrieve with lookupValue the corresponding StreamController from the map + /// of controllers. + BehaviorSubject>? _getController(String lookupValue) => + _controller[lookupValue]; //TODO: handle null safety + + ///Retrieve Stream of reactions with activityId and filter it if necessary + Stream>? getStream(String lookupValue, [String? kind]) { + final isFiltered = kind != null; + final reactionStream = _getController(lookupValue)?.stream; + return isFiltered + ? reactionStream?.map((reactions) => + reactions.where((reaction) => reaction.kind == kind).toList()) + : reactionStream; //TODO: handle null safety + } + + /// Convert the Stream of reactions to a List of reactions. + List getReactions(String lookupValue, [Reaction? reaction]) => + _getController(lookupValue)?.valueOrNull ?? + (reaction != null ? [reaction] : []); + + /// Check if controller is not empty. + bool hasValue(String lookupValue) => + _getController(lookupValue)?.hasValue != null; + + /// Lookup latest Reactions by Id and inserts the given reaction to the + /// beginning of the list. + void unshiftById(String lookupValue, Reaction reaction, + [ShiftType type = ShiftType.increment]) => + _controller.unshiftById(lookupValue, reaction, type); + + /// Close every stream controllers. + void close() => _controller.forEach((key, value) { + value.close(); + }); + + /// Update controller value with given reactions. + void update(String lookupValue, List reactions) { + if (hasValue(lookupValue)) { + _getController(lookupValue)!.value = reactions; + } + } + + /// Add given reactions to the correct controller. + void add(String lookupValue, List temp) { + if (hasValue(lookupValue)) { + _getController(lookupValue)!.add(temp); + } //TODO: handle null safety + } + + /// Add error to the correct controller. + void addError(String lookupValue, Object e, StackTrace stk) { + if (hasValue(lookupValue)) { + _getController(lookupValue)!.addError(e, stk); + } //TODO: handle null safety + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/extensions.dart b/packages/stream_feed_flutter_core/lib/src/extensions.dart new file mode 100644 index 000000000..5e137c97f --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/extensions.dart @@ -0,0 +1,140 @@ +import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_feed/stream_feed.dart'; + +@visibleForTesting +extension ReactionX on List { + ///Filter reactions by reaction kind + List filterByKind(String kind) => + where((reaction) => reaction.kind == kind).toList(); + + Reaction getReactionPath(Reaction reaction) => + firstWhere((r) => r.id! == reaction.id!); //TODO; handle doesn't exist +} + +@visibleForTesting +extension EnrichedActivityX + on List> { + GenericEnrichedActivity getEnrichedActivityPath( + GenericEnrichedActivity enrichedActivity) => + firstWhere( + (e) => e.id! == enrichedActivity.id!); //TODO; handle doesn't exist + +} + +@visibleForTesting +extension UpdateIn + on List> { + List> updateIn( + GenericEnrichedActivity enrichedActivity, int indexPath) { + var result = List>.from(this); + result.isNotEmpty + ? result.removeAt(indexPath) //removes the item at index 1 + : null; + result.insert(indexPath, enrichedActivity); + return result; + } +} + +@visibleForTesting +extension UpdateInReaction on List { + List updateIn(Reaction enrichedActivity, int indexPath) { + var result = List.from(this); + result.isNotEmpty + ? result.removeAt(indexPath) //removes the item at index 1 + : null; + result.insert(indexPath, enrichedActivity); + return result; + } +} + +@visibleForTesting +extension UnshiftMapList on Map>? { + //TODO: maybe refactor to an operator maybe [Reaction] + Reaction + Map> unshiftByKind(String kind, Reaction reaction, + [ShiftType type = ShiftType.increment]) { + Map>? result; + result = this; + final latestReactionsByKind = this?[kind] ?? []; + if (result != null) { + result = {...result, kind: latestReactionsByKind.unshift(reaction, type)}; + } else { + result = { + //TODO: handle decrement: should we throw? + kind: [reaction] + }; + } + return result; + } +} + +@visibleForTesting +extension UnshiftMapController + on Map>>? { + ///Lookup latest Reactions by Id and inserts the given reaction to the beginning of the list + Map>> unshiftById( + String activityId, Reaction reaction, + [ShiftType type = ShiftType.increment]) { + Map>>? result; + result = this; + final latestReactionsById = this?[activityId]?.valueOrNull ?? []; + if (result != null && result[activityId] != null) { + result[activityId]!.add(latestReactionsById.unshift(reaction, type)); + } else { + result = { + //TODO: handle decrement + activityId: BehaviorSubject.seeded([reaction]) + }; + } + return result; + } +} + +//TODO: find a better name +enum ShiftType { increment, decrement } + +@visibleForTesting +extension UnshiftMapInt on Map? { + Map unshiftByKind(String kind, + [ShiftType type = ShiftType.increment]) { + Map? result; + result = this; + final reactionCountsByKind = result?[kind] ?? 0; + if (result != null) { + result = { + ...result, + kind: reactionCountsByKind.unshift(type) + }; //+1 if increment else -1 + } else { + if (type == ShiftType.increment) { + result = {kind: 1}; + } else { + result = {kind: 0}; + } + } + return result; + } +} + +@visibleForTesting +extension Unshift on List { + List unshift(T item, [ShiftType type = ShiftType.increment]) { + final result = List.from(this); + if (type == ShiftType.increment) { + return [item, ...result]; + } else { + return result..remove(item); + } + } +} + +@visibleForTesting +extension UnshiftInt on int { + int unshift([ShiftType type = ShiftType.increment]) { + if (type == ShiftType.increment) { + return this + 1; + } else { + return this - 1; + } + } +} 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 new file mode 100644 index 000000000..c8069c12e --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/flat_feed_core.dart @@ -0,0 +1,131 @@ +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'; + +/// The generic version of [FlatFeedCore] +/// +/// {@macro flatFeedCore} +/// {@macro genericParameters} +class GenericFlatFeedCore extends StatefulWidget { + ///{@macro flatFeedCore} + const GenericFlatFeedCore({ + Key? key, + required this.feedGroup, + required this.feedBuilder, + this.onErrorWidget = const ErrorStateWidget(), + this.onProgressWidget = const ProgressStateWidget(), + this.onEmptyWidget = + const EmptyStateWidget(message: 'No activities to display'), + this.limit, + this.offset, + this.session, + this.filter, + this.flags, + this.ranking, + this.userId, + }) : super(key: key); + + /// A builder that let you build a ListView of EnrichedActivity based Widgets + final EnrichedFeedBuilder feedBuilder; + + /// An error widget to show when an error occurs + final Widget onErrorWidget; + + /// A progress widget to show when a request is in progress + final Widget onProgressWidget; + + /// A widget to show when the feed is empty + final Widget onEmptyWidget; + + /// The limit of activities to fetch + final int? limit; + + /// The offset of activities to fetch + final int? offset; + + /// The session to use for the request + final String? session; + + /// The filter to use for the request + final Filter? filter; + + /// The flags to use for the request + final EnrichmentFlags? flags; + + /// The ranking to use for the request + final String? ranking; + + /// The user id to use for the request + final String? userId; + + /// The feed group to use for the request + final String feedGroup; + + @override + _GenericFlatFeedCoreState createState() => + _GenericFlatFeedCoreState(); +} + +class _GenericFlatFeedCoreState + extends State> { + late GenericFeedBloc bloc; + + /// Fetches initial reactions and updates the widget + Future loadData() => bloc.queryEnrichedActivities( + feedGroup: widget.feedGroup, + limit: widget.limit, + offset: widget.offset, + session: widget.session, + filter: widget.filter, + flags: widget.flags, + ranking: widget.ranking, + userId: widget.userId, + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + bloc = GenericFeedProvider.of(context).bloc; + loadData(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>>( + stream: bloc.getActivitiesStream(widget.feedGroup), + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget + .onErrorWidget; //TODO: snapshot.error / do we really want backend error here? + } + if (!snapshot.hasData) { + return widget.onProgressWidget; + } + final activities = snapshot.data!; + if (activities.isEmpty) { + return widget.onEmptyWidget; + } + return ListView.builder( + itemCount: activities.length, + itemBuilder: (context, idx) => widget.feedBuilder( + context, + activities, + idx, + ), + ); + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty>('bloc', bloc)); + } +} 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 new file mode 100644 index 000000000..8de4645e5 --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/reactions_list_core.dart @@ -0,0 +1,116 @@ +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/states.dart'; +import 'package:stream_feed_flutter_core/src/typedefs.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +// ignore_for_file: cascade_invocations + +//TODO: other things to add to core: FollowListCore, UserListCore + +/// The generic version of [ReactionListCore] +/// +/// {@macro reactionListCore} +/// {@macro genericParameters} +class GenericReactionListCore extends StatefulWidget { + ///{@macro reactionListCore} + const GenericReactionListCore({ + Key? key, + required this.reactionsBuilder, + required this.lookupValue, + this.onErrorWidget = const ErrorStateWidget(), + this.onProgressWidget = const ProgressStateWidget(), + this.onEmptyWidget = + const EmptyStateWidget(message: 'No comments to display'), + this.lookupAttr = LookupAttribute.activityId, + this.filter, + this.flags, + this.kind, + this.limit, + }) : super(key: key); + + /// {@macro reactionsBuilder} + final ReactionsBuilder reactionsBuilder; + + ///{@macro onErrorWidget} + final Widget onErrorWidget; + + ///{@macro onProgressWidget} + final Widget onProgressWidget; + + ///{@macro onEmptyWidget} + final Widget onEmptyWidget; + + ///{@macro lookupAttr} + final LookupAttribute lookupAttr; + + /// TODO: document me + final String lookupValue; + + /// {@macro filter} + final Filter? filter; + + /// {@macro enrichmentFlags} + final EnrichmentFlags? flags; + + /// The limit of activities to fetch + final int? limit; + + /// The kind of reaction, usually i.e 'comment', 'like', 'reaction' etc + final String? kind; + + @override + _GenericReactionListCoreState createState() => + _GenericReactionListCoreState(); +} + +class _GenericReactionListCoreState + extends State> { + late GenericFeedBloc bloc; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + bloc = GenericFeedProvider.of(context).bloc; + loadData(); + } + + /// Fetches initial reactions and updates the widget + Future loadData() => bloc.queryReactions( + widget.lookupAttr, + widget.lookupValue, + filter: widget.filter, + flags: widget.flags, + limit: widget.limit, + kind: widget.kind, + ); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: bloc.getReactionsStream(widget.lookupValue, widget.kind), + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.onErrorWidget; //snapshot.error + } + if (!snapshot.hasData) { + return widget.onProgressWidget; + } + final reactions = snapshot.data!; + if (reactions.isEmpty) { + return widget.onEmptyWidget; + } + return ListView.builder( + shrinkWrap: true, + itemCount: reactions.length, + itemBuilder: (context, idx) => widget.reactionsBuilder( + context, + reactions, + idx, + ), + ); + }); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/states/empty.dart b/packages/stream_feed_flutter_core/lib/src/states/empty.dart new file mode 100644 index 000000000..5bd6f8a5e --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/states/empty.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// {@template onEmptyWidget} +/// A builder for building a widget when the state is empty. +/// An Empty State Widget to be used for empty states +/// {@endtemplate} +class EmptyStateWidget extends StatelessWidget { + /// Builds an [EmptyStateWidget]. + /// {@macro onEmptyWidget} + const EmptyStateWidget({ + Key? key, + this.message = 'Nothing here...', + }) : super(key: key); + + /// The message to be displayed + final String message; + + @override + Widget build(BuildContext context) { + return Center(child: Text(message)); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('message', message)); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/states/error.dart b/packages/stream_feed_flutter_core/lib/src/states/error.dart new file mode 100644 index 000000000..71191ed2c --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/states/error.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// {@template onErrorWidget} +/// A builder for building a widget when an error occurs. +/// +/// [ErrorStateWidget] is the default widget +/// {@endtemplate} + +class ErrorStateWidget extends StatelessWidget { + /// Builds an [ErrorStateWidget]. + /// {@macro onErrorWidget} + const ErrorStateWidget({ + Key? key, + this.message = 'Sorry an error has occured', + }) : super(key: key); + + /// The error message to display + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + const Icon( + Icons.announcement, + color: Colors.red, + size: 40, + ), + const SizedBox(height: 10), + Text( + message, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('message', message)); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/states/progress.dart b/packages/stream_feed_flutter_core/lib/src/states/progress.dart new file mode 100644 index 000000000..a209b1e00 --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/states/progress.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// {@template onProgressWidget} +/// A builder for building widgets to show on progress +/// +/// [ProgressStateWidget] is the default progress indicator to display progress +/// state in [GenericFlatFeedCore] +/// {@endtemplate} +class ProgressStateWidget extends StatelessWidget { + /// Builds a [ProgressStateWidget]. + /// {@macro onProgressWidget} + const ProgressStateWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator(), + ); + } +} diff --git a/packages/stream_feed_flutter_core/lib/src/states/states.dart b/packages/stream_feed_flutter_core/lib/src/states/states.dart new file mode 100644 index 000000000..e545488ab --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/states/states.dart @@ -0,0 +1,3 @@ +export 'empty.dart'; +export 'error.dart'; +export 'progress.dart'; diff --git a/packages/stream_feed_flutter_core/lib/src/typedefs.dart b/packages/stream_feed_flutter_core/lib/src/typedefs.dart new file mode 100644 index 000000000..5dd250e5a --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/src/typedefs.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feed_flutter_core/src/bloc/bloc.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +/* BUILDERS */ +/// {@template enrichedFeedBuilder} +/// A builder that allows building a ListView of EnrichedActivity based Widgets +/// {@endtemplate} +typedef EnrichedFeedBuilder = Widget Function( + BuildContext context, + List> activities, + int idx, +); + +/// {@template reactionsBuilder} +/// A builder that allows building a ListView of Reaction based Widgets +/// {@endtemplate} +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; diff --git a/packages/stream_feed_flutter_core/lib/stream_feed_flutter_core.dart b/packages/stream_feed_flutter_core/lib/stream_feed_flutter_core.dart new file mode 100644 index 000000000..d246ae6ac --- /dev/null +++ b/packages/stream_feed_flutter_core/lib/stream_feed_flutter_core.dart @@ -0,0 +1,10 @@ +library stream_feed_flutter_core; + +export 'package:stream_feed/stream_feed.dart'; + +export 'src/bloc/bloc.dart'; +export 'src/extensions.dart'; +export 'src/flat_feed_core.dart'; +export 'src/reactions_list_core.dart'; +export 'src/states/states.dart'; +export 'src/typedefs.dart'; diff --git a/packages/stream_feed_flutter_core/pubspec.yaml b/packages/stream_feed_flutter_core/pubspec.yaml new file mode 100644 index 000000000..5fbc37978 --- /dev/null +++ b/packages/stream_feed_flutter_core/pubspec.yaml @@ -0,0 +1,20 @@ +name: stream_feed_flutter_core +description: Stream Feed official Flutter SDK Core. Build your own feed experience using Dart and Flutter. +version: 0.4.0 +repository: https://github.com/GetStream/stream-feed-flutter +issue_tracker: https://github.com/GetStream/stream-feed-flutter/issues +homepage: https://getstream.io/ +environment: + sdk: '>=2.14.0 <3.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + rxdart: ^0.27.1 + stream_feed: ^0.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^0.1.2 diff --git a/packages/stream_feed_flutter_core/test/bloc/bloc_test.dart b/packages/stream_feed_flutter_core/test/bloc/bloc_test.dart new file mode 100644 index 000000000..98620d9f1 --- /dev/null +++ b/packages/stream_feed_flutter_core/test/bloc/bloc_test.dart @@ -0,0 +1,375 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feed_flutter_core/src/bloc/activities_controller.dart'; +import 'package:stream_feed_flutter_core/src/bloc/reactions_controller.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +import '../mocks.dart'; + +void main() { + late MockStreamFeedClient mockClient; + late MockReactions mockReactions; + late LookupAttribute lookupAttr; + late String lookupValue; + late Filter filter; + late int limit; + late String kind; + late List reactions; + late String activityId; + late GenericFeedBloc bloc; + late MockReactionsControllers mockReactionControllers; + late String feedGroup; + late MockFeedAPI mockFeed; + late MockFeedAPI mockSecondFeed; + late Activity activity; + late MockStreamUser mockUser; + late String userId; + late MockStreamUser mockSecondUser; + late String secondUserId; + late GenericEnrichedActivity enrichedActivity; + late Activity addedActivity; + late List following; + + tearDown(() => bloc.dispose()); + + setUp(() { + mockFeed = MockFeedAPI(); + mockSecondFeed = MockFeedAPI(); + mockReactions = MockReactions(); + mockReactionControllers = MockReactionsControllers(); + mockClient = MockStreamFeedClient(); + lookupAttr = LookupAttribute.activityId; + lookupValue = 'ed2837a6-0a3b-4679-adc1-778a1704852d'; + filter = Filter().idGreaterThan('e561de8f-00f1-11e4-b400-0cc47a024be0'); + + limit = 5; + kind = 'like'; + activityId = 'activityId'; + userId = 'john-doe'; + feedGroup = 'user'; + reactions = [ + Reaction( + id: 'id', + kind: 'like', + activityId: activityId, + childrenCounts: const { + 'like': 0, + }, + latestChildren: const {'like': []}, + ownChildren: const {'like': []}, + ) + ]; + when(() => mockClient.reactions).thenReturn(mockReactions); + + activity = Activity(actor: 'test', verb: 'post', object: 'test'); + + addedActivity = const Activity( + id: 'test', + actor: 'test', + verb: 'post', + object: 'test', + ); + + enrichedActivity = const GenericEnrichedActivity( + id: 'test', + actor: 'test', + verb: 'post', + object: 'test', + ); + + following = []; + + mockUser = MockStreamUser(); + userId = '1'; + mockSecondUser = MockStreamUser(); + secondUserId = '2'; + when(() => mockClient.flatFeed('user', 'test')).thenReturn(mockFeed); + when(() => mockClient.flatFeed('timeline')).thenReturn(mockFeed); + when(() => mockClient.flatFeed('user', userId)).thenReturn(mockFeed); + when(() => mockClient.flatFeed('user', secondUserId)).thenReturn(mockFeed); + when(() => mockFeed.addActivity(activity)) + .thenAnswer((invocation) async => addedActivity); + when(() => mockClient.currentUser).thenReturn(mockUser); + when(() => mockUser.id).thenReturn(userId); + when(() => mockSecondUser.id).thenReturn(secondUserId); + when(() => mockUser.ref).thenReturn('test'); + bloc = GenericFeedBloc(client: mockClient); + when(() => + mockFeed.getEnrichedActivityDetail( + addedActivity.id!)).thenAnswer((_) async => enrichedActivity); + }); + + group('ReactionBloc', () { + test('queryReactions', () async { + when(() => mockReactions.filter( + lookupAttr, + lookupValue, + filter: filter, + limit: limit, + kind: kind, + )).thenAnswer((_) async => reactions); + await bloc.queryReactions( + lookupAttr, + lookupValue, + filter: filter, + limit: limit, + kind: kind, + ); + verify(() => mockReactions.filter(lookupAttr, lookupValue, + filter: filter, limit: limit, kind: kind)).called(1); + await expectLater(bloc.getReactionsStream(lookupValue), emits(reactions)); + }); + + test('onAddReaction', () async { + final controller = ActivitiesController(); + const addedReaction = Reaction(id: '1'); + // ignore: cascade_invocations + controller.init(feedGroup); + bloc.activitiesController = controller; + // ignore: cascade_invocations + bloc.reactionsController = mockReactionControllers; + expect(bloc.activitiesController.hasValue(feedGroup), true); + when(() => mockReactionControllers.getReactions(activityId)) + .thenAnswer((_) => reactions); + expect(bloc.reactionsController.getReactions(activityId), reactions); + when(() => mockReactions.add( + kind, + activityId, + )).thenAnswer((_) async => addedReaction); + + await bloc.onAddReaction( + activity: GenericEnrichedActivity(id: activityId), + feedGroup: feedGroup, + kind: kind, + ); + verify(() => mockReactions.add( + kind, + activityId, + )).called(1); + await expectLater( + bloc.getActivitiesStream(feedGroup), + emits([ + GenericEnrichedActivity(id: activityId, reactionCounts: const { + 'like': 1 + }, ownReactions: const { + 'like': [addedReaction] + }, latestReactions: const { + 'like': [addedReaction] + }) + ])); + + //TODO: test reaction Stream + }); + test('onRemoveReaction', () async { + final controller = ActivitiesController(); + const reactionId = 'reactionId'; + const reaction = Reaction(id: reactionId); + controller.init(feedGroup); + bloc.activitiesController = controller; + // ignore: cascade_invocations + bloc.reactionsController = mockReactionControllers; + when(() => mockReactionControllers.getReactions(activityId)) + .thenAnswer((_) => reactions); + expect(bloc.reactionsController.getReactions(activityId), reactions); + when(() => mockReactions.delete(reactionId)) + .thenAnswer((invocation) => Future.value()); + + await bloc.onRemoveReaction( + activity: + GenericEnrichedActivity(id: activityId, reactionCounts: const { + 'like': 1 + }, ownReactions: const { + 'like': [reaction] + }, latestReactions: const { + 'like': [reaction] + }), + feedGroup: feedGroup, + kind: kind, + reaction: reaction, + ); + verify(() => mockReactions.delete(reactionId)).called(1); + await expectLater( + bloc.getActivitiesStream(feedGroup), + emits([ + GenericEnrichedActivity( + id: activityId, + reactionCounts: const {'like': 0}, + ownReactions: const {'like': []}, + latestReactions: const {'like': []}) + ])); + + //TODO: test reaction Stream + }); + + group('child reactions', () { + test('onAddChildReaction', () async { + final controller = ReactionsController(); + const parentId = 'parentId'; + const childId = 'childId'; + final now = DateTime.now(); + final reactedActivity = GenericEnrichedActivity( + id: 'id', + time: now, + actor: const User( + data: { + 'name': 'Rosemary', + 'handle': '@rosemary', + 'subtitle': 'likes playing fresbee in the park', + 'profile_image': + 'https://randomuser.me/api/portraits/women/20.jpg', + }, + ), + ); + controller.init(reactedActivity.id!); + bloc.reactionsController = controller; + expect(bloc.reactionsController.hasValue(reactedActivity.id!), true); + final parentReaction = Reaction( + id: parentId, kind: 'comment', activityId: reactedActivity.id); + final childReaction = + Reaction(id: childId, kind: 'like', activityId: reactedActivity.id); + + when(() => mockReactions.addChild('like', parentId)) + .thenAnswer((_) async => childReaction); + await bloc.onAddChildReaction( + kind: 'like', activity: reactedActivity, reaction: parentReaction); + + verify(() => mockClient.reactions.addChild( + 'like', + parentId, + )).called(1); + await expectLater( + bloc.getReactionsStream(reactedActivity.id!), + emits([ + Reaction( + id: parentId, + kind: 'comment', + activityId: reactedActivity.id, + childrenCounts: const {'like': 1}, + latestChildren: { + 'like': [childReaction] + }, + ownChildren: { + 'like': [childReaction] + }, + ) + ])); + + //TODO: test reaction Stream + }); + + test('onRemoveChildReaction', () async { + final controller = ReactionsController(); + final now = DateTime.now(); + const childId = 'childId'; + const parentId = 'parentId'; + final reactedActivity = GenericEnrichedActivity( + id: 'id', + time: now, + actor: const User(data: { + 'name': 'Rosemary', + 'handle': '@rosemary', + 'subtitle': 'likes playing fresbee in the park', + 'profile_image': 'https://randomuser.me/api/portraits/women/20.jpg', + }), + ); + const childReaction = Reaction(id: childId, kind: 'like'); + final parentReaction = Reaction( + id: parentId, + kind: 'comment', + activityId: reactedActivity.id, + childrenCounts: const {'like': 1}, + latestChildren: const { + 'like': [childReaction] + }, + ownChildren: const { + 'like': [childReaction] + }, + ); + + controller.init(reactedActivity.id!); + bloc.reactionsController = controller; + expect(bloc.reactionsController.hasValue(reactedActivity.id!), true); + when(() => mockReactions.delete(childId)) + .thenAnswer((_) async => Future.value()); + await bloc.onRemoveChildReaction( + kind: 'like', + activity: reactedActivity, + parentReaction: parentReaction, + childReaction: childReaction); + + verify(() => mockClient.reactions.delete(childId)).called(1); + + await expectLater( + bloc.getReactionsStream(reactedActivity.id!), + emits([ + Reaction( + id: parentId, + kind: 'comment', + activityId: reactedActivity.id, + childrenCounts: const {'like': 0}, + latestChildren: const {'like': []}, + ownChildren: const {'like': []}, + ) + ])); + }); + }); + }); + + group('Activities', () { + test( + '''When we await onAddActivity the stream gets updated with the new expected value''', + () async { + final controller = ActivitiesController(); + // ignore: cascade_invocations + controller.init(feedGroup); + bloc.activitiesController = controller; + await bloc.onAddActivity( + feedGroup: 'user', + verb: 'post', + object: 'test', + userId: 'test', + ); + verify(() => mockFeed.addActivity(activity)).called(1); + verify(() => mockFeed.getEnrichedActivityDetail(addedActivity.id!)) + .called(1); + await expectLater( + bloc.getActivitiesStream(feedGroup), emits([enrichedActivity])); + }); + }); + + group('Follows', () { + test('isFollowingUser', () async { + when(() => mockClient.flatFeed('timeline')).thenReturn(mockFeed); + when(() => mockFeed.following( + limit: 1, + offset: 0, + filter: [ + FeedId.id('user:2'), + ], + )).thenAnswer((_) async => following); + + final isFollowing = await bloc.isFollowingUser('2'); + expect(isFollowing, false); + }); + + test('followFlatFeed', () async { + when(() => mockClient.flatFeed('timeline')).thenReturn(mockFeed); + when(() => mockClient.flatFeed('user', '2')).thenReturn(mockSecondFeed); + when(() => mockFeed.follow(mockSecondFeed)) + .thenAnswer((_) => Future.value()); + + await bloc.followFlatFeed('2'); + verify(() => mockFeed.follow(mockSecondFeed)).called(1); + }); + + test('unfollowFlatFeed', () async { + when(() => mockClient.flatFeed('timeline')).thenReturn(mockFeed); + when(() => mockClient.flatFeed('user', '2')).thenReturn(mockSecondFeed); + when(() => mockFeed.unfollow(mockSecondFeed)) + .thenAnswer((_) => Future.value()); + + await bloc.unfollowFlatFeed('2'); + verify(() => mockFeed.unfollow(mockSecondFeed)).called(1); + }); + }); +} diff --git a/packages/stream_feed_flutter_core/test/bloc/extensions_test.dart b/packages/stream_feed_flutter_core/test/bloc/extensions_test.dart new file mode 100644 index 000000000..a98065c31 --- /dev/null +++ b/packages/stream_feed_flutter_core/test/bloc/extensions_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_feed/stream_feed.dart'; +import 'package:stream_feed_flutter_core/src/extensions.dart'; + +main() { + group('unshiftByKind', () { + group('Map', () { + late Map childrenCounts; + late Map expectedResult; + test('increment', () { + childrenCounts = {'like': 10, 'post': 27, 'repost': 69}; + expectedResult = {'like': 11, 'post': 27, 'repost': 69}; + expect(childrenCounts.unshiftByKind('like'), expectedResult); + }); + + test('decrement', () { + childrenCounts = {'like': 10, 'post': 27, 'repost': 69}; + expectedResult = {'like': 9, 'post': 27, 'repost': 69}; + expect(childrenCounts.unshiftByKind('like', ShiftType.decrement), + expectedResult); + }); + //TODO: null + }); + + group('Map>>', () { + late Map>> ownChildren; + late Map> expectedResult; + + test('increment', () async { + ownChildren = { + 'like': BehaviorSubject.seeded([const Reaction(id: 'id')]), + 'post': BehaviorSubject.seeded([const Reaction(id: 'id2')]), + }; + expectedResult = { + 'like': [const Reaction(id: 'id3'), const Reaction(id: 'id')] + }; + ownChildren.unshiftById('like', const Reaction(id: 'id3')); + await expectLater( + ownChildren['like']!.stream, emits(expectedResult['like'])); + }); + + test('decrement', () async { + ownChildren = { + 'like': BehaviorSubject.seeded( + [const Reaction(id: 'id3'), Reaction(id: 'id')]), + 'post': BehaviorSubject.seeded([const Reaction(id: 'id2')]) + }; + expectedResult = { + 'like': [Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')] + }; + ownChildren.unshiftById( + 'like', Reaction(id: 'id3'), ShiftType.decrement); + await expectLater( + ownChildren['like']!.stream, emits(expectedResult['like'])); + }); + }); + group('Map>', () { + late Map> ownChildren; + late Map> expectedResult; + + group('increment', () { + test('not null', () { + ownChildren = { + 'like': [Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')], + }; + expectedResult = { + 'like': [Reaction(id: 'id3'), Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')] + }; + + expect(ownChildren.unshiftByKind('like', Reaction(id: 'id3')), + expectedResult); + }); + + test('null', () { + ownChildren = { + 'like': [Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')], + }; + expectedResult = { + 'like': [Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')], + 'repost': [Reaction(id: 'id3')] + }; + + expect(ownChildren.unshiftByKind('repost', Reaction(id: 'id3')), + expectedResult); + }); + }); + + test('decrement', () { + ownChildren = { + 'like': [Reaction(id: 'id3'), Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')] + }; + expectedResult = { + 'like': [Reaction(id: 'id')], + 'post': [Reaction(id: 'id2')] + }; + expect( + ownChildren.unshiftByKind( + 'like', Reaction(id: 'id3'), ShiftType.decrement), + expectedResult); + }); + }); + }); +} diff --git a/packages/stream_feed_flutter_core/test/flat_feed_core_test.dart b/packages/stream_feed_flutter_core/test/flat_feed_core_test.dart new file mode 100644 index 000000000..0b2c233ec --- /dev/null +++ b/packages/stream_feed_flutter_core/test/flat_feed_core_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets('FlatFeedCore', (tester) async { + final mockClient = MockStreamFeedClient(); + final mockFeed = MockFeedAPI(); + final mockStreamAnalytics = MockStreamAnalytics(); + final activities = [ + GenericEnrichedActivity( + // reactionCounts: { + // 'like': 139, + // 'repost': 23, + // }, + time: DateTime.now(), + actor: const User( + data: { + 'name': 'Rosemary', + 'handle': '@rosemary', + 'subtitle': 'likes playing fresbee in the park', + 'profile_image': 'https://randomuser.me/api/portraits/women/20.jpg', + }, + ), + ), + GenericEnrichedActivity( + time: DateTime.now(), + actor: const User( + data: { + 'name': 'Rosemary', + 'handle': '@rosemary', + 'subtitle': 'likes playing fresbee in the park', + 'profile_image': 'https://randomuser.me/api/portraits/women/20.jpg', + }, + ), + ), + ]; + when(() => mockClient.flatFeed('user')).thenReturn(mockFeed); + when(mockFeed.getEnrichedActivities).thenAnswer((_) async => activities); + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + GenericFeedProvider( + bloc: GenericFeedBloc( + client: mockClient, + analyticsClient: mockStreamAnalytics, + ), + child: child!, + ), + home: Scaffold( + body: GenericFlatFeedCore( + feedGroup: 'user', + feedBuilder: (BuildContext context, activities, int idx) { + return Column( + children: [ + Text("${activities[idx].reactionCounts?['like']}") //counts + ], + ); + }, + ), + ), + ), + ); + + verify(() => mockClient.flatFeed('user')).called(1); + verify(mockFeed.getEnrichedActivities).called(1); + }); + + // test('Default FlatFeedCore debugFillProperties', () { + // final builder = DiagnosticPropertiesBuilder(); + // final flatFeedCore = FlatFeedCore( + // feedGroup: 'user', + // feedBuilder: (BuildContext context, + // List> activities, + // int idx) { + // return Column( + // children: [ + // Text("${activities[idx].reactionCounts?['like']}") //counts + // ], + // ); + // }, + // ); + + // // ignore: cascade_invocations + // flatFeedCore.debugFillProperties(builder); + + // final description = builder.properties + // .where((node) => !node.isFiltered(DiagnosticLevel.info)) + // .map((node) => node.toDescription()) + // .toList(); + + // expect(description, ['has feedBuilder', '"user"']); + // }); +} diff --git a/packages/stream_feed_flutter_core/test/mocks.dart b/packages/stream_feed_flutter_core/test/mocks.dart new file mode 100644 index 000000000..a582979e0 --- /dev/null +++ b/packages/stream_feed_flutter_core/test/mocks.dart @@ -0,0 +1,21 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feed/stream_feed.dart'; +import 'package:stream_feed_flutter_core/src/bloc/reactions_controller.dart'; + +class MockLogger extends Mock implements Logger {} + +class MockStreamFeedClient extends Mock implements StreamFeedClient {} + +class MockReactions extends Mock implements ReactionsClient {} + +class MockStreamAnalytics extends Mock implements StreamAnalytics {} + +class MockFeedAPI extends Mock implements FlatFeed {} + +class MockReactionsControllers extends Mock implements ReactionsController {} + +class MockClient extends Mock implements StreamFeedClient { + final Logger logger = MockLogger(); +} + +class MockStreamUser extends Mock implements StreamUser {} diff --git a/packages/stream_feed_flutter_core/test/reactions_list_core_test.dart b/packages/stream_feed_flutter_core/test/reactions_list_core_test.dart new file mode 100644 index 000000000..dfbcc063f --- /dev/null +++ b/packages/stream_feed_flutter_core/test/reactions_list_core_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feed_flutter_core/src/reactions_list_core.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + late MockStreamFeedClient mockClient; + late MockReactions mockReactions; + late MockStreamAnalytics mockStreamAnalytics; + late LookupAttribute lookupAttr; + late String lookupValue; + late Filter filter; + late String kind; + late int limit; + late String activityId; + late String userId; + late List targetFeeds; + late Map data; + late List reactions; + + setUp(() { + mockClient = MockStreamFeedClient(); + mockReactions = MockReactions(); + mockStreamAnalytics = MockStreamAnalytics(); + when(() => mockClient.reactions).thenReturn(mockReactions); + lookupAttr = LookupAttribute.activityId; + lookupValue = 'ed2837a6-0a3b-4679-adc1-778a1704852d'; + filter = Filter().idGreaterThan('e561de8f-00f1-11e4-b400-0cc47a024be0'); + kind = 'like'; + limit = 5; + activityId = 'activityId'; + userId = 'john-doe'; + targetFeeds = []; + data = {'text': 'awesome post!'}; + reactions = [ + Reaction( + kind: kind, + activityId: activityId, + userId: userId, + data: data, + targetFeeds: targetFeeds, + ) + ]; + when(() => mockReactions.filter( + lookupAttr, + lookupValue, + filter: filter, + limit: limit, + kind: kind, + )).thenAnswer((_) async => reactions); + }); + testWidgets('ReactionListCore', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => GenericFeedProvider( + bloc: GenericFeedBloc( + client: mockClient, + analyticsClient: mockStreamAnalytics, + ), + child: child!, + ), + home: Scaffold( + body: GenericReactionListCore( + reactionsBuilder: (context, reactions, idx) => const Offstage(), + lookupValue: lookupValue, + filter: filter, + limit: limit, + kind: kind, + ), + ), + ), + ); + verify(() => mockReactions.filter(lookupAttr, lookupValue, + filter: filter, limit: limit, kind: kind)).called(1); + }); + + // test('Default ReactionListCore debugFillProperties', () { + // final builder = DiagnosticPropertiesBuilder(); + // final reactionListCore = ReactionListCore( + // reactionsBuilder: (context, reactions, idx) => const Offstage(), + // lookupValue: lookupValue, + // filter: filter, + // limit: limit, + // kind: kind, + // ); + + // // ignore: cascade_invocations + // reactionListCore.debugFillProperties(builder); + + // final description = builder.properties + // .where((node) => !node.isFiltered(DiagnosticLevel.info)) + // .map((node) => node.toDescription()) + // .toList(); + + // expect(description, [ + // 'has reactionsBuilder', + // 'activityId', + // '"ed2837a6-0a3b-4679-adc1-778a1704852d"', + // '{_Filter.idGreaterThan: e561de8f-00f1-11e4-b400-0cc47a024be0}', + // 'null', + // '5', + // '"like"' + // ]); + // }); +} diff --git a/packages/stream_feed_flutter_core/test/states/empty_test.dart b/packages/stream_feed_flutter_core/test/states/empty_test.dart new file mode 100644 index 000000000..bec40acca --- /dev/null +++ b/packages/stream_feed_flutter_core/test/states/empty_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_feed_flutter_core/src/states/empty.dart'; + +void main() { + testWidgets('EmptyStateWidget', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: EmptyStateWidget(), + ))); + final text = find.text('Nothing here...').first; + expect(text, findsOneWidget); + }); + + test('Default EmptyStateWidget debugFillProperties', () { + final builder = DiagnosticPropertiesBuilder(); + const emptyStateWidget = EmptyStateWidget(); + + // ignore: cascade_invocations + emptyStateWidget.debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toDescription()) + .toList(); + + expect(description, ['"Nothing here..."']); + }); +} diff --git a/packages/stream_feed_flutter_core/test/states/error_test.dart b/packages/stream_feed_flutter_core/test/states/error_test.dart new file mode 100644 index 000000000..262d38411 --- /dev/null +++ b/packages/stream_feed_flutter_core/test/states/error_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_feed_flutter_core/src/states/error.dart'; + +void main() { + testWidgets('ErrorStateWidget', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: ErrorStateWidget(), + ))); + final text = find.text('Sorry an error has occured').first; + expect(text, findsOneWidget); + }); + + test('Default ErrorStateWidget debugFillProperties', () { + final builder = DiagnosticPropertiesBuilder(); + const errorStateWidget = ErrorStateWidget(); + + // ignore: cascade_invocations + errorStateWidget.debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toDescription()) + .toList(); + + expect(description, ['"Sorry an error has occured"']); + }); +} diff --git a/packages/stream_feed_flutter_core/test/stream_feed_flutter_core_test.dart b/packages/stream_feed_flutter_core/test/stream_feed_flutter_core_test.dart new file mode 100644 index 000000000..794dc4054 --- /dev/null +++ b/packages/stream_feed_flutter_core/test/stream_feed_flutter_core_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart'; + +import 'mocks.dart'; + +class TestWidget extends StatefulWidget { + const TestWidget({Key? key}) : super(key: key); + + @override + _TestWidgetState createState() => _TestWidgetState(); +} + +class _TestWidgetState extends State { + @override + Widget build(BuildContext context) { + return Container(); + } +} + +void main() { + testWidgets( + 'should render StreamFeedCore if both client and child is provided', + (tester) async { + final mockClient = MockClient(); + // const streamFeedCoreKey = Key('streamFeedCore'); + final childKey = GlobalKey(); + final streamFeedCore = GenericFeedProvider( + bloc: GenericFeedBloc( + client: mockClient, + ), + child: TestWidget(key: childKey), + ); + + await tester.pumpWidget(streamFeedCore); + + // expect(find.byKey(streamFeedCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + expect(GenericFeedProvider.of(childKey.currentState!.context).bloc, + isNotNull); + }, + ); + testWidgets( + 'throw an error if StreamFeedCore is not in the tree', + (tester) async { + final childKey = GlobalKey(); + + await tester.pumpWidget(TestWidget(key: childKey)); + + expect( + () => FeedProvider.of(childKey.currentState!.context), + throwsA(predicate((e) => + e.message == + 'No GenericFeedProvider found in context'))); + }, + ); +}