kasy-cli 1.38.0 → 1.39.1
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/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/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 +2 -2
- package/templates/firebase/DESIGN_SYSTEM.md +23 -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 +5 -2
- package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
- 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 +27 -18
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
- 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 +11 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
- 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 +28 -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/web_device_preview/web_device_preview.dart +51 -19
- package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- 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 +253 -125
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
- 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 +111 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -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 +2 -2
- 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 +53 -32
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
- 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 +171 -41
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
- 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 +48 -47
- 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 +753 -712
- package/templates/firebase/lib/i18n/es.i18n.json +753 -712
- package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +32 -26
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
- package/templates/firebase/test/app_bar_config_test.dart +70 -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/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 -218
- package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
- 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 -179
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
|
@@ -199,13 +199,26 @@ class _MyAppState extends ConsumerState<MyApp> {
|
|
|
199
199
|
facebookEventApiProvider,
|
|
200
200
|
],
|
|
201
201
|
onReady: FocusVisibility(
|
|
202
|
-
child:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
child: ValueListenableBuilder<bool>(
|
|
203
|
+
valueListenable: webDevicePreviewActiveNotifier,
|
|
204
|
+
builder: (context, devicePreviewActive, _) {
|
|
205
|
+
final Widget app = DevicePreview.appBuilder(
|
|
206
|
+
context,
|
|
207
|
+
ResponsiveTextTheme(
|
|
208
|
+
child: child ?? const SizedBox.shrink(),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
// The web render-scale correction applies to the REAL
|
|
212
|
+
// web app on every breakpoint. Once the device preview
|
|
213
|
+
// frame is actually on screen it simulates a NATIVE
|
|
214
|
+
// device, so it renders at native 1.0 — skip the scale.
|
|
215
|
+
// Gated on the *active* (frame-built) notifier, not the
|
|
216
|
+
// raw toggle, so the scale only drops when the frame is
|
|
217
|
+
// up (no flash of big unframed app while it builds).
|
|
218
|
+
return devicePreviewActive
|
|
219
|
+
? app
|
|
220
|
+
: WebViewportScale.wrap(app);
|
|
221
|
+
},
|
|
209
222
|
),
|
|
210
223
|
),
|
|
211
224
|
onError: (_, error) => InitializationErrorPage(error: error),
|
|
@@ -220,7 +220,11 @@ GoRouter generateRouter({
|
|
|
220
220
|
path: '/onboarding',
|
|
221
221
|
pageBuilder: (context, state) => kasyTransitionPage(
|
|
222
222
|
key: state.pageKey,
|
|
223
|
-
|
|
223
|
+
// ?preview=true opens the flow as a side-effect-free walkthrough from
|
|
224
|
+
// the admin Debug screen (no account creation, returns to Debug).
|
|
225
|
+
child: OnboardingPage(
|
|
226
|
+
preview: state.uri.queryParameters['preview'] == 'true',
|
|
227
|
+
),
|
|
224
228
|
),
|
|
225
229
|
),
|
|
226
230
|
GoRoute(
|
|
@@ -311,8 +315,7 @@ GoRouter generateRouter({
|
|
|
311
315
|
// are ALL branches — each its own URL, reached from the sidebar. Registered
|
|
312
316
|
// always (admins reach it in release too); the redirect above blocks
|
|
313
317
|
// /admin* for non-admins in production. adminSections() is the single
|
|
314
|
-
// source the sidebar reads too, so branches and nav rows stay aligned
|
|
315
|
-
// debug-only sections drop out of both together).
|
|
318
|
+
// source the sidebar reads too, so branches and nav rows stay aligned.
|
|
316
319
|
StatefulShellRoute.indexedStack(
|
|
317
320
|
// Enter /admin with the app's standard page transition
|
|
318
321
|
// (KasyNavigationConfig.push), same as every other route — pageBuilder
|
|
@@ -338,8 +341,32 @@ GoRouter generateRouter({
|
|
|
338
341
|
],
|
|
339
342
|
),
|
|
340
343
|
// Drill-downs pushed full-screen from inside the console (their own back
|
|
341
|
-
// button)
|
|
342
|
-
|
|
344
|
+
// button) — the redirect above keeps /admin* admin-only.
|
|
345
|
+
//
|
|
346
|
+
// Paywall variant preview is pushed from the Paywalls section, which ships
|
|
347
|
+
// in production, so it's registered always.
|
|
348
|
+
GoRoute(
|
|
349
|
+
name: 'admin_premium_preview',
|
|
350
|
+
path: '/admin/premium/:variant',
|
|
351
|
+
pageBuilder: (context, state) {
|
|
352
|
+
final paywall = paywallFactoryFromAdminRoute(
|
|
353
|
+
state.pathParameters['variant'],
|
|
354
|
+
);
|
|
355
|
+
if (paywall == null || !withRevenuecat) {
|
|
356
|
+
return kasyTransitionPage(
|
|
357
|
+
key: state.pageKey,
|
|
358
|
+
child: const PageNotFound(),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return kasyTransitionPage(
|
|
362
|
+
key: state.pageKey,
|
|
363
|
+
child: PremiumPage(paywall: paywall),
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
),
|
|
367
|
+
// Home-widgets panel: developer-only, pushed from the Debug section's
|
|
368
|
+
// (debug-gated) tile — its route registers only in kDebugMode.
|
|
369
|
+
if (kDebugMode)
|
|
343
370
|
GoRoute(
|
|
344
371
|
name: 'admin_home_widgets',
|
|
345
372
|
path: adminRouteHomeWidgets,
|
|
@@ -348,27 +375,6 @@ GoRouter generateRouter({
|
|
|
348
375
|
child: const AdminHomeWidgets(),
|
|
349
376
|
),
|
|
350
377
|
),
|
|
351
|
-
// Paywall variant preview — pushed from the Paywalls section.
|
|
352
|
-
GoRoute(
|
|
353
|
-
name: 'admin_premium_preview',
|
|
354
|
-
path: '/admin/premium/:variant',
|
|
355
|
-
pageBuilder: (context, state) {
|
|
356
|
-
final paywall = paywallFactoryFromAdminRoute(
|
|
357
|
-
state.pathParameters['variant'],
|
|
358
|
-
);
|
|
359
|
-
if (paywall == null || !withRevenuecat) {
|
|
360
|
-
return kasyTransitionPage(
|
|
361
|
-
key: state.pageKey,
|
|
362
|
-
child: const PageNotFound(),
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
return kasyTransitionPage(
|
|
366
|
-
key: state.pageKey,
|
|
367
|
-
child: PremiumPage(paywall: paywall),
|
|
368
|
-
);
|
|
369
|
-
},
|
|
370
|
-
),
|
|
371
|
-
],
|
|
372
378
|
GoRoute(
|
|
373
379
|
name: '404',
|
|
374
380
|
path: '/404',
|
|
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|
|
16
16
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
|
17
17
|
# In Windows, build-name is used as the major, minor, and patch parts
|
|
18
18
|
# of the product and file versions while build-number is used as the build suffix.
|
|
19
|
-
version: 1.0.0+
|
|
19
|
+
version: 1.0.0+67
|
|
20
20
|
|
|
21
21
|
environment:
|
|
22
22
|
sdk: ^3.11.0
|
|
@@ -59,6 +59,7 @@ dependencies:
|
|
|
59
59
|
flutter_native_splash: ^2.4.7
|
|
60
60
|
flutter_riverpod: ^3.1.0
|
|
61
61
|
flutter_secure_storage: ^9.2.4
|
|
62
|
+
flutter_svg: ^2.3.0
|
|
62
63
|
flutter_timezone: ^5.0.1
|
|
63
64
|
freezed_annotation: ^3.1.0
|
|
64
65
|
go_router: ^17.1.0
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import 'package:flutter/material.dart';
|
|
2
2
|
import 'package:flutter_test/flutter_test.dart';
|
|
3
3
|
import 'package:go_router/go_router.dart';
|
|
4
|
+
import 'package:kasy_kit/components/kasy_app_bar.dart';
|
|
4
5
|
import 'package:kasy_kit/components/kasy_sidebar.dart';
|
|
5
|
-
import 'package:kasy_kit/components/kasy_web_header.dart';
|
|
6
6
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
7
7
|
import 'package:kasy_kit/core/states/models/user_state.dart';
|
|
8
8
|
import 'package:kasy_kit/features/settings/ui/components/admin/admin_page.dart';
|
|
@@ -49,9 +49,15 @@ UserState _adminUser() => UserState(
|
|
|
49
49
|
),
|
|
50
50
|
);
|
|
51
51
|
|
|
52
|
+
/// The desktop application bar is a [KasyAppBar.application] — same class as the
|
|
53
|
+
/// page bar, told apart by [KasyAppBar.isApplication].
|
|
54
|
+
final Finder _applicationBar = find.byWidgetPredicate(
|
|
55
|
+
(Widget w) => w is KasyAppBar && w.isApplication,
|
|
56
|
+
);
|
|
57
|
+
|
|
52
58
|
void main() {
|
|
53
59
|
testWidgets(
|
|
54
|
-
'admin section (desktop) renders the real KasySidebar +
|
|
60
|
+
'admin section (desktop) renders the real KasySidebar + application bar',
|
|
55
61
|
(tester) async {
|
|
56
62
|
tester.view.physicalSize = const Size(1400, 900);
|
|
57
63
|
tester.view.devicePixelRatio = 1.0;
|
|
@@ -66,7 +72,7 @@ void main() {
|
|
|
66
72
|
|
|
67
73
|
// The console uses the app's real chrome components, not a bespoke copy.
|
|
68
74
|
expect(find.byType(KasySidebar), findsOneWidget);
|
|
69
|
-
expect(
|
|
75
|
+
expect(_applicationBar, findsOneWidget);
|
|
70
76
|
expect(find.text('section-overview'), findsOneWidget);
|
|
71
77
|
},
|
|
72
78
|
);
|
|
@@ -87,9 +93,9 @@ void main() {
|
|
|
87
93
|
await tester.pump(const Duration(milliseconds: 300));
|
|
88
94
|
|
|
89
95
|
// Tools sub-screens get the SAME chrome as every other section now (the
|
|
90
|
-
// whole point of this change): the persistent rail AND the
|
|
96
|
+
// whole point of this change): the persistent rail AND the application bar.
|
|
91
97
|
expect(find.byType(KasySidebar), findsOneWidget);
|
|
92
|
-
expect(
|
|
98
|
+
expect(_applicationBar, findsOneWidget);
|
|
93
99
|
expect(find.text('section-sendPush'), findsOneWidget);
|
|
94
100
|
|
|
95
101
|
// The rail shows the "Ferramentas" group, auto-expanded because one of its
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
2
|
+
import 'package:kasy_kit/components/components.dart';
|
|
3
|
+
|
|
4
|
+
/// The per-screen desktop bar override is just data: a screen transforms the
|
|
5
|
+
/// shell default into its own [KasyAppBarConfig]. These lock that logic.
|
|
6
|
+
void main() {
|
|
7
|
+
group('KasyAppBarConfig', () {
|
|
8
|
+
test('bare config shows every element (mirrors the shell default)', () {
|
|
9
|
+
const KasyAppBarConfig c = KasyAppBarConfig();
|
|
10
|
+
expect(c.showSearch, isTrue);
|
|
11
|
+
expect(c.showThemeToggle, isTrue);
|
|
12
|
+
expect(c.showNotifications, isTrue);
|
|
13
|
+
expect(c.showCreate, isTrue);
|
|
14
|
+
expect(c.showAvatar, isTrue);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('only() shows just the listed elements but keeps their wiring', () {
|
|
18
|
+
void bell() {}
|
|
19
|
+
void toggle() {}
|
|
20
|
+
final KasyAppBarConfig base = KasyAppBarConfig(
|
|
21
|
+
onNotifications: bell,
|
|
22
|
+
onToggleTheme: toggle,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
final KasyAppBarConfig cfg = base.only(notifications: true, themeToggle: true);
|
|
26
|
+
|
|
27
|
+
expect(cfg.showNotifications, isTrue);
|
|
28
|
+
expect(cfg.showThemeToggle, isTrue);
|
|
29
|
+
expect(cfg.showSearch, isFalse);
|
|
30
|
+
expect(cfg.showCreate, isFalse);
|
|
31
|
+
expect(cfg.showAvatar, isFalse);
|
|
32
|
+
// The shell's bell/theme callbacks survive the override.
|
|
33
|
+
expect(cfg.onNotifications, same(bell));
|
|
34
|
+
expect(cfg.onToggleTheme, same(toggle));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('search preset = search + theme only', () {
|
|
38
|
+
const KasyAppBarConfig cfg = KasyAppBarConfig.search();
|
|
39
|
+
expect(cfg.showSearch, isTrue);
|
|
40
|
+
expect(cfg.showThemeToggle, isTrue);
|
|
41
|
+
expect(cfg.showNotifications, isFalse);
|
|
42
|
+
expect(cfg.showCreate, isFalse);
|
|
43
|
+
expect(cfg.showAvatar, isFalse);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('minimal preset = theme toggle only', () {
|
|
47
|
+
const KasyAppBarConfig cfg = KasyAppBarConfig.minimal();
|
|
48
|
+
expect(cfg.showThemeToggle, isTrue);
|
|
49
|
+
expect(cfg.showSearch, isFalse);
|
|
50
|
+
expect(cfg.showNotifications, isFalse);
|
|
51
|
+
expect(cfg.showCreate, isFalse);
|
|
52
|
+
expect(cfg.showAvatar, isFalse);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('copyWith with no changes is value-equal (no churn on the bar)', () {
|
|
56
|
+
const KasyAppBarConfig a = KasyAppBarConfig();
|
|
57
|
+
final KasyAppBarConfig b = a.copyWith();
|
|
58
|
+
expect(a, equals(b));
|
|
59
|
+
expect(a.hashCode, equals(b.hashCode));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('copyWith changes only the named field', () {
|
|
63
|
+
const KasyAppBarConfig a = KasyAppBarConfig();
|
|
64
|
+
final KasyAppBarConfig b = a.copyWith(showSearch: false);
|
|
65
|
+
expect(b.showSearch, isFalse);
|
|
66
|
+
expect(b.showCreate, isTrue);
|
|
67
|
+
expect(a == b, isFalse);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
3
|
+
import 'package:kasy_kit/components/kasy_text_field.dart';
|
|
4
|
+
import 'package:kasy_kit/core/theme/colors.dart';
|
|
5
|
+
import 'package:kasy_kit/core/theme/providers/theme_provider.dart';
|
|
6
|
+
import 'package:kasy_kit/core/theme/responsive_text_theme.dart';
|
|
7
|
+
import 'package:kasy_kit/core/theme/texts.dart';
|
|
8
|
+
import 'package:kasy_kit/core/theme/universal_theme.dart';
|
|
9
|
+
import 'package:shared_preferences/shared_preferences.dart';
|
|
10
|
+
|
|
11
|
+
import '../device_test_utils.dart';
|
|
12
|
+
|
|
13
|
+
/// A single-line [KasyTextField] must render at exactly
|
|
14
|
+
/// [KasyTextField.singleLineHeight] on EVERY breakpoint. The height is locked
|
|
15
|
+
/// with a forced strut (see kasy_text_field.dart) precisely so it cannot drift
|
|
16
|
+
/// between renderers (web CanvasKit vs native) or viewport widths. Run this on
|
|
17
|
+
/// the VM and with `--platform chrome` to cover both renderers.
|
|
18
|
+
void main() {
|
|
19
|
+
Widget appWithField() {
|
|
20
|
+
return Builder(builder: (context) {
|
|
21
|
+
return MaterialApp(
|
|
22
|
+
theme: ThemeProvider.of(context).light,
|
|
23
|
+
home: const ResponsiveTextTheme(
|
|
24
|
+
child: Scaffold(
|
|
25
|
+
body: Center(
|
|
26
|
+
child: SizedBox(
|
|
27
|
+
width: 320,
|
|
28
|
+
child: KasyTextField(
|
|
29
|
+
hint: 'Email',
|
|
30
|
+
variant: KasyTextFieldVariant.flat,
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
testWidgets('single-line field height stays locked across breakpoints',
|
|
41
|
+
(tester) async {
|
|
42
|
+
SharedPreferences.setMockInitialValues({});
|
|
43
|
+
final sharedPrefs = await SharedPreferences.getInstance();
|
|
44
|
+
|
|
45
|
+
final widget = ThemeProvider(
|
|
46
|
+
notifier: AppTheme.uniform(
|
|
47
|
+
sharedPreferences: sharedPrefs,
|
|
48
|
+
themeFactory: const UniversalThemeFactory(),
|
|
49
|
+
lightColors: KasyColors.light(),
|
|
50
|
+
darkColors: KasyColors.dark(),
|
|
51
|
+
textTheme: KasyTextTheme.build(),
|
|
52
|
+
defaultMode: ThemeMode.light,
|
|
53
|
+
),
|
|
54
|
+
child: appWithField(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const breakpoints = <Device>[
|
|
58
|
+
Device(name: 'mobile', width: 375, height: 812, pixelDensity: 3),
|
|
59
|
+
Device(name: 'tablet', width: 800, height: 1024, pixelDensity: 2),
|
|
60
|
+
Device(name: 'desktop', width: 1400, height: 900, pixelDensity: 1),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (final device in breakpoints) {
|
|
64
|
+
await tester.setScreenSize(device);
|
|
65
|
+
await tester.pumpWidget(widget);
|
|
66
|
+
await tester.pumpAndSettle();
|
|
67
|
+
final double boxHeight =
|
|
68
|
+
tester.getSize(find.byType(TextField).first).height;
|
|
69
|
+
expect(
|
|
70
|
+
boxHeight,
|
|
71
|
+
KasyTextField.singleLineHeight,
|
|
72
|
+
reason: 'field box should be ${KasyTextField.singleLineHeight}px on '
|
|
73
|
+
'${device.name} (${device.width}px wide), got $boxHeight',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import 'package:flutter_test/flutter_test.dart';
|
|
2
2
|
import 'package:kasy_kit/core/web_viewport_scale.dart';
|
|
3
3
|
|
|
4
|
-
/// Locks the
|
|
5
|
-
///
|
|
6
|
-
///
|
|
7
|
-
///
|
|
8
|
-
///
|
|
4
|
+
/// Locks the web scaling behaviour so a future change to the constants or the
|
|
5
|
+
/// formula can't silently regress it. Two headline rules:
|
|
6
|
+
/// - the flat cap applies on EVERY web breakpoint (phone/tablet/desktop) so the
|
|
7
|
+
/// whole web app matches native, with no size jump at the desktop breakpoint;
|
|
8
|
+
/// - the desktop high-OS-scale compensation must key off the SCREEN width, NOT
|
|
9
|
+
/// the window width — merely resizing the browser window must not shrink the
|
|
10
|
+
/// UI further; the extra shrink happens only when the screen itself is small.
|
|
9
11
|
void main() {
|
|
10
12
|
group('webViewportEffectiveScale', () {
|
|
11
|
-
test('
|
|
13
|
+
test('flat cap applies on tablet/phone web too (no jump at the breakpoint)',
|
|
12
14
|
() {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
expect(webViewportEffectiveScale(
|
|
18
|
-
expect(webViewportEffectiveScale(900
|
|
15
|
+
// Below the desktop breakpoint the scale is the flat cap (not 1.0): the
|
|
16
|
+
// same ~10% web oversize is corrected, and there is no size jump when the
|
|
17
|
+
// window crosses the desktop breakpoint. Compensation is desktop-only, so
|
|
18
|
+
// the screen width is ignored here.
|
|
19
|
+
expect(webViewportEffectiveScale(375), kWebViewportScale); // phone
|
|
20
|
+
expect(webViewportEffectiveScale(900), kWebViewportScale); // tablet
|
|
21
|
+
expect(webViewportEffectiveScale(900, screenWidth: 1470), kWebViewportScale);
|
|
22
|
+
// A small screen does NOT compensate below the breakpoint — that is a
|
|
23
|
+
// desktop-only concern; tablet/phone always take the flat cap.
|
|
24
|
+
expect(webViewportEffectiveScale(900, screenWidth: 300), kWebViewportScale);
|
|
19
25
|
expect(
|
|
20
26
|
webViewportEffectiveScale(kWebViewportScaleDesktopBreakpoint - 1),
|
|
21
|
-
|
|
27
|
+
kWebViewportScale,
|
|
22
28
|
);
|
|
23
29
|
});
|
|
24
30
|
|
|
25
|
-
test('desktop on a normal/large screen stays at the flat cap
|
|
31
|
+
test('desktop on a normal/large screen stays at the flat cap', () {
|
|
26
32
|
// Unknown screen (null) → flat cap.
|
|
27
33
|
expect(webViewportEffectiveScale(1280), kWebViewportScale);
|
|
28
34
|
expect(webViewportEffectiveScale(1500), kWebViewportScale);
|
|
@@ -43,7 +49,7 @@ void main() {
|
|
|
43
49
|
});
|
|
44
50
|
|
|
45
51
|
test('high OS scale (small SCREEN) compensates below the cap', () {
|
|
46
|
-
// Drop below
|
|
52
|
+
// Drop below the cap only because the screen is small — independent of window.
|
|
47
53
|
expect(webViewportEffectiveScale(1024, screenWidth: 1024), closeTo(0.80, 1e-4));
|
|
48
54
|
expect(webViewportEffectiveScale(1097, screenWidth: 1097), closeTo(0.857, 1e-3));
|
|
49
55
|
// Even a wide window on a small (high-scale) screen compensates.
|
|
@@ -58,7 +64,8 @@ void main() {
|
|
|
58
64
|
});
|
|
59
65
|
|
|
60
66
|
test('the maxScale override is respected as the cap', () {
|
|
61
|
-
|
|
67
|
+
// Use a non-default cap so this genuinely exercises the override.
|
|
68
|
+
expect(webViewportEffectiveScale(1920, screenWidth: 1920, maxScale: 0.8), 0.8);
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
test('an absurdly small screen is clamped to the 0.5 floor', () {
|
|
@@ -77,6 +77,15 @@ final List<_Rule> _rules = <_Rule>[
|
|
|
77
77
|
RegExp(r'Color\(0x|(?<![A-Za-z])Colors\.(?!transparent\b)(?!white)(?!black)[A-Za-z]'),
|
|
78
78
|
'Use a colour token (context.colors.*) instead of a raw Color/Colors value.',
|
|
79
79
|
),
|
|
80
|
+
_Rule(
|
|
81
|
+
'hardcoded-radius',
|
|
82
|
+
// A numeric literal right after `circular(` — catches BorderRadius.circular(16),
|
|
83
|
+
// Radius.circular(8) and the only/vertical/horizontal forms (they nest a
|
|
84
|
+
// Radius.circular). `circular(KasyRadius.lg)` starts with a letter, so it
|
|
85
|
+
// passes; `circular(size / 2)` likewise (calibrated, not a token miss).
|
|
86
|
+
RegExp(r'\bcircular\(\s*\d'),
|
|
87
|
+
'Use a KasyRadius.* token instead of a literal corner radius.',
|
|
88
|
+
),
|
|
80
89
|
];
|
|
81
90
|
|
|
82
91
|
// The icon rule needs two signals on the same line, so it is handled separately.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/// Application top header for the **web desktop breakpoint** (viewport ≥ 1024px).
|
|
2
|
-
///
|
|
3
|
-
/// This is the *application* chrome (global search, quick-create, notifications,
|
|
4
|
-
/// profile) — distinct from [KasyAppBar], which is *page* chrome (title / back /
|
|
5
|
-
/// theme) used on phone and tablet. On desktop the sidebar handles navigation,
|
|
6
|
-
/// so this header carries global actions instead of a title or back button.
|
|
7
|
-
///
|
|
8
|
-
/// Composed entirely from existing kit widgets: a [KasyTextField] search box
|
|
9
|
-
/// (fixed 220px), a ghost [KasyButton.iconOnly] for notifications, a neutral
|
|
10
|
-
/// pill [KasyButton] for create, and a gradient [KasyAvatar].
|
|
11
|
-
///
|
|
12
|
-
/// Barrel: [components.dart].
|
|
13
|
-
library;
|
|
14
|
-
|
|
15
|
-
import 'package:flutter/material.dart';
|
|
16
|
-
import 'package:kasy_kit/components/kasy_avatar.dart';
|
|
17
|
-
import 'package:kasy_kit/components/kasy_avatar_presets.dart';
|
|
18
|
-
import 'package:kasy_kit/components/kasy_button.dart';
|
|
19
|
-
import 'package:kasy_kit/components/kasy_text_field.dart';
|
|
20
|
-
import 'package:kasy_kit/core/theme/theme.dart';
|
|
21
|
-
|
|
22
|
-
/// Total header height (matches the design: 36px content band + 16px top/bottom).
|
|
23
|
-
const double kasyWebHeaderHeight = 68;
|
|
24
|
-
|
|
25
|
-
/// Fixed width of the leading search field (matches the design).
|
|
26
|
-
const double kasyWebHeaderSearchWidth = 220;
|
|
27
|
-
|
|
28
|
-
/// Desktop application header. Place it at the top of the content area (to the
|
|
29
|
-
/// right of the sidebar) on viewports ≥ 1024px.
|
|
30
|
-
class KasyWebHeader extends StatelessWidget {
|
|
31
|
-
/// Controller for the search field. Optional — omit for a display-only header.
|
|
32
|
-
final TextEditingController? searchController;
|
|
33
|
-
|
|
34
|
-
/// Placeholder shown in the search field.
|
|
35
|
-
final String searchHint;
|
|
36
|
-
|
|
37
|
-
/// Called as the user types in the search field.
|
|
38
|
-
final ValueChanged<String>? onSearchChanged;
|
|
39
|
-
|
|
40
|
-
/// Called when the search field is submitted (Enter).
|
|
41
|
-
final ValueChanged<String>? onSearchSubmitted;
|
|
42
|
-
|
|
43
|
-
/// Notifications (bell) action. When null the bell is disabled. Ignored when
|
|
44
|
-
/// [notifications] is provided.
|
|
45
|
-
final VoidCallback? onNotifications;
|
|
46
|
-
|
|
47
|
-
/// Shows the unread dot on the notifications bell. Ignored when
|
|
48
|
-
/// [notifications] is provided.
|
|
49
|
-
final bool showNotificationBadge;
|
|
50
|
-
|
|
51
|
-
/// Custom notifications control. When set, it replaces the built-in bell —
|
|
52
|
-
/// pass a data-aware widget (e.g. a bell that opens a recent-notifications
|
|
53
|
-
/// dropdown) so the header itself stays a pure presentational component.
|
|
54
|
-
final Widget? notifications;
|
|
55
|
-
|
|
56
|
-
/// Primary quick-create action. When null the button is disabled.
|
|
57
|
-
final VoidCallback? onCreate;
|
|
58
|
-
|
|
59
|
-
/// Label for the create button.
|
|
60
|
-
final String createLabel;
|
|
61
|
-
|
|
62
|
-
/// Gradient used for the profile avatar fallback (when [avatar] is null).
|
|
63
|
-
final KasyAvatarGradientData avatarGradient;
|
|
64
|
-
|
|
65
|
-
/// Custom avatar widget — pass the signed-in user's avatar (e.g.
|
|
66
|
-
/// `KasyUserAvatar`) to show their real photo. When null (and [showAvatar] is
|
|
67
|
-
/// true), a gradient-fill avatar is shown instead.
|
|
68
|
-
final Widget? avatar;
|
|
69
|
-
|
|
70
|
-
/// Whether the profile avatar is shown at all. Set false for a header that
|
|
71
|
-
/// carries no account chip (e.g. when the sidebar already owns the profile).
|
|
72
|
-
final bool showAvatar;
|
|
73
|
-
|
|
74
|
-
/// Profile avatar tap (open menu / profile). When null the avatar is inert.
|
|
75
|
-
final VoidCallback? onAvatarTap;
|
|
76
|
-
|
|
77
|
-
/// Theme toggle. When set, a sun/moon ghost button is shown before the bell
|
|
78
|
-
/// (on desktop the web header replaces the app bar's theme toggle).
|
|
79
|
-
final VoidCallback? onToggleTheme;
|
|
80
|
-
|
|
81
|
-
const KasyWebHeader({
|
|
82
|
-
super.key,
|
|
83
|
-
this.searchController,
|
|
84
|
-
this.searchHint = 'Search...',
|
|
85
|
-
this.onSearchChanged,
|
|
86
|
-
this.onSearchSubmitted,
|
|
87
|
-
this.onNotifications,
|
|
88
|
-
this.showNotificationBadge = false,
|
|
89
|
-
this.notifications,
|
|
90
|
-
this.onCreate,
|
|
91
|
-
this.createLabel = 'Create',
|
|
92
|
-
this.avatarGradient = KasyAvatarGradients.orange,
|
|
93
|
-
this.avatar,
|
|
94
|
-
this.showAvatar = true,
|
|
95
|
-
this.onAvatarTap,
|
|
96
|
-
this.onToggleTheme,
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
@override
|
|
100
|
-
Widget build(BuildContext context) {
|
|
101
|
-
final KasyColors c = context.colors;
|
|
102
|
-
return DecoratedBox(
|
|
103
|
-
decoration: BoxDecoration(
|
|
104
|
-
// Matches KasySidebar exactly: surface fill + the same `border` hairline
|
|
105
|
-
// used by the sidebar's vertical edge line, at the same 0.5px width — so
|
|
106
|
-
// the header's bottom border and the sidebar's top divider read as one
|
|
107
|
-
// continuous line across the whole chrome in light and dark.
|
|
108
|
-
color: c.surface,
|
|
109
|
-
border: Border(
|
|
110
|
-
bottom: BorderSide(color: c.border, width: 0.5),
|
|
111
|
-
),
|
|
112
|
-
),
|
|
113
|
-
// minHeight (not a fixed height) keeps the band at 68 while letting the
|
|
114
|
-
// search field size naturally, so it never overflows the toolbar row.
|
|
115
|
-
child: ConstrainedBox(
|
|
116
|
-
constraints: const BoxConstraints(minHeight: kasyWebHeaderHeight),
|
|
117
|
-
child: Padding(
|
|
118
|
-
// Design gutters: 20px horizontal; vertical breathes around the field.
|
|
119
|
-
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
120
|
-
child: Row(
|
|
121
|
-
children: [
|
|
122
|
-
SizedBox(
|
|
123
|
-
width: kasyWebHeaderSearchWidth,
|
|
124
|
-
child: _buildSearch(context),
|
|
125
|
-
),
|
|
126
|
-
const Spacer(),
|
|
127
|
-
if (onToggleTheme != null) ...[
|
|
128
|
-
_buildThemeToggle(context),
|
|
129
|
-
const SizedBox(width: KasySpacing.md),
|
|
130
|
-
],
|
|
131
|
-
notifications ?? _buildNotifications(context),
|
|
132
|
-
const SizedBox(width: KasySpacing.md),
|
|
133
|
-
KasyButton(
|
|
134
|
-
label: createLabel,
|
|
135
|
-
variant: KasyButtonVariant.neutral,
|
|
136
|
-
size: KasyButtonSize.small,
|
|
137
|
-
onPressed: onCreate,
|
|
138
|
-
),
|
|
139
|
-
if (showAvatar) ...[
|
|
140
|
-
const SizedBox(width: KasySpacing.md),
|
|
141
|
-
avatar ??
|
|
142
|
-
KasyAvatar.gradientFill(
|
|
143
|
-
size: KasyAvatarSize.small,
|
|
144
|
-
diameter: 36,
|
|
145
|
-
gradient: avatarGradient,
|
|
146
|
-
showShadow: false,
|
|
147
|
-
onTap: onAvatarTap,
|
|
148
|
-
),
|
|
149
|
-
],
|
|
150
|
-
],
|
|
151
|
-
),
|
|
152
|
-
),
|
|
153
|
-
),
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
Widget _buildSearch(BuildContext context) {
|
|
158
|
-
return KasyTextField(
|
|
159
|
-
variant: KasyTextFieldVariant.flat,
|
|
160
|
-
controller: searchController,
|
|
161
|
-
hint: searchHint,
|
|
162
|
-
onChanged: onSearchChanged,
|
|
163
|
-
onSubmitted: onSearchSubmitted,
|
|
164
|
-
prefix: Icon(
|
|
165
|
-
KasyIcons.search,
|
|
166
|
-
size: KasyIconSize.md,
|
|
167
|
-
color: context.colors.muted,
|
|
168
|
-
),
|
|
169
|
-
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
Widget _buildThemeToggle(BuildContext context) {
|
|
174
|
-
final bool isDark = Theme.of(context).brightness == Brightness.dark;
|
|
175
|
-
return KasyButton.iconOnly(
|
|
176
|
-
icon: isDark ? KasyIcons.lightMode : KasyIcons.darkMode,
|
|
177
|
-
variant: KasyButtonVariant.ghost,
|
|
178
|
-
size: KasyButtonSize.small,
|
|
179
|
-
iconOnlyLayoutExtent: 36,
|
|
180
|
-
iconGlyphSize: KasyIconSize.md,
|
|
181
|
-
onPressed: onToggleTheme,
|
|
182
|
-
semanticLabel: isDark ? 'Light mode' : 'Dark mode',
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
Widget _buildNotifications(BuildContext context) {
|
|
187
|
-
final KasyColors c = context.colors;
|
|
188
|
-
final Widget bell = KasyButton.iconOnly(
|
|
189
|
-
icon: KasyIcons.notification,
|
|
190
|
-
variant: KasyButtonVariant.ghost,
|
|
191
|
-
size: KasyButtonSize.small,
|
|
192
|
-
iconOnlyLayoutExtent: 36,
|
|
193
|
-
iconGlyphSize: KasyIconSize.md,
|
|
194
|
-
onPressed: onNotifications,
|
|
195
|
-
semanticLabel: 'Notifications',
|
|
196
|
-
);
|
|
197
|
-
if (!showNotificationBadge) return bell;
|
|
198
|
-
return Stack(
|
|
199
|
-
clipBehavior: Clip.none,
|
|
200
|
-
children: [
|
|
201
|
-
bell,
|
|
202
|
-
Positioned(
|
|
203
|
-
top: 8,
|
|
204
|
-
right: 8,
|
|
205
|
-
child: Container(
|
|
206
|
-
width: 8,
|
|
207
|
-
height: 8,
|
|
208
|
-
decoration: BoxDecoration(
|
|
209
|
-
color: c.error,
|
|
210
|
-
shape: BoxShape.circle,
|
|
211
|
-
border: Border.all(color: c.background, width: 1.5),
|
|
212
|
-
),
|
|
213
|
-
),
|
|
214
|
-
),
|
|
215
|
-
],
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import 'package:flutter/widgets.dart';
|
|
2
|
-
|
|
3
|
-
/// Marks the subtree that sits BELOW the desktop web header ([KasyWebHeader]) —
|
|
4
|
-
/// i.e. the shell content area, provided by [WebContentWrapper].
|
|
5
|
-
///
|
|
6
|
-
/// [KasyAppBar] uses it to decide whether to hide on desktop: INSIDE this scope
|
|
7
|
-
/// the web header already owns the top chrome, so the page app bar hides; OUTSIDE
|
|
8
|
-
/// it (a full-screen pushed route with no web header above) the app bar stays
|
|
9
|
-
/// visible — so the back button is never lost on desktop.
|
|
10
|
-
class KasyWebHeaderScope extends InheritedWidget {
|
|
11
|
-
const KasyWebHeaderScope({super.key, required super.child});
|
|
12
|
-
|
|
13
|
-
/// True when a [KasyWebHeaderScope] is an ancestor (no rebuild dependency —
|
|
14
|
-
/// presence is fixed for a given subtree).
|
|
15
|
-
static bool of(BuildContext context) =>
|
|
16
|
-
context.getInheritedWidgetOfExactType<KasyWebHeaderScope>() != null;
|
|
17
|
-
|
|
18
|
-
@override
|
|
19
|
-
bool updateShouldNotify(KasyWebHeaderScope oldWidget) => false;
|
|
20
|
-
}
|