rodydavis / signals.dart

Reactive programming made simple for Dart and Flutter
http://dartsignals.dev
Apache License 2.0
465 stars 52 forks source link

Changes on mapSignal in one screen doesn't update other screen #207

Closed abbjetmus closed 8 months ago

abbjetmus commented 8 months ago

I have this code at the top of a drawer:

final defaultEventFilters = {
  'startDateTime': '',
  'endDateTime': '',
  'categories': [],
  'eventTypes': [],
  'minParticipants': '',
  'maxParticipants': '',
  'minAge': '',
  'maxAge': ''
};

final eventFilters = mapSignal(defaultEventFilters);

final eventFiltersChanged =
    computed(() => eventFilters.value != defaultEventFilters);

Then when tapping a button I update the eventFilters:

  Expanded(
      child: SizedBox(
    height: 50,
    child: ElevatedButton(
        style: ElevatedButton.styleFrom(
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.zero,
          ),
          padding: EdgeInsets.all(0),
          backgroundColor: jocPrimary,
        ),
        onPressed: () {
          batch(() {

            eventFilters['minParticipants'] =
                _minParticipantsController.text;
            eventFilters['maxParticipants'] =
                _maxParticipantsController.text;
            eventFilters['minAge'] = _minAgeController.text;
            eventFilters['maxAge'] = _maxAgeController.text;
          });
          Navigator.of(context).pop();
        },
        child: Text('Applicera')),
  ))

This changes the signal and closes the drawer.

Then in another screen I have:

Watch((context) {
    if (eventFilters.value != defaultEventFilters) {
      return Positioned(
        right: 13,
        top: 16,
        child: Container(
          padding: EdgeInsets.all(1),
          decoration: BoxDecoration(
            color: jocPrimary,
            borderRadius: BorderRadius.circular(6),
          ),
          constraints: BoxConstraints(
            minWidth: 8,
            minHeight: 8,
          ),
        ),
      );
    }
    return Container();
  })

But this doesn't rebuild that part even though the condition is correct. Doesn't work either when using the computed. What am I doing wrong here is it that the signals in the drawer class are not global although it's defined at the top of the file?

That doesn't seem to be the case either. Not sure what the issue is here, kind of trivial example?

rodydavis commented 8 months ago

Can you paste the full screen for context?

abbjetmus commented 8 months ago

// Drawer

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:joinorcreate_app/constants.dart';
import 'package:signals/signals_flutter.dart';

final defaultEventFilters = {
  'startDateTime': '',
  'endDateTime': '',
  'categories': [],
  'eventTypes': [],
  'minParticipants': '',
  'maxParticipants': '',
  'minAge': '',
  'maxAge': ''
};

final eventFilters = mapSignal(defaultEventFilters);

final eventFiltersChanged =
    computed(() => eventFilters.value != defaultEventFilters);

final Signal<String?> _filterSportCategory = signal(null);
final Signal<String?> _filterEventType = signal(null);

Signal<int?> _filterMinParticipants = signal(null);
Signal<int?> _filterMaxParticipants = signal(null);
Signal<int?> _filterMinAge = signal(null);
Signal<int?> _filterMaxAge = signal(null);

class EventFilterDrawer extends StatefulWidget {
  const EventFilterDrawer({super.key});

  @override
  State<EventFilterDrawer> createState() => _EventFilterDrawerState();
}

