marcglasberg / async_redux

Flutter Package: A Redux version tailored for Flutter, which is easy to learn, to use, to test, and has no boilerplate. Allows for both sync and async reducers.
Other
234 stars 40 forks source link

Events can not be used as one-to-many #136

Closed ValeriusGC closed 2 years ago

ValeriusGC commented 2 years ago

Hi! This is a case.

  1. I want to use one TimerWidget for 1+ forms.

Screenshot from 2022-07-14 09-52-58

  1. And i don`t want to save its state in the Store.

So I created it as an Event, and realized like this.

/// Action
class TimeIsOnAction extends AppAction {
  TimeIsOnAction(this.timerCounter);
  final int timerCounter;
  @override
  Future<AppState?> reduce() async {
    return state.copyWith(timerCounter: Event(timerCounter));
  }
}

/// Widget
class TimerWidget extends StatelessWidget {
  const TimerWidget({Key? key, required this.timerCounter}) : super(key: key);

  final Event<int> timerCounter;

  @override
  Widget build(BuildContext context) {
    final timer = timerCounter.state ?? 0;
   // !!!! Consume or Not ???
    timerCounter.consume();
    return Center(child: Text('$timer'));
  }
}

//////////////////////////////////////////////////////////////////////////////
/// Connector
class TimerWidgetConnector extends StatelessWidget {
  const TimerWidgetConnector({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, _Vm>(
      vm: () => _Factory(),
      builder: (context, vm) {
        return TimerWidget(
          timerCounter: vm.timerCounter,
        );
      },
    );
  }
}

///
class _Factory extends AppVmFactory {
  @override
  _Vm fromStore() {
    return _Vm(
      timerCounter: state.timerCounter,
    );
  }
}

///
class _Vm extends Vm {
  final Event<int> timerCounter;
  _Vm({
    required this.timerCounter,
  }) : super(equals: [timerCounter]);
}

/// Persisting
  @override
  Future<void> persistDifference(
      {AppState? lastPersistedState, required AppState newState}) async {

    if (lastPersistedState == null || lastPersistedState != newState) {
      return _safeWrapperS(() async {
        final json = newState.toJson();
        final s = jsonEncode(json);
        _saveString(_appStateKey, s);
        return;
      });
    }
  }

/// Applying 1
          children: [
            const Center(child: TimerWidgetConnector()),
            Center(child: Text('$isDarkMode')),

/// Applying 2
            10.verticalSpace,
            const Center(child: TimerWidgetConnector()),
            10.verticalSpace,

But! If i consume event in TimerWidget.build after applying - it works only on one Form Screenshot from 2022-07-14 09-53-22

If i don't consume - its state automatically persisted with every event changing.

How can I do that in right way?

PS. Maybe, if events supposed to be "transparent" for store (as described in doc), Store could "remove" them from saving by default?

ValeriusGC commented 2 years ago

Hi there! I'm still waiting for the answer. Is it bug or is it feature? If the last one please give me advice how do you solve these cases?

marcglasberg commented 2 years ago

You should not persist Events. Keep in mind Events are hacks. They use the store state as a delivery mechanism, but they should not really be considered part of the state. They are mutable, while the store state should immutable. Events are mutated when you consume them. That's why they can only be used for one form at a time. If you want to have 2 forms you need to separate events. You should consume them in the didUpdateWidget method. Not in build. Please see the provided example. If you are not willing to work with these limitations, you should avoid using Events.

To sum up:

marcglasberg commented 2 years ago

Probably not applicable, but in case you are creating a timer or a countdown to change a widget you can use the TimeBuilder widget from https://pub.dev/packages/assorted_layout_widgets

ValeriusGC commented 2 years ago

You should not persist Events. Keep in mind Events are hacks. They use the store state as a delivery mechanism, but they should not really be considered part of the state. They are mutable, while the store state should immutable. Events are mutated when you consume them. That's why they can only be used for one form at a time. If you want to have 2 forms you need to separate events. You should consume them in the didUpdateWidget method. Not in build. Please see the provided example. If you are not willing to work with these limitations, you should avoid using Events.

To sum up:

  • Events should not be persisted.
  • Events should not be considered part of the state. They are a hack.
  • Events are mutable, and they are mutated when you consume them.
  • You can't use events to mutate more than one widget.
  • You should consume Events in the didUpdateWidget method. Not in build.

I got it. But it is very, very uncomfortable to make a bunch of a duplicated classes to realize events like in my case. Alas. It would be much more comfortable if one can specify some part of State not to be persisted, something like 'non-persisted flag'.

ValeriusGC commented 2 years ago

Probably not applicable, but in case you are creating a timer or a countdown to change a widget you can use the TimeBuilder widget from https://pub.dev/packages/assorted_layout_widgets

Thanks, very interesting. It seems there's something typo. 2022-08-12_11-08

marcglasberg commented 2 years ago

Fixed. Thanks!

marcglasberg commented 2 years ago

@ValeriusGC

The whole point of the Event is that is gives the widget a "pulse" that you can use to change something. But your question got me thinking, and I think it would not be difficult to achieve what you want, with something like this:

@immutable
class EvtState<T> {
  static final _random = Random.secure();

  final T? value;
  final int _rand;

  EvtState([this.value]) : _rand = _random.nextInt(1 << 32);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is EvtState &&
          runtimeType == other.runtimeType &&
          value == other.value &&
          _rand == other._rand;

  @override
  int get hashCode => value.hashCode ^ _rand.hashCode;
}

Then:

@override
void didUpdateWidget(MyWidget oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (evt != oldWidget.evt) doSomethingWith(evt.value);
}

Explanation: The random part of the event will make sure an event you create is always different from the previous one, even when the value (the payload) is the same. This will trigger a widget rebuild in the connector (don't forget to include the event in the view-model). Then, the didUpdateWidget method will be called. Since evt is now different from oldWidget.evt, it will run the doSomething method.

This event class is never consumed, which means you can use it with more than one widget.

I haven't tested it. In case you do try it out, could you please tell me if it worked or not?

ValeriusGC commented 2 years ago

Hi, Marcello.

@immutable class EvtState {

This did not take off (If i got you right of course).

I replaced ordinary int to EvtState<int> in the Event:

class TimeIsOnAction extends AppAction {
  TimeIsOnAction(this.timerCounter);

  final Int timerCounter;

  //
  @override
  Future<AppState?> reduce() async {
    return state.copyWith(timerCounter: Event<Int>(timerCounter));
  }
}

typedef Int = EvtState<int>;

@immutable
class EvtState<T> {
  static final _random = Random.secure();

  final T? value;
  final int _rand;

  EvtState([this.value]) : _rand = _random.nextInt(1 << 32);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
          other is EvtState &&
              runtimeType == other.runtimeType &&
              value == other.value &&
              _rand == other._rand;

  @override
  int get hashCode => value.hashCode ^ _rand.hashCode;

  @override
  String toString() {
    return 'EvtState{value: $value, _rand: $_rand}';
  }
}

So Connector and VM were replaced to:

class TimerWidgetConnector extends StatelessWidget {
  const TimerWidgetConnector({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, _Vm>(
      vm: () => _Factory(),
      builder: (context, vm) {
        return TimerWidget(
          timerCounter: vm.timerCounter,
        );
      },
    );
  }
}

////////////////////////////////////////////////////////////////////////////////////////////////////

class _Factory extends AppVmFactory {
  @override
  _Vm fromStore() {
    return _Vm(
      timerCounter: state.timerCounter,
    );
  }
}

////////////////////////////////////////////////////////////////////////////////////////////////////

class _Vm extends Vm {
  final Event<Int> timerCounter;
  _Vm({
    required this.timerCounter,
  }) : super(equals: [timerCounter]);
}

and widget itself became:

class TimerWidget extends StatefulWidget {
  const TimerWidget({Key? key, required this.timerCounter}) : super(key: key);

  final Event<Int> timerCounter;

  @override
  State<TimerWidget> createState() => _TimerWidgetState();
}

class _TimerWidgetState extends State<TimerWidget> {

  var counter = Int(0);

  @override
  void didUpdateWidget(covariant TimerWidget oldWidget) {
    debugPrint('$now: _TimerWidgetState.didUpdateWidget: old=${oldWidget.timerCounter.state}, new=${widget.timerCounter.state}');
    super.didUpdateWidget(oldWidget);
    counter = widget.timerCounter.consume() ?? Int(0);
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('$now: _TimerWidgetState.build: ${counter.value}');
    return Center(child: Text('${counter.value}'));
  }
}

==============================================================

But result is the same. If event is been consuming widget.timerCounter.consume() it never triggers later in another copy of widget on another screen. If not - AppState marked as changed and AppStore stores event in DB.

ValeriusGC commented 2 years ago

I even played with hooks like that (one of variants)

class _Vm extends Vm {
  final Event<Int> timerCounter;
  _Vm({
    required Event<Int> timerCounter2,
  }) :  timerCounter = Event<Int>(timerCounter2.state), super(equals: [Event<Int>(timerCounter2.state)]) {
    debugPrint('$now: _Vm._Vm: ${timerCounter}');
    timerCounter2.consume();
  }
}

but alas - if basic event (those that registered in AppState) has been consumed, no data in the second form. If not - AppStore has been storing event )))

marcglasberg commented 2 years ago

@ValeriusGC Sorry if I was not clear, but I think you did not understand my suggestion. I am saying that for multiple widgets you should try it out with the new EvtState class I suggested above, not the Event class. You should not use the Event class at all anymore.

You are doing this:

return state.copyWith(timerCounter: Event<Int>(timerCounter));

But you should be doing this:

class TimeIsOnAction extends AppAction {
  TimeIsOnAction(this.newCounter);
  final int newCounter;

  Future<AppState?> reduce() async {
    return state.copyWith(timerCounter: EvtState<int>(newCounter));
}}

Please note the EvtState class does not have a consume method.

int counter = 0;

@override
void didUpdateWidget(MyWidget oldWidget) {
  super.didUpdateWidget(oldWidget);

  if (evt != oldWidget.evt) 
       counter = widget.timerCounter.value;
 }

@override
Widget build(BuildContext context) {
    return Center(child: Text('${counter.value}'));
  }
ValeriusGC commented 2 years ago

Hi @marcglasberg . But as soon it stopped being Event it automatically became a part of AppState and every timer tick now saves in store as before trying use Event. Where is the difference between int and EvtState<int> from the point of view of AppState? In any case I tried it of course ))). But alas - its state automatically persisted with every EvtState<int> changing (as with simple int).

marcglasberg commented 2 years ago

The only difference between int and EvtState<int> is that an integer is only different if it changed, while the event is different whenever it's recreated:

30 == 30 // true
EvtState(30) == EvtState(30) // false

Another example:

true == true // true
EvtState(true) == EvtState(true) // false

Suppose you have a list of items, and when the user types a number n into a TextField and presses a button you want to scroll the list to show the n-th item. When the button is pressed you can add EvtState(n) to the state, and that will trigger the event even if the number hasn't changed from last time. If you just add the integer n to the state, it will only trigger the event if last time it was a different number.

ValeriusGC commented 2 years ago

Hi, @marcglasberg.

30 == 30 // true EvtState(30) == EvtState(30) // false

Of course, it's obvious )) . Your case with scroll is interesting. But i meant that in my context (where every changes of state is been saving in AppStore) it is useless. I'd like to reuse one TimerWidget in multiple cases without duplicating VM+Connector+Widget for every place where it needs, but without saving it in Store. Event<T> would be nice choice if it allows using more than once. It would be interesting when AppState has possibility to adjust its "persist behavior", something like that )) :

in store.dart Screenshot from 2022-08-23 08-19-23 Screenshot from 2022-08-23 08-19-38

in app_state.dart Screenshot from 2022-08-23 08-19-51 Screenshot from 2022-08-23 08-19-56

ValeriusGC commented 2 years ago

Wow, it workd @marcglasberg ))) I placed identicalWith now in right place Screenshot from 2022-08-23 09-42-01

and now i can control what i want to persist Screenshot from 2022-08-23 09-41-38

of course it's just raw code, and maybe not in your roadmap. But it is very handful for case like mine and with minimal code injection.

ValeriusGC commented 2 years ago

And there is problem with naming, should name PersistControllerMixin.ignoreWhere() or something )))

ValeriusGC commented 2 years ago

There is a way with even less dramatic changes: Just define mixin somewhere in the code

mixin PersistorMixin<St> {
  bool isIdenticalForPersist(St? oldState) => identical(this, oldState);
}

And change just one string in class ProcessPersistence<St> (line 37)

    if (isPaused || (newState is PersistorMixin && newState.isIdenticalForPersist(lastPersistedState))) return false;
    // if (isPaused || identical(lastPersistedState, newState)) return false;

and voila:

  @override
  bool isIdenticalForPersist(AppState? oldState) {
    if(oldState == null) return false;
    final r = oldState.isDarkMode == isDarkMode && oldState.currentIndex == currentIndex;
    return r;
  }