kasy-cli 1.34.0 → 1.36.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/README.md +1 -1
- package/bin/kasy.js +24 -2
- package/docs/cli-reference.md +7 -7
- package/lib/commands/new.js +11 -9
- package/lib/commands/release-version.js +234 -0
- package/lib/commands/update.js +27 -0
- package/lib/scaffold/CHANGELOG.json +18 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +35 -21
- package/lib/scaffold/backends/patch-base-hashes.json +66 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
- package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +82 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/generate.js +53 -4
- package/lib/utils/i18n/messages-en.js +23 -0
- package/lib/utils/i18n/messages-es.js +23 -0
- package/lib/utils/i18n/messages-pt.js +23 -0
- package/package.json +5 -2
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
- package/templates/firebase/AGENTS.md +83 -0
- package/templates/firebase/DESIGN_SYSTEM.md +37 -2
- package/templates/firebase/docs/auth-setup.en.md +2 -0
- package/templates/firebase/docs/auth-setup.es.md +2 -0
- package/templates/firebase/docs/auth-setup.pt.md +2 -0
- package/templates/firebase/firebase.json +56 -1
- package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
- package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
- package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
- package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
- package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
- package/templates/firebase/lib/components/kasy_alert.dart +0 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +31 -16
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
- package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
- package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_sidebar.dart +215 -178
- package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
- package/templates/firebase/lib/components/kasy_toast.dart +107 -41
- package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
- package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
- package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
- package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
- package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
- package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
- package/templates/firebase/lib/core/guards/guard.dart +16 -2
- package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +5 -3
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
- package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
- package/templates/firebase/lib/core/states/logout_action.dart +5 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
- package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
- package/templates/firebase/lib/core/theme/texts.dart +90 -57
- package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
- package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
- package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
- package/templates/firebase/lib/core/web_screen_width.dart +15 -0
- package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
- package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
- package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +1 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
- package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
- package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +205 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
- package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
- package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
- package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
- package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
- package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
- package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +59 -0
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
- package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
- package/templates/firebase/lib/features/home/home_components_page.dart +4 -3
- package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +226 -105
- package/templates/firebase/lib/features/home/home_page.dart +4 -0
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +8 -3
- package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +8 -3
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
- package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +43 -15
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
- package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
- package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
- package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
- package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
- package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
- package/templates/firebase/lib/i18n/en.i18n.json +49 -3
- package/templates/firebase/lib/i18n/es.i18n.json +49 -3
- package/templates/firebase/lib/i18n/pt.i18n.json +49 -3
- package/templates/firebase/lib/main.dart +11 -2
- package/templates/firebase/lib/router.dart +92 -13
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
- package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
- package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
- package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
- package/templates/firebase/web/index.html +162 -14
- package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
2
|
+
|
|
3
|
+
/// Responsive type scale — explicit font size per role, per breakpoint.
|
|
4
|
+
///
|
|
5
|
+
/// Single source of truth for typography sizes. Instead of a blind global
|
|
6
|
+
/// multiplier, every role declares its own size at each breakpoint — the way a
|
|
7
|
+
/// design tool (Figma variables / modes) or a fluid web scale does it.
|
|
8
|
+
///
|
|
9
|
+
/// The professional pattern this follows: **headings are largest on desktop and
|
|
10
|
+
/// compress on smaller viewports** (a 36px title would feel huge and wrap badly
|
|
11
|
+
/// on a phone), while **body and label text stay constant** for stable,
|
|
12
|
+
/// comfortable reading on every device. Desktop is the authored reference;
|
|
13
|
+
/// tablet and mobile step the headings down.
|
|
14
|
+
///
|
|
15
|
+
/// To re-tune the whole app's typography, edit the numbers here — nothing else.
|
|
16
|
+
/// The live ramp is visible under Design System -> Typography (tabs per
|
|
17
|
+
/// breakpoint).
|
|
18
|
+
class RampSize {
|
|
19
|
+
/// Phone (< 768).
|
|
20
|
+
final double mobile;
|
|
21
|
+
|
|
22
|
+
/// Tablet (768-1024).
|
|
23
|
+
final double tablet;
|
|
24
|
+
|
|
25
|
+
/// Desktop (>= 1024) — the authored reference.
|
|
26
|
+
final double desktop;
|
|
27
|
+
|
|
28
|
+
/// line-height / font-size. Kept constant across breakpoints so the vertical
|
|
29
|
+
/// rhythm scales together with the font size.
|
|
30
|
+
final double lineHeightRatio;
|
|
31
|
+
|
|
32
|
+
const RampSize({
|
|
33
|
+
required this.mobile,
|
|
34
|
+
required this.tablet,
|
|
35
|
+
required this.desktop,
|
|
36
|
+
required this.lineHeightRatio,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/// The font size (logical px) for [device].
|
|
40
|
+
double size(DeviceType device) => switch (device) {
|
|
41
|
+
DeviceType.small => mobile,
|
|
42
|
+
DeviceType.medium => tablet,
|
|
43
|
+
DeviceType.large || DeviceType.xlarge => desktop,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/// The line height (logical px) for [device].
|
|
47
|
+
double lineHeight(DeviceType device) => size(device) * lineHeightRatio;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The Kasy type scale: every typographic role's size across the 3 breakpoints.
|
|
51
|
+
class KasyTypeScale {
|
|
52
|
+
const KasyTypeScale._();
|
|
53
|
+
|
|
54
|
+
// Hero / display — large, compress hard on the way down.
|
|
55
|
+
static const displayLarge =
|
|
56
|
+
RampSize(mobile: 40, tablet: 48, desktop: 57, lineHeightRatio: 64 / 57);
|
|
57
|
+
static const displayMedium =
|
|
58
|
+
RampSize(mobile: 34, tablet: 40, desktop: 45, lineHeightRatio: 52 / 45);
|
|
59
|
+
|
|
60
|
+
// Headings — largest on desktop, step down on tablet then mobile.
|
|
61
|
+
static const heading1 =
|
|
62
|
+
RampSize(mobile: 28, tablet: 32, desktop: 36, lineHeightRatio: 40 / 36);
|
|
63
|
+
static const heading2 =
|
|
64
|
+
RampSize(mobile: 22, tablet: 23, desktop: 24, lineHeightRatio: 32 / 24);
|
|
65
|
+
static const heading3 =
|
|
66
|
+
RampSize(mobile: 18, tablet: 19, desktop: 20, lineHeightRatio: 28 / 20);
|
|
67
|
+
static const heading4 =
|
|
68
|
+
RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
|
|
69
|
+
|
|
70
|
+
// Body & labels — constant across breakpoints for stable, comfortable reading.
|
|
71
|
+
static const bodyBase =
|
|
72
|
+
RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
|
|
73
|
+
static const bodySm =
|
|
74
|
+
RampSize(mobile: 14, tablet: 14, desktop: 14, lineHeightRatio: 20 / 14);
|
|
75
|
+
static const bodyXs =
|
|
76
|
+
RampSize(mobile: 12, tablet: 12, desktop: 12, lineHeightRatio: 16 / 12);
|
|
77
|
+
}
|
|
@@ -2,17 +2,31 @@ import 'package:flutter/material.dart';
|
|
|
2
2
|
import 'package:web/web.dart' as web;
|
|
3
3
|
|
|
4
4
|
void syncWebBackgroundColor(Brightness brightness) {
|
|
5
|
-
final
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
final bool dark = brightness == Brightness.dark;
|
|
6
|
+
// iOS Safari tints its status bar / address bar with the page's background
|
|
7
|
+
// colour once the Flutter canvas covers the viewport (it ignores the
|
|
8
|
+
// theme-color meta there). It samples whichever element fills the top of the
|
|
9
|
+
// viewport — the flutter-view host, not just <body> — so we paint <html>,
|
|
10
|
+
// <body> AND the Flutter host chain with `surface` (the app bar colour). That
|
|
11
|
+
// way the Safari bars match the app bar with no seam, no matter which element
|
|
12
|
+
// Safari reads. The app's own canvas draws over these, so the page background
|
|
13
|
+
// colour only ever shows in the browser chrome.
|
|
14
|
+
final String chromeColor = dark ? '#18181B' : '#FFFFFF';
|
|
15
|
+
|
|
16
|
+
// Keep the <html data-theme> attribute (set pre-boot by the index.html script)
|
|
17
|
+
// in sync, so a later reload / hot restart paints the splash + Safari chrome
|
|
18
|
+
// with the theme currently chosen in the app.
|
|
19
|
+
final html = web.document.documentElement as web.HTMLElement?;
|
|
20
|
+
html?.setAttribute('data-theme', dark ? 'dark' : 'light');
|
|
21
|
+
html?.style.setProperty('background-color', chromeColor);
|
|
22
|
+
web.document.body?.style.setProperty('background-color', chromeColor);
|
|
23
|
+
|
|
10
24
|
final views = web.document.querySelectorAll(
|
|
11
25
|
'flutter-view, flt-glass-pane, flt-scene-host',
|
|
12
26
|
);
|
|
13
27
|
for (var i = 0; i < views.length; i++) {
|
|
14
28
|
(views.item(i) as web.HTMLElement?)
|
|
15
29
|
?.style
|
|
16
|
-
.setProperty('background-color',
|
|
30
|
+
.setProperty('background-color', chromeColor);
|
|
17
31
|
}
|
|
18
32
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// Loads raw image bytes from a remote URL, choosing a platform-specific
|
|
2
|
+
/// strategy so social-profile photos (e.g. Google) import reliably:
|
|
3
|
+
/// - native: a direct HTTP download (see `image_bytes_loader_io.dart`),
|
|
4
|
+
/// - web: an `<img>` + canvas read that avoids the CDN throttling that hits
|
|
5
|
+
/// programmatic `fetch()`/XHR (see `image_bytes_loader_web.dart`).
|
|
6
|
+
///
|
|
7
|
+
/// Both implementations expose the same `loadImageBytes(url)` entry point and
|
|
8
|
+
/// return null (or rethrow) on failure so the caller can keep its fallback.
|
|
9
|
+
library;
|
|
10
|
+
|
|
11
|
+
export 'image_bytes_loader_io.dart'
|
|
12
|
+
if (dart.library.js_interop) 'image_bytes_loader_web.dart';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import 'package:dio/dio.dart';
|
|
2
|
+
|
|
3
|
+
/// Downloads image bytes on native platforms.
|
|
4
|
+
///
|
|
5
|
+
/// Native has no browser fetch throttling, so a direct request with a short
|
|
6
|
+
/// retry on transient errors (HTTP 429/503, timeouts) is reliable. Returns the
|
|
7
|
+
/// raw bytes, or rethrows after exhausting retries so the caller can fall back.
|
|
8
|
+
Future<List<int>?> loadImageBytes(String url) async {
|
|
9
|
+
const delays = [Duration(milliseconds: 600), Duration(seconds: 2)];
|
|
10
|
+
for (var attempt = 0; ; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
final response = await Dio().get<List<int>>(
|
|
13
|
+
url,
|
|
14
|
+
options: Options(responseType: ResponseType.bytes),
|
|
15
|
+
);
|
|
16
|
+
return response.data;
|
|
17
|
+
} on DioException catch (e) {
|
|
18
|
+
final status = e.response?.statusCode;
|
|
19
|
+
final transient = status == 429 ||
|
|
20
|
+
status == 503 ||
|
|
21
|
+
e.type == DioExceptionType.connectionTimeout ||
|
|
22
|
+
e.type == DioExceptionType.receiveTimeout ||
|
|
23
|
+
e.type == DioExceptionType.connectionError;
|
|
24
|
+
if (!transient || attempt >= delays.length) rethrow;
|
|
25
|
+
await Future<void>.delayed(delays[attempt]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:js_interop';
|
|
3
|
+
|
|
4
|
+
import 'package:web/web.dart' as web;
|
|
5
|
+
|
|
6
|
+
/// Downloads image bytes on web by loading the URL through an `<img>` element
|
|
7
|
+
/// and reading it back from a canvas.
|
|
8
|
+
///
|
|
9
|
+
/// Browsers serve `<img>` requests reliably, while some CDNs (notably Google's
|
|
10
|
+
/// `lh3.googleusercontent.com`) throttle programmatic `fetch()`/XHR with HTTP
|
|
11
|
+
/// 429 - which is what an HTTP client like dio uses. The image is requested with
|
|
12
|
+
/// CORS (`crossOrigin = 'anonymous'`) so the canvas is not tainted and its
|
|
13
|
+
/// pixels can be exported. Returns null on any failure, so the caller keeps its
|
|
14
|
+
/// fallback avatar.
|
|
15
|
+
Future<List<int>?> loadImageBytes(String url) async {
|
|
16
|
+
final image = web.HTMLImageElement()..crossOrigin = 'anonymous';
|
|
17
|
+
final loaded = Completer<bool>();
|
|
18
|
+
image.addEventListener(
|
|
19
|
+
'load',
|
|
20
|
+
(web.Event _) {
|
|
21
|
+
if (!loaded.isCompleted) loaded.complete(true);
|
|
22
|
+
}.toJS,
|
|
23
|
+
);
|
|
24
|
+
image.addEventListener(
|
|
25
|
+
'error',
|
|
26
|
+
(web.Event _) {
|
|
27
|
+
if (!loaded.isCompleted) loaded.complete(false);
|
|
28
|
+
}.toJS,
|
|
29
|
+
);
|
|
30
|
+
image.src = url;
|
|
31
|
+
|
|
32
|
+
final ok = await loaded.future
|
|
33
|
+
.timeout(const Duration(seconds: 10), onTimeout: () => false);
|
|
34
|
+
if (!ok || image.naturalWidth == 0) return null;
|
|
35
|
+
|
|
36
|
+
final canvas = web.HTMLCanvasElement()
|
|
37
|
+
..width = image.naturalWidth
|
|
38
|
+
..height = image.naturalHeight;
|
|
39
|
+
final context = canvas.getContext('2d') as web.CanvasRenderingContext2D?;
|
|
40
|
+
if (context == null) return null;
|
|
41
|
+
context.drawImage(image, 0, 0);
|
|
42
|
+
|
|
43
|
+
final blobReady = Completer<web.Blob?>();
|
|
44
|
+
canvas.toBlob(
|
|
45
|
+
(web.Blob? blob) {
|
|
46
|
+
blobReady.complete(blob);
|
|
47
|
+
}.toJS,
|
|
48
|
+
'image/jpeg',
|
|
49
|
+
0.92.toJS,
|
|
50
|
+
);
|
|
51
|
+
final blob = await blobReady.future;
|
|
52
|
+
if (blob == null) return null;
|
|
53
|
+
|
|
54
|
+
final buffer = await blob.arrayBuffer().toDart;
|
|
55
|
+
return buffer.toDart.asUint8List();
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// Reads the physical screen width (logical/CSS px) so the desktop web scale can
|
|
2
|
+
/// compensate for high OS display scaling WITHOUT reacting to mere window resize.
|
|
3
|
+
///
|
|
4
|
+
/// The browser window width (what the layout uses) changes whenever the user
|
|
5
|
+
/// drags the window narrower — using it to decide the scale made the whole UI
|
|
6
|
+
/// shrink unnecessarily. The *screen* width, on the other hand, only shrinks when
|
|
7
|
+
/// the OS display scale goes up (Windows 150%, Mac scaled modes), which is exactly
|
|
8
|
+
/// when the compensation is actually wanted.
|
|
9
|
+
///
|
|
10
|
+
/// Both implementations expose `currentScreenWidth()`; it returns null off the web
|
|
11
|
+
/// (where the scaling path is never reached anyway).
|
|
12
|
+
library;
|
|
13
|
+
|
|
14
|
+
export 'web_screen_width_io.dart'
|
|
15
|
+
if (dart.library.js_interop) 'web_screen_width_web.dart';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import 'package:web/web.dart' as web;
|
|
2
|
+
|
|
3
|
+
/// The screen width in logical (CSS) px. This shrinks when the OS display scale
|
|
4
|
+
/// goes up (Windows 150%, Mac scaled modes) but does NOT change when the user
|
|
5
|
+
/// merely resizes the browser window — exactly the signal the desktop scale
|
|
6
|
+
/// compensation needs. Returns null if the value is unavailable (0/invalid).
|
|
7
|
+
double? currentScreenWidth() {
|
|
8
|
+
final int width = web.window.screen.width;
|
|
9
|
+
return width > 0 ? width.toDouble() : null;
|
|
10
|
+
}
|
|
@@ -1,32 +1,73 @@
|
|
|
1
1
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
2
2
|
import 'package:flutter/widgets.dart';
|
|
3
|
+
import 'package:kasy_kit/core/web_screen_width.dart';
|
|
3
4
|
|
|
4
|
-
/// Maximum render scale applied to the app on web (
|
|
5
|
+
/// Maximum render scale applied to the app on web (desktop breakpoint only).
|
|
5
6
|
///
|
|
6
7
|
/// Flutter web tends to render ~10% larger than equivalent HTML apps at the
|
|
7
8
|
/// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.95`
|
|
8
9
|
/// brings it to the proportion the design targets (i.e. what 95% zoom looked
|
|
9
|
-
/// like) without the user having to touch the browser zoom.
|
|
10
|
-
///
|
|
10
|
+
/// like) without the user having to touch the browser zoom. It acts as the cap:
|
|
11
|
+
/// a desktop whose logical width is below the design target (high OS scale)
|
|
12
|
+
/// reduces it further to pin the layout (see [kWebViewportScaleTargetWidth]).
|
|
13
|
+
///
|
|
14
|
+
/// Mobile and tablet web are deliberately left at 1.0 — same as native and as the
|
|
15
|
+
/// device preview, so a developer previewing a phone/tablet sees exactly what the
|
|
16
|
+
/// native build renders. NATIVE is never scaled: the mechanism is gated on
|
|
17
|
+
/// [kIsWeb], so iOS/Android/macOS/Windows apps render at 1.0 and keep respecting
|
|
18
|
+
/// the user's system text-size (accessibility).
|
|
11
19
|
const double kWebViewportScale = 0.95;
|
|
12
20
|
|
|
13
21
|
/// Design target width (logical px) the desktop shell is laid out against.
|
|
14
22
|
///
|
|
15
|
-
/// A
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
///
|
|
19
|
-
/// `
|
|
20
|
-
///
|
|
23
|
+
/// A display with high OS scaling (Windows at 125/150/175%, or a Mac in a scaled
|
|
24
|
+
/// "more space"/"larger text" mode) reports a smaller logical SCREEN width, so the
|
|
25
|
+
/// `0.95` baseline alone left the shell cramped/cropped (the user had to
|
|
26
|
+
/// Ctrl-minus). When the screen is below this target the scale drops further
|
|
27
|
+
/// (`screenWidth / kWebViewportScaleTargetWidth`) so the full design still fits.
|
|
28
|
+
/// Compared against the SCREEN width, not the window width — see
|
|
29
|
+
/// [webViewportEffectiveScale].
|
|
21
30
|
const double kWebViewportScaleTargetWidth = 1280;
|
|
22
31
|
|
|
23
|
-
///
|
|
32
|
+
/// Viewport width (logical px) where the desktop shell begins.
|
|
33
|
+
///
|
|
34
|
+
/// Below it the layout is mobile/tablet (web renders at natural 1.0); at and above
|
|
35
|
+
/// it the desktop scale applies. Uses the WINDOW width (the layout follows the
|
|
36
|
+
/// window). Tied to the responsive system's desktop breakpoint so they stay in sync.
|
|
37
|
+
const double kWebViewportScaleDesktopBreakpoint = 1024; // DeviceType.large.breakpoint
|
|
38
|
+
|
|
39
|
+
/// Effective web render scale (pure math, unit-testable — see
|
|
40
|
+
/// web_viewport_scale_test.dart).
|
|
41
|
+
///
|
|
42
|
+
/// [windowWidth] is the browser window width (drives the desktop breakpoint, since
|
|
43
|
+
/// the layout follows the window). [screenWidth] is the physical screen width in
|
|
44
|
+
/// logical px (null = unknown/native).
|
|
24
45
|
///
|
|
25
|
-
///
|
|
26
|
-
/// web
|
|
27
|
-
///
|
|
28
|
-
///
|
|
29
|
-
|
|
46
|
+
/// Returns 1.0 below the desktop breakpoint ([kWebViewportScaleDesktopBreakpoint]):
|
|
47
|
+
/// tablet/phone web render at natural size — same as native and the device preview,
|
|
48
|
+
/// so previewing a phone/tablet shows what the native build does.
|
|
49
|
+
///
|
|
50
|
+
/// On desktop it returns the flat [maxScale] cap (0.95) and only drops BELOW it
|
|
51
|
+
/// when the SCREEN is small (high OS scale), via
|
|
52
|
+
/// `screenWidth / kWebViewportScaleTargetWidth`. Keying off the screen — not the
|
|
53
|
+
/// window — is the whole point: merely resizing the browser window narrower does
|
|
54
|
+
/// NOT shrink the UI (the layout just reflows); the extra shrink happens only when
|
|
55
|
+
/// the screen itself is cramped, which is what the compensation is for. With
|
|
56
|
+
/// [screenWidth] null (native, or web before the screen is known) it stays at the
|
|
57
|
+
/// flat cap.
|
|
58
|
+
///
|
|
59
|
+
/// This is web-only semantics: native never reaches the scaling path (the
|
|
60
|
+
/// [WebViewportScale] widget short-circuits when not on web, via [kIsWeb]), so
|
|
61
|
+
/// iOS/Android and macOS/Windows apps always render at 1.0.
|
|
62
|
+
double webViewportEffectiveScale(
|
|
63
|
+
double windowWidth, {
|
|
64
|
+
double? screenWidth,
|
|
65
|
+
double maxScale = kWebViewportScale,
|
|
66
|
+
}) {
|
|
67
|
+
if (windowWidth < kWebViewportScaleDesktopBreakpoint) return 1.0;
|
|
68
|
+
final double basis = screenWidth ?? double.infinity;
|
|
69
|
+
return (basis / kWebViewportScaleTargetWidth).clamp(0.5, maxScale);
|
|
70
|
+
}
|
|
30
71
|
|
|
31
72
|
/// Renders [child] uniformly scaled by [scale] on web (no-op elsewhere).
|
|
32
73
|
///
|
|
@@ -52,16 +93,11 @@ class WebViewportScale extends StatelessWidget {
|
|
|
52
93
|
Widget build(BuildContext context) {
|
|
53
94
|
if (!kIsWeb || scale == 1.0) return child;
|
|
54
95
|
final MediaQueryData mq = MediaQuery.of(context);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// scale down just enough to lay the shell out at the design target width
|
|
61
|
-
// instead of cropping it. Mac (wide) stays at 0.95; Windows at 125/150/175%
|
|
62
|
-
// shrinks proportionally so both look identical.
|
|
63
|
-
final double effectiveScale =
|
|
64
|
-
(mq.size.width / kWebViewportScaleTargetWidth).clamp(0.5, scale);
|
|
96
|
+
final double effectiveScale = webViewportEffectiveScale(
|
|
97
|
+
mq.size.width,
|
|
98
|
+
screenWidth: currentScreenWidth(),
|
|
99
|
+
maxScale: scale,
|
|
100
|
+
);
|
|
65
101
|
if (effectiveScale == 1.0) return child;
|
|
66
102
|
final Size logicalSize = Size(
|
|
67
103
|
mq.size.width / effectiveScale,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import 'package:flutter/gestures.dart';
|
|
2
|
+
import 'package:flutter/services.dart';
|
|
3
|
+
import 'package:flutter/widgets.dart';
|
|
4
|
+
|
|
5
|
+
/// Makes focus rings keyboard-only — the web's `:focus-visible` behaviour.
|
|
6
|
+
///
|
|
7
|
+
/// Flutter's default [FocusHighlightStrategy.automatic] only hides focus rings
|
|
8
|
+
/// for *touch*; on desktop a mouse click keeps them in "traditional" mode, so a
|
|
9
|
+
/// ring raised by Tab navigation never clears once the user grabs the mouse.
|
|
10
|
+
/// This wraps the app and flips the strategy by input type:
|
|
11
|
+
/// - a navigation key (Tab / arrows / Home / End / Page Up·Down) shows rings;
|
|
12
|
+
/// - any pointer press hides them, and — when the user was mid keyboard
|
|
13
|
+
/// navigation — also drops focus, so the next Tab restarts from the top of
|
|
14
|
+
/// the traversal (skip link → sidebar → content) instead of resuming
|
|
15
|
+
/// wherever the ring happened to be.
|
|
16
|
+
///
|
|
17
|
+
/// Plain typing does NOT turn rings on (only navigation keys do), so editing a
|
|
18
|
+
/// text field with the mouse stays ring-free. Uses a global pointer route so it
|
|
19
|
+
/// catches clicks anywhere, including empty areas with no hit target.
|
|
20
|
+
class FocusVisibility extends StatefulWidget {
|
|
21
|
+
const FocusVisibility({super.key, required this.child});
|
|
22
|
+
|
|
23
|
+
final Widget child;
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
State<FocusVisibility> createState() => _FocusVisibilityState();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class _FocusVisibilityState extends State<FocusVisibility> {
|
|
30
|
+
static final Set<LogicalKeyboardKey> _navigationKeys = <LogicalKeyboardKey>{
|
|
31
|
+
LogicalKeyboardKey.tab,
|
|
32
|
+
LogicalKeyboardKey.arrowUp,
|
|
33
|
+
LogicalKeyboardKey.arrowDown,
|
|
34
|
+
LogicalKeyboardKey.arrowLeft,
|
|
35
|
+
LogicalKeyboardKey.arrowRight,
|
|
36
|
+
LogicalKeyboardKey.home,
|
|
37
|
+
LogicalKeyboardKey.end,
|
|
38
|
+
LogicalKeyboardKey.pageUp,
|
|
39
|
+
LogicalKeyboardKey.pageDown,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
void initState() {
|
|
44
|
+
super.initState();
|
|
45
|
+
// Start ring-free; the first navigation key turns them on.
|
|
46
|
+
FocusManager.instance.highlightStrategy =
|
|
47
|
+
FocusHighlightStrategy.alwaysTouch;
|
|
48
|
+
HardwareKeyboard.instance.addHandler(_handleKey);
|
|
49
|
+
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointer);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
void dispose() {
|
|
54
|
+
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointer);
|
|
55
|
+
HardwareKeyboard.instance.removeHandler(_handleKey);
|
|
56
|
+
super.dispose();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
bool _handleKey(KeyEvent event) {
|
|
60
|
+
if (event is KeyDownEvent && _navigationKeys.contains(event.logicalKey)) {
|
|
61
|
+
_setStrategy(FocusHighlightStrategy.alwaysTraditional);
|
|
62
|
+
}
|
|
63
|
+
return false; // observe only — never consume the event
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
void _handlePointer(PointerEvent event) {
|
|
67
|
+
if (event is! PointerDownEvent) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
final FocusManager fm = FocusManager.instance;
|
|
71
|
+
final bool wasKeyboardNav =
|
|
72
|
+
fm.highlightMode == FocusHighlightMode.traditional;
|
|
73
|
+
_setStrategy(FocusHighlightStrategy.alwaysTouch);
|
|
74
|
+
// Only reset focus when leaving keyboard navigation; a normal click (e.g.
|
|
75
|
+
// into a text field) then keeps the focus it's about to request.
|
|
76
|
+
if (wasKeyboardNav) {
|
|
77
|
+
fm.primaryFocus?.unfocus();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
void _setStrategy(FocusHighlightStrategy strategy) {
|
|
82
|
+
if (FocusManager.instance.highlightStrategy != strategy) {
|
|
83
|
+
FocusManager.instance.highlightStrategy = strategy;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@override
|
|
88
|
+
Widget build(BuildContext context) => widget.child;
|
|
89
|
+
}
|
|
@@ -142,7 +142,6 @@ class _DetailPlaceholder extends StatelessWidget {
|
|
|
142
142
|
t.home.cards.assistant_title,
|
|
143
143
|
textAlign: TextAlign.center,
|
|
144
144
|
style: context.textTheme.titleMedium?.copyWith(
|
|
145
|
-
fontWeight: FontWeight.w600,
|
|
146
145
|
color: context.colors.onBackground,
|
|
147
146
|
),
|
|
148
147
|
),
|
|
@@ -150,7 +149,7 @@ class _DetailPlaceholder extends StatelessWidget {
|
|
|
150
149
|
Text(
|
|
151
150
|
t.ai_chat.no_conversation_selected,
|
|
152
151
|
textAlign: TextAlign.center,
|
|
153
|
-
style: context.textTheme.
|
|
152
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
154
153
|
color: context.colors.muted,
|
|
155
154
|
height: 1.4,
|
|
156
155
|
),
|
|
@@ -186,7 +186,6 @@ class _EmptyState extends StatelessWidget {
|
|
|
186
186
|
t.home.cards.assistant_title,
|
|
187
187
|
textAlign: TextAlign.center,
|
|
188
188
|
style: context.textTheme.titleMedium?.copyWith(
|
|
189
|
-
fontWeight: FontWeight.w600,
|
|
190
189
|
color: context.colors.onBackground,
|
|
191
190
|
),
|
|
192
191
|
),
|
|
@@ -194,7 +193,7 @@ class _EmptyState extends StatelessWidget {
|
|
|
194
193
|
Text(
|
|
195
194
|
t.ai_chat.empty_state,
|
|
196
195
|
textAlign: TextAlign.center,
|
|
197
|
-
style: context.textTheme.
|
|
196
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
198
197
|
color: context.colors.muted,
|
|
199
198
|
height: 1.4,
|
|
200
199
|
),
|
|
@@ -207,8 +207,7 @@ class _Header extends StatelessWidget {
|
|
|
207
207
|
t.ai_chat.title,
|
|
208
208
|
maxLines: 1,
|
|
209
209
|
overflow: TextOverflow.ellipsis,
|
|
210
|
-
style: context.
|
|
211
|
-
fontWeight: FontWeight.w700,
|
|
210
|
+
style: context.kasyTextTheme.pageTitle.copyWith(
|
|
212
211
|
color: context.colors.onBackground,
|
|
213
212
|
),
|
|
214
213
|
),
|
|
@@ -274,7 +273,7 @@ class _EmptyConversations extends StatelessWidget {
|
|
|
274
273
|
child: Text(
|
|
275
274
|
t.ai_chat.conversations_empty,
|
|
276
275
|
textAlign: TextAlign.center,
|
|
277
|
-
style: context.textTheme.
|
|
276
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
278
277
|
color: context.colors.muted,
|
|
279
278
|
height: 1.4,
|
|
280
279
|
),
|
|
@@ -75,8 +75,7 @@ class _AiConversationTileState extends ConsumerState<AiConversationTile> {
|
|
|
75
75
|
title,
|
|
76
76
|
maxLines: 1,
|
|
77
77
|
overflow: TextOverflow.ellipsis,
|
|
78
|
-
style: context.
|
|
79
|
-
fontWeight: FontWeight.w600,
|
|
78
|
+
style: context.kasyTextTheme.rowTitle.copyWith(
|
|
80
79
|
color: titleColor,
|
|
81
80
|
),
|
|
82
81
|
),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// Whether the app is running inside a *mobile* web browser (phone/tablet).
|
|
2
|
+
///
|
|
3
|
+
/// Drives the OAuth flow choice on web: mobile browsers handle provider popups
|
|
4
|
+
/// unreliably — the provider opens in a new tab and the popup result is
|
|
5
|
+
/// frequently lost when the browser reclaims the backgrounded opener tab, so the
|
|
6
|
+
/// sign-in future never resolves and the app stays stuck "signing in". On mobile
|
|
7
|
+
/// we use a full-page redirect instead (completed at startup by
|
|
8
|
+
/// `getRedirectResult`); desktop web keeps the smoother popup.
|
|
9
|
+
///
|
|
10
|
+
/// Non-web stub: always false. The real implementation lives in
|
|
11
|
+
/// `auth_web_support_web.dart` and is selected via conditional import on web.
|
|
12
|
+
bool isMobileWebBrowser() => false;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import 'package:web/web.dart' as web;
|
|
2
|
+
|
|
3
|
+
/// Web implementation: detects a mobile browser from the user-agent (iOS, Android
|
|
4
|
+
/// and other common phone/tablet agents). iPadOS 13+ reports a desktop Safari
|
|
5
|
+
/// user-agent, so we also treat a touch-capable "Macintosh" as mobile.
|
|
6
|
+
///
|
|
7
|
+
/// A false positive only costs the (also-working) redirect flow instead of the
|
|
8
|
+
/// popup, so the heuristic deliberately errs toward catching mobiles.
|
|
9
|
+
bool isMobileWebBrowser() {
|
|
10
|
+
final ua = web.window.navigator.userAgent.toLowerCase();
|
|
11
|
+
const needles = [
|
|
12
|
+
'android',
|
|
13
|
+
'iphone',
|
|
14
|
+
'ipad',
|
|
15
|
+
'ipod',
|
|
16
|
+
'mobile',
|
|
17
|
+
'windows phone',
|
|
18
|
+
'blackberry',
|
|
19
|
+
'opera mini',
|
|
20
|
+
'iemobile',
|
|
21
|
+
];
|
|
22
|
+
if (needles.any(ua.contains)) return true;
|
|
23
|
+
// iPadOS 13+ masquerades as desktop Safari but still exposes touch points.
|
|
24
|
+
return ua.contains('macintosh') && web.window.navigator.maxTouchPoints > 1;
|
|
25
|
+
}
|