class _EventFilterDrawerState extends State<EventFilterDrawer>
    with SingleTickerProviderStateMixin {
  final Signal<int> _currentStep = signal(0);
  late TabController _tabController;
  final _minParticipantsController = TextEditingController();
  final _maxParticipantsController = TextEditingController();
  final _minAgeController = TextEditingController();
  final _maxAgeController = TextEditingController();
  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
    _tabController.addListener(() {
      _currentStep.value = _tabController.index;
    });
    _minParticipantsController.text =
        eventFilters['minParticipants'].toString();
    _maxParticipantsController.text =
        eventFilters['maxParticipants'].toString();
    _minAgeController.text = eventFilters['minAge'].toString();
    _maxAgeController.text = eventFilters['maxAge'].toString();
  }

  @override
  Widget build(BuildContext context) {
    double height = MediaQuery.of(context).size.height;
    return Drawer(
        child: ListView(children: [
      SizedBox(
          height: height,
          child: Watch((context) {
            return Column(children: [
              TabBar(
                controller: _tabController,
                tabs: const [
                  Tab(text: 'Event Info'),
                ],
              ),
              Expanded(
                child: TabBarView(
                  controller: _tabController,
                  children: <Widget>[
                    _buildStep2(),
                  ],
                ),
              ),
              Row(
                mainAxisSize: MainAxisSize.max,
                children: [
                  Expanded(
                      child: SizedBox(
                    height: 50,
                    child: OutlinedButton(
                        style: OutlinedButton.styleFrom(
                            shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.zero,
                            ),
                            backgroundColor: Colors.white,
                            padding: EdgeInsets.all(0),
                            foregroundColor: jocPrimary),
                        onPressed: () {
                          eventFilters.value = defaultEventFilters;
                          Navigator.of(context).pop();
                        },
                        child: Text('Återställ')),
                  )),
                  Expanded(
                      child: SizedBox(
                    height: 50,
                    child: ElevatedButton(
                        style: ElevatedButton.styleFrom(
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.zero,
                          ),
                          padding: EdgeInsets.all(0),
                          backgroundColor: jocPrimary,
                        ),
                        onPressed: () {
                          batch(() {
                            eventFilters['minParticipants'] =
                                _minParticipantsController.text;
                            eventFilters['maxParticipants'] =
                                _maxParticipantsController.text;
                            eventFilters['minAge'] = _minAgeController.text;
                            eventFilters['maxAge'] = _maxAgeController.text;
                          });
                          Navigator.of(context).pop();
                        },
                        child: Text('Applicera')),
                  ))
                ],
              )
            ]);
          }))
    ]));
  }

  _buildStep2() {
    return Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        children: [
          DropdownButtonFormField(
            value: _filterSportCategory.value,
            decoration: InputDecoration(
              labelText: 'Sport Category',
              border: OutlineInputBorder(),
            ),
            items: ['Football', 'Basketball', 'Tennis', 'Cricket']
                .map((label) => DropdownMenuItem(
                      value: label,
                      child: Text(label),
                    ))
                .toList(),
            onChanged: (value) {
              _filterSportCategory.value = value;
            },
          ),
          SizedBox(
            height: 16,
          ),
          DropdownButtonFormField(
            value: _filterEventType.value,
            decoration: InputDecoration(
              labelText: 'Event Type',
              border: OutlineInputBorder(),
            ),
            items: ['Match', 'Training', 'Tryout']
                .map((label) => DropdownMenuItem(
                      value: label,
                      child: Text(label),
                    ))
                .toList(),
            onChanged: (value) {
              _filterEventType.value = value;
            },
          ),
          Padding(
              padding: EdgeInsets.only(top: 16),
              child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text('Antal deltagare'))),
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
                  child: TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Min',
                      border: OutlineInputBorder(),
                    ),
                    controller: _minParticipantsController,
                    keyboardType: TextInputType.number,
                    inputFormatters: <TextInputFormatter>[
                      FilteringTextInputFormatter.digitsOnly
                    ],
                    onChanged: (value) {
                      _filterMinParticipants.value = int.tryParse(value);
                    },
                  ),
                ),
              ),
              Flexible(
                child: Container(
                  padding: const EdgeInsets.only(top: 8, bottom: 8),
                  child: TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Max',
                      border: OutlineInputBorder(),
                    ),
                    controller: _maxParticipantsController,
                    keyboardType: TextInputType.number,
                    inputFormatters: <TextInputFormatter>[
                      FilteringTextInputFormatter.digitsOnly
                    ],
                    onChanged: (value) {
                      _filterMaxParticipants.value = int.tryParse(value);
                    },
                  ),
                ),
              ),
            ],
          ),
          Padding(
              padding: EdgeInsets.only(top: 16),
              child:
                  Align(alignment: Alignment.centerLeft, child: Text('Ålder'))),
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
                  child: TextFormField(
                    decoration: InputDecoration(
                      labelText: 'Min',
                      border: OutlineInputBorder(),
                    ),
                    controller: _minAgeController,
                    keyboardType: TextInputType.number,
                    inputFormatters: <TextInputFormatter>[
                      FilteringTextInputFormatter.digitsOnly
                    ],
                    onChanged: (value) {
                      _filterMinAge.value = int.tryParse(value);
                    },
                  ),
                ),
              ),
              Flexible(
                child: Container(
                  padding: const EdgeInsets.only(top: 8, bottom: 8),
                  child: TextFormField(
                    controller: _maxAgeController,
                    decoration: InputDecoration(
                      labelText: 'Max',
                      border: OutlineInputBorder(),
                    ),
                    keyboardType: TextInputType.number,
                    inputFormatters: <TextInputFormatter>[
                      FilteringTextInputFormatter.digitsOnly
                    ],
                    onChanged: (value) {
                      _filterMaxAge.value = int.tryParse(value);
                    },
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// Other screen using signal

import 'package:flutter/material.dart';
import 'package:joinorcreate_app/constants.dart';
import 'package:joinorcreate_app/main.dart';
import 'package:joinorcreate_app/src/app_drawer.dart';
import 'package:joinorcreate_app/src/events/events_list_screen.dart';
import 'package:joinorcreate_app/src/events/events_map_screen.dart';
import 'package:joinorcreate_app/src/events/widgets/event_filter_drawer.dart';
import 'package:signals/signals_flutter.dart';

class EventTabsScreen extends StatefulWidget {
  const EventTabsScreen({Key? key}) : super(key: key);
  static const routeName = 'event-tabs';

  @override
  State<EventTabsScreen> createState() => _EventTabsScreenState();
}

class _EventTabsScreenState extends State<EventTabsScreen>
    with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 4, vsync: this);
  }

  @override
  bool get wantKeepAlive => true; // Prevents screens from rebuilding

  @override
  Widget build(BuildContext context) {
    super.build(context); // Required for AutomaticKeepAliveClientMixin

    return Scaffold(
      appBar: AppBar(
          title: const Text('Event'),
          bottom: TabBar(
            controller: _tabController,
            tabs: const [
              Tab(text: 'lista'),
              Tab(text: 'karta'),
              Tab(text: 'mina event'),
              Tab(text: 'konversationer'),
            ],
          ),
          actions: [
            Builder(
              builder: (context) => Stack(
                children: <Widget>[
                  IconButton(
                    icon: Icon(
                      Icons.manage_search,
                      size: 26,
                    ),
                    onPressed: () => Scaffold.of(context).openEndDrawer(),
                  ),
                  Watch((context) {
                    if (eventFilters.value != defaultEventFilters) {
                      return Positioned(
                        right: 13,
                        top: 16,
                        child: Container(
                          padding: EdgeInsets.all(1),
                          decoration: BoxDecoration(
                            color: jocPrimary,
                            borderRadius: BorderRadius.circular(6),
                          ),
                          constraints: BoxConstraints(
                            minWidth: 8,
                            minHeight: 8,
                          ),
                        ),
                      );
                    }
                    return Container();
                  })
                ],
              ),
            )
          ]),
      drawer: const AppDrawer(),
      endDrawer: EventFilterDrawer(),
      body: TabBarView(
        controller: _tabController,
        physics: NeverScrollableScrollPhysics(),
        children: const [
          // Replace with your custom widgets for each tab
          EventsListScreen(),
          EventsMapScreen(),
          Center(child: Text('Tab 3 Content')),
          Center(child: Text('Tab 4 Content')),
        ],
      ),
    );
  }
}
jinyus commented 8 months ago

