Skip to content

Commit cd61ed2

Browse files
feat(cat-voices): incident reporting (#3504)
* feat(cat-voices): unification of notifications (#3457) * wip: CatalystMessenger with initial banners * notification type colors and icons * fix: reading map while removing * wip * chore: remove old banners * chore: remove exiting banners + add event bus * move CatalystNotification to app * Bring back user verification banner * chore: remove unused class * chore: cleanup * chore: spacer * Use ScaffoldMessenger.maybeOf instead of global key * feat(cat-voices): status reporting (#3519) * feat(cat-voices): implement system status monitoring * feat(cat-voices): refactor * feat(cat-voices): set system status url in one place * feat(cat-voices): set `SystemStatusIssueBanner` id as a const value * feat(cat-voices): prevent the same signals from queuing (#3532) * feat: prevent duplicate notifications from queuing * refactor * add docs --------- Co-authored-by: Bartek Stoliński <[email protected]> Co-authored-by: Bartek Stoliński <[email protected]>
1 parent 674cddc commit cd61ed2

File tree

54 files changed

+1120
-730
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1120
-730
lines changed

catalyst_voices/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ docs/
2020
# Un-ignore vit, cat-reviews and cat-gateway openapi spec as they're not generated
2121
!**/openapi/vit.json
2222
!**/openapi/cat-reviews.json
23+
!**/openapi/cat-status.json
2324
!**/openapi/cat-gateway.json
2425

2526
# Un-ignore generated files in public packages

catalyst_voices/apps/voices/lib/app/view/app.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class _AppState extends State<App> {
4141
BlocProvider<AdminToolsCubit>(
4242
create: (_) => Dependencies.instance.get<AdminToolsCubit>(),
4343
),
44+
BlocProvider<SystemStatusCubit>(
45+
create: (_) => Dependencies.instance.get<SystemStatusCubit>(),
46+
),
4447
BlocProvider<SessionCubit>(
4548
create: (_) => Dependencies.instance.get<SessionCubit>(),
4649
),
@@ -74,9 +77,6 @@ class _AppState extends State<App> {
7477
BlocProvider<DevToolsBloc>(
7578
create: (_) => Dependencies.instance.get<DevToolsBloc>(),
7679
),
77-
BlocProvider<PublicProfileEmailStatusCubit>(
78-
create: (_) => Dependencies.instance.get<PublicProfileEmailStatusCubit>(),
79-
),
8080
BlocProvider<CampaignPhaseAwareCubit>(
8181
// Making it not lazy to not show two loading screens in a row (one for app splash screen and one for campaign phase aware)
8282
lazy: false,

catalyst_voices/apps/voices/lib/app/view/app_content.dart

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import 'package:catalyst_voices/app/view/app_mobile_access_restriction.dart';
44
import 'package:catalyst_voices/app/view/app_precache_image_assets.dart';
55
import 'package:catalyst_voices/app/view/app_session_listener.dart';
66
import 'package:catalyst_voices/app/view/app_splash_screen_manager.dart';
7+
import 'package:catalyst_voices/app/view/app_system_status_listener.dart';
78
import 'package:catalyst_voices/app/view/video_cache/app_video_manager_scope.dart';
89
import 'package:catalyst_voices/app/view/video_cache/app_video_precache.dart';
910
import 'package:catalyst_voices/common/ext/preferences_ext.dart';
11+
import 'package:catalyst_voices/notification/catalyst_messenger.dart';
1012
import 'package:catalyst_voices/pages/campaign_phase_aware/widgets/bubble_campaign_phase_aware_background.dart';
1113
import 'package:catalyst_voices/share/share_manager.dart';
1214
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
@@ -76,19 +78,23 @@ final class _AppContent extends StatelessWidget {
7678
child: AppVideoManagerScope(
7779
child: AppVideoPrecache(
7880
child: GlobalPrecacheImages(
79-
child: GlobalSessionListener(
80-
// IMPORTANT: AppSplashScreenManager must be placed above all
81-
// widgets that render visible UI elements. Any widget that
82-
// displays content should be a descendant of
83-
// AppSplashScreenManager to ensure proper splash
84-
// screen behavior.
85-
child: AppSplashScreenManager(
86-
child: AppMobileAccessRestriction(
87-
routerConfig: routerConfig,
88-
child: DefaultShareManager(
89-
child: _AppContentBackground(
90-
key: const Key('AppContentBackground'),
91-
child: child,
81+
child: CatalystMessenger(
82+
child: GlobalSessionListener(
83+
// IMPORTANT: AppSplashScreenManager must be placed above all
84+
// widgets that render visible UI elements. Any widget that
85+
// displays content should be a descendant of
86+
// AppSplashScreenManager to ensure proper splash
87+
// screen behavior.
88+
child: AppSplashScreenManager(
89+
child: AppMobileAccessRestriction(
90+
routerConfig: routerConfig,
91+
child: DefaultShareManager(
92+
child: SystemStatusListener(
93+
child: _AppContentBackground(
94+
key: const Key('AppContentBackground'),
95+
child: child,
96+
),
97+
),
9298
),
9399
),
94100
),

catalyst_voices/apps/voices/lib/app/view/app_session_listener.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'package:catalyst_voices/common/signal_handler.dart';
2+
import 'package:catalyst_voices/notification/catalyst_messenger.dart';
3+
import 'package:catalyst_voices/notification/specialized/account_needs_verification_banner.dart';
14
import 'package:catalyst_voices/routes/routes.dart';
25
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar.dart';
36
import 'package:catalyst_voices/widgets/snackbar/voices_snackbar_type.dart';
@@ -21,18 +24,35 @@ class GlobalSessionListener extends StatefulWidget {
2124
State<GlobalSessionListener> createState() => _GlobalSessionListenerState();
2225
}
2326

24-
class _GlobalSessionListenerState extends State<GlobalSessionListener> {
27+
class _GlobalSessionListenerState extends State<GlobalSessionListener>
28+
with SignalHandlerStateMixin<SessionCubit, SessionSignal, GlobalSessionListener> {
2529
String? _lastLocation;
2630

2731
@override
2832
Widget build(BuildContext context) {
33+
// TODO(damian-molinski): refactor it to use signals
2934
return BlocListener<SessionCubit, SessionState>(
3035
listenWhen: _listenToSessionChangesWhen,
3136
listener: _onSessionChanged,
3237
child: widget.child,
3338
);
3439
}
3540

41+
@override
42+
void handleSignal(SessionSignal signal) {
43+
switch (signal) {
44+
case AccountNeedsVerificationSignal(:final isProposer):
45+
final notification = isProposer
46+
? AccountProposerNeedsVerificationBanner()
47+
: AccountContributorNeedsVerificationBanner();
48+
CatalystMessenger.of(context).add(notification);
49+
case CancelAccountNeedsVerificationSignal():
50+
CatalystMessenger.of(
51+
context,
52+
).cancelWhere((notification) => notification is AccountNeedsVerificationBanner);
53+
}
54+
}
55+
3656
bool _listenToSessionChangesWhen(SessionState prev, SessionState next) {
3757
// We deliberately check if previous was guest because we don't
3858
// want to show the snackbar after the registration is completed.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'package:catalyst_voices/common/signal_handler.dart';
2+
import 'package:catalyst_voices/notification/catalyst_messenger.dart';
3+
import 'package:catalyst_voices/notification/specialized/system_status_issue_banner.dart';
4+
import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart';
5+
import 'package:flutter/material.dart';
6+
7+
class SystemStatusListener extends StatefulWidget {
8+
final Widget child;
9+
10+
const SystemStatusListener({
11+
super.key,
12+
required this.child,
13+
});
14+
15+
@override
16+
State<SystemStatusListener> createState() => _SystemStatusListenerState();
17+
}
18+
19+
class _SystemStatusListenerState extends State<SystemStatusListener>
20+
with SignalHandlerStateMixin<SystemStatusCubit, SystemStatusSignal, SystemStatusListener> {
21+
@override
22+
Widget build(BuildContext context) {
23+
return widget.child;
24+
}
25+
26+
@override
27+
void handleSignal(SystemStatusSignal signal) {
28+
switch (signal) {
29+
case SystemStatusIssueSignal():
30+
CatalystMessenger.of(context).add(SystemStatusIssueBanner());
31+
case CancelSystemStatusIssueSignal():
32+
CatalystMessenger.of(
33+
context,
34+
).cancelWhere((notification) => notification is SystemStatusIssueBanner);
35+
}
36+
}
37+
}

catalyst_voices/apps/voices/lib/dependency/dependencies.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ final class Dependencies extends DependencyProvider {
8383
..registerLazySingleton<AdminTools>(
8484
() => get<AdminToolsCubit>(),
8585
)
86+
..registerLazySingleton<SystemStatusCubit>(
87+
() => SystemStatusCubit(get<SystemStatusRepository>()),
88+
)
8689
..registerLazySingleton<SessionCubit>(
8790
() {
8891
return SessionCubit(
@@ -185,11 +188,6 @@ final class Dependencies extends DependencyProvider {
185188
get<DocumentsService>(),
186189
);
187190
})
188-
..registerFactory<PublicProfileEmailStatusCubit>(() {
189-
return PublicProfileEmailStatusCubit(
190-
get<UserService>(),
191-
);
192-
})
193191
..registerFactory<DocumentLookupBloc>(() {
194192
return DocumentLookupBloc(
195193
get<DocumentsService>(),
@@ -299,6 +297,11 @@ final class Dependencies extends DependencyProvider {
299297
() => VotingRepository(
300298
get<CastedVotesObserver>(),
301299
),
300+
)
301+
..registerLazySingleton<SystemStatusRepository>(
302+
() => SystemStatusRepository(
303+
get<ApiServices>(),
304+
),
302305
);
303306
}
304307

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart';
2+
import 'package:catalyst_voices_assets/catalyst_voices_assets.dart';
3+
import 'package:flutter/foundation.dart';
4+
import 'package:flutter/material.dart';
5+
6+
class BannerCloseButton extends StatelessWidget {
7+
const BannerCloseButton({super.key});
8+
9+
@override
10+
Widget build(BuildContext context) {
11+
return VoicesIconButton(
12+
style: const ButtonStyle(iconSize: WidgetStatePropertyAll(18)),
13+
onTap: () {
14+
final messengerState = ScaffoldMessenger.maybeOf(context);
15+
if (messengerState == null) {
16+
if (kDebugMode) {
17+
print('Can not dismiss banner because messenger key state is empty!');
18+
}
19+
return;
20+
}
21+
22+
messengerState.hideCurrentMaterialBanner(reason: MaterialBannerClosedReason.dismiss);
23+
},
24+
child: VoicesAssets.icons.x.buildIcon(),
25+
);
26+
}
27+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import 'package:catalyst_voices/notification/catalyst_notification.dart';
2+
import 'package:catalyst_voices/widgets/widgets.dart';
3+
import 'package:flutter/gestures.dart';
4+
import 'package:flutter/material.dart';
5+
6+
const _titleKey = 'title';
7+
8+
class BannerContent extends StatefulWidget {
9+
final BannerNotification notification;
10+
11+
const BannerContent({
12+
super.key,
13+
required this.notification,
14+
});
15+
16+
@override
17+
State<BannerContent> createState() => _BannerContentState();
18+
}
19+
20+
class _BannerContentState extends State<BannerContent> {
21+
final _gestureRecognizers = <String, GestureRecognizer>{};
22+
23+
@override
24+
Widget build(BuildContext context) {
25+
final title = widget.notification.title(context);
26+
final message = widget.notification.message(context);
27+
28+
final text = '{$_titleKey}: ${message.text}';
29+
final placeholders = <String, CatalystNotificationTextPart>{
30+
_titleKey: CatalystNotificationTextPart(text: title, bold: true),
31+
...message.placeholders,
32+
};
33+
34+
return PlaceholderRichText(
35+
text,
36+
placeholderSpanBuilder: (context, placeholder) {
37+
if (!placeholders.containsKey(placeholder)) {
38+
return TextSpan(text: placeholder);
39+
}
40+
41+
final replacement = placeholders[placeholder]!;
42+
final onTap = replacement.onTap;
43+
44+
return TextSpan(
45+
text: replacement.text,
46+
style: TextStyle(
47+
fontWeight: replacement.bold ? FontWeight.bold : null,
48+
decoration: replacement.underlined ? TextDecoration.underline : null,
49+
),
50+
recognizer: onTap != null
51+
? _gestureRecognizers.putIfAbsent(
52+
placeholder,
53+
() => TapGestureRecognizer()..onTap = () => onTap(context),
54+
)
55+
: null,
56+
);
57+
},
58+
);
59+
}
60+
61+
@override
62+
void dispose() {
63+
final keys = List.of(_gestureRecognizers.keys);
64+
for (final key in keys) {
65+
_gestureRecognizers.remove(key)?.dispose();
66+
}
67+
super.dispose();
68+
}
69+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
part of 'catalyst_notification.dart';
2+
3+
abstract base class BannerNotification extends CatalystNotification {
4+
const BannerNotification({
5+
required super.id,
6+
super.priority,
7+
super.type,
8+
super.routerPredicate,
9+
});
10+
11+
BannerNotificationMessage message(BuildContext context);
12+
13+
String title(BuildContext context);
14+
}

0 commit comments

Comments
 (0)