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.
@@ -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 === 'ai-chat' || name === 'revenuecat-webhook' || name === 'send-push-notification' || name === 'stripe-webhook') ? ' --no-verify-jwt' : '';
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: DevicePreview.appBuilder(context, child),`);
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.8",
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-3xl`).
21
- const double _kFloatingRadius = KasyRadius.rounded3xl;
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 = 72;
24
+ const double _kBarHeight = 60;
25
25
 
26
26
  /// Tab icon size.
27
- const double _kNavIconSize = 24;
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: colors.surface,
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
- _icon(context, color),
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(color: color),
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.9` brings
8
- /// it to the proportion the design targets (i.e. what 90% zoom looked like)
9
- /// without the user having to touch the browser zoom.
10
- const double kWebViewportScale = 0.9;
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
- // Smaller, denser cards on phones; full size on tablet/desktop.
211
- final bool isMobile = MediaQuery.sizeOf(context).width < 768;
212
- final double thumbSize = isMobile ? 56 : 79;
213
- final double cardWidth = isMobile ? 232 : 302;
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
- if (width >= 1024) return 4;
96
- if (width >= 600) return 3;
97
- return 2;
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
- // Tapping the photo opens the full-screen viewer; the Hero gives a
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: KasyFocusRing(
185
- onActivate: _openViewer,
186
- borderRadius: BorderRadius.circular(KasyRadius.xl),
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: Padding(
212
- padding: const EdgeInsets.all(KasySpacing.md),
213
- child: Row(
214
- crossAxisAlignment: CrossAxisAlignment.end,
215
- children: <Widget>[
216
- Expanded(
217
- child: Column(
218
- crossAxisAlignment: CrossAxisAlignment.start,
219
- mainAxisSize: MainAxisSize.min,
220
- children: <Widget>[
221
- Text(
222
- photo.author,
223
- maxLines: 1,
224
- overflow: TextOverflow.ellipsis,
225
- style: context.textTheme.titleSmall?.copyWith(
226
- color: context.colors.onSurface,
227
- ),
228
- ),
229
- Text(
230
- photo.ago,
231
- maxLines: 1,
232
- overflow: TextOverflow.ellipsis,
233
- style: context.textTheme.bodySmall?.copyWith(
234
- color: context.colors.muted,
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
- const SizedBox(width: KasySpacing.sm),
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: 36,
358
- height: 36,
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+36
19
+ version: 1.0.0+38
20
20
 
21
21
  environment:
22
22
  sdk: ^3.11.0