diff --git a/app/lib/features/calendar_sync/calendar_sync.dart b/app/lib/features/calendar_sync/calendar_sync.dart index 7375031152f2..9f4eb30b8cbb 100644 --- a/app/lib/features/calendar_sync/calendar_sync.dart +++ b/app/lib/features/calendar_sync/calendar_sync.dart @@ -188,7 +188,7 @@ Future _refreshCalendar( Future _updateEventDetails( CalendarEvent acterEvent, - String? rsvp, + RsvpStatusTag? rsvp, Event localEvent, ) async { localEvent.title = acterEvent.title(); @@ -203,8 +203,8 @@ Future _updateEventDetails( UTC, ); localEvent.status = switch (rsvp) { - 'yes' => EventStatus.Confirmed, - 'maybe' => EventStatus.Tentative, + RsvpStatusTag.Yes => EventStatus.Confirmed, + RsvpStatusTag.Maybe => EventStatus.Tentative, _ => EventStatus.None }; return localEvent; diff --git a/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart b/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart index 6e901663e866..4ddf40171515 100644 --- a/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart +++ b/app/lib/features/calendar_sync/providers/events_to_sync_provider.dart @@ -4,7 +4,7 @@ import 'package:acter/features/events/providers/event_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -typedef EventAndRsvp = ({CalendarEvent event, String? rsvp}); +typedef EventAndRsvp = ({CalendarEvent event, RsvpStatusTag? rsvp}); final eventsToSyncProvider = FutureProvider.autoDispose((ref) async { // fetch all from all spaces @@ -19,13 +19,12 @@ final eventsToSyncProvider = FutureProvider.autoDispose((ref) async { for (final event in upcomingAndOngoing) { final eventId = event.eventId().toString(); final myRsvpStatus = await ref.watch(myRsvpStatusProvider(eventId).future); - final rsvpStatus = myRsvpStatus.statusStr(); - if (rsvpStatus != 'no') { + if (myRsvpStatus == RsvpStatusTag.No) { // we sync all that aren't denied yet final event = await ref.watch( calendarEventProvider(eventId).future, ); // ensure we are listening to updates of the events themselves - toSync.add((event: event, rsvp: rsvpStatus)); + toSync.add((event: event, rsvp: myRsvpStatus)); } } return toSync; diff --git a/app/lib/features/events/pages/event_details_page.dart b/app/lib/features/events/pages/event_details_page.dart index 6c807cc55126..4aef0d60e57f 100644 --- a/app/lib/features/events/pages/event_details_page.dart +++ b/app/lib/features/events/pages/event_details_page.dart @@ -4,6 +4,7 @@ import 'package:acter/common/actions/redact_content.dart'; import 'package:acter/common/actions/report_content.dart'; import 'package:acter/common/providers/common_providers.dart'; import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/toolkit/errors/error_page.dart'; import 'package:acter/common/utils/utils.dart'; import 'package:acter/common/widgets/edit_html_description_sheet.dart'; import 'package:acter/common/widgets/edit_title_sheet.dart'; @@ -72,9 +73,17 @@ class _EventDetailPageConsumerState extends ConsumerState { ], ); }, - error: (e, s) { - _log.severe('Failed to load cal event', e, s); - return Text(L10n.of(context).errorLoadingEventDueTo(e)); + error: (error, stack) { + _log.severe('Failed to load cal event', error, stack); + return ErrorPage( + background: const EventDetailsSkeleton(), + error: error, + stack: stack, + textBuilder: L10n.of(context).errorLoadingEventDueTo, + onRetryTap: () { + ref.invalidate(calendarEventProvider(widget.calendarId)); + }, + ); }, loading: () => const EventDetailsSkeleton(), ), @@ -366,27 +375,7 @@ class _EventDetailPageConsumerState extends ConsumerState { } Widget _buildEventRsvpActions(CalendarEvent calendarEvent) { - final myRsvpStatus = ref.watch(myRsvpStatusProvider(widget.calendarId)); - Set rsvp = {null}; - myRsvpStatus.maybeWhen( - data: (data) { - final status = data.statusStr(); - if (status != null) { - switch (status) { - case 'yes': - rsvp = {RsvpStatusTag.Yes}; - break; - case 'maybe': - rsvp = {RsvpStatusTag.Maybe}; - break; - case 'no': - rsvp = {RsvpStatusTag.No}; - break; - } - } - }, - orElse: () => null, - ); + final rsvp = ref.watch(myRsvpStatusProvider(widget.calendarId)).valueOrNull; return Container( color: Theme.of(context).colorScheme.surface, @@ -400,7 +389,7 @@ class _EventDetailPageConsumerState extends ConsumerState { iconData: Icons.check, actionName: L10n.of(context).going, rsvpStatusColor: Theme.of(context).colorScheme.secondary, - isSelected: rsvp.single == RsvpStatusTag.Yes, + isSelected: rsvp == RsvpStatusTag.Yes, ), _buildVerticalDivider(), _buildEventRsvpActionItem( @@ -410,7 +399,7 @@ class _EventDetailPageConsumerState extends ConsumerState { iconData: Icons.close, actionName: L10n.of(context).notGoing, rsvpStatusColor: Theme.of(context).colorScheme.error, - isSelected: rsvp.single == RsvpStatusTag.No, + isSelected: rsvp == RsvpStatusTag.No, ), _buildVerticalDivider(), _buildEventRsvpActionItem( @@ -420,7 +409,7 @@ class _EventDetailPageConsumerState extends ConsumerState { iconData: Icons.question_mark, actionName: L10n.of(context).maybe, rsvpStatusColor: Colors.white, - isSelected: rsvp.single == RsvpStatusTag.Maybe, + isSelected: rsvp == RsvpStatusTag.Maybe, ), ], ), diff --git a/app/lib/features/events/providers/event_providers.dart b/app/lib/features/events/providers/event_providers.dart index 4fe22f4c7716..8b9de574bfde 100644 --- a/app/lib/features/events/providers/event_providers.dart +++ b/app/lib/features/events/providers/event_providers.dart @@ -15,7 +15,7 @@ final calendarEventProvider = AsyncNotifierProvider.autoDispose //MY RSVP STATUS PROVIDER final myRsvpStatusProvider = AsyncNotifierProvider.autoDispose - .family( + .family( () => AsyncRsvpStatusNotifier(), ); @@ -57,7 +57,7 @@ final myOngoingEventListProvider = FutureProvider.autoDispose for (final event in allOngoingEventList) { final myRsvpStatus = await ref .watch(myRsvpStatusProvider(event.eventId().toString()).future); - if (myRsvpStatus.statusStr() == 'yes') { + if (myRsvpStatus == ffi.RsvpStatusTag.Yes) { myOngoingEventList.add(event); } } @@ -83,7 +83,7 @@ final myUpcomingEventListProvider = FutureProvider.autoDispose for (final event in allUpcomingEventList) { final myRsvpStatus = await ref .watch(myRsvpStatusProvider(event.eventId().toString()).future); - if (myRsvpStatus.statusStr() == 'yes') { + if (myRsvpStatus == ffi.RsvpStatusTag.Yes) { myUpcomingEventList.add(event); } } @@ -109,7 +109,7 @@ final myPastEventListProvider = FutureProvider.autoDispose for (final event in allPastEventList) { final myRsvpStatus = await ref .watch(myRsvpStatusProvider(event.eventId().toString()).future); - if (myRsvpStatus.statusStr() == 'yes') { + if (myRsvpStatus == ffi.RsvpStatusTag.Yes) { myPastEventList.add(event); } } diff --git a/app/lib/features/events/providers/notifiers/rsvp_notifier.dart b/app/lib/features/events/providers/notifiers/rsvp_notifier.dart index b18b11bdf607..f28c577e44db 100644 --- a/app/lib/features/events/providers/notifiers/rsvp_notifier.dart +++ b/app/lib/features/events/providers/notifiers/rsvp_notifier.dart @@ -4,17 +4,22 @@ import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' as ffi; import 'package:riverpod/riverpod.dart'; class AsyncRsvpStatusNotifier - extends AutoDisposeFamilyAsyncNotifier { + extends AutoDisposeFamilyAsyncNotifier { late Stream _listener; - Future _getMyResponse() async { + Future _getMyResponse() async { final client = ref.read(alwaysClientProvider); final calEvent = await client.waitForCalendarEvent(arg, null); - return await calEvent.respondedByMe(); + return switch ((await calEvent.respondedByMe()).statusStr()) { + 'yes' => ffi.RsvpStatusTag.Yes, + 'no' => ffi.RsvpStatusTag.No, + 'maybe' => ffi.RsvpStatusTag.Maybe, + _ => null, + }; } @override - Future build(String arg) async { + Future build(String arg) async { final client = ref.watch(alwaysClientProvider); _listener = client.subscribeStream('$arg::rsvp'); // keep it resident in memory diff --git a/app/lib/features/events/widgets/event_item.dart b/app/lib/features/events/widgets/event_item.dart index ce307002afbc..373c1988b74a 100644 --- a/app/lib/features/events/widgets/event_item.dart +++ b/app/lib/features/events/widgets/event_item.dart @@ -6,7 +6,7 @@ import 'package:acter/features/events/actions/get_event_type.dart'; import 'package:acter/features/events/providers/event_providers.dart'; import 'package:acter/features/events/widgets/event_date_widget.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' - show CalendarEvent; + show CalendarEvent, RsvpStatusTag; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -110,8 +110,7 @@ class EventItem extends StatelessWidget { final eventId = event.eventId().toString(); final rsvpLoader = ref.watch(myRsvpStatusProvider(eventId)); return rsvpLoader.when( - data: (rsvp) { - final status = rsvp.statusStr(); // kebab-case + data: (status) { final widget = _getRsvpStatus(context, status); // kebab-case return widget ?? const SizedBox.shrink(); }, @@ -132,24 +131,19 @@ class EventItem extends StatelessWidget { ); } - Widget? _getRsvpStatus(BuildContext context, String? status) { - if (status != null) { - switch (status) { - case 'yes': - return Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.secondary, - ); - case 'no': - return Icon( - Icons.cancel, - color: Theme.of(context).colorScheme.error, - ); - case 'maybe': - return const Icon(Icons.question_mark_rounded); - } - } - return null; + Widget? _getRsvpStatus(BuildContext context, RsvpStatusTag? status) { + return switch (status) { + RsvpStatusTag.Yes => Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + RsvpStatusTag.No => Icon( + Icons.cancel, + color: Theme.of(context).colorScheme.error, + ), + RsvpStatusTag.Maybe => const Icon(Icons.question_mark_rounded), + null => null, + }; } Widget _buildHappeningIndication(BuildContext context) { diff --git a/app/lib/features/events/widgets/skeletons/event_details_skeleton_widget.dart b/app/lib/features/events/widgets/skeletons/event_details_skeleton_widget.dart index e5a0bcdc69a0..a98777d2f6f2 100644 --- a/app/lib/features/events/widgets/skeletons/event_details_skeleton_widget.dart +++ b/app/lib/features/events/widgets/skeletons/event_details_skeleton_widget.dart @@ -2,31 +2,23 @@ import 'package:flutter/material.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -class EventDetailsSkeleton extends StatefulWidget { +class EventDetailsSkeleton extends StatelessWidget { const EventDetailsSkeleton({super.key}); - - @override - State createState() => _EventListSkeletonState(); -} - -class _EventListSkeletonState extends State { @override Widget build(BuildContext context) { - return Skeletonizer(child: _buildSkeletonUI()); - } - - Widget _buildSkeletonUI() { - return Column( - children: [ - _buildEventHeaderSkeletonUI(), - _buildEventBasicInfoSkeletonUI(), - _buildEventRsvpButtonsSkeletonUI(), - _buildEventAboutSkeletonUI(), - ], + return SingleChildScrollView( + child: Column( + children: [ + Skeletonizer(child: _buildEventHeaderSkeletonUI(context)), + Skeletonizer(child: _buildEventBasicInfoSkeletonUI(context)), + Skeletonizer(child: _buildEventRsvpButtonsSkeletonUI(context)), + Skeletonizer(child: _buildEventAboutSkeletonUI(context)), + ], + ), ); } - Widget _buildEventHeaderSkeletonUI() { + Widget _buildEventHeaderSkeletonUI(BuildContext context) { return Padding( padding: const EdgeInsets.all(10), child: Column( @@ -42,7 +34,7 @@ class _EventListSkeletonState extends State { ); } - Widget _buildEventBasicInfoSkeletonUI() { + Widget _buildEventBasicInfoSkeletonUI(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Row( @@ -53,14 +45,12 @@ class _EventListSkeletonState extends State { color: Colors.white, ), const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(L10n.of(context).eventTitleData), - Text(L10n.of(context).eventDescriptionsData), - ], - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(L10n.of(context).eventTitleData), + Text(L10n.of(context).eventDescriptionsData), + ], ), const SizedBox(width: 20), Text(L10n.of(context).rsvp), @@ -69,40 +59,35 @@ class _EventListSkeletonState extends State { ); } - Widget _buildEventRsvpButtonsSkeletonUI() { + Widget _buildEventRsvpButtonsSkeletonUI(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Expanded( - child: Container( - height: 80, - width: 80, - color: Colors.white, - ), + Container( + height: 80, + width: 80, + color: Colors.white, ), const SizedBox(width: 20), - Expanded( - child: Container( - height: 80, - width: 80, - color: Colors.white, - ), + Container( + height: 80, + width: 80, + color: Colors.white, ), const SizedBox(width: 20), - Expanded( - child: Container( - height: 80, - width: 80, - color: Colors.white, - ), + Container( + height: 80, + width: 80, + color: Colors.white, ), ], ), ); } - Widget _buildEventAboutSkeletonUI() { + Widget _buildEventAboutSkeletonUI(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Container( diff --git a/app/test/features/events/error_pages_test.dart b/app/test/features/events/error_pages_test.dart index 88ddb517a10a..8568a7be0ce8 100644 --- a/app/test/features/events/error_pages_test.dart +++ b/app/test/features/events/error_pages_test.dart @@ -1,11 +1,15 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/widgets/acter_search_widget.dart'; +import 'package:acter/features/bookmarks/providers/bookmarks_provider.dart'; +import 'package:acter/features/events/pages/event_details_page.dart'; import 'package:acter/features/events/pages/event_list_page.dart'; import 'package:acter/features/events/providers/event_providers.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/error_helpers.dart'; +import '../../helpers/mock_event_providers.dart'; +import '../../helpers/mock_space_providers.dart'; import '../../helpers/test_util.dart'; void main() { @@ -97,4 +101,24 @@ void main() { await tester.ensureErrorPageWithRetryWorks(); }); }); + group('Event Details Error Pages', () { + testWidgets('body error page', (tester) async { + final mockedNofitier = MockAsyncCalendarEventNotifier(); + await tester.pumpProviderWidget( + overrides: [ + isBookmarkedProvider.overrideWith((a, b) => false), + roomAvatarInfoProvider + .overrideWith(() => MockRoomAvatarInfoNotifier()), + calendarEventProvider.overrideWith(() => mockedNofitier), + myRsvpStatusProvider + .overrideWith(() => MockAsyncRsvpStatusNotifier()), + roomMembershipProvider.overrideWith((a, b) => null), + ], + child: const EventDetailPage( + calendarId: '!asdf', + ), + ); + await tester.ensureErrorPageWithRetryWorks(); + }); + }); } diff --git a/app/test/helpers/mock_event_providers.dart b/app/test/helpers/mock_event_providers.dart new file mode 100644 index 000000000000..35002dcf1782 --- /dev/null +++ b/app/test/helpers/mock_event_providers.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:acter/features/events/providers/notifiers/event_notifiers.dart'; +import 'package:acter/features/events/providers/notifiers/rsvp_notifier.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:mockito/mockito.dart'; +import 'package:riverpod/riverpod.dart'; + +class MockAsyncCalendarEventNotifier + extends AutoDisposeFamilyAsyncNotifier + with Mock + implements AsyncCalendarEventNotifier { + bool shouldFail; + + MockAsyncCalendarEventNotifier({this.shouldFail = true}); + + @override + Future build(String arg) async { + if (shouldFail) { + // toggle failure so the retry works + shouldFail = !shouldFail; + throw 'Expected fail: Space not loaded'; + } + return MockEvent(); + } +} + +class MockAsyncRsvpStatusNotifier + extends AutoDisposeFamilyAsyncNotifier + with Mock + implements AsyncRsvpStatusNotifier { + @override + Future build(String arg) async { + return null; + } +} + +class MockEvent extends Fake implements CalendarEvent { + @override + String roomIdStr() => 'testRoomId'; + @override + String title() => 'Fake Event'; + @override + TextMessageContent? description() => null; + @override + UtcDateTime utcStart() => FakeUtcDateTime(); + @override + UtcDateTime utcEnd() => FakeUtcDateTime(); + + @override + Future participants() => + Completer().future; + + @override + Future attachments() => + Completer().future; + + @override + Future comments() => Completer().future; +} + +class FakeUtcDateTime extends Fake implements UtcDateTime { + @override + int timestampMillis() => 10; +}