kasy-cli 1.37.1 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -0
- package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/backends/patch-base-hashes.json +6 -6
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/shared/generator-utils.js +12 -6
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
- package/templates/firebase/AGENTS.md +7 -1
- package/templates/firebase/DESIGN_SYSTEM.md +35 -8
- package/templates/firebase/assets/icons/apple_black.svg +3 -0
- package/templates/firebase/assets/icons/apple_white.svg +4 -0
- package/templates/firebase/assets/icons/facebook.svg +49 -0
- package/templates/firebase/assets/icons/google.svg +1 -0
- package/templates/firebase/functions/src/admin/functions.ts +2 -0
- package/templates/firebase/functions/src/authentication/functions.ts +13 -7
- package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
- package/templates/firebase/lib/components/components.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
- package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
- package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
- package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
- package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
- package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
- package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
- package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
- package/templates/firebase/lib/core/states/logout_action.dart +11 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
- package/templates/firebase/lib/core/theme/texts.dart +21 -6
- package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
- package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
- package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
- package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
- package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
- package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
- package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
- package/templates/firebase/lib/features/home/home_feed.dart +2 -2
- package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
- package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
- package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
- package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
- package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
- package/templates/firebase/lib/i18n/en.i18n.json +749 -698
- package/templates/firebase/lib/i18n/es.i18n.json +749 -698
- package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +70 -46
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
- package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
- package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
- package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
- package/templates/firebase/tool/design_check.dart +9 -0
- package/templates/firebase/assets/icons/apple.png +0 -0
- package/templates/firebase/assets/icons/facebook.png +0 -0
- package/templates/firebase/assets/icons/google.png +0 -0
- package/templates/firebase/assets/icons/google_play_games.png +0 -0
- package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
- package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
- package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
- package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
|
@@ -114,9 +114,17 @@ class FirebaseDeviceApi implements DeviceApi {
|
|
|
114
114
|
await Future.delayed(const Duration(seconds: 1));
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// Register the installation even without a push token. On iOS the FCM
|
|
118
|
+
// token stays null until the user grants notification permission (and
|
|
119
|
+
// getToken can even throw before APNS is ready). We still create the
|
|
120
|
+
// device row — so the welcome notification fires and the install is
|
|
121
|
+
// tracked — and the token fills in later via onTokenRefresh once push is
|
|
122
|
+
// enabled.
|
|
123
|
+
String token = '';
|
|
124
|
+
try {
|
|
125
|
+
token = await _messaging.getToken() ?? '';
|
|
126
|
+
} catch (_) {
|
|
127
|
+
token = '';
|
|
120
128
|
}
|
|
121
129
|
final os = Platform.isAndroid
|
|
122
130
|
? OperatingSystem.android //
|
|
@@ -5,14 +5,15 @@ import 'package:flutter/rendering.dart';
|
|
|
5
5
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
6
6
|
import 'package:kasy_kit/components/components.dart';
|
|
7
7
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
8
|
+
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
8
9
|
import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
|
|
9
10
|
as app;
|
|
10
11
|
import 'package:kasy_kit/features/notifications/providers/models/notification_list.dart';
|
|
11
12
|
import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
|
|
12
|
-
import 'package:kasy_kit/features/notifications/ui/components/notification_settings_sheet.dart';
|
|
13
13
|
import 'package:kasy_kit/features/notifications/ui/components/notification_tile.dart';
|
|
14
14
|
import 'package:kasy_kit/features/notifications/ui/widgets/empty_notifications.dart';
|
|
15
15
|
import 'package:kasy_kit/features/notifications/ui/widgets/notification_tile.dart';
|
|
16
|
+
import 'package:kasy_kit/features/notifications/ui/widgets/push_permission_banner.dart';
|
|
16
17
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
17
18
|
|
|
18
19
|
class NotificationsPage extends ConsumerStatefulWidget {
|
|
@@ -105,6 +106,8 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
105
106
|
return KasyOverlayScaffold(
|
|
106
107
|
title: t.notifications.title,
|
|
107
108
|
appBarStyle: KasyAppBarStyle.rootTab,
|
|
109
|
+
// Contain + center the list on desktop so cards never stretch edge-to-edge.
|
|
110
|
+
maxContentWidth: kKasyContentMaxWidth,
|
|
108
111
|
hideAppBarOnScroll: true,
|
|
109
112
|
scrollController: _scrollController,
|
|
110
113
|
trailing: Builder(
|
|
@@ -121,12 +124,17 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
121
124
|
tooltip: t.notifications.delete_all,
|
|
122
125
|
),
|
|
123
126
|
if (hasAny) const SizedBox(width: KasySpacing.xs),
|
|
127
|
+
// Light/dark theme toggle, matching the other root-tab screens.
|
|
124
128
|
KasyChromeOrbIconButton(
|
|
125
|
-
icon:
|
|
129
|
+
icon: Theme.of(ctx).brightness == Brightness.dark
|
|
130
|
+
? KasyIcons.lightMode
|
|
131
|
+
: KasyIcons.darkMode,
|
|
126
132
|
iconSize: 18,
|
|
127
133
|
foregroundColor: ctx.colors.onSurface,
|
|
128
|
-
onPressed: () =>
|
|
129
|
-
tooltip:
|
|
134
|
+
onPressed: () => ThemeProvider.of(ctx).toggle(),
|
|
135
|
+
tooltip: Theme.of(ctx).brightness == Brightness.dark
|
|
136
|
+
? 'Light mode'
|
|
137
|
+
: 'Dark mode',
|
|
130
138
|
),
|
|
131
139
|
],
|
|
132
140
|
),
|
|
@@ -136,6 +144,13 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
136
144
|
requestReadAll();
|
|
137
145
|
},
|
|
138
146
|
slivers: [
|
|
147
|
+
// Native-only push nudge. Self-hides on web and once granted, and the
|
|
148
|
+
// first time it appears in the "never asked" state it fires the native
|
|
149
|
+
// OS prompt automatically. Sits above the list so it shows even when the
|
|
150
|
+
// welcome notification already fills the screen.
|
|
151
|
+
const SliverToBoxAdapter(
|
|
152
|
+
child: PushPermissionBanner(autoRequest: true),
|
|
153
|
+
),
|
|
139
154
|
notificationsState.when(
|
|
140
155
|
loading: () => SliverList(
|
|
141
156
|
delegate: SliverChildBuilderDelegate(
|
|
@@ -1,40 +1,16 @@
|
|
|
1
|
-
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
2
1
|
import 'package:flutter/material.dart';
|
|
3
|
-
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
|
-
import 'package:kasy_kit/components/components.dart';
|
|
5
2
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
6
|
-
import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
|
|
7
|
-
import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
|
|
8
3
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
9
4
|
|
|
10
|
-
|
|
5
|
+
/// Empty state for the notifications list: just the illustration and copy.
|
|
6
|
+
///
|
|
7
|
+
/// The "enable push" call-to-action lives in [PushPermissionBanner] at the top
|
|
8
|
+
/// of the screen now, so it shows whether or not the list is empty (the welcome
|
|
9
|
+
/// notification used to hide the empty-state button). Keeping it in one place
|
|
10
|
+
/// avoids a duplicate CTA.
|
|
11
|
+
class EmptyNotifications extends StatelessWidget {
|
|
11
12
|
const EmptyNotifications({super.key});
|
|
12
13
|
|
|
13
|
-
@override
|
|
14
|
-
ConsumerState<EmptyNotifications> createState() => _EmptyNotificationsState();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
class _EmptyNotificationsState extends ConsumerState<EmptyNotifications> {
|
|
18
|
-
late Future<NotificationPermission> _permissionFuture;
|
|
19
|
-
|
|
20
|
-
@override
|
|
21
|
-
void initState() {
|
|
22
|
-
super.initState();
|
|
23
|
-
_permissionFuture = _loadPermission();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
Future<NotificationPermission> _loadPermission() {
|
|
27
|
-
return ref.read(notificationRepositoryProvider).getPermissionStatus();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
Future<void> _onAskPressed(NotificationPermission permission) async {
|
|
31
|
-
await permission.maybeAsk();
|
|
32
|
-
if (!mounted) return;
|
|
33
|
-
setState(() {
|
|
34
|
-
_permissionFuture = _loadPermission();
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
14
|
@override
|
|
39
15
|
Widget build(BuildContext context) {
|
|
40
16
|
final tr = context.t.notifications;
|
|
@@ -74,31 +50,6 @@ class _EmptyNotificationsState extends ConsumerState<EmptyNotifications> {
|
|
|
74
50
|
color: context.colors.muted,
|
|
75
51
|
),
|
|
76
52
|
),
|
|
77
|
-
const SizedBox(height: KasySpacing.xl),
|
|
78
|
-
FutureBuilder<NotificationPermission>(
|
|
79
|
-
future: _permissionFuture,
|
|
80
|
-
builder: (context, snapshot) {
|
|
81
|
-
final permission = snapshot.data;
|
|
82
|
-
if (permission == null) return const SizedBox.shrink();
|
|
83
|
-
// Web has no usable notification-permission prompt (askPermission is a
|
|
84
|
-
// no-op on web and permission_handler has no web support), so the CTA
|
|
85
|
-
// would be a dead button. Show the empty state without it.
|
|
86
|
-
if (kIsWeb) return const SizedBox.shrink();
|
|
87
|
-
if (permission is NotificationPermissionGranted) {
|
|
88
|
-
return const SizedBox.shrink();
|
|
89
|
-
}
|
|
90
|
-
final isLocked =
|
|
91
|
-
permission is NotificationPermissionPermanentlyDenied;
|
|
92
|
-
return KasyButton(
|
|
93
|
-
label: isLocked ? tr.empty_cta_open_settings : tr.empty_cta,
|
|
94
|
-
variant: KasyButtonVariant.soft,
|
|
95
|
-
icon: isLocked
|
|
96
|
-
? KasyIcons.settings
|
|
97
|
-
: KasyIcons.notificationAdd,
|
|
98
|
-
onPressed: () => _onAskPressed(permission),
|
|
99
|
-
);
|
|
100
|
-
},
|
|
101
|
-
),
|
|
102
53
|
const SizedBox(height: KasySpacing.xxxl),
|
|
103
54
|
],
|
|
104
55
|
),
|
|
@@ -137,7 +137,7 @@ class NotificationTile extends StatelessWidget {
|
|
|
137
137
|
Expanded(
|
|
138
138
|
child: Text(
|
|
139
139
|
title,
|
|
140
|
-
style: context.
|
|
140
|
+
style: context.kasyTextTheme.listRowTitle.copyWith(
|
|
141
141
|
color: titleColor,
|
|
142
142
|
fontWeight: FontWeight.w600,
|
|
143
143
|
),
|
|
@@ -211,11 +211,11 @@ class TileNotificationImage extends StatelessWidget {
|
|
|
211
211
|
Widget build(BuildContext context) {
|
|
212
212
|
return KasyFocusRing(
|
|
213
213
|
onActivate: () => _openFullscreen(context),
|
|
214
|
-
borderRadius: BorderRadius.circular(
|
|
214
|
+
borderRadius: BorderRadius.circular(KasyRadius.sm),
|
|
215
215
|
child: GestureDetector(
|
|
216
216
|
onTap: () => _openFullscreen(context),
|
|
217
217
|
child: ClipRRect(
|
|
218
|
-
borderRadius: BorderRadius.circular(
|
|
218
|
+
borderRadius: BorderRadius.circular(KasyRadius.sm),
|
|
219
219
|
child: Image.network(
|
|
220
220
|
url,
|
|
221
221
|
width: 36,
|
|
@@ -252,7 +252,7 @@ class TileNotificationIcon extends StatelessWidget {
|
|
|
252
252
|
color: active
|
|
253
253
|
? context.colors.primary.withValues(alpha: 0.12)
|
|
254
254
|
: context.colors.onSurface.withValues(alpha: 0.06),
|
|
255
|
-
borderRadius: BorderRadius.circular(
|
|
255
|
+
borderRadius: BorderRadius.circular(KasyRadius.md),
|
|
256
256
|
),
|
|
257
257
|
child: Icon(
|
|
258
258
|
active ? KasyIcons.notificationActive : KasyIcons.notification,
|
|
@@ -285,7 +285,7 @@ class NotificationSkeletonTile extends StatelessWidget {
|
|
|
285
285
|
KasySkeleton(
|
|
286
286
|
width: 36,
|
|
287
287
|
height: 36,
|
|
288
|
-
borderRadius: BorderRadius.all(Radius.circular(
|
|
288
|
+
borderRadius: BorderRadius.all(Radius.circular(KasyRadius.md)),
|
|
289
289
|
),
|
|
290
290
|
SizedBox(width: KasySpacing.sm),
|
|
291
291
|
Expanded(
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
2
|
+
import 'package:flutter/material.dart';
|
|
3
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
|
+
import 'package:kasy_kit/components/components.dart';
|
|
5
|
+
import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
|
|
6
|
+
import 'package:kasy_kit/core/theme/theme.dart';
|
|
7
|
+
import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
|
|
8
|
+
import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
|
|
9
|
+
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
10
|
+
|
|
11
|
+
/// Inline call-to-action that nudges the user to turn on push notifications.
|
|
12
|
+
///
|
|
13
|
+
/// Push is native-only: on web this renders nothing (permission_handler has no
|
|
14
|
+
/// web support and there is no native prompt). It also renders nothing once
|
|
15
|
+
/// permission is granted, so it self-hides the moment it's no longer needed.
|
|
16
|
+
///
|
|
17
|
+
/// States while not granted (native only):
|
|
18
|
+
/// - never asked / denied (Android can re-ask) → "Enable notifications",
|
|
19
|
+
/// which fires the native OS prompt directly (no custom pre-dialog: the OS
|
|
20
|
+
/// already shows its own localized prompt).
|
|
21
|
+
/// - permanently denied (iOS after a refusal) → "Open settings", since the OS
|
|
22
|
+
/// won't show the native prompt again and the only way back is the system
|
|
23
|
+
/// settings of the app.
|
|
24
|
+
///
|
|
25
|
+
/// When [autoRequest] is true, the first time it mounts in the "never asked"
|
|
26
|
+
/// state it fires the native prompt automatically — once per install, guarded
|
|
27
|
+
/// by a shared-preferences flag. Used on the notifications screen so simply
|
|
28
|
+
/// arriving there surfaces the native prompt.
|
|
29
|
+
class PushPermissionBanner extends ConsumerStatefulWidget {
|
|
30
|
+
const PushPermissionBanner({super.key, this.autoRequest = false});
|
|
31
|
+
|
|
32
|
+
/// Auto-fire the native prompt once when first shown in the "never asked"
|
|
33
|
+
/// state. Leave false to require an explicit tap on the CTA.
|
|
34
|
+
final bool autoRequest;
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
ConsumerState<PushPermissionBanner> createState() =>
|
|
38
|
+
_PushPermissionBannerState();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class _PushPermissionBannerState extends ConsumerState<PushPermissionBanner>
|
|
42
|
+
with WidgetsBindingObserver {
|
|
43
|
+
NotificationPermission? _permission;
|
|
44
|
+
bool _busy = false;
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
void initState() {
|
|
48
|
+
super.initState();
|
|
49
|
+
WidgetsBinding.instance.addObserver(this);
|
|
50
|
+
_init();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
void dispose() {
|
|
55
|
+
WidgetsBinding.instance.removeObserver(this);
|
|
56
|
+
super.dispose();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
61
|
+
// Returning from the OS settings: re-check so the banner self-hides if the
|
|
62
|
+
// user just enabled notifications, or comes back if they disabled them —
|
|
63
|
+
// without the user having to tap anything.
|
|
64
|
+
if (state == AppLifecycleState.resumed && !kIsWeb) {
|
|
65
|
+
_reload();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Future<void> _init() async {
|
|
70
|
+
// Push is native-only — nothing to do (and nothing to render) on web.
|
|
71
|
+
if (kIsWeb) return;
|
|
72
|
+
final permission =
|
|
73
|
+
await ref.read(notificationRepositoryProvider).getPermissionStatus();
|
|
74
|
+
if (!mounted) return;
|
|
75
|
+
|
|
76
|
+
if (widget.autoRequest && permission is NotificationPermissionWaiting) {
|
|
77
|
+
final prefs = ref.read(sharedPreferencesProvider);
|
|
78
|
+
if (!prefs.getPushAutoRequested()) {
|
|
79
|
+
await prefs.setPushAutoRequested(true);
|
|
80
|
+
await permission.maybeAsk();
|
|
81
|
+
await _reload();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
setState(() => _permission = permission);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Future<void> _reload() async {
|
|
89
|
+
final permission =
|
|
90
|
+
await ref.read(notificationRepositoryProvider).getPermissionStatus();
|
|
91
|
+
if (mounted) setState(() => _permission = permission);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Future<void> _onPressed() async {
|
|
95
|
+
if (_busy || _permission == null) return;
|
|
96
|
+
setState(() => _busy = true);
|
|
97
|
+
await _permission!.maybeAsk();
|
|
98
|
+
await _reload();
|
|
99
|
+
if (mounted) setState(() => _busy = false);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
Widget build(BuildContext context) {
|
|
104
|
+
// Native-only, and only while permission is still missing.
|
|
105
|
+
if (kIsWeb) return const SizedBox.shrink();
|
|
106
|
+
final permission = _permission;
|
|
107
|
+
if (permission == null || permission is NotificationPermissionGranted) {
|
|
108
|
+
return const SizedBox.shrink();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
final tr = context.t.notifications;
|
|
112
|
+
final bool locked = permission is NotificationPermissionPermanentlyDenied;
|
|
113
|
+
|
|
114
|
+
// Discreet single-row nudge: small muted bell + one line of copy + a compact
|
|
115
|
+
// neutral button. Intentionally low-key so it doesn't compete with the list.
|
|
116
|
+
return Padding(
|
|
117
|
+
padding: const EdgeInsets.only(bottom: KasySpacing.smd),
|
|
118
|
+
child: Container(
|
|
119
|
+
padding: const EdgeInsets.fromLTRB(
|
|
120
|
+
KasySpacing.md,
|
|
121
|
+
KasySpacing.sm,
|
|
122
|
+
KasySpacing.sm,
|
|
123
|
+
KasySpacing.sm,
|
|
124
|
+
),
|
|
125
|
+
decoration: BoxDecoration(
|
|
126
|
+
color: context.colors.surface,
|
|
127
|
+
borderRadius: KasyRadius.mdBorderRadius,
|
|
128
|
+
border: Border.all(
|
|
129
|
+
color: context.colors.outline.withValues(alpha: 0.4),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
child: Row(
|
|
133
|
+
children: [
|
|
134
|
+
Icon(
|
|
135
|
+
KasyIcons.notification,
|
|
136
|
+
size: KasyIconSize.rowLeading,
|
|
137
|
+
color: context.colors.muted,
|
|
138
|
+
),
|
|
139
|
+
const SizedBox(width: KasySpacing.sm),
|
|
140
|
+
Expanded(
|
|
141
|
+
child: Text(
|
|
142
|
+
locked ? tr.push_subtitle_disabled : tr.push_subtitle_waiting,
|
|
143
|
+
style: context.textTheme.bodySmall?.copyWith(
|
|
144
|
+
color: context.colors.onSurface.withValues(alpha: 0.75),
|
|
145
|
+
),
|
|
146
|
+
maxLines: 2,
|
|
147
|
+
overflow: TextOverflow.ellipsis,
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
const SizedBox(width: KasySpacing.sm),
|
|
151
|
+
KasyButton(
|
|
152
|
+
label: locked ? tr.empty_cta_open_settings : tr.empty_cta,
|
|
153
|
+
variant: KasyButtonVariant.outline,
|
|
154
|
+
size: KasyButtonSize.small,
|
|
155
|
+
isLoading: _busy,
|
|
156
|
+
onPressed: _busy ? null : _onPressed,
|
|
157
|
+
),
|
|
158
|
+
],
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
3
|
+
import 'package:go_router/go_router.dart';
|
|
4
|
+
import 'package:kasy_kit/components/components.dart';
|
|
5
|
+
import 'package:kasy_kit/core/theme/theme.dart';
|
|
6
|
+
import 'package:kasy_kit/core/widgets/kasy_hover.dart';
|
|
7
|
+
import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
|
|
8
|
+
as app;
|
|
9
|
+
import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
|
|
10
|
+
import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
|
|
11
|
+
import 'package:kasy_kit/features/notifications/ui/widgets/notification_tile.dart';
|
|
12
|
+
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
13
|
+
|
|
14
|
+
/// Desktop web header bell that opens a recent-notifications dropdown anchored
|
|
15
|
+
/// below it (Gmail / GitHub pattern). The full list still lives at
|
|
16
|
+
/// `/notifications` — the panel's "See all" link goes there.
|
|
17
|
+
///
|
|
18
|
+
/// Owns its own overlay (OverlayPortal + LayerLink, the same popover technique
|
|
19
|
+
/// as [KasySidebar]), so [KasyAppBar.application] stays a pure presentational
|
|
20
|
+
/// component and the data wiring lives here.
|
|
21
|
+
class WebNotificationsBell extends ConsumerStatefulWidget {
|
|
22
|
+
const WebNotificationsBell({super.key});
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
ConsumerState<WebNotificationsBell> createState() =>
|
|
26
|
+
_WebNotificationsBellState();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class _WebNotificationsBellState extends ConsumerState<WebNotificationsBell> {
|
|
30
|
+
/// Max number of recent notifications shown in the panel before "See all".
|
|
31
|
+
static const int _maxPreview = 5;
|
|
32
|
+
|
|
33
|
+
/// Width of the dropdown panel.
|
|
34
|
+
static const double _panelWidth = 380;
|
|
35
|
+
|
|
36
|
+
final OverlayPortalController _controller = OverlayPortalController();
|
|
37
|
+
final LayerLink _link = LayerLink();
|
|
38
|
+
|
|
39
|
+
void _toggle() {
|
|
40
|
+
if (_controller.isShowing) {
|
|
41
|
+
_controller.hide();
|
|
42
|
+
} else {
|
|
43
|
+
_controller.show();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
void _openFull() {
|
|
48
|
+
_controller.hide();
|
|
49
|
+
context.go('/notifications');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void _onTap(app.Notification notification) {
|
|
53
|
+
_controller.hide();
|
|
54
|
+
ref.read(notificationsProvider.notifier).onTapNotification(notification);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
Widget build(BuildContext context) {
|
|
59
|
+
final int unread = ref.watch(unreadNotificationsCountProvider).value ?? 0;
|
|
60
|
+
return OverlayPortal(
|
|
61
|
+
controller: _controller,
|
|
62
|
+
overlayChildBuilder: _buildPanel,
|
|
63
|
+
child: CompositedTransformTarget(
|
|
64
|
+
link: _link,
|
|
65
|
+
child: _bell(context, unread: unread),
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Widget _bell(BuildContext context, {required int unread}) {
|
|
71
|
+
final Widget button = KasyButton.iconOnly(
|
|
72
|
+
icon: KasyIcons.notification,
|
|
73
|
+
variant: KasyButtonVariant.ghost,
|
|
74
|
+
size: KasyButtonSize.small,
|
|
75
|
+
iconOnlyLayoutExtent: 36,
|
|
76
|
+
iconGlyphSize: KasyIconSize.md,
|
|
77
|
+
onPressed: _toggle,
|
|
78
|
+
semanticLabel: 'Notifications',
|
|
79
|
+
);
|
|
80
|
+
if (unread == 0) return button;
|
|
81
|
+
return Stack(
|
|
82
|
+
clipBehavior: Clip.none,
|
|
83
|
+
children: [
|
|
84
|
+
button,
|
|
85
|
+
Positioned(
|
|
86
|
+
top: 8,
|
|
87
|
+
right: 8,
|
|
88
|
+
child: Container(
|
|
89
|
+
width: 8,
|
|
90
|
+
height: 8,
|
|
91
|
+
decoration: BoxDecoration(
|
|
92
|
+
color: context.colors.error,
|
|
93
|
+
shape: BoxShape.circle,
|
|
94
|
+
border: Border.all(color: context.colors.background, width: 1.5),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Widget _buildPanel(BuildContext context) {
|
|
103
|
+
// Full-screen barrier so a click anywhere outside the panel dismisses it.
|
|
104
|
+
return Stack(
|
|
105
|
+
children: [
|
|
106
|
+
Positioned.fill(
|
|
107
|
+
child: GestureDetector(
|
|
108
|
+
behavior: HitTestBehavior.opaque,
|
|
109
|
+
onTap: _controller.hide,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
CompositedTransformFollower(
|
|
113
|
+
link: _link,
|
|
114
|
+
showWhenUnlinked: false,
|
|
115
|
+
targetAnchor: Alignment.bottomRight,
|
|
116
|
+
followerAnchor: Alignment.topRight,
|
|
117
|
+
// Small gap below the bell — the icon button already has internal
|
|
118
|
+
// padding, so 6 reads "attached to the bell" without looking glued.
|
|
119
|
+
offset: const Offset(0, 6),
|
|
120
|
+
child: Align(
|
|
121
|
+
alignment: Alignment.topRight,
|
|
122
|
+
child: _panelCard(context),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
Widget _panelCard(BuildContext context) {
|
|
130
|
+
final KasyColors c = context.colors;
|
|
131
|
+
return Material(
|
|
132
|
+
color: Colors.transparent,
|
|
133
|
+
child: Container(
|
|
134
|
+
width: _panelWidth,
|
|
135
|
+
constraints: const BoxConstraints(maxHeight: 480),
|
|
136
|
+
decoration: BoxDecoration(
|
|
137
|
+
color: c.surface,
|
|
138
|
+
// Same hairline as the sidebar/header chrome: `c.border` at 0.5px, so
|
|
139
|
+
// the whole web chrome reads as one continuous border weight.
|
|
140
|
+
border: Border.all(color: c.border, width: 0.5),
|
|
141
|
+
borderRadius: KasyRadius.lgBorderRadius,
|
|
142
|
+
// Floating dropdown → the design-system overlay shadow (menus/popovers),
|
|
143
|
+
// not the inline-card `component` shadow (which is muted on web).
|
|
144
|
+
boxShadow: KasyShadows.overlay,
|
|
145
|
+
),
|
|
146
|
+
clipBehavior: Clip.antiAlias,
|
|
147
|
+
child: Column(
|
|
148
|
+
mainAxisSize: MainAxisSize.min,
|
|
149
|
+
children: [
|
|
150
|
+
_header(context),
|
|
151
|
+
Flexible(child: _body(context)),
|
|
152
|
+
Divider(height: 1, thickness: 1, color: c.border),
|
|
153
|
+
_footer(context),
|
|
154
|
+
],
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
Widget _header(BuildContext context) {
|
|
161
|
+
final int unread = ref.watch(unreadNotificationsCountProvider).value ?? 0;
|
|
162
|
+
return Padding(
|
|
163
|
+
padding: const EdgeInsets.fromLTRB(
|
|
164
|
+
KasySpacing.md,
|
|
165
|
+
KasySpacing.smd,
|
|
166
|
+
KasySpacing.sm,
|
|
167
|
+
KasySpacing.smd,
|
|
168
|
+
),
|
|
169
|
+
child: Row(
|
|
170
|
+
children: [
|
|
171
|
+
Expanded(
|
|
172
|
+
child: Text(
|
|
173
|
+
t.notifications.title,
|
|
174
|
+
style: context.textTheme.titleSmall?.copyWith(
|
|
175
|
+
fontWeight: FontWeight.w600,
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
if (unread > 0)
|
|
180
|
+
KasyButton(
|
|
181
|
+
label: t.notifications.mark_all_read,
|
|
182
|
+
variant: KasyButtonVariant.ghost,
|
|
183
|
+
size: KasyButtonSize.small,
|
|
184
|
+
onPressed: () =>
|
|
185
|
+
ref.read(notificationsProvider.notifier).readAll(),
|
|
186
|
+
),
|
|
187
|
+
],
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Widget _body(BuildContext context) {
|
|
193
|
+
final state = ref.watch(notificationsProvider);
|
|
194
|
+
return state.when(
|
|
195
|
+
loading: () => Column(
|
|
196
|
+
mainAxisSize: MainAxisSize.min,
|
|
197
|
+
children: List<Widget>.generate(
|
|
198
|
+
3,
|
|
199
|
+
(_) => const NotificationSkeletonTile(),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
error: (_, _) => _message(context, t.notifications.error_fetching),
|
|
203
|
+
data: (list) {
|
|
204
|
+
if (list.data.isEmpty) {
|
|
205
|
+
return _message(context, t.notifications.empty_title);
|
|
206
|
+
}
|
|
207
|
+
final items = list.data.take(_maxPreview).toList();
|
|
208
|
+
return SingleChildScrollView(
|
|
209
|
+
child: Column(
|
|
210
|
+
mainAxisSize: MainAxisSize.min,
|
|
211
|
+
children: [
|
|
212
|
+
for (int i = 0; i < items.length; i++) ...[
|
|
213
|
+
if (i > 0)
|
|
214
|
+
Divider(height: 1, thickness: 1, color: context.colors.border),
|
|
215
|
+
KasyHover(
|
|
216
|
+
onTap: () => _onTap(items[i]),
|
|
217
|
+
padding: const EdgeInsets.symmetric(
|
|
218
|
+
horizontal: KasySpacing.md,
|
|
219
|
+
vertical: KasySpacing.smd,
|
|
220
|
+
),
|
|
221
|
+
child: NotificationTile.from(context, items[i]),
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
],
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
Widget _message(BuildContext context, String text) {
|
|
232
|
+
return Padding(
|
|
233
|
+
padding: const EdgeInsets.symmetric(
|
|
234
|
+
horizontal: KasySpacing.md,
|
|
235
|
+
vertical: KasySpacing.xl,
|
|
236
|
+
),
|
|
237
|
+
child: Center(
|
|
238
|
+
child: Text(
|
|
239
|
+
text,
|
|
240
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
241
|
+
color: context.colors.muted,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Widget _footer(BuildContext context) {
|
|
249
|
+
return Padding(
|
|
250
|
+
padding: const EdgeInsets.all(KasySpacing.sm),
|
|
251
|
+
child: SizedBox(
|
|
252
|
+
width: double.infinity,
|
|
253
|
+
child: KasyButton(
|
|
254
|
+
label: t.notifications.see_all,
|
|
255
|
+
variant: KasyButtonVariant.neutral,
|
|
256
|
+
size: KasyButtonSize.small,
|
|
257
|
+
onPressed: _openFull,
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -9,18 +9,27 @@ class OnboardingState {
|
|
|
9
9
|
/// here and flush them once the account is created.
|
|
10
10
|
final List<UserInfoDetail> pendingUserInfo;
|
|
11
11
|
|
|
12
|
+
/// Preview mode: the flow is being walked from the admin Debug screen just to
|
|
13
|
+
/// see the screens. Every real side effect (guest account creation, profile
|
|
14
|
+
/// writes, permission prompts, the onboarded flag) is suppressed and the flow
|
|
15
|
+
/// returns to Debug instead of Home. See [OnboardingNotifier].
|
|
16
|
+
final bool preview;
|
|
17
|
+
|
|
12
18
|
OnboardingState({
|
|
13
19
|
this.reminder,
|
|
14
20
|
this.pendingUserInfo = const [],
|
|
21
|
+
this.preview = false,
|
|
15
22
|
});
|
|
16
23
|
|
|
17
24
|
OnboardingState copyWith({
|
|
18
25
|
DateTime? reminder,
|
|
19
26
|
List<UserInfoDetail>? pendingUserInfo,
|
|
27
|
+
bool? preview,
|
|
20
28
|
}) {
|
|
21
29
|
return OnboardingState(
|
|
22
30
|
reminder: reminder ?? this.reminder,
|
|
23
31
|
pendingUserInfo: pendingUserInfo ?? this.pendingUserInfo,
|
|
32
|
+
preview: preview ?? this.preview,
|
|
24
33
|
);
|
|
25
34
|
}
|
|
26
35
|
}
|