kasy-cli 1.31.7 → 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.
@@ -1733,11 +1733,6 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1733
1733
  // generation step once choices are made. The success card at the end shows
1734
1734
  // the full summary. Cancel any time with Ctrl+C.
1735
1735
 
1736
- // Windows: disable native assets before the generator runs pub get / builds,
1737
- // so a username with a space (C:\Users\John Silva) doesn't break the
1738
- // objective_c hook compile. No-op elsewhere, idempotent, best-effort.
1739
- require('../utils/env-tools').disableNativeAssetsWindows();
1740
-
1741
1736
  // ── Generate ────────────────────────────────────────────────────────────
1742
1737
  // Quick: single rolling line that mutates message. Advanced: stack each step.
1743
1738
  const stepper = ui.makeQuickStepper({ color: paintLime });
@@ -7,7 +7,7 @@ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
7
7
  const { printCompactHeader } = require('../utils/brand');
8
8
  const { readBundleId, readPackageName } = require('../utils/mobile-identity');
9
9
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
10
- const { spawnSyncFlutter, disableNativeAssetsWindows } = require('../utils/env-tools');
10
+ const { spawnSyncFlutter } = require('../utils/env-tools');
11
11
 
12
12
  function runCmd(cmd, args) {
13
13
  const res = spawnSync(cmd, args, { encoding: 'utf8' });
@@ -264,10 +264,6 @@ async function runReset(directory, options = {}) {
264
264
  throw new Error(t('reset.error.notFlutterProject'));
265
265
  }
266
266
 
267
- // Windows: disable native assets before the reinstall rebuild so a username
268
- // with a space doesn't break the objective_c hook compile. No-op elsewhere.
269
- disableNativeAssetsWindows();
270
-
271
267
  printCompactHeader(t);
272
268
  ui.intro(t('reset.title'));
273
269
 
@@ -5,7 +5,7 @@ const ui = require('../utils/ui');
5
5
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
6
6
  const { printCompactHeader, paintLime } = require('../utils/brand');
7
7
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
8
- const { spawnSyncFlutter, disableNativeAssetsWindows } = require('../utils/env-tools');
8
+ const { spawnSyncFlutter } = require('../utils/env-tools');
9
9
 
10
10
  function listFlutterDevices(projectDir) {
11
11
  // Shared flutter spawner: exposes a freshly-installed SDK on PATH (the terminal
@@ -249,11 +249,6 @@ async function runRun(directory, options = {}) {
249
249
  throw new Error(t('run.error.notFlutterProject'));
250
250
  }
251
251
 
252
- // Windows: turn off native assets before building so a username with a space
253
- // (C:\Users\John Silva) doesn't break the objective_c hook compile. No-op on
254
- // macOS/Linux, idempotent, and best-effort — see disableNativeAssetsWindows.
255
- disableNativeAssetsWindows();
256
-
257
252
  // Resolve device flag. If none of the platform shortcuts or -d is set,
258
253
  // ask the user when more than one device is available — `flutter run`
259
254
  // bails out with "More than one device connected" otherwise.
@@ -58,6 +58,13 @@ dependencies:
58
58
  open_filex: ^4.7.0
59
59
  package_info_plus: ^8.3.0
60
60
  path_provider: ^2.1.5
61
+ # Pin the Apple impl of path_provider below 2.6.0/2.5.0. Those versions pull in
62
+ # objective_c via "native assets", whose build hook is unquoted and breaks every
63
+ # build on Windows when the username has a space (C:\Users\John Silva) — even a
64
+ # Chrome/web run, where the Apple-only hook is dead weight. 2.5.1 is the last
65
+ # release without objective_c (added in 2.5.0, reverted in 2.5.1, re-added in
66
+ # 2.6.0). Drop this pin once the upstream web-target regression is fixed.
67
+ path_provider_foundation: 2.5.1
61
68
  permission_handler: ^12.0.1
62
69
  provider: ^6.1.0
63
70
  pub_semver: ^2.2.0
@@ -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
  }
@@ -59,6 +59,13 @@ dependencies:
59
59
  open_filex: ^4.7.0
60
60
  package_info_plus: ^8.3.0
61
61
  path_provider: ^2.1.5
62
+ # Pin the Apple impl of path_provider below 2.6.0/2.5.0. Those versions pull in
63
+ # objective_c via "native assets", whose build hook is unquoted and breaks every
64
+ # build on Windows when the username has a space (C:\Users\John Silva) — even a
65
+ # Chrome/web run, where the Apple-only hook is dead weight. 2.5.1 is the last
66
+ # release without objective_c (added in 2.5.0, reverted in 2.5.1, re-added in
67
+ # 2.6.0). Drop this pin once the upstream web-target regression is fixed.
68
+ path_provider_foundation: 2.5.1
62
69
  permission_handler: ^12.0.1
63
70
  provider: ^6.1.0
64
71
  pub_semver: ^2.2.0
@@ -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(`);
@@ -201,47 +201,6 @@ function flutterSpawn(spawnFn, args, options = {}) {
201
201
  const spawnFlutter = (args, options) => flutterSpawn(spawn, args, options);
202
202
  const spawnSyncFlutter = (args, options) => flutterSpawn(spawnSync, args, options);
203
203
 
204
- // Run the native-assets disable at most once per process — the setting is
205
- // persisted in Flutter's global config, so re-running it every command would
206
- // just add ~1s of cold flutter startup for nothing.
207
- let nativeAssetsHandled = false;
208
-
209
- /**
210
- * Disable Flutter "native assets" compilation — Windows only.
211
- *
212
- * Why: `path_provider_foundation` (the Apple impl of `path_provider`, pulled in
213
- * transitively by almost everything — google_fonts, home_widget,
214
- * flutter_secure_storage…) ships a native-assets build hook for the
215
- * `objective_c` package. On Windows, Dart's hook runner builds that compile
216
- * command WITHOUT quoting the SDK/project path, so a username containing a
217
- * SPACE (e.g. `C:\Users\John Silva`) breaks it — the shell reads only up to the
218
- * space: `'C:\Users\John' is not recognized as a command`. It even fires for a
219
- * Chrome/web run, where the Apple-only hook is pure dead weight.
220
- *
221
- * The hook is irrelevant on Windows (no iOS/macOS builds ever happen there), so
222
- * turning native assets off removes the broken step at zero cost. `flutter
223
- * config` is per-machine and global, so this never touches a Mac's iOS build.
224
- *
225
- * Idempotent and best-effort: it must never block `kasy run` / `kasy new`.
226
- *
227
- * @returns {{ ok: boolean, skipped?: boolean, error?: string }}
228
- */
229
- function disableNativeAssetsWindows() {
230
- if (!isWindows) return { ok: false, skipped: true };
231
- if (nativeAssetsHandled) return { ok: true, skipped: true };
232
- nativeAssetsHandled = true;
233
- try {
234
- const r = spawnSyncFlutter(['config', '--no-enable-native-assets'], {
235
- stdio: ['ignore', 'pipe', 'pipe'],
236
- encoding: 'utf8',
237
- timeout: 120_000,
238
- });
239
- return { ok: r.status === 0 };
240
- } catch (e) {
241
- return { ok: false, error: e.message };
242
- }
243
- }
244
-
245
204
  module.exports = {
246
205
  isWindows,
247
206
  homeDir,
@@ -251,5 +210,4 @@ module.exports = {
251
210
  augmentedEnv,
252
211
  spawnFlutter,
253
212
  spawnSyncFlutter,
254
- disableNativeAssetsWindows,
255
213
  };
@@ -106,14 +106,6 @@ if ($LASTEXITCODE -ne 0) {
106
106
  throw 'Flutter was installed but its first run failed (Dart SDK bootstrap). Check your internet connection and run this again.'
107
107
  }
108
108
 
109
- # 3b. Disable native assets. path_provider_foundation (Apple impl of
110
- # path_provider) ships an objective_c native-assets build hook whose compile
111
- # command isn't quoted, so a username with a SPACE (C:\\Users\\John Silva)
112
- # breaks every build — even Chrome/web, where the Apple hook is dead weight.
113
- # No iOS/macOS builds happen on Windows, so turning it off is free. Best
114
- # effort: never fail the install over it.
115
- & (Join-Path $bin 'flutter.bat') config --no-enable-native-assets 2>&1 | Out-Null
116
-
117
109
  # 4. Persist flutter\\bin on the User PATH so every future terminal finds it.
118
110
  $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
119
111
  if (-not $userPath) { $userPath = '' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.31.7",
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",
@@ -101,16 +101,14 @@ workflows:
101
101
  integrations:
102
102
  app_store_connect: codemagic # <-- Name of your App Store Connect integration (Codemagic UI)
103
103
  environment:
104
- groups:
105
- - appstore_credentials # <-- Group holding APP_ID (and any app keys)
106
104
  ios_signing:
107
105
  distribution_type: app_store
108
106
  bundle_identifier: com.aicrus.firebase.kit # <-- Your iOS bundle identifier
109
- vars:
110
- # APP_ID = the numeric App Store Connect app id. `kasy codemagic release`
111
- # sends it automatically (read from APP_STORE_ID in your .env); or define
112
- # it in the appstore_credentials group.
113
- APP_ID: $APP_ID
107
+ # APP_ID (numeric App Store Connect id) and the app keys arrive as
108
+ # environment variables: `kasy codemagic release` sends them from your .env.
109
+ # No variable group is required for iOS (App Store auth uses the integration
110
+ # above). For dashboard/push-triggered builds, add the keys as environment
111
+ # variables in the Codemagic app settings and a `groups:` entry here.
114
112
  flutter: stable
115
113
  xcode: latest # <-- set to specific version e.g. 15.0 to avoid unexpected updates.
116
114
  cocoapods: default
@@ -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
@@ -79,6 +79,13 @@ dependencies:
79
79
  open_filex: ^4.7.0
80
80
  package_info_plus: ^8.3.0
81
81
  path_provider: ^2.1.5
82
+ # Pin the Apple impl of path_provider below 2.6.0/2.5.0. Those versions pull in
83
+ # objective_c via "native assets", whose build hook is unquoted and breaks every
84
+ # build on Windows when the username has a space (C:\Users\John Silva) — even a
85
+ # Chrome/web run, where the Apple-only hook is dead weight. 2.5.1 is the last
86
+ # release without objective_c (added in 2.5.0, reverted in 2.5.1, re-added in
87
+ # 2.6.0). Drop this pin once the upstream web-target regression is fixed.
88
+ path_provider_foundation: 2.5.1
82
89
  permission_handler: ^12.0.1
83
90
  provider: ^6.1.0
84
91
  pub_semver: ^2.2.0