diff --git a/core/eventbus/.github/workflows/dart.yml b/core/eventbus/.github/workflows/dart.yml new file mode 100644 index 00000000..21b7ff68 --- /dev/null +++ b/core/eventbus/.github/workflows/dart.yml @@ -0,0 +1,54 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Dart + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Note: This workflow uses the latest stable version of the Dart SDK. + # You can specify other versions if desired, see documentation here: + # https://github.com/dart-lang/setup-dart/blob/main/README.md + # - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + + - name: Flutter action + # You may pin to the exact commit or the version. + # uses: subosito/flutter-action@4389e6cbc6cb8a4b18c628ff96ff90be0e926aa8 + uses: subosito/flutter-action@v1.5.3 + + - name: Install dependencies + run: dart pub get + + # Uncomment this step to verify the use of 'dart format' on each commit. + # - name: Verify formatting + # run: dart format --output=none --set-exit-if-changed . + + # Consider passing '--fatal-infos' for slightly stricter analysis. + - name: Analyze project source + run: dart analyze + + # Your project will need to have tests in test/ and a dependency on + # package:test for this step to succeed. Note that Flutter projects will + # want to change this to 'flutter test'. + - name: Run tests + run: flutter test --coverage + + - name: Install lcov + run: sudo apt-get install -y lcov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + file: coverage/lcov.info diff --git a/core/eventbus/.gitignore b/core/eventbus/.gitignore new file mode 100644 index 00000000..3d342185 --- /dev/null +++ b/core/eventbus/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +coverage diff --git a/core/eventbus/.metadata b/core/eventbus/.metadata new file mode 100644 index 00000000..0bb64e4a --- /dev/null +++ b/core/eventbus/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7e9793dee1b85a243edd0e06cb1658e98b077561 + channel: stable + +project_type: package diff --git a/core/eventbus/CHANGELOG.md b/core/eventbus/CHANGELOG.md new file mode 100644 index 00000000..5302c519 --- /dev/null +++ b/core/eventbus/CHANGELOG.md @@ -0,0 +1,67 @@ +## 0.6.2 + +* update `logger` dependency + +## 0.6.1 + +* [Add] `allowLogging` to control logging (thanks [@hatemragab](https://github.com/hatemragab)) + +## 0.6.0 + +* **[Change]** fire `EmptyEvent` after each event to keep stream empty. +* [Add] [Logger](https://pub.dev/packages/logger) + +## 0.5.0 + +* [Add] timestamp for each `event`. + +## 0.4.0 + +* [Add] mapper + +## 0.3.0+3 + +* Downgrade clock version + +## 0.3.0+2 + +* Improve documentation + +## 0.3.0+1 + +* Improve documentation + +## 0.3.0 + +* [Add] `EventCompletionEvent` +* Improve documentation + +## 0.2.2 + +* [Fix] `clock` dependency + +## 0.2.1 + +* [Add] debug logging + +## 0.2.0 + +* [Add] history + +## 0.1.0 + +* [Add] distinct + +## 0.0.2+1 + +* [Fix] GitHub repository link + +## 0.0.2 + +* [Remove] `id` of `AppEvent` + +## 0.0.1 + +* Initial release. +* [Add] `EventBus` +* [Add] `AppEvent` superclass diff --git a/core/eventbus/LICENSE b/core/eventbus/LICENSE new file mode 100644 index 00000000..c560cf31 --- /dev/null +++ b/core/eventbus/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Andrew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/eventbus/README.md b/core/eventbus/README.md new file mode 100644 index 00000000..4c8f4bd9 --- /dev/null +++ b/core/eventbus/README.md @@ -0,0 +1,98 @@ +# EventBus: Events for Dart/Flutter + +[![pub package](https://img.shields.io/pub/v/event_bus_plus.svg?label=event_bus_plus&color=blue)](https://pub.dev/packages/event_bus_plus) +[![codecov](https://codecov.io/gh/AndrewPiterov/event_bus_plus/branch/main/graph/badge.svg?token=VM9LTJXGQS)](https://codecov.io/gh/AndrewPiterov/event_bus_plus) +[![Dart](https://github.com/AndrewPiterov/event_bus_plus/actions/workflows/dart.yml/badge.svg)](https://github.com/AndrewPiterov/event_bus_plus/actions/workflows/dart.yml) + +**EventBus** is an open-source library for Dart and Flutter using the **publisher/subscriber** pattern for loose coupling. **EventBus** enables central communication to decoupled classes with just a few lines of code – simplifying the code, removing dependencies, and speeding up app development. + +event bus publish subscribe + +## Your benefits using EventBus: It… +- simplifies the communication between components; +- decouples event senders and receivers; +- performs well with UI artifacts (e.g. Widgets, Controllers); +- avoids complex and error-prone dependencies and life cycle issues. + + +event bus plus + +### Define the app's events + +```dart +// Initialize the Service Bus +IAppEventBus eventBus = AppEventBus(); + +// Define your app events +final event = FollowEvent('@devcraft.ninja'); +final event = CommentEvent('Awesome package 😎'); +``` + +### Subscribe + +```dart +// listen the latest event +final sub = eventBus.last$ + .listen((AppEvent event) { /*do something*/ }); + +// Listen particular event +final sub2 = eventBus.on() + .listen((e) { /*do something*/ }); +``` + +### Publish + +```dart +// fire the event +eventBus.fire(event); +``` + +### Watch events in progress + +```dart +// start watch the event till its completion +eventBus.watch(event); + +// and check the progress +eventBus.isInProgress(); + +// or listen stream to check the processing +eventBus.inProgress$.map((List events) => + events.whereType().isNotEmpty); + +// complete +_eventBus.complete(event); + +// or complete with completion event +_eventBus.complete(event, nextEvent: SomeAnotherEvent); +``` + +## History + +```dart +final events = eventBus.history; +``` + +## Mapping + +```dart +final eventBus = bus = EventBus( + map: { + SomeEvent: [ + (e) => SomeAnotherEvent(), + ], + }, + ); +``` + +## Contributing + +We accept the following contributions: + +* Improving the documentation +* [Reporting issues](https://github.com/AndrewPiterov/event_bus_plus/issues/new) +* Fixing bugs + +## Maintainers + +* [Andrew Piterov](mailto:contact@andrewpiterov.com?subject=[GitHub]%20Source%20Dart%event_bus_plus) diff --git a/core/eventbus/analysis_options.yaml b/core/eventbus/analysis_options.yaml new file mode 100644 index 00000000..67016e95 --- /dev/null +++ b/core/eventbus/analysis_options.yaml @@ -0,0 +1,224 @@ +include: package:flutter_lints/flutter.yaml +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: true + errors: + # Close instances of dart.core.Sink. + close_sinks: error + # Cancel instances of dart.async.StreamSubscription. + cancel_subscriptions: error + # treat missing required parameters as a error (not a hint) + missing_required_param: error + # treat missing returns as a error (not a hint) + missing_return: error + # allow having TODOs in the code and treat as warning + todo: warning + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: ignore + # Ignore analyzer hints for updating pubspecs when using Future or + # Stream and not importing dart:async + # Please see https://github.com/flutter/flutter/pull/24528 for details. + sdk_version_async_exported_from_core: ignore + # await + unawaited_futures: error + avoid_print: warning + always_declare_return_types: error + unrelated_type_equality_checks: error + implementation_imports: error + require_trailing_commas: warning + avoid_slow_async_io: error + use_build_context_synchronously: error + sized_box_for_whitespace: error + dead_code: warning + invalid_assignment: error + + exclude: + - "bin/cache/**" + # the following two are relative to the stocks example and the flutter package respectively + # see https://github.com/dart-lang/sdk/issues/28463 + - "lib/i18n/messages_*.dart" + - "lib/src/http/**" + - "build/**" + - "lib/**.freezed.dart" + - "lib/**.g.dart" + - "test/**" + - "example/**" + +linter: + rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + - always_require_non_null_named_parameters + # - always_specify_types + - annotate_overrides + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + # - avoid_as # required for implicit-casts: true + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # we do this commonly + # - avoid_catching_errors # we do this commonly + - avoid_classes_with_only_static_members + # - avoid_double_and_int_checks # only useful when targeting JS runtime + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + # - avoid_implementing_value_types # not yet tested + - avoid_init_to_null + # - avoid_js_rounded_ints # only useful when targeting JS runtime + - avoid_null_checks_in_equality_operators + # - avoid_positional_boolean_parameters # not yet tested + - avoid_print # not yet tested + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + # - avoid_redundant_argument_values # not yet tested + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + # - avoid_returning_null # there are plenty of valid reasons to return null + # - avoid_returning_null_for_future # not yet tested + - avoid_returning_null_for_void + # - avoid_returning_this # there are plenty of valid reasons to return this + # - avoid_setters_without_getters # not yet tested + - avoid_shadowing_type_parameters # not yet tested + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + # - avoid_unnecessary_containers # not yet tested + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # not yet tested + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # not yet tested + # - close_sinks # not reliable enough + # - comment_references # blocked on https://github.com/flutter/flutter/issues/20765 + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + # - curly_braces_in_flow_control_structures # not yet tested + # - diagnostic_describe_all_properties # not yet tested + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + # - file_names # not yet tested + - flutter_style_todos + - hash_and_equals + - implementation_imports + # - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811 + - iterable_contains_unrelated_type + # - join_return_with_assignment # not yet tested + - library_names + - library_prefixes + # - lines_longer_than_80_chars # not yet tested + - list_remove_unrelated_type + # - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181 + # - missing_whitespace_between_adjacent_strings # not yet tested + - no_adjacent_strings_in_list + - no_duplicate_case_values + # - no_logic_in_create_state # not yet tested + # - no_runtimeType_toString # not yet tested + - non_constant_identifier_names + # - null_closures # not yet tested + - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + # - only_throw_errors # https://github.com/flutter/flutter/issues/5792 + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not yet tested + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # not yet tested + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + - prefer_equal_for_default_values + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + # - prefer_function_declarations_over_variables # not yet tested + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # not yet tested + # - prefer_interpolation_to_compose_strings # not yet tested + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + # - prefer_null_aware_operators # disable until NNBD, see https://github.com/flutter/flutter/pull/32711#issuecomment-492930932 + # - prefer_relative_imports # not yet tested + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + # - provide_deprecation_message # not yet tested + - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - slash_for_doc_comments + # - sort_child_properties_last # not yet tested + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + - unawaited_futures # too many false positives + - unnecessary_await_in_return # not yet tested + - unnecessary_brace_in_string_interps + - unnecessary_const + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_in_if_null_operators + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_statements + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + # - unsafe_html # not yet tested + - use_full_hex_values_for_flutter_colors + # - use_function_type_syntax_for_parameters # not yet tested + # - use_key_in_widget_constructors # not yet tested + - use_late_for_private_fields_and_variables + - use_rethrow_when_possible + # - use_setters_to_change_properties # not yet tested + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + - void_checks + +dart_code_metrics: + rules: + - newline-before-return: + severity: style + - no-boolean-literal-compare: + severity: error + anti-patterns: + - long-method: + severity: warning diff --git a/core/eventbus/doc/pub_sub.webp b/core/eventbus/doc/pub_sub.webp new file mode 100644 index 00000000..8e13702a Binary files /dev/null and b/core/eventbus/doc/pub_sub.webp differ diff --git a/core/eventbus/doc/video_presentation.gif b/core/eventbus/doc/video_presentation.gif new file mode 100644 index 00000000..0ef19815 Binary files /dev/null and b/core/eventbus/doc/video_presentation.gif differ diff --git a/core/eventbus/lib/event_bus_plus.dart b/core/eventbus/lib/event_bus_plus.dart new file mode 100644 index 00000000..6eaec57c --- /dev/null +++ b/core/eventbus/lib/event_bus_plus.dart @@ -0,0 +1,3 @@ +library event_bus_plus; + +export 'res/res.dart'; diff --git a/core/eventbus/lib/res/app_event.dart b/core/eventbus/lib/res/app_event.dart new file mode 100644 index 00000000..81823517 --- /dev/null +++ b/core/eventbus/lib/res/app_event.dart @@ -0,0 +1,29 @@ +import 'package:clock/clock.dart'; +import 'package:equatable/equatable.dart'; + +/// The base class for all events +abstract class AppEvent extends Equatable { + /// Create the event + const AppEvent(); + + /// The event time + DateTime get timestamp => clock.now(); +} + +/// The event completion event +class EventCompletionEvent extends AppEvent { + /// Create the event + const EventCompletionEvent(this.event); + + /// The event that is completed + final AppEvent event; + + @override + List get props => [event]; +} + +/// The empty event +class EmptyEvent extends AppEvent { + @override + List get props => []; +} diff --git a/core/eventbus/lib/res/event_bus.dart b/core/eventbus/lib/res/event_bus.dart new file mode 100644 index 00000000..178a5518 --- /dev/null +++ b/core/eventbus/lib/res/event_bus.dart @@ -0,0 +1,212 @@ +import 'package:logger/logger.dart'; +import 'package:angel3_reactivex/subjects.dart'; + +import 'app_event.dart'; +import 'history_entry.dart'; +import 'subscription.dart'; + +/// The event bus interface +abstract class IEventBus { + /// Whether the event bus is busy + bool get isBusy; + + /// Whether the event bus is busy + Stream get isBusy$; + + /// The last event + AppEvent? get last; + + /// The last event + Stream get last$; + + /// The list of events that are in progress + Stream> get inProgress$; + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Stream on(); + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Stream whileInProgress(); + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + Subscription respond(Responder responder); + + /// The history of events + List get history; + + /// Fire a event + void fire(AppEvent event); + + /// Fire a event and wait for it to be completed + void watch(AppEvent event); + + /// Complete a event + void complete(AppEvent event, {AppEvent? nextEvent}); + + /// + bool isInProgress(); + + /// Reset the event bus + void reset(); + + /// Dispose the event bus + void dispose(); + + /// Clear the history + void clearHistory(); +} + +/// The event bus implementation +class EventBus implements IEventBus { + /// Create the event bus + EventBus({ + this.maxHistoryLength = 100, + this.map = const {}, + this.allowLogging = false, + }); + + final _logger = Logger(); + + /// The maximum length of history + final int maxHistoryLength; + + /// allow to log all events this when you call [fire] + /// the event will be in console log + final bool allowLogging; + + /// The map of events + final Map> map; + + @override + bool get isBusy => _inProgress.value.isNotEmpty; + @override + Stream get isBusy$ => _inProgress.map((event) => event.isNotEmpty); + + final _lastEventSubject = BehaviorSubject(); + @override + AppEvent? get last => _lastEventSubject.valueOrNull; + @override + Stream get last$ => _lastEventSubject.distinct(); + + final _inProgress = BehaviorSubject>.seeded([]); + List get _isInProgressEvents => _inProgress.value; + @override + Stream> get inProgress$ => _inProgress; + + @override + List get history => List.unmodifiable(_history); + final List _history = []; + + @override + void fire(AppEvent event) { + if (_history.length >= maxHistoryLength) { + _history.removeAt(0); + } + _history.add(EventBusHistoryEntry(event, event.timestamp)); + // 1. Fire the event + _lastEventSubject.add(event); + // 2. Map if needed + _map(event); + // 3. Reset stream + _lastEventSubject.add(EmptyEvent()); + if (allowLogging) { + _logger.d(' ⚑️ [${event.timestamp}] $event'); + } + } + + @override + void watch(AppEvent event) { + fire(event); + _inProgress.add([ + ..._isInProgressEvents, + event, + ]); + } + + @override + void complete(AppEvent event, {AppEvent? nextEvent}) { + // complete the event + if (_isInProgressEvents.any((e) => e == event)) { + final newArr = _isInProgressEvents.toList() + ..removeWhere((e) => e == event); + _inProgress.add(newArr); + fire(EventCompletionEvent(event)); + } + + // fire next event if any + if (nextEvent != null) { + fire(nextEvent); + } + } + + @override + bool isInProgress() { + return _isInProgressEvents.whereType().isNotEmpty; + } + + @override + Stream on() { + if (T == dynamic) { + return _lastEventSubject.stream as Stream; + } else { + return _lastEventSubject.stream.where((event) => event is T).cast(); + } + } + + /// Subscribe `EventBus` on a specific type of event, and register responder to it. + /// + /// When [T] is not given or given as `dynamic`, it listens to all events regardless of the type. + /// Returns [Subscription], which can be disposed to cancel all the subscription registered to itself. + @override + Subscription respond(Responder responder) => + Subscription(_lastEventSubject).respond(responder); + + @override + Stream whileInProgress() { + return _inProgress.map((events) { + return events.whereType().isNotEmpty; + }); + } + + void _map(AppEvent? event) { + if (event == null) { + return; + } + + final functions = map[event.runtimeType] ?? []; + if (functions.isEmpty) { + return; + } + + for (final func in functions) { + final newEvent = func(event); + if (newEvent.runtimeType == event.runtimeType) { + if (allowLogging) { + _logger.d( + ' 🟠 SKIP EVENT: ${newEvent.runtimeType} => ${event.runtimeType}', + ); + } + continue; + } + fire(newEvent); + } + } + + @override + void clearHistory() { + _history.clear(); + } + + @override + void reset() { + clearHistory(); + _inProgress.add([]); + _lastEventSubject.add(EmptyEvent()); + } + + @override + void dispose() { + _inProgress.close(); + _lastEventSubject.close(); + } +} diff --git a/core/eventbus/lib/res/history_entry.dart b/core/eventbus/lib/res/history_entry.dart new file mode 100644 index 00000000..65c7a11f --- /dev/null +++ b/core/eventbus/lib/res/history_entry.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +import 'app_event.dart'; + +/// The history entry +class EventBusHistoryEntry extends Equatable { + /// The history entry + const EventBusHistoryEntry(this.event, this.timestamp); + + /// The event + final AppEvent event; + + /// The timestamp + final DateTime timestamp; + + @override + List get props => [event, timestamp]; +} diff --git a/core/eventbus/lib/res/res.dart b/core/eventbus/lib/res/res.dart new file mode 100644 index 00000000..c2e36e14 --- /dev/null +++ b/core/eventbus/lib/res/res.dart @@ -0,0 +1,4 @@ +export 'app_event.dart'; +export 'event_bus.dart'; +export 'history_entry.dart'; +export 'subscription.dart'; diff --git a/core/eventbus/lib/res/subscription.dart b/core/eventbus/lib/res/subscription.dart new file mode 100644 index 00000000..74bbb418 --- /dev/null +++ b/core/eventbus/lib/res/subscription.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +/// The function/method signature for the event handler +typedef Responder = void Function(T event); + +/// The class manages the subscription to event bus +class Subscription { + /// Create the subscription + /// + /// Should barely used directly, to subscribe to event bus, use `EventBus.respond`. + Subscription(this._stream); + + /// Returns an instance that indicates there is no subscription + factory Subscription.empty() => const _EmptySubscription(); + + final Stream _stream; + + /// Subscriptions that registered to event bus + final List subscriptions = []; + + Stream _cast() { + if (T == dynamic) { + return _stream as Stream; + } else { + return _stream.where((event) => event is T).cast(); + } + } + + /// Register a [responder] to event bus for the event type [T]. + /// If [T] is omitted or given as `dynamic`, it listens to all events that published on [EventBus]. + /// + /// Method call can be safely chained, and the order doesn't matter. + /// + /// ``` + /// eventBus + /// .respond(responderA) + /// .respond(responderB); + /// ``` + Subscription respond(Responder responder) { + subscriptions.add(_cast().listen(responder)); + return this; + } + + /// Cancel all the registered subscriptions. + /// After calling this method, all the events published won't be delivered to the cleared responders any more. + /// + /// No harm to call more than once. + void dispose() { + if (subscriptions.isEmpty) { + return; + } + for (final s in subscriptions) { + s.cancel(); + } + subscriptions.clear(); + } +} + +class _EmptySubscription implements Subscription { + const _EmptySubscription(); + static final List emptyList = + List.unmodifiable([]); + + @override + void dispose() {} + + @override + Subscription respond(responder) => throw Exception('Not supported'); + + @override + List get subscriptions => emptyList; + + @override + Stream _cast() => throw Exception('Not supported'); + + @override + Stream get _stream => throw Exception('Not supported'); +} diff --git a/core/eventbus/pubspec.yaml b/core/eventbus/pubspec.yaml new file mode 100644 index 00000000..bb71c80a --- /dev/null +++ b/core/eventbus/pubspec.yaml @@ -0,0 +1,20 @@ +name: angel3_event_bus +description: Event Bus for Dart. +version: 0.6.2 +homepage: https://github.com/AndrewPiterov/event_bus_plus + +environment: + sdk: '>=2.17.1 <4.0.0' + +dependencies: + clock: ^1.1.0 + equatable: ^2.0.5 + logger: ^2.0.2+1 + angel3_reactivex: ^0.27.5 + +dev_dependencies: + flutter_lints: ^3.0.0 + given_when_then_unit_test: ^0.2.1 + shouldly: ^0.5.0+1 + test: ^1.22.0 + diff --git a/core/eventbus/test/completion_test.dart b/core/eventbus/test/completion_test.dart new file mode 100644 index 00000000..6105911b --- /dev/null +++ b/core/eventbus/test/completion_test.dart @@ -0,0 +1,70 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'models.dart'; +import 'package:test/test.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('emit Follower Events', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + EmptyEvent(), + const FollowAppEvent('username3'), + EmptyEvent(), + const FollowAppEvent('username2'), + EmptyEvent(), + ])); + + bus.fire(const FollowAppEvent('username')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username2')); + }); + + test('start watch but not complete', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + ])); + + bus.watch(const FollowAppEvent('username')); + }); + + test('start watch and complete', () { + const watchable = FollowAppEvent('username3'); + expect( + bus.on(), + emitsInOrder([ + watchable, + EmptyEvent(), + const EventCompletionEvent(watchable), + EmptyEvent(), + const FollowSuccessfullyEvent(watchable), + EmptyEvent(), + ])); + + bus.watch(watchable); + bus.complete(watchable, + nextEvent: const FollowSuccessfullyEvent(watchable)); + }); + + // test('emit Follower Events', () { + // final watchable = FollowAppEvent('username3', id: '3'); + // expect( + // _bus.on(), + // emitsInAnyOrder([ + // FollowAppEvent('username3', id: '3'), + // FollowSuccessfullyAppEvent(watchable), + // ])); + + // _bus.watch(watchable); + // _bus.complete(watchable, withh: FollowSuccessfullyAppEvent(watchable)); + // }); +} diff --git a/core/eventbus/test/distinct_test.dart b/core/eventbus/test/distinct_test.dart new file mode 100644 index 00000000..5aeaf9fd --- /dev/null +++ b/core/eventbus/test/distinct_test.dart @@ -0,0 +1,58 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('Call once', () { + expectLater( + bus.last$, + emitsInOrder([ + const SomeEvent(), + EmptyEvent(), + const SomeAnotherEvent(), + EmptyEvent(), + ])); + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + }); + + // test('Call twice', () { + // const event = SomeEvent(); + // expectLater( + // bus.last$, + // emitsInOrder([ + // const SomeEvent(), + // EmptyEvent(), + // const SomeAnotherEvent(), + // EmptyEvent(), + // ])); + // bus.fire(event); + // bus.fire(event); + // bus.fire(const SomeAnotherEvent()); + // }); + + // test('Call three times', () { + // const event = SomeEvent(); + // expectLater( + // bus.last$, + // emitsInOrder([ + // const SomeEvent(), + // EmptyEvent(), + // const SomeAnotherEvent(), + // EmptyEvent(), + // ])); + // bus.fire(event); + // bus.fire(event); + // bus.fire(event); + // bus.fire(const SomeAnotherEvent()); + // }); +} diff --git a/core/eventbus/test/empty_event_test.dart b/core/eventbus/test/empty_event_test.dart new file mode 100644 index 00000000..050505e7 --- /dev/null +++ b/core/eventbus/test/empty_event_test.dart @@ -0,0 +1,20 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:test/test.dart'; +import 'models.dart'; + +void main() { + final IEventBus _bus = EventBus(); + + test('Should fire Empty event', () { + expect( + _bus.last$, + emitsInOrder( + [ + const SomeEvent(), + EmptyEvent(), + ], + ), + ); + _bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/core/eventbus/test/event_bus_test.dart b/core/eventbus/test/event_bus_test.dart new file mode 100644 index 00000000..5d7a5b10 --- /dev/null +++ b/core/eventbus/test/event_bus_test.dart @@ -0,0 +1,63 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('Empty event bus', () { + bus.isBusy.should.beFalse(); + }); + + when('start some event', () { + const event = FollowAppEvent('username'); + // const eventId = '1'; + + before(() { + bus.watch(event); + }); + + then('should be busy', () { + bus.isBusy.should.beTrue(); + }); + + then('should be in progress', () { + bus.isInProgress().should.beTrue(); + }); + + and('complete the event', () { + before(() { + bus.complete(event); + }); + + then('should not be busy', () { + bus.isBusy.should.beFalse(); + }); + + then('should not be in progress', () { + bus.isInProgress().should.not.beTrue(); + }); + }); + }); + + group('compare equality', () { + // test('compare two equal events - should not be equal', () { + // final event = FollowAppEvent('username'); + // final event2 = FollowAppEvent('username'); + // event.should.not.be(event2); + // }); + + test('compare two equal events - should be equal', () { + const event = FollowAppEvent('username'); + const event2 = FollowAppEvent('username'); + event.should.be(event2); + }); + }); +} diff --git a/core/eventbus/test/history_test.dart b/core/eventbus/test/history_test.dart new file mode 100644 index 00000000..9eaf7096 --- /dev/null +++ b/core/eventbus/test/history_test.dart @@ -0,0 +1,75 @@ +import 'package:clock/clock.dart'; +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/test.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(maxHistoryLength: 3); + }); + + test('Keep history', () { + final date = DateTime(2022, 9, 1); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + ]); + }); + }); + + test('Keep full history without debounce', () { + final date = DateTime(2022, 9, 1, 4, 59, 55); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + ]); + }); + }); + + test('Clear history', () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + + bus.clearHistory(); + + bus.history.should.beEmpty(); + }); + + group('History length', () { + test('Add more than max', () { + final date = DateTime(2022, 9, 1, 4, 59, 55); + + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + bus.fire(const NewCommentEvent('text')); + + bus.history.length.should.be(3); + + bus.history.should.be([ + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + EventBusHistoryEntry(const NewCommentEvent('text'), date), + ]); + }); + }); + }); +} diff --git a/core/eventbus/test/mapping/map_ignore_test.dart b/core/eventbus/test/mapping/map_ignore_test.dart new file mode 100644 index 00000000..496aed78 --- /dev/null +++ b/core/eventbus/test/mapping/map_ignore_test.dart @@ -0,0 +1,36 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; + +import '../models.dart'; + +void main() { + late IEventBus bus; + + before( + () { + bus = EventBus( + map: { + SomeEvent: [ + (e) => e, + (e) => const SomeAnotherEvent(), + ], + }, + ); + }, + ); + + test('does not emit the same event', () { + expect( + bus.last$, + emitsInOrder( + [ + const SomeEvent(), + const SomeAnotherEvent(), + EmptyEvent(), + ], + ), + ); + bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/core/eventbus/test/mapping/map_test.dart b/core/eventbus/test/mapping/map_test.dart new file mode 100644 index 00000000..f83ef02c --- /dev/null +++ b/core/eventbus/test/mapping/map_test.dart @@ -0,0 +1,34 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; + +import '../models.dart'; + +void main() { + late IEventBus bus; + + before( + () { + bus = EventBus( + map: { + SomeEvent: [ + (e) => const SomeAnotherEvent(), + ], + }, + ); + }, + ); + + test('emits another', () { + expect( + bus.last$, + emitsInOrder( + [ + const SomeEvent(), + const SomeAnotherEvent(), + ], + ), + ); + bus.fire(const SomeEvent()); + }, timeout: const Timeout(Duration(seconds: 1))); +} diff --git a/core/eventbus/test/models.dart b/core/eventbus/test/models.dart new file mode 100644 index 00000000..899c50f7 --- /dev/null +++ b/core/eventbus/test/models.dart @@ -0,0 +1,42 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; + +class FollowAppEvent extends AppEvent { + const FollowAppEvent(this.username); + + final String username; + + @override + List get props => [username]; +} + +class FollowSuccessfullyEvent extends AppEvent { + const FollowSuccessfullyEvent(this.starting); + + final FollowAppEvent starting; + + @override + List get props => [starting]; +} + +class NewCommentEvent extends AppEvent { + const NewCommentEvent(this.text); + + final String text; + + @override + List get props => [text]; +} + +class SomeEvent extends AppEvent { + const SomeEvent(); + + @override + List get props => []; +} + +class SomeAnotherEvent extends AppEvent { + const SomeAnotherEvent(); + + @override + List get props => []; +} diff --git a/core/eventbus/test/respond_test.dart b/core/eventbus/test/respond_test.dart new file mode 100644 index 00000000..3c9b5cfd --- /dev/null +++ b/core/eventbus/test/respond_test.dart @@ -0,0 +1,74 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:async'; +import 'dart:developer'; + +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('emit Some Event', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }); + + expect(ctrl.stream, emitsInOrder([1, 2])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + }); + + test('emit Some Events', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }); + + expect(ctrl.stream, emitsInOrder([1, 2, 1])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + bus.fire(FollowAppEvent('username2')); + }); + + test('emit all Event', () { + final ctrl = StreamController(); + + final sub = bus.respond((event) { + log('new comment'); + ctrl.add(2); + }).respond((event) { + log('new follower'); + ctrl.add(1); + }).respond((event) { + log('event $event'); + ctrl.add(3); + }); + + expect(ctrl.stream, emitsInOrder([1, 3, 3, 2, 3, 3])); + + bus.fire(FollowAppEvent('username')); + bus.fire(NewCommentEvent('comment #1')); + }); +} diff --git a/core/eventbus/test/streams_test.dart b/core/eventbus/test/streams_test.dart new file mode 100644 index 00000000..1cec696e --- /dev/null +++ b/core/eventbus/test/streams_test.dart @@ -0,0 +1,56 @@ +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:test/test.dart'; +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(); + }); + + test('description', () { + bus.inProgress$.map((List events) => + events.whereType().isNotEmpty); + }, skip: 'should skip'); + + test('emit Follower Event', () { + const id = 'id'; + expect(bus.on(), emitsInAnyOrder([const FollowAppEvent('username')])); + bus.fire(const FollowAppEvent('username')); + }); + + test('emit Follower Events', () { + expect( + bus.on(), + emitsInOrder([ + const FollowAppEvent('username'), + EmptyEvent(), + const FollowAppEvent('username3'), + EmptyEvent(), + const FollowAppEvent('username2'), + EmptyEvent(), + ])); + + bus.fire(const FollowAppEvent('username')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username2')); + }); + + test('emit New Comment Event', () { + expect( + bus.on(), + emitsInOrder([ + const NewCommentEvent('comment #1'), + const NewCommentEvent('comment #2'), + ])); + + bus.fire(const FollowAppEvent('username3')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const NewCommentEvent('comment #1')); + bus.fire(const FollowAppEvent('username3')); + bus.fire(const NewCommentEvent('comment #2')); + bus.fire(const FollowAppEvent('username3')); + }); +} diff --git a/core/eventbus/test/timestamp_test.dart b/core/eventbus/test/timestamp_test.dart new file mode 100644 index 00000000..a8dc0d08 --- /dev/null +++ b/core/eventbus/test/timestamp_test.dart @@ -0,0 +1,36 @@ +import 'package:clock/clock.dart'; +import 'package:event_bus_plus/event_bus_plus.dart'; +import 'package:given_when_then_unit_test/given_when_then_unit_test.dart'; +import 'package:shouldly/shouldly.dart'; +import 'package:test/test.dart'; +import 'dart:developer' as dev; + +import 'models.dart'; + +void main() { + late IEventBus bus; + + before(() { + bus = EventBus(maxHistoryLength: 3); + }); + + test('right timestamp', () { + final date = DateTime(2022, 9, 1); + withClock(Clock.fixed(date), () { + bus.fire(const SomeEvent()); + bus.fire(const SomeAnotherEvent()); + bus.fire(const FollowAppEvent('@username')); + + for (final e in bus.history) { + e.timestamp.should.be(date); + dev.log(e.toString()); + } + + bus.history.should.be([ + EventBusHistoryEntry(const SomeEvent(), date), + EventBusHistoryEntry(const SomeAnotherEvent(), date), + EventBusHistoryEntry(const FollowAppEvent('@username'), date), + ]); + }); + }); +} diff --git a/core/eventbus/tool/coverage.sh b/core/eventbus/tool/coverage.sh new file mode 100755 index 00000000..5313d46b --- /dev/null +++ b/core/eventbus/tool/coverage.sh @@ -0,0 +1,13 @@ +#!/bin/sh +cd .. +# Generate `coverage/lcov.info` file +flutter test --coverage +# Generate HTML report +# Note: on macOS you need to have lcov installed on your system (`brew install lcov`) to use this: +genhtml coverage/lcov.info -o coverage/html +# Open the report +open coverage/html/index.html + + + +