MapSignals use the same instance so the computed will always be false. You're comparing it to itself.

eventFilters.value != defaultEventFilters // always false
test('map signal', () {
    final defaultEventFilters = {
      'startDateTime': '',
      'endDateTime': '',
      'categories': [],
      'eventTypes': [],
      'minParticipants': '',
      'maxParticipants': '',
      'minAge': '',
      'maxAge': ''
    };

    final eventFilters = mapSignal(defaultEventFilters);

    final eventFiltersChanged = computed(
      () => eventFilters.value != defaultEventFilters,
    );

    expect(eventFiltersChanged.value, false);

    eventFilters['minAge'] = '50';

    // still false even though the value changed
    expect(eventFiltersChanged.value, false);
  });

It doesn't look like you even need the computed/comparison in your case...Why not just watch the map signal directly? It sends a notification when updated so the widgets should rebuild.

abbjetmus commented 8 months ago

@jinyus your correct, this works. Compared maps in the wrong way. Would be nice if there are examples in the documentation where concrete object types are used instead of maps if that is possible?

Thanks guys!

final defaultEventFilters = {
  'startDateTime': '',
  'endDateTime': '',
  'categories': [],
  'eventTypes': [],
  'minParticipants': '',
  'maxParticipants': '',
  'minAge': '',
  'maxAge': ''
};

final eventFilters = mapSignal({
  'startDateTime': '',
  'endDateTime': '',
  'categories': [],
  'eventTypes': [],
  'minParticipants': '',
  'maxParticipants': '',
  'minAge': '',
  'maxAge': ''
});

final eventFiltersChanged = computed(() =>
    !DeepCollectionEquality().equals(eventFilters.value, defaultEventFilters));
jinyus commented 8 months ago

You could reduce code duplication:

final eventFilters = mapSignal(Map.from(defaultEventFilters));

Another minor improvement is to instantiate the Equality object outside of the closure. You're creating a new instance everytime which gives the GC extra work.

final deepEq = DeepCollectionEquality();

final eventFiltersChanged = computed(() =>
    !deepEq.equals(eventFilters.value, defaultEventFilters));