kasy-cli 1.31.8 → 1.31.9
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/backends/supabase/deploy.js +15 -1
- package/lib/scaffold/shared/generator-utils.js +53 -1
- package/package.json +2 -2
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +41 -7
- package/templates/firebase/lib/core/web_viewport_scale.dart +15 -4
- package/templates/firebase/lib/features/home/home_feed.dart +52 -4
- package/templates/firebase/lib/features/home/home_image_grid.dart +81 -52
- package/templates/firebase/pubspec.yaml +1 -1
|
@@ -570,11 +570,25 @@ async function deployFunctions(projectDir, functionNames = []) {
|
|
|
570
570
|
}
|
|
571
571
|
if (toDeploy.length === 0) return { ok: true, skipped: true };
|
|
572
572
|
|
|
573
|
+
// Functions deployed WITHOUT the platform JWT gate (--no-verify-jwt). Two cases:
|
|
574
|
+
// 1. Webhooks (Stripe, RevenueCat, push) — called server-to-server, no user JWT.
|
|
575
|
+
// 2. Browser-invoked functions that authenticate THEMSELVES inside the handler:
|
|
576
|
+
// delete-user-account (checks the token), admin-list-users (token + admin
|
|
577
|
+
// role), stripe-list-prices (public prices), ai-chat (checks the token).
|
|
578
|
+
// With the platform gate ON, the browser's CORS preflight (an OPTIONS with
|
|
579
|
+
// no Authorization header) gets rejected before our CORS handler runs, so the
|
|
580
|
+
// web app sees "Failed to fetch". Letting the function do its own auth makes
|
|
581
|
+
// the preflight pass while staying secure (the handler still 401s anon calls).
|
|
582
|
+
const NO_VERIFY_JWT = new Set([
|
|
583
|
+
'ai-chat', 'revenuecat-webhook', 'send-push-notification', 'stripe-webhook',
|
|
584
|
+
'delete-user-account', 'admin-list-users', 'stripe-list-prices',
|
|
585
|
+
]);
|
|
586
|
+
|
|
573
587
|
const steps = [];
|
|
574
588
|
for (const name of toDeploy) {
|
|
575
589
|
const fnPath = path.join(functionsDir, name);
|
|
576
590
|
if (!(await fs.pathExists(fnPath))) continue;
|
|
577
|
-
const noVerifyJwt = (name
|
|
591
|
+
const noVerifyJwt = NO_VERIFY_JWT.has(name) ? ' --no-verify-jwt' : '';
|
|
578
592
|
const result = await run(`supabase functions deploy ${name}${noVerifyJwt}`, projectDir);
|
|
579
593
|
steps.push({ name: `deploy ${name}`, ok: result.ok, error: result.error });
|
|
580
594
|
}
|
|
@@ -315,6 +315,21 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
315
315
|
const withAnalytics = modules.includes('analytics');
|
|
316
316
|
const withWidget = modules.includes('widget');
|
|
317
317
|
|
|
318
|
+
// The engine already copied the kit's router.dart here (with the package name
|
|
319
|
+
// rewritten). That kit router is the SOURCE OF TRUTH: backend-agnostic, feature
|
|
320
|
+
// -flag aware (reads core/config/features.dart), and it carries the real
|
|
321
|
+
// navigation — the custom kasyTransitionPage transitions, the /admin console and
|
|
322
|
+
// every route. When all router-relevant features are present (the default Quick
|
|
323
|
+
// setup) we KEEP it verbatim instead of rebuilding a hand-written copy that
|
|
324
|
+
// drifts from the kit. We only fall through to the trimmed builder below when a
|
|
325
|
+
// feature was removed, because its source files (and deps) are gone and its
|
|
326
|
+
// imports must go with them. See PUBLISHING.md ("propagação para os 3 backends").
|
|
327
|
+
const routerPath = path.join(projectDir, 'lib', 'router.dart');
|
|
328
|
+
const ROUTER_FEATURES = ['onboarding', 'revenuecat', 'feedback', 'local_reminders', 'widget', 'analytics'];
|
|
329
|
+
if (ROUTER_FEATURES.every((m) => modules.includes(m)) && (await fs.pathExists(routerPath))) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
const fallback = withOnboarding ? '/onboarding' : '/signin';
|
|
319
334
|
|
|
320
335
|
const lines = [];
|
|
@@ -856,6 +871,40 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
|
|
|
856
871
|
// makes Firebase.initializeApp safe to call in all of them.
|
|
857
872
|
const withFirebase = true;
|
|
858
873
|
|
|
874
|
+
// Derive main.dart FROM the kit (the source of truth) whenever every
|
|
875
|
+
// main-relevant feature is present (the default Quick setup). The engine already
|
|
876
|
+
// copied the kit's main.dart here with the package name rewritten; that file is a
|
|
877
|
+
// complete Firebase main — the custom WebViewportScale proportion, the web device
|
|
878
|
+
// preview, and every Initializer service. For Firebase and API backends it is
|
|
879
|
+
// already exactly right, so we keep it verbatim. For Supabase we splice in
|
|
880
|
+
// Supabase.initialize and keep everything else, so kit improvements still
|
|
881
|
+
// propagate to all three backends. We only fall through to the hand-built builder
|
|
882
|
+
// below when a feature was removed (its service/import must be trimmed) or, as a
|
|
883
|
+
// safety net, when the kit main changed shape and the Supabase splice can't find
|
|
884
|
+
// its anchors. See PUBLISHING.md ("propagação para os 3 backends").
|
|
885
|
+
const mainPath = path.join(projectDir, 'lib', 'main.dart');
|
|
886
|
+
const MAIN_FEATURES = ['sentry', 'widget', 'revenuecat'];
|
|
887
|
+
if (MAIN_FEATURES.every((m) => modules.includes(m)) && (await fs.pathExists(mainPath))) {
|
|
888
|
+
if (backend !== 'supabase') {
|
|
889
|
+
// Firebase & API: the copied kit main is already correct.
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
let src = await fs.readFile(mainPath, 'utf8');
|
|
893
|
+
src = src.replace(
|
|
894
|
+
`import 'package:device_preview/device_preview.dart';\n`,
|
|
895
|
+
`import 'package:device_preview/device_preview.dart';\nimport 'package:supabase_flutter/supabase_flutter.dart';\n`,
|
|
896
|
+
);
|
|
897
|
+
src = src.replace(
|
|
898
|
+
` // initialize firebase app for notifications\n`,
|
|
899
|
+
` // initialize Supabase\n await Supabase.initialize(\n url: AppEnv.backendUrl,\n publishableKey: AppEnv.supabaseToken,\n );\n\n // initialize firebase app for notifications\n`,
|
|
900
|
+
);
|
|
901
|
+
if (src.includes('Supabase.initialize') && src.includes('supabase_flutter')) {
|
|
902
|
+
await fs.writeFile(mainPath, src, 'utf8');
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
// Anchors not found (kit main changed) — fall back to the hand-built main below.
|
|
906
|
+
}
|
|
907
|
+
|
|
859
908
|
const lines = [];
|
|
860
909
|
|
|
861
910
|
// ── Imports ────────────────────────────────────────────────────────────────
|
|
@@ -886,6 +935,7 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
|
|
|
886
935
|
lines.push(`import 'package:${pkg}/core/dev_inspector/dev_inspector.dart';`);
|
|
887
936
|
lines.push(`import 'package:${pkg}/core/theme/theme.dart';`);
|
|
888
937
|
lines.push(`import 'package:${pkg}/core/web_device_preview/web_device_preview.dart';`);
|
|
938
|
+
lines.push(`import 'package:${pkg}/core/web_viewport_scale.dart';`);
|
|
889
939
|
lines.push(`import 'package:${pkg}/environments.dart';`);
|
|
890
940
|
if (withFirebase) {
|
|
891
941
|
lines.push(`import 'package:${pkg}/firebase_options_dev.dart' as firebase_dev;`);
|
|
@@ -1106,7 +1156,9 @@ async function writeMainDart(projectDir, backend, modules, packageName, options
|
|
|
1106
1156
|
lines.push(` analyticsApiProvider,`);
|
|
1107
1157
|
lines.push(` facebookEventApiProvider,`);
|
|
1108
1158
|
lines.push(` ],`);
|
|
1109
|
-
lines.push(` onReady:
|
|
1159
|
+
lines.push(` onReady: WebViewportScale.wrap(`);
|
|
1160
|
+
lines.push(` DevicePreview.appBuilder(context, child),`);
|
|
1161
|
+
lines.push(` ),`);
|
|
1110
1162
|
lines.push(` onError: (_, error) => InitializationErrorPage(error: error),`);
|
|
1111
1163
|
lines.push(` onLoading: Scaffold(`);
|
|
1112
1164
|
lines.push(` body: Center(`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kasy-cli",
|
|
3
|
-
"version": "1.31.
|
|
3
|
+
"version": "1.31.9",
|
|
4
4
|
"description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kasy": "./bin/kasy.js"
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js",
|
|
34
|
+
"prepack": "node scripts/check-feature-patches.js && node scripts/bundle-template.js && node test/generated-matches-kit.test.js",
|
|
35
35
|
"start": "node ./bin/kasy.js",
|
|
36
36
|
"setup": "node ./bin/kasy.js setup",
|
|
37
37
|
"doctor": "node ./bin/kasy.js doctor",
|
|
@@ -17,22 +17,33 @@ const double _kFloatingTopGap = 6;
|
|
|
17
17
|
/// inset is used instead so the home indicator stays clear.
|
|
18
18
|
const double _kFloatingMinBottomGap = 16;
|
|
19
19
|
|
|
20
|
-
/// Corner radius of the floating pill (HeroUI `rounded-
|
|
21
|
-
const double _kFloatingRadius = KasyRadius.
|
|
20
|
+
/// Corner radius of the floating pill (HeroUI `rounded-2.5xl`).
|
|
21
|
+
const double _kFloatingRadius = KasyRadius.rounded2_5xl;
|
|
22
22
|
|
|
23
23
|
/// Bar (pill) inner height — the row of tab items.
|
|
24
|
-
const double _kBarHeight =
|
|
24
|
+
const double _kBarHeight = 60;
|
|
25
25
|
|
|
26
26
|
/// Tab icon size.
|
|
27
|
-
const double _kNavIconSize =
|
|
27
|
+
const double _kNavIconSize = 22;
|
|
28
28
|
|
|
29
29
|
/// Gap between a tab's icon and its label.
|
|
30
30
|
const double _kNavLabelGap = 3;
|
|
31
31
|
|
|
32
|
+
/// Tab label size. Fixed (not derived from the text theme) so every label —
|
|
33
|
+
/// "Início" and "Configurações" alike — reads at the exact same size and the
|
|
34
|
+
/// longer ones don't get clipped with an ellipsis on narrow phones.
|
|
35
|
+
const double _kNavLabelSize = 11;
|
|
36
|
+
|
|
32
37
|
/// Selection color-fade timing (muted ↔ primary highlight).
|
|
33
38
|
const Duration _kSelectDuration = Duration(milliseconds: 200);
|
|
34
39
|
const Curve _kSelectCurve = Curves.easeOut;
|
|
35
40
|
|
|
41
|
+
/// Subtle "pop" the icon does when its tab becomes active: a gentle scale-up
|
|
42
|
+
/// with a slight overshoot that settles — modern, not bouncy.
|
|
43
|
+
const Duration _kPopDuration = Duration(milliseconds: 260);
|
|
44
|
+
const Curve _kPopCurve = Curves.easeOutBack;
|
|
45
|
+
const double _kPopScale = 0.12;
|
|
46
|
+
|
|
36
47
|
/// [BartScaffold] requires a [bart.BartBottomBar]. This factory draws a single,
|
|
37
48
|
/// custom navigation bar (same look on iOS and Android) wrapped in a floating,
|
|
38
49
|
/// fully rounded surface that hovers above the screen edges with a safe-area
|
|
@@ -114,9 +125,16 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
|
|
|
114
125
|
Radius.circular(_kFloatingRadius),
|
|
115
126
|
);
|
|
116
127
|
|
|
128
|
+
// In dark mode the cards use `surface`, so a `surface` bar melts into them.
|
|
129
|
+
// A one-step-lighter surface lifts the floating bar above the content and
|
|
130
|
+
// reads as elevated. In light mode the white `surface` already pops against
|
|
131
|
+
// the grey canvas, so it stays as is.
|
|
132
|
+
final Color pillColor =
|
|
133
|
+
context.isDark ? colors.surfaceSecondary : colors.surface;
|
|
134
|
+
|
|
117
135
|
final Widget pill = Container(
|
|
118
136
|
decoration: BoxDecoration(
|
|
119
|
-
color:
|
|
137
|
+
color: pillColor,
|
|
120
138
|
borderRadius: radius,
|
|
121
139
|
// Single, very light shadow — just enough to lift the pill off the canvas.
|
|
122
140
|
boxShadow: [
|
|
@@ -227,13 +245,29 @@ class _KasyNavItem extends StatelessWidget {
|
|
|
227
245
|
return Column(
|
|
228
246
|
mainAxisSize: MainAxisSize.min,
|
|
229
247
|
children: [
|
|
230
|
-
|
|
248
|
+
// Gentle scale pop when the tab becomes active.
|
|
249
|
+
TweenAnimationBuilder<double>(
|
|
250
|
+
tween: Tween<double>(end: selected ? 1.0 : 0.0),
|
|
251
|
+
duration: _kPopDuration,
|
|
252
|
+
curve: _kPopCurve,
|
|
253
|
+
builder: (context, p, child) => Transform.scale(
|
|
254
|
+
scale: 1 + _kPopScale * p,
|
|
255
|
+
child: child,
|
|
256
|
+
),
|
|
257
|
+
child: _icon(context, color),
|
|
258
|
+
),
|
|
231
259
|
const SizedBox(height: _kNavLabelGap),
|
|
232
260
|
Text(
|
|
233
261
|
label,
|
|
234
262
|
maxLines: 1,
|
|
235
263
|
overflow: TextOverflow.ellipsis,
|
|
236
|
-
style: labelBase.copyWith(
|
|
264
|
+
style: labelBase.copyWith(
|
|
265
|
+
color: color,
|
|
266
|
+
fontSize: _kNavLabelSize,
|
|
267
|
+
height: 1.0,
|
|
268
|
+
fontWeight:
|
|
269
|
+
selected ? FontWeight.w600 : FontWeight.w500,
|
|
270
|
+
),
|
|
237
271
|
),
|
|
238
272
|
],
|
|
239
273
|
);
|
|
@@ -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,
|
|
@@ -43,6 +43,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
43
43
|
'1525966222134-fcfa99b8ae77',
|
|
44
44
|
'1484704849700-f032a568e944',
|
|
45
45
|
'1542219550-37153d387c27',
|
|
46
|
+
'1634986666676-ec8fd927c23d',
|
|
47
|
+
'1633899306328-c5e70574aaa2',
|
|
48
|
+
'1634017839464-5c339ebe3cb4',
|
|
49
|
+
'1618005182384-a83a8bd57fbe',
|
|
50
|
+
'1635776062127-d379bfcba9f8',
|
|
51
|
+
'1618556450994-a6a128ef0d9d',
|
|
52
|
+
'1644143379190-08a5f055de1d',
|
|
53
|
+
'1617791160505-6f00504e3519',
|
|
46
54
|
],
|
|
47
55
|
),
|
|
48
56
|
HomeCategory.background: _CategoryData(
|
|
@@ -62,6 +70,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
62
70
|
'1454496522488-7a8e488e8606',
|
|
63
71
|
'1439066615861-d1af74d74000',
|
|
64
72
|
'1470071459604-3b5ec3a7fe05',
|
|
73
|
+
'1502691876148-a84978e59af8',
|
|
74
|
+
'1574169208507-84376144848b',
|
|
75
|
+
'1518837695005-2083093ee35b',
|
|
76
|
+
'1534796636912-3b95b3ab5986',
|
|
77
|
+
'1508614999368-9260051292e5',
|
|
78
|
+
'1554189097-ffe88e998a2b',
|
|
79
|
+
'1557683316-973673baf926',
|
|
80
|
+
'1557682224-5b8590cd9ec5',
|
|
65
81
|
],
|
|
66
82
|
),
|
|
67
83
|
HomeCategory.animated: _CategoryData(
|
|
@@ -81,6 +97,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
81
97
|
'1579546929518-9e396f3cc809',
|
|
82
98
|
'1614851099175-e5b30eb6f696',
|
|
83
99
|
'1557672172-298e090bd0f1',
|
|
100
|
+
'1469474968028-56623f02e42e',
|
|
101
|
+
'1472214103451-9374bd1c798e',
|
|
102
|
+
'1447752875215-b2761acb3c5d',
|
|
103
|
+
'1426604966848-d7adac402bff',
|
|
104
|
+
'1554189097-ffe88e998a2b',
|
|
105
|
+
'1508614999368-9260051292e5',
|
|
106
|
+
'1557683316-973673baf926',
|
|
107
|
+
'1557682224-5b8590cd9ec5',
|
|
84
108
|
],
|
|
85
109
|
),
|
|
86
110
|
HomeCategory.icons3d: _CategoryData(
|
|
@@ -100,6 +124,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
100
124
|
'1620207418302-439b387441b0',
|
|
101
125
|
'1604079628040-94301bb21b91',
|
|
102
126
|
'1557682250-33bd709cbe85',
|
|
127
|
+
'1556228720-195a672e8a03',
|
|
128
|
+
'1542291026-7eec264c27ff',
|
|
129
|
+
'1522338242992-e1a54906a8da',
|
|
130
|
+
'1572635196237-14b3f281503f',
|
|
131
|
+
'1526170375885-4d8ecf77b99f',
|
|
132
|
+
'1560769629-975ec94e6a86',
|
|
133
|
+
'1525966222134-fcfa99b8ae77',
|
|
134
|
+
'1542219550-37153d387c27',
|
|
103
135
|
],
|
|
104
136
|
),
|
|
105
137
|
HomeCategory.gradients: _CategoryData(
|
|
@@ -119,6 +151,14 @@ const Map<HomeCategory, _CategoryData> _categories = <HomeCategory, _CategoryDat
|
|
|
119
151
|
'1508614999368-9260051292e5',
|
|
120
152
|
'1554189097-ffe88e998a2b',
|
|
121
153
|
'1620641788421-7a1c342ea42e',
|
|
154
|
+
'1518837695005-2083093ee35b',
|
|
155
|
+
'1534796636912-3b95b3ab5986',
|
|
156
|
+
'1462331940025-496dfbfc7564',
|
|
157
|
+
'1559827260-dc66d52bef19',
|
|
158
|
+
'1541701494587-cb58502866ab',
|
|
159
|
+
'1574169208507-84376144848b',
|
|
160
|
+
'1502691876148-a84978e59af8',
|
|
161
|
+
'1469474968028-56623f02e42e',
|
|
122
162
|
],
|
|
123
163
|
),
|
|
124
164
|
};
|
|
@@ -207,10 +247,18 @@ class _FilterRow extends StatelessWidget {
|
|
|
207
247
|
Widget build(BuildContext context) {
|
|
208
248
|
const List<HomeCategory> all = HomeCategory.values;
|
|
209
249
|
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
250
|
+
// Cards scale fluidly with the screen width instead of snapping at a single
|
|
251
|
+
// breakpoint: smallest/densest on phones, growing smoothly to full size by
|
|
252
|
+
// the time we reach a desktop-width viewport.
|
|
253
|
+
const double minWidth = 360; // small phone
|
|
254
|
+
const double maxWidth = 1024; // desktop — cards at full size from here up
|
|
255
|
+
final double screenWidth = MediaQuery.sizeOf(context).width;
|
|
256
|
+
final double t = ((screenWidth - minWidth) / (maxWidth - minWidth)).clamp(
|
|
257
|
+
0.0,
|
|
258
|
+
1.0,
|
|
259
|
+
);
|
|
260
|
+
final double thumbSize = 44 + (79 - 44) * t;
|
|
261
|
+
final double cardWidth = 196 + (302 - 196) * t;
|
|
214
262
|
final double cardHeight = thumbSize + KasySpacing.md * 2;
|
|
215
263
|
|
|
216
264
|
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(
|
|
@@ -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+38
|
|
20
20
|
|
|
21
21
|
environment:
|
|
22
22
|
sdk: ^3.11.0
|