kasy-cli 1.31.8 → 1.31.10
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/commands/new.js +7 -10
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/config.toml +39 -2
- package/lib/scaffold/backends/supabase/deploy.js +14 -2
- package/lib/scaffold/catalog.js +24 -0
- package/lib/scaffold/shared/generator-utils.js +53 -1
- package/package.json +2 -2
- package/templates/firebase/assets/images/premium-bg.jpg +0 -0
- package/templates/firebase/assets/images/premium-switch-header.png +0 -0
- package/templates/firebase/lib/components/kasy_app_bar.dart +107 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +63 -40
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
- package/templates/firebase/lib/core/web_viewport_scale.dart +15 -4
- package/templates/firebase/lib/features/home/home_feed.dart +59 -5
- package/templates/firebase/lib/features/home/home_image_grid.dart +81 -52
- package/templates/firebase/lib/features/home/home_page.dart +6 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
- package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
- package/templates/firebase/lib/i18n/en.i18n.json +1 -0
- package/templates/firebase/lib/i18n/es.i18n.json +1 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +1 -0
- package/templates/firebase/pubspec.yaml +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import 'package:flutter/widgets.dart';
|
|
2
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
3
|
+
import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
|
|
4
|
+
|
|
5
|
+
/// Single source of truth for how much of the app's "chrome" — the top
|
|
6
|
+
/// [KasyAppBar] and the bottom navigation bar — is revealed while the user
|
|
7
|
+
/// scrolls. `1` = fully shown, `0` = fully hidden; both bars interpolate on
|
|
8
|
+
/// this value so they slide away and come back together.
|
|
9
|
+
///
|
|
10
|
+
/// Behaviour (mobile only — tablet/desktop use the sidebar, which never hides):
|
|
11
|
+
/// - Scrolling DOWN collapses the chrome glued to the gesture: a fast flick
|
|
12
|
+
/// hides it fast, a slow drag hides it gradually.
|
|
13
|
+
/// - Scrolling UP slowly keeps it hidden; only a fast fling up brings it back.
|
|
14
|
+
/// - Reaching the top always brings it back.
|
|
15
|
+
///
|
|
16
|
+
/// Exposed as a global singleton (same pattern as `activeTabRouteNotifier` /
|
|
17
|
+
/// `kasyContentFocusTarget`) because both the app bar component and the bottom
|
|
18
|
+
/// bar factory need the same value without threading it through every page.
|
|
19
|
+
class KasyChromeVisibility {
|
|
20
|
+
KasyChromeVisibility._();
|
|
21
|
+
|
|
22
|
+
static final KasyChromeVisibility instance = KasyChromeVisibility._();
|
|
23
|
+
|
|
24
|
+
/// `1` = chrome fully visible, `0` = fully hidden.
|
|
25
|
+
final ValueNotifier<double> reveal = ValueNotifier<double>(1);
|
|
26
|
+
|
|
27
|
+
bool _enabled = true;
|
|
28
|
+
|
|
29
|
+
/// Whether the "hide on scroll" feature is on. Off → the chrome stays pinned.
|
|
30
|
+
bool get enabled => _enabled;
|
|
31
|
+
set enabled(bool value) {
|
|
32
|
+
_enabled = value;
|
|
33
|
+
if (!value) reveal.value = 1; // pin the chrome back when turned off
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Distance (logical px) over which the chrome fully collapses while scrolling
|
|
37
|
+
/// down. Small, so it feels glued to the gesture.
|
|
38
|
+
static const double _collapseDistance = 90;
|
|
39
|
+
|
|
40
|
+
/// An upward scroll delta at least this large in a single update counts as a
|
|
41
|
+
/// fast fling up and snaps the chrome back into view, even mid-page.
|
|
42
|
+
static const double _flingUpThreshold = 14;
|
|
43
|
+
|
|
44
|
+
/// Within this many px of the top, the chrome is always fully shown.
|
|
45
|
+
static const double _topThreshold = 6;
|
|
46
|
+
|
|
47
|
+
/// Bring the chrome fully back. Call on tab changes so a new screen never
|
|
48
|
+
/// starts with hidden chrome.
|
|
49
|
+
void resetShown() => reveal.value = 1;
|
|
50
|
+
|
|
51
|
+
/// Feed every vertical scroll update here (from a [NotificationListener] in
|
|
52
|
+
/// the shell). Updates [reveal] in place.
|
|
53
|
+
void handleScrollUpdate(ScrollUpdateNotification notification) {
|
|
54
|
+
if (!_enabled) return;
|
|
55
|
+
if (notification.metrics.axis != Axis.vertical) return;
|
|
56
|
+
final double delta = notification.scrollDelta ?? 0;
|
|
57
|
+
final double pixels = notification.metrics.pixels;
|
|
58
|
+
|
|
59
|
+
if (pixels <= _topThreshold) {
|
|
60
|
+
reveal.value = 1; // at the top → always shown
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (delta > 0) {
|
|
64
|
+
// Scrolling down: collapse proportionally — fast scroll (big delta) hides
|
|
65
|
+
// fast, slow scroll hides gradually. Glued to the gesture.
|
|
66
|
+
reveal.value = (reveal.value - delta / _collapseDistance).clamp(0.0, 1.0);
|
|
67
|
+
} else if (delta < 0 && -delta >= _flingUpThreshold) {
|
|
68
|
+
// Scrolling up: a slow drag keeps it hidden; only a fast fling reveals.
|
|
69
|
+
reveal.value = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Configuration — edit these to change the experience your app ships with.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/// Whether the Settings screen shows the "hide bars when scrolling" toggle to
|
|
79
|
+
/// the END USER.
|
|
80
|
+
///
|
|
81
|
+
/// - `true` → the toggle appears in Settings → Preferences and the user
|
|
82
|
+
/// decides; its initial value is [kHideChromeOnScrollDefault].
|
|
83
|
+
/// - `false` → the toggle is hidden and the behaviour is LOCKED to
|
|
84
|
+
/// [kHideChromeOnScrollDefault] (the user cannot change it).
|
|
85
|
+
const bool kShowHideChromeOnScrollSetting = true;
|
|
86
|
+
|
|
87
|
+
/// The default behaviour: `true` = the app bar and bottom menu hide while
|
|
88
|
+
/// scrolling down (and come back on scroll up / at the top); `false` = they
|
|
89
|
+
/// stay fixed. Used as the toggle's initial value when shown, and as the locked
|
|
90
|
+
/// value when [kShowHideChromeOnScrollSetting] is `false`.
|
|
91
|
+
const bool kHideChromeOnScrollDefault = true;
|
|
92
|
+
|
|
93
|
+
/// Persisted on/off for the hide-on-scroll behaviour. Wires the effective value
|
|
94
|
+
/// into [KasyChromeVisibility] so the bars react immediately.
|
|
95
|
+
final hideChromeOnScrollProvider =
|
|
96
|
+
NotifierProvider<HideChromeOnScrollNotifier, bool>(
|
|
97
|
+
HideChromeOnScrollNotifier.new,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
class HideChromeOnScrollNotifier extends Notifier<bool> {
|
|
101
|
+
@override
|
|
102
|
+
bool build() {
|
|
103
|
+
// When the user toggle is hidden, the behaviour is locked to the default
|
|
104
|
+
// and any previously saved preference is ignored.
|
|
105
|
+
final bool enabled = kShowHideChromeOnScrollSetting
|
|
106
|
+
? (ref.read(sharedPreferencesProvider).getHideChromeOnScroll() ??
|
|
107
|
+
kHideChromeOnScrollDefault)
|
|
108
|
+
: kHideChromeOnScrollDefault;
|
|
109
|
+
KasyChromeVisibility.instance.enabled = enabled;
|
|
110
|
+
return enabled;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Future<void> toggle() async {
|
|
114
|
+
final bool next = !state;
|
|
115
|
+
state = next;
|
|
116
|
+
KasyChromeVisibility.instance.enabled = next;
|
|
117
|
+
await ref.read(sharedPreferencesProvider).setHideChromeOnScroll(next);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -52,6 +52,18 @@ class SharedPreferencesBuilder implements OnStartService {
|
|
|
52
52
|
return prefs.getBool('haptic_feedback_enabled') ?? true;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/// Whether the top app bar and bottom menu hide while scrolling down and come
|
|
56
|
+
/// back on scroll up / at the top. Defaults to on.
|
|
57
|
+
Future<void> setHideChromeOnScroll(bool enabled) async {
|
|
58
|
+
await prefs.setBool('hide_chrome_on_scroll', enabled);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Null when the user has never set it — the caller applies the configured
|
|
62
|
+
/// default ([kHideChromeOnScrollDefault]).
|
|
63
|
+
bool? getHideChromeOnScroll() {
|
|
64
|
+
return prefs.getBool('hide_chrome_on_scroll');
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
Future<void> setBiometricEnabled(bool enabled) async {
|
|
56
68
|
await prefs.setBool('biometric_enabled', enabled);
|
|
57
69
|
}
|
|
@@ -533,7 +533,9 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
|
533
533
|
void _onFrameVisibleChanged() {
|
|
534
534
|
if (!mounted) return;
|
|
535
535
|
final store = Provider.of<DevicePreviewStore>(context, listen: false);
|
|
536
|
-
|
|
536
|
+
final data = _readData(store);
|
|
537
|
+
if (data == null) return;
|
|
538
|
+
if (data.isFrameVisible != widget.frameVisibleNotifier.value) {
|
|
537
539
|
store.toggleFrame();
|
|
538
540
|
}
|
|
539
541
|
}
|
|
@@ -543,11 +545,31 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
|
543
545
|
void _syncOrientation() {
|
|
544
546
|
if (!mounted) return;
|
|
545
547
|
final store = Provider.of<DevicePreviewStore>(context, listen: false);
|
|
548
|
+
final data = _readData(store);
|
|
549
|
+
if (data == null) {
|
|
550
|
+
// DevicePreview initializes asynchronously (it loads saved preferences), so
|
|
551
|
+
// on the first web frame the store can still be uninitialized and reading
|
|
552
|
+
// store.data throws "Not initialized". Retry next frame instead of surfacing
|
|
553
|
+
// a scary (and harmless) exception in the console.
|
|
554
|
+
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
546
557
|
final target = widget.landscapeNotifier.value
|
|
547
558
|
? Orientation.landscape
|
|
548
559
|
: Orientation.portrait;
|
|
549
|
-
if (
|
|
550
|
-
store.data =
|
|
560
|
+
if (data.orientation != target) {
|
|
561
|
+
store.data = data.copyWith(orientation: target);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/// [DevicePreviewStore.data] throws while the store is still finishing its async
|
|
566
|
+
/// initialization. Returns null instead of throwing so callers can skip or retry
|
|
567
|
+
/// cleanly (see _syncOrientation).
|
|
568
|
+
DevicePreviewData? _readData(DevicePreviewStore store) {
|
|
569
|
+
try {
|
|
570
|
+
return store.data;
|
|
571
|
+
} catch (_) {
|
|
572
|
+
return null;
|
|
551
573
|
}
|
|
552
574
|
}
|
|
553
575
|
|
|
@@ -4,10 +4,18 @@ import 'package:flutter/widgets.dart';
|
|
|
4
4
|
/// Global render scale applied to the app on web.
|
|
5
5
|
///
|
|
6
6
|
/// Flutter web tends to render ~10% larger than equivalent HTML apps at the
|
|
7
|
-
/// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.
|
|
8
|
-
/// it to the proportion the design targets (i.e. what
|
|
9
|
-
/// without the user having to touch the browser zoom.
|
|
10
|
-
const double kWebViewportScale = 0.
|
|
7
|
+
/// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.95`
|
|
8
|
+
/// 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
|
+
const double kWebViewportScale = 0.95;
|
|
11
|
+
|
|
12
|
+
/// Minimum real viewport width (logical px) at which the web scale kicks in.
|
|
13
|
+
///
|
|
14
|
+
/// The "oversized" problem only shows up on tablet/desktop layouts. On mobile
|
|
15
|
+
/// web (a narrow browser) the app should render at its natural size — exactly
|
|
16
|
+
/// like the native iOS/Android build, which never scales. Tied to the tablet
|
|
17
|
+
/// breakpoint so the rule stays in sync with the rest of the responsive system.
|
|
18
|
+
const double kWebViewportScaleMinWidth = 768; // DeviceType.medium.breakpoint
|
|
11
19
|
|
|
12
20
|
/// Renders [child] uniformly scaled by [scale] on web (no-op elsewhere).
|
|
13
21
|
///
|
|
@@ -33,6 +41,9 @@ class WebViewportScale extends StatelessWidget {
|
|
|
33
41
|
Widget build(BuildContext context) {
|
|
34
42
|
if (!kIsWeb || scale == 1.0) return child;
|
|
35
43
|
final MediaQueryData mq = MediaQuery.of(context);
|
|
44
|
+
// Mobile web (narrow browser) renders at its natural size, just like the
|
|
45
|
+
// native build. The scale only applies from the tablet breakpoint up.
|
|
46
|
+
if (mq.size.width < kWebViewportScaleMinWidth) return child;
|
|
36
47
|
final Size logicalSize = Size(
|
|
37
48
|
mq.size.width / scale,
|
|
38
49
|
mq.size.height / scale,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:kasy_kit/components/kasy_app_bar.dart';
|
|
2
3
|
import 'package:kasy_kit/components/kasy_card.dart';
|
|
3
4
|
import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
|
|
4
5
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
@@ -43,6 +44,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
43
44
|
'1525966222134-fcfa99b8ae77',
|
|
44
45
|
'1484704849700-f032a568e944',
|
|
45
46
|
'1542219550-37153d387c27',
|
|
47
|
+
'1634986666676-ec8fd927c23d',
|
|
48
|
+
'1633899306328-c5e70574aaa2',
|
|
49
|
+
'1634017839464-5c339ebe3cb4',
|
|
50
|
+
'1618005182384-a83a8bd57fbe',
|
|
51
|
+
'1635776062127-d379bfcba9f8',
|
|
52
|
+
'1618556450994-a6a128ef0d9d',
|
|
53
|
+
'1644143379190-08a5f055de1d',
|
|
54
|
+
'1617791160505-6f00504e3519',
|
|
46
55
|
],
|
|
47
56
|
),
|
|
48
57
|
HomeCategory.background: _CategoryData(
|
|
@@ -62,6 +71,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
62
71
|
'1454496522488-7a8e488e8606',
|
|
63
72
|
'1439066615861-d1af74d74000',
|
|
64
73
|
'1470071459604-3b5ec3a7fe05',
|
|
74
|
+
'1502691876148-a84978e59af8',
|
|
75
|
+
'1574169208507-84376144848b',
|
|
76
|
+
'1518837695005-2083093ee35b',
|
|
77
|
+
'1534796636912-3b95b3ab5986',
|
|
78
|
+
'1508614999368-9260051292e5',
|
|
79
|
+
'1554189097-ffe88e998a2b',
|
|
80
|
+
'1557683316-973673baf926',
|
|
81
|
+
'1557682224-5b8590cd9ec5',
|
|
65
82
|
],
|
|
66
83
|
),
|
|
67
84
|
HomeCategory.animated: _CategoryData(
|
|
@@ -81,6 +98,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
81
98
|
'1579546929518-9e396f3cc809',
|
|
82
99
|
'1614851099175-e5b30eb6f696',
|
|
83
100
|
'1557672172-298e090bd0f1',
|
|
101
|
+
'1469474968028-56623f02e42e',
|
|
102
|
+
'1472214103451-9374bd1c798e',
|
|
103
|
+
'1447752875215-b2761acb3c5d',
|
|
104
|
+
'1426604966848-d7adac402bff',
|
|
105
|
+
'1554189097-ffe88e998a2b',
|
|
106
|
+
'1508614999368-9260051292e5',
|
|
107
|
+
'1557683316-973673baf926',
|
|
108
|
+
'1557682224-5b8590cd9ec5',
|
|
84
109
|
],
|
|
85
110
|
),
|
|
86
111
|
HomeCategory.icons3d: _CategoryData(
|
|
@@ -100,6 +125,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
100
125
|
'1620207418302-439b387441b0',
|
|
101
126
|
'1604079628040-94301bb21b91',
|
|
102
127
|
'1557682250-33bd709cbe85',
|
|
128
|
+
'1556228720-195a672e8a03',
|
|
129
|
+
'1542291026-7eec264c27ff',
|
|
130
|
+
'1522338242992-e1a54906a8da',
|
|
131
|
+
'1572635196237-14b3f281503f',
|
|
132
|
+
'1526170375885-4d8ecf77b99f',
|
|
133
|
+
'1560769629-975ec94e6a86',
|
|
134
|
+
'1525966222134-fcfa99b8ae77',
|
|
135
|
+
'1542219550-37153d387c27',
|
|
103
136
|
],
|
|
104
137
|
),
|
|
105
138
|
HomeCategory.gradients: _CategoryData(
|
|
@@ -119,6 +152,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
119
152
|
'1508614999368-9260051292e5',
|
|
120
153
|
'1554189097-ffe88e998a2b',
|
|
121
154
|
'1620641788421-7a1c342ea42e',
|
|
155
|
+
'1518837695005-2083093ee35b',
|
|
156
|
+
'1534796636912-3b95b3ab5986',
|
|
157
|
+
'1462331940025-496dfbfc7564',
|
|
158
|
+
'1559827260-dc66d52bef19',
|
|
159
|
+
'1541701494587-cb58502866ab',
|
|
160
|
+
'1574169208507-84376144848b',
|
|
161
|
+
'1502691876148-a84978e59af8',
|
|
162
|
+
'1469474968028-56623f02e42e',
|
|
122
163
|
],
|
|
123
164
|
),
|
|
124
165
|
};
|
|
@@ -173,7 +214,12 @@ class _HomeFeedState extends State<HomeFeed> {
|
|
|
173
214
|
child: Column(
|
|
174
215
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
175
216
|
children: <Widget>[
|
|
176
|
-
|
|
217
|
+
// App bar inset lives INSIDE the scroll so it scrolls away and the
|
|
218
|
+
// feed slides under the frosted bar (overlay pattern).
|
|
219
|
+
SizedBox(
|
|
220
|
+
height: kasyAppBarBodyTopOverlap(context) +
|
|
221
|
+
KasySpacing.belowChromeContentGap,
|
|
222
|
+
),
|
|
177
223
|
_FilterRow(selected: _selected, onSelect: _select),
|
|
178
224
|
const SizedBox(height: KasySpacing.lg),
|
|
179
225
|
Padding(
|
|
@@ -207,10 +253,18 @@ class _FilterRow extends StatelessWidget {
|
|
|
207
253
|
Widget build(BuildContext context) {
|
|
208
254
|
const List<HomeCategory> all = HomeCategory.values;
|
|
209
255
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
256
|
+
// Cards scale fluidly with the screen width instead of snapping at a single
|
|
257
|
+
// breakpoint: smallest/densest on phones, growing smoothly to full size by
|
|
258
|
+
// the time we reach a desktop-width viewport.
|
|
259
|
+
const double minWidth = 360; // small phone
|
|
260
|
+
const double maxWidth = 1024; // desktop — cards at full size from here up
|
|
261
|
+
final double screenWidth = MediaQuery.sizeOf(context).width;
|
|
262
|
+
final double t = ((screenWidth - minWidth) / (maxWidth - minWidth)).clamp(
|
|
263
|
+
0.0,
|
|
264
|
+
1.0,
|
|
265
|
+
);
|
|
266
|
+
final double thumbSize = 44 + (79 - 44) * t;
|
|
267
|
+
final double cardWidth = 196 + (302 - 196) * t;
|
|
214
268
|
final double cardHeight = thumbSize + KasySpacing.md * 2;
|
|
215
269
|
|
|
216
270
|
return SizedBox(
|
|
@@ -71,7 +71,8 @@ class _ImageSkeleton extends StatelessWidget {
|
|
|
71
71
|
|
|
72
72
|
/// Pinterest-style masonry feed. Tile heights vary by a repeating ratio set and
|
|
73
73
|
/// tiles are balanced across columns by running height. Column count is
|
|
74
|
-
/// responsive: 2 on phones, 3 on tablets, 4 on desktop
|
|
74
|
+
/// responsive: 2 on phones, 3 on tablets, 4 on desktop and up to 8 on very
|
|
75
|
+
/// wide displays.
|
|
75
76
|
class HomeImageGrid extends StatelessWidget {
|
|
76
77
|
const HomeImageGrid({super.key, required this.photos});
|
|
77
78
|
|
|
@@ -91,10 +92,22 @@ class HomeImageGrid extends StatelessWidget {
|
|
|
91
92
|
1.4,
|
|
92
93
|
];
|
|
93
94
|
|
|
95
|
+
/// Target tile width. Columns are derived from the available width so the
|
|
96
|
+
/// grid stays dense on big desktops without ever letting tiles get too
|
|
97
|
+
/// narrow — naturally stepping 5 → 4 → 3 → 2 as the screen shrinks.
|
|
98
|
+
static const double _targetTileWidth = 300;
|
|
99
|
+
|
|
100
|
+
/// Hard floor/ceiling on column count: never fewer than 2 (the mobile feel)
|
|
101
|
+
/// and never more than 8 (lets big desktops fit 6–8 columns while still
|
|
102
|
+
/// capping density so tiles don't get tiny on huge monitors).
|
|
103
|
+
static const int _minColumns = 2;
|
|
104
|
+
static const int _maxColumns = 8;
|
|
105
|
+
|
|
94
106
|
int _columnsFor(double width) {
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
107
|
+
final int columns = (width / _targetTileWidth).ceil();
|
|
108
|
+
if (columns < _minColumns) return _minColumns;
|
|
109
|
+
if (columns > _maxColumns) return _maxColumns;
|
|
110
|
+
return columns;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
@override
|
|
@@ -140,6 +153,10 @@ class HomeImageGrid extends StatelessWidget {
|
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
/// Diameter of the circular "like" button — shared so the caption text can
|
|
157
|
+
/// reserve exactly enough room to sit beside it without overlapping.
|
|
158
|
+
const double _likeButtonSize = 36;
|
|
159
|
+
|
|
143
160
|
class _PhotoTile extends StatefulWidget {
|
|
144
161
|
final HomePhoto photo;
|
|
145
162
|
final double aspectRatio;
|
|
@@ -178,71 +195,83 @@ class _PhotoTileState extends State<_PhotoTile> {
|
|
|
178
195
|
child: Stack(
|
|
179
196
|
fit: StackFit.expand,
|
|
180
197
|
children: <Widget>[
|
|
181
|
-
//
|
|
182
|
-
// smooth zoom into and out of it.
|
|
198
|
+
// Photo. The Hero gives the smooth zoom into and out of the viewer.
|
|
183
199
|
Positioned.fill(
|
|
184
|
-
child:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
child: GestureDetector(
|
|
188
|
-
onTap: _openViewer,
|
|
189
|
-
behavior: HitTestBehavior.opaque,
|
|
190
|
-
child: Hero(
|
|
191
|
-
tag: photo.id,
|
|
192
|
-
child: KasyNetworkImage(url: photo.url),
|
|
193
|
-
),
|
|
194
|
-
),
|
|
200
|
+
child: Hero(
|
|
201
|
+
tag: photo.id,
|
|
202
|
+
child: KasyNetworkImage(url: photo.url),
|
|
195
203
|
),
|
|
196
204
|
),
|
|
197
205
|
|
|
198
206
|
// Smooth caption scrim — keeps text legible over any photo, in
|
|
199
|
-
// both light and dark themes.
|
|
207
|
+
// both light and dark themes. Decorative only: never eats taps.
|
|
200
208
|
const Positioned(
|
|
201
209
|
left: 0,
|
|
202
210
|
right: 0,
|
|
203
211
|
bottom: 0,
|
|
204
|
-
child: _CaptionScrim(),
|
|
212
|
+
child: IgnorePointer(child: _CaptionScrim()),
|
|
205
213
|
),
|
|
206
214
|
|
|
215
|
+
// Caption text — decorative only. The right padding reserves room
|
|
216
|
+
// for the like button so the text never runs underneath it.
|
|
207
217
|
Positioned(
|
|
208
218
|
left: 0,
|
|
209
219
|
right: 0,
|
|
210
220
|
bottom: 0,
|
|
211
|
-
child:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
221
|
+
child: IgnorePointer(
|
|
222
|
+
child: Padding(
|
|
223
|
+
padding: const EdgeInsets.fromLTRB(
|
|
224
|
+
KasySpacing.md,
|
|
225
|
+
KasySpacing.md,
|
|
226
|
+
KasySpacing.md + _likeButtonSize + KasySpacing.sm,
|
|
227
|
+
KasySpacing.md,
|
|
228
|
+
),
|
|
229
|
+
child: Column(
|
|
230
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
231
|
+
mainAxisSize: MainAxisSize.min,
|
|
232
|
+
children: <Widget>[
|
|
233
|
+
Text(
|
|
234
|
+
photo.author,
|
|
235
|
+
maxLines: 1,
|
|
236
|
+
overflow: TextOverflow.ellipsis,
|
|
237
|
+
style: context.textTheme.titleSmall?.copyWith(
|
|
238
|
+
color: context.colors.onSurface,
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
Text(
|
|
242
|
+
photo.ago,
|
|
243
|
+
maxLines: 1,
|
|
244
|
+
overflow: TextOverflow.ellipsis,
|
|
245
|
+
style: context.textTheme.bodySmall?.copyWith(
|
|
246
|
+
color: context.colors.muted,
|
|
247
|
+
),
|
|
238
248
|
),
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
_LikeButton(liked: _liked, onTap: _toggleLike),
|
|
242
|
-
],
|
|
249
|
+
],
|
|
250
|
+
),
|
|
243
251
|
),
|
|
244
252
|
),
|
|
245
253
|
),
|
|
254
|
+
|
|
255
|
+
// Full-tile tap target sits ABOVE the scrim and caption so tapping
|
|
256
|
+
// anywhere on the tile (gradient included) opens the viewer.
|
|
257
|
+
Positioned.fill(
|
|
258
|
+
child: KasyFocusRing(
|
|
259
|
+
onActivate: _openViewer,
|
|
260
|
+
borderRadius: BorderRadius.circular(KasyRadius.xl),
|
|
261
|
+
child: GestureDetector(
|
|
262
|
+
onTap: _openViewer,
|
|
263
|
+
behavior: HitTestBehavior.opaque,
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
|
|
268
|
+
// Like button — kept on top of the tap target so it still wins
|
|
269
|
+
// taps within its own area.
|
|
270
|
+
Positioned(
|
|
271
|
+
right: KasySpacing.md,
|
|
272
|
+
bottom: KasySpacing.md,
|
|
273
|
+
child: _LikeButton(liked: _liked, onTap: _toggleLike),
|
|
274
|
+
),
|
|
246
275
|
],
|
|
247
276
|
),
|
|
248
277
|
),
|
|
@@ -354,8 +383,8 @@ class _LikeButtonState extends State<_LikeButton>
|
|
|
354
383
|
child: BackdropFilter(
|
|
355
384
|
filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
|
356
385
|
child: Container(
|
|
357
|
-
width:
|
|
358
|
-
height:
|
|
386
|
+
width: _likeButtonSize,
|
|
387
|
+
height: _likeButtonSize,
|
|
359
388
|
alignment: Alignment.center,
|
|
360
389
|
decoration: BoxDecoration(
|
|
361
390
|
color: context.colors.surface.withValues(
|
|
@@ -31,14 +31,11 @@ class HomePage extends ConsumerWidget {
|
|
|
31
31
|
color: context.colors.background,
|
|
32
32
|
child: Stack(
|
|
33
33
|
children: [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
child: const HomeFeed(),
|
|
40
|
-
),
|
|
41
|
-
),
|
|
34
|
+
// True overlay: the feed fills the whole height and scrolls UNDER
|
|
35
|
+
// the frosted app bar (the bar's top inset lives inside the feed's
|
|
36
|
+
// scroll, so it scrolls away). This way, when the bar hides on
|
|
37
|
+
// scroll, content already fills the top — no empty app-bar band.
|
|
38
|
+
const Positioned.fill(child: HomeFeed()),
|
|
42
39
|
Positioned(
|
|
43
40
|
top: 0,
|
|
44
41
|
left: 0,
|
|
@@ -46,6 +43,7 @@ class HomePage extends ConsumerWidget {
|
|
|
46
43
|
child: KasyAppBar(
|
|
47
44
|
title: t.home.dashboard.brand,
|
|
48
45
|
style: KasyAppBarStyle.rootTab,
|
|
46
|
+
hideOnScroll: true,
|
|
49
47
|
onThemeToggle: () {
|
|
50
48
|
KasyHaptics.light(context);
|
|
51
49
|
ThemeProvider.of(context).toggle();
|
|
@@ -105,6 +105,7 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
105
105
|
return KasyOverlayScaffold(
|
|
106
106
|
title: t.notifications.title,
|
|
107
107
|
appBarStyle: KasyAppBarStyle.rootTab,
|
|
108
|
+
hideAppBarOnScroll: true,
|
|
108
109
|
scrollController: _scrollController,
|
|
109
110
|
trailing: Builder(
|
|
110
111
|
builder: (ctx) => Row(
|
|
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|
|
3
3
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
4
|
import 'package:go_router/go_router.dart';
|
|
5
5
|
import 'package:kasy_kit/components/components.dart';
|
|
6
|
+
import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
|
|
6
7
|
import 'package:kasy_kit/core/config/features.dart';
|
|
7
8
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
8
9
|
import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
|
|
@@ -41,6 +42,7 @@ class SettingsPage extends ConsumerWidget {
|
|
|
41
42
|
return KasyOverlayScaffold(
|
|
42
43
|
title: tr.title,
|
|
43
44
|
appBarStyle: KasyAppBarStyle.rootTab,
|
|
45
|
+
hideAppBarOnScroll: true,
|
|
44
46
|
trailing: Builder(
|
|
45
47
|
builder: (ctx) => KasyChromeOrbIconButton(
|
|
46
48
|
icon: KasyIcons.logout,
|
|
@@ -118,6 +120,10 @@ class SettingsPage extends ConsumerWidget {
|
|
|
118
120
|
const SettingsDivider(),
|
|
119
121
|
const HapticFeedbackSwitcher(),
|
|
120
122
|
const SettingsDivider(),
|
|
123
|
+
if (kShowHideChromeOnScrollSetting) ...[
|
|
124
|
+
const HideChromeOnScrollSwitcher(),
|
|
125
|
+
const SettingsDivider(),
|
|
126
|
+
],
|
|
121
127
|
const LanguageSwitcher(),
|
|
122
128
|
if (withLocalReminders) ...[
|
|
123
129
|
const SettingsDivider(),
|
|
@@ -641,6 +647,10 @@ class _DesktopDetail extends ConsumerWidget {
|
|
|
641
647
|
const SettingsDivider(),
|
|
642
648
|
const HapticFeedbackSwitcher(),
|
|
643
649
|
const SettingsDivider(),
|
|
650
|
+
if (kShowHideChromeOnScrollSetting) ...[
|
|
651
|
+
const HideChromeOnScrollSwitcher(),
|
|
652
|
+
const SettingsDivider(),
|
|
653
|
+
],
|
|
644
654
|
const LanguageSwitcher(),
|
|
645
655
|
if (withLocalReminders) ...[
|
|
646
656
|
const SettingsDivider(),
|
|
@@ -953,6 +963,22 @@ class HapticFeedbackSwitcher extends ConsumerWidget {
|
|
|
953
963
|
}
|
|
954
964
|
}
|
|
955
965
|
|
|
966
|
+
class HideChromeOnScrollSwitcher extends ConsumerWidget {
|
|
967
|
+
const HideChromeOnScrollSwitcher({super.key});
|
|
968
|
+
|
|
969
|
+
@override
|
|
970
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
971
|
+
final isEnabled = ref.watch(hideChromeOnScrollProvider);
|
|
972
|
+
return SettingsSwitchTile(
|
|
973
|
+
icon: KasyIcons.eyeOff,
|
|
974
|
+
title: context.t.settings.hide_chrome_on_scroll_title,
|
|
975
|
+
value: isEnabled,
|
|
976
|
+
onChanged: (_) =>
|
|
977
|
+
ref.read(hideChromeOnScrollProvider.notifier).toggle(),
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
956
982
|
class ThemeSwitcher extends StatelessWidget {
|
|
957
983
|
const ThemeSwitcher({super.key});
|
|
958
984
|
|