From 3b583094fd871d8358e72327a686d67b42f92424 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Fri, 3 Jul 2026 11:25:03 +0530 Subject: [PATCH 1/7] feat(bundles): client bundle mode and atomic CLI bundle deploy - StacBundle/StacBundleConfig models; SharedPreferences bundle store with schema-version guard - StacBundleService: conditional sync (ETag/since), seed-asset hydration (highest version wins), updates stream, offline fallback - StacBundleUpdater: sync on app-resume + optional foreground polling - StacCloud serves screens/themes from the bundle when enabled (opt-in; falls through to legacy per-artifact fetch) - CLI: single atomic POST /bundles with checksum + payload size log, seed asset emission with pubspec warning, --legacy fallback flag; build now cleans stale .build outputs --- packages/stac/lib/src/framework/stac.dart | 8 + .../stac/lib/src/framework/stac_service.dart | 17 + packages/stac/lib/src/models/models.dart | 2 + packages/stac/lib/src/models/stac_bundle.dart | 99 +++ .../stac/lib/src/models/stac_bundle.g.dart | 28 + .../lib/src/models/stac_bundle_config.dart | 126 ++++ packages/stac/lib/src/services/services.dart | 2 + .../lib/src/services/stac_bundle_service.dart | 313 ++++++++++ .../lib/src/services/stac_bundle_store.dart | 100 +++ .../lib/src/services/stac_bundle_updater.dart | 83 +++ .../stac/lib/src/services/stac_cloud.dart | 47 ++ .../stac/test/models/stac_bundle_test.dart | 79 +++ .../services/stac_bundle_service_test.dart | 589 ++++++++++++++++++ packages/stac_cli/CHANGELOG.md | 7 + .../lib/src/commands/deploy_command.dart | 9 +- .../lib/src/services/build_service.dart | 7 +- .../lib/src/services/deploy_service.dart | 292 ++++++++- packages/stac_cli/pubspec.lock | 2 +- packages/stac_cli/pubspec.yaml | 1 + .../test/services/deploy_service_test.dart | 300 +++++++++ 20 files changed, 2102 insertions(+), 9 deletions(-) create mode 100644 packages/stac/lib/src/models/stac_bundle.dart create mode 100644 packages/stac/lib/src/models/stac_bundle.g.dart create mode 100644 packages/stac/lib/src/models/stac_bundle_config.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_service.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_store.dart create mode 100644 packages/stac/lib/src/services/stac_bundle_updater.dart create mode 100644 packages/stac/test/models/stac_bundle_test.dart create mode 100644 packages/stac/test/services/stac_bundle_service_test.dart create mode 100644 packages/stac_cli/test/services/deploy_service_test.dart diff --git a/packages/stac/lib/src/framework/stac.dart b/packages/stac/lib/src/framework/stac.dart index a761594b..e2fd4ebf 100644 --- a/packages/stac/lib/src/framework/stac.dart +++ b/packages/stac/lib/src/framework/stac.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/services/stac_cloud.dart'; import 'package:stac_core/actions/network_request/stac_network_request.dart'; @@ -165,6 +166,11 @@ class Stac extends StatelessWidget { /// - [cacheConfig]: Global cache configuration for all Stac widgets and /// StacCloud calls. Defaults to networkFirst strategy if not provided. /// + /// - [bundleConfig]: Configuration for bundle mode, where all screens and + /// themes are downloaded as a single version-gated bundle. Disabled by + /// default (opt-in); when not provided, legacy per-screen fetching is + /// used. + /// /// ## Example /// /// ```dart @@ -188,6 +194,7 @@ class Stac extends StatelessWidget { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { return StacService.initialize( options: options, @@ -199,6 +206,7 @@ class Stac extends StatelessWidget { logStackTraces: logStackTraces, errorWidgetBuilder: errorWidgetBuilder, cacheConfig: cacheConfig, + bundleConfig: bundleConfig, ); } diff --git a/packages/stac/lib/src/framework/stac_service.dart b/packages/stac/lib/src/framework/stac_service.dart index b05ff824..d272a3ee 100644 --- a/packages/stac/lib/src/framework/stac_service.dart +++ b/packages/stac/lib/src/framework/stac_service.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:stac/src/framework/stac.dart'; import 'package:stac/src/framework/stac_error.dart'; import 'package:stac/src/framework/stac_registry.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/parsers/actions/stac_form_validate/stac_form_validate_parser.dart'; import 'package:stac/src/parsers/actions/stac_get_form_value/stac_get_form_value_parser.dart'; @@ -18,6 +19,8 @@ import 'package:stac/src/parsers/widgets/stac_inkwell/stac_inkwell_parser.dart'; import 'package:stac/src/parsers/widgets/stac_row/stac_row_parser.dart'; import 'package:stac/src/parsers/widgets/stac_text/stac_text_parser.dart'; import 'package:stac/src/parsers/widgets/stac_tool_tip/stac_tool_tip_parser.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; import 'package:stac/src/services/stac_network_service.dart'; import 'package:stac/src/utils/variable_resolver.dart'; import 'package:stac_core/stac_core.dart'; @@ -173,6 +176,10 @@ class StacService { ); static StacCacheConfig get defaultCacheConfig => _defaultCacheConfig; + // Bundle configuration for bundle mode (opt-in, disabled by default). + static StacBundleConfig _bundleConfig = const StacBundleConfig(); + static StacBundleConfig get bundleConfig => _bundleConfig; + static Future initialize({ StacOptions? options, List parsers = const [], @@ -183,11 +190,21 @@ class StacService { bool logStackTraces = true, StacErrorWidgetBuilder? errorWidgetBuilder, StacCacheConfig? cacheConfig, + StacBundleConfig? bundleConfig, }) async { _options = options; if (cacheConfig != null) { _defaultCacheConfig = cacheConfig; } + if (bundleConfig != null) { + _bundleConfig = bundleConfig; + } + if (_bundleConfig.enabled) { + StacBundleUpdater.start(); + if (_bundleConfig.prefetchOnInit && options != null) { + unawaited(StacBundleService.sync()); + } + } _parsers.addAll(parsers); _actionParsers.addAll(actionParsers); StacRegistry.instance.registerAll(_parsers, override); diff --git a/packages/stac/lib/src/models/models.dart b/packages/stac/lib/src/models/models.dart index 34b2f513..125f3be9 100644 --- a/packages/stac/lib/src/models/models.dart +++ b/packages/stac/lib/src/models/models.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/models/stac_bundle.dart'; +export 'package:stac/src/models/stac_bundle_config.dart'; export 'package:stac/src/models/stac_cache_config.dart'; export 'package:stac/src/models/stac_cache.dart'; diff --git a/packages/stac/lib/src/models/stac_bundle.dart b/packages/stac/lib/src/models/stac_bundle.dart new file mode 100644 index 00000000..ad91cfca --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +part 'stac_bundle.g.dart'; + +/// Model representing a bundle of screens and themes from Stac Cloud. +/// +/// A bundle contains every screen and theme for a project at a single +/// server-owned monotonic [version]. Screens render from the cached bundle; +/// the body is re-downloaded only when a conditional version check returns +/// a new version. +@JsonSerializable() +class StacBundle { + /// Creates a [StacBundle] instance. + const StacBundle({ + required this.projectId, + required this.version, + this.etag, + this.checksum, + required this.fetchedAt, + required this.screens, + required this.themes, + }); + + /// The Stac Cloud project this bundle belongs to. + final String projectId; + + /// The server-owned monotonic bundle version. + final int version; + + /// The HTTP ETag for conditional (`If-None-Match`) requests. + final String? etag; + + /// The sha256 checksum of the bundle content. + final String? checksum; + + /// The timestamp when this bundle was fetched or hydrated. + final DateTime fetchedAt; + + /// Screen name to Stac JSON string. + final Map screens; + + /// Theme name to Stac JSON string. + final Map themes; + + /// Returns the Stac JSON string for the screen [name], or `null` if the + /// screen is not part of this bundle. + String? screen(String name) => screens[name]; + + /// Returns the Stac JSON string for the theme [name], or `null` if the + /// theme is not part of this bundle. + String? theme(String name) => themes[name]; + + /// Creates a [StacBundle] from a JSON map. + factory StacBundle.fromJson(Map json) => + _$StacBundleFromJson(json); + + /// Converts this [StacBundle] to a JSON map. + Map toJson() => _$StacBundleToJson(this); + + /// Creates a [StacBundle] from a JSON string. + factory StacBundle.fromJsonString(String jsonString) { + return StacBundle.fromJson(jsonDecode(jsonString) as Map); + } + + /// Converts this [StacBundle] to a JSON string. + String toJsonString() { + return jsonEncode(toJson()); + } + + /// Creates a copy of this [StacBundle] with the given fields replaced. + StacBundle copyWith({ + String? projectId, + int? version, + String? etag, + String? checksum, + DateTime? fetchedAt, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId ?? this.projectId, + version: version ?? this.version, + etag: etag ?? this.etag, + checksum: checksum ?? this.checksum, + fetchedAt: fetchedAt ?? this.fetchedAt, + screens: screens ?? this.screens, + themes: themes ?? this.themes, + ); + } + + @override + String toString() { + return 'StacBundle(projectId: $projectId, version: $version, ' + 'screens: ${screens.length}, themes: ${themes.length}, ' + 'fetchedAt: $fetchedAt)'; + } +} diff --git a/packages/stac/lib/src/models/stac_bundle.g.dart b/packages/stac/lib/src/models/stac_bundle.g.dart new file mode 100644 index 00000000..8039702c --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stac_bundle.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StacBundle _$StacBundleFromJson(Map json) => StacBundle( + projectId: json['projectId'] as String, + version: (json['version'] as num).toInt(), + etag: json['etag'] as String?, + checksum: json['checksum'] as String?, + fetchedAt: DateTime.parse(json['fetchedAt'] as String), + screens: Map.from(json['screens'] as Map), + themes: Map.from(json['themes'] as Map), +); + +Map _$StacBundleToJson(StacBundle instance) => + { + 'projectId': instance.projectId, + 'version': instance.version, + 'etag': instance.etag, + 'checksum': instance.checksum, + 'fetchedAt': instance.fetchedAt.toIso8601String(), + 'screens': instance.screens, + 'themes': instance.themes, + }; diff --git a/packages/stac/lib/src/models/stac_bundle_config.dart b/packages/stac/lib/src/models/stac_bundle_config.dart new file mode 100644 index 00000000..18f82c5b --- /dev/null +++ b/packages/stac/lib/src/models/stac_bundle_config.dart @@ -0,0 +1,126 @@ +/// Configuration for Stac bundle mode. +/// +/// Bundle mode replaces per-screen fetches with a single version-gated +/// bundle download containing every screen and theme for a project. +/// It is opt-in: [enabled] defaults to `false`, so an app without a +/// `bundleConfig` behaves exactly as before. +/// +/// Bundle caching behavior is fixed (no strategies): screens always render +/// from the cached bundle, and the body is re-downloaded only when a +/// conditional version check returns a new version. The check runs at app +/// launch ([prefetchOnInit]), on app-resume ([checkOnResume]), on an optional +/// poll interval ([pollingInterval]), or via an explicit +/// `StacBundleService.sync()` call. +/// +/// ## Basic Usage +/// +/// ```dart +/// await Stac.initialize( +/// options: StacOptions(...), +/// bundleConfig: StacBundleConfig( +/// enabled: true, +/// seedAsset: 'assets/stac_bundle.json', +/// ), +/// ); +/// ``` +class StacBundleConfig { + /// Creates a [StacBundleConfig] instance. + const StacBundleConfig({ + this.enabled = false, + this.prefetchOnInit = true, + this.baseUrl = 'https://api.stac.dev', + this.seedAsset, + this.checkOnResume = true, + this.pollingInterval, + }); + + /// Whether bundle mode is enabled. + /// + /// Defaults to `false` (opt-in). When disabled, the legacy per-artifact + /// fetch and cache behavior is untouched. + final bool enabled; + + /// Whether to kick off a conditional bundle sync during initialization. + /// + /// Defaults to `true`. The sync is not awaited, so it never delays startup. + final bool prefetchOnInit; + + /// The base URL used for bundle requests. + /// + /// Defaults to `https://api.stac.dev`. + final String baseUrl; + + /// Optional asset path of a seed bundle shipped with the app + /// (e.g. `assets/stac_bundle.json`, written by `stac deploy`). + /// + /// When set, first launch hydrates from the seed instantly — no loading + /// wait, and it works offline. Defaults to `null` (no seed). + final String? seedAsset; + + /// Whether to run a conditional bundle sync when the app returns to the + /// foreground. + /// + /// Defaults to `true`. + final bool checkOnResume; + + /// Optional interval for periodic conditional bundle syncs while the app + /// is foregrounded. + /// + /// Defaults to `null` (polling off). + final Duration? pollingInterval; + + // ───────────────────────────────────────────────────────────────────────── + // Methods + // ───────────────────────────────────────────────────────────────────────── + + /// Creates a copy of this config with the given fields replaced. + StacBundleConfig copyWith({ + bool? enabled, + bool? prefetchOnInit, + String? baseUrl, + String? seedAsset, + bool? checkOnResume, + Duration? pollingInterval, + }) { + return StacBundleConfig( + enabled: enabled ?? this.enabled, + prefetchOnInit: prefetchOnInit ?? this.prefetchOnInit, + baseUrl: baseUrl ?? this.baseUrl, + seedAsset: seedAsset ?? this.seedAsset, + checkOnResume: checkOnResume ?? this.checkOnResume, + pollingInterval: pollingInterval ?? this.pollingInterval, + ); + } + + @override + String toString() { + return 'StacBundleConfig(enabled: $enabled, prefetchOnInit: $prefetchOnInit, ' + 'baseUrl: $baseUrl, seedAsset: $seedAsset, checkOnResume: $checkOnResume, ' + 'pollingInterval: $pollingInterval)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StacBundleConfig && + other.enabled == enabled && + other.prefetchOnInit == prefetchOnInit && + other.baseUrl == baseUrl && + other.seedAsset == seedAsset && + other.checkOnResume == checkOnResume && + other.pollingInterval == pollingInterval; + } + + @override + int get hashCode { + return Object.hash( + enabled, + prefetchOnInit, + baseUrl, + seedAsset, + checkOnResume, + pollingInterval, + ); + } +} diff --git a/packages/stac/lib/src/services/services.dart b/packages/stac/lib/src/services/services.dart index c9fdb624..c07ed940 100644 --- a/packages/stac/lib/src/services/services.dart +++ b/packages/stac/lib/src/services/services.dart @@ -1,2 +1,4 @@ +export 'package:stac/src/services/stac_bundle_service.dart'; +export 'package:stac/src/services/stac_bundle_store.dart'; export 'package:stac/src/services/stac_cache_service.dart'; export 'package:stac/src/services/stac_network_service.dart'; diff --git a/packages/stac/lib/src/services/stac_bundle_service.dart b/packages/stac/lib/src/services/stac_bundle_service.dart new file mode 100644 index 00000000..33937716 --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_service.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Reads a string asset; seam for testing seed hydration without a real +/// asset bundle. +typedef StacBundleAssetReader = Future Function(String assetPath); + +/// Service for syncing and serving Stac bundles. +/// +/// A bundle is a single blob containing every screen and theme for a +/// project at one server-owned version. Screens always render from the +/// cached bundle; the body is re-downloaded only when a conditional +/// version check ([sync]) returns a new version (decision: no +/// strategies/TTL — the server-owned version is the only invalidator). +class StacBundleService { + const StacBundleService._(); + + static Dio _dio = _createDio(); + + static Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); + } + + /// Overrides the Dio client used for bundle requests. + @visibleForTesting + static set dio(Dio dio) => _dio = dio; + + static StacBundleStore _store = const SharedPreferencesBundleStore(); + + /// Overrides the store used to persist bundles. + @visibleForTesting + static set store(StacBundleStore store) => _store = store; + + static StacBundleAssetReader _assetReader = rootBundle.loadString; + + /// Overrides the asset reader used for seed bundle hydration. + @visibleForTesting + static set assetReader(StacBundleAssetReader reader) => + _assetReader = reader; + + /// The bundle currently held in memory, if any. + static StacBundle? _bundle; + + /// The bundle currently held in memory, if any. + static StacBundle? get current => _bundle; + + /// Whether hydration (store + seed asset) has completed. + static bool _hydrated = false; + + /// In-flight hydration, deduped across concurrent callers. + static Future? _inFlightHydration; + + /// In-flight sync, deduped so at most one GET runs at a time. + static Future? _inFlightSync; + + static StreamController _updatesController = + StreamController.broadcast(); + + /// Broadcast stream emitting whenever a sync lands a new bundle version, + /// so host apps can prompt the user or trigger a rebuild on their own + /// terms. Nothing is emitted on `304`/`204` (no change) or on errors. + static Stream get updates => _updatesController.stream; + + static StacBundleConfig get _config => StacService.bundleConfig; + + static String get _projectId { + final options = StacService.options; + if (options == null) { + throw Exception('StacOptions is not set'); + } + return options.projectId; + } + + /// Runs a conditional bundle version check against the server. + /// + /// Sends `GET {baseUrl}/bundles?projectId=..&since=` with + /// `If-None-Match` when an etag is cached. A `200` parses, persists and + /// swaps in the new bundle (and emits on [updates]); `304`/`204` keep the + /// current bundle; errors (offline, server failures) return the stale + /// bundle. Concurrent calls are deduped onto a single in-flight request. + /// + /// Set [force] to skip the conditional headers and re-download the body + /// unconditionally. + static Future sync({bool force = false}) { + return _inFlightSync ??= _syncInternal(force: force).whenComplete(() { + _inFlightSync = null; + }); + } + + static Future _syncInternal({required bool force}) async { + final projectId = _projectId; + + // Hydrate first so the request can be conditional on the cached version. + await _hydrate(); + + final cached = _bundle; + try { + final response = await _dio.get( + '${_config.baseUrl}/bundles', + queryParameters: { + 'projectId': projectId, + if (!force && cached != null) 'since': cached.version, + }, + options: Options( + headers: { + if (!force && cached?.etag != null) 'If-None-Match': cached!.etag, + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + + final statusCode = response.statusCode; + + if (statusCode == 200) { + final data = response.data; + if (data is! Map) { + Log.w('StacBundleService: Unexpected bundle response body'); + return cached; + } + + final bundle = _bundleFromResponse( + Map.from(data), + projectId: projectId, + etagHeader: response.headers.value('etag'), + ); + if (bundle == null) { + Log.w('StacBundleService: Bundle response is missing a version'); + return cached; + } + + _bundle = bundle; + await _store.write(bundle); + _updatesController.add(bundle); + Log.d('StacBundleService: Synced bundle v${bundle.version}'); + return bundle; + } + + if (statusCode == 304 || statusCode == 204) { + // Not modified: keep the cached bundle. + return cached; + } + + Log.w( + 'StacBundleService: Bundle sync failed with status $statusCode, ' + 'using ${cached == null ? 'no bundle' : 'stale bundle v${cached.version}'}', + ); + return cached; + } catch (e) { + // Offline or server error: keep serving the stale bundle. + Log.d('StacBundleService: Bundle sync failed ($e), using stale bundle'); + return cached; + } + } + + /// Builds a [StacBundle] from a `200` response body. + /// + /// Returns `null` when the body has no usable version. + static StacBundle? _bundleFromResponse( + Map data, { + required String projectId, + String? etagHeader, + }) { + final version = data['version']; + if (version is! int) return null; + + return StacBundle( + projectId: data['projectId'] as String? ?? projectId, + version: version, + etag: etagHeader ?? data['etag'] as String?, + checksum: data['checksum'] as String?, + fetchedAt: DateTime.now(), + screens: _stringMap(data['screens']), + themes: _stringMap(data['themes']), + ); + } + + static Map _stringMap(dynamic value) { + if (value is! Map) return const {}; + return Map.from(value); + } + + /// Ensures a bundle is loaded, hydrating from on-device storage and the + /// seed asset (highest version wins) without any network call. + /// + /// Only when no bundle exists in either source (first launch of a + /// seedless app) does this await [sync] so the first screens can render. + static Future ensureLoaded() async { + await _hydrate(); + if (_bundle != null) return _bundle; + return sync(); + } + + /// Hydrates the in-memory bundle once from the store and seed asset. + static Future _hydrate() { + if (_hydrated) return Future.value(); + return _inFlightHydration ??= _hydrateInternal().whenComplete(() { + _hydrated = true; + _inFlightHydration = null; + }); + } + + static Future _hydrateInternal() async { + final projectId = _projectId; + + // Read the persisted bundle (schema mismatch reads as empty). + var stored = await _store.read(projectId); + if (stored != null && stored.projectId != projectId) { + // Stale data from another project: clear it. + Log.w( + 'StacBundleService: Stored bundle belongs to project ' + '${stored.projectId}, expected $projectId — clearing', + ); + await _store.clear(projectId); + stored = null; + } + + // Read the seed asset shipped with the app, when configured. + final seed = await _readSeed(projectId); + + // Highest version wins (an app-store update can ship a seed newer than + // the cached bundle, and vice versa). + if (seed != null && (stored == null || seed.version > stored.version)) { + _bundle = seed; + await _store.write(seed); + Log.d('StacBundleService: Hydrated from seed bundle v${seed.version}'); + } else if (stored != null) { + _bundle = stored; + Log.d('StacBundleService: Hydrated from stored bundle v${stored.version}'); + } + } + + /// Loads and parses the seed bundle asset, tolerating a missing or + /// malformed asset. + static Future _readSeed(String projectId) async { + final seedAsset = _config.seedAsset; + if (seedAsset == null) return null; + + try { + final raw = await _assetReader(seedAsset); + final data = jsonDecode(raw); + if (data is! Map) { + Log.w('StacBundleService: Seed asset $seedAsset is not a JSON object'); + return null; + } + + final map = Map.from(data); + // Seed bundles written by `stac deploy` carry no fetchedAt. + map.putIfAbsent('fetchedAt', () => DateTime.now().toIso8601String()); + + final seed = StacBundle.fromJson(map); + if (seed.projectId != projectId) { + Log.w( + 'StacBundleService: Seed bundle belongs to project ' + '${seed.projectId}, expected $projectId — ignoring', + ); + return null; + } + return seed; + } catch (e) { + // Missing or unreadable seed asset is not an error. + Log.d('StacBundleService: No usable seed bundle at $seedAsset ($e)'); + return null; + } + } + + /// Returns the Stac JSON string for the screen [name] from the bundle, + /// or `null` if the screen is not in the bundle. + static Future getScreenJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.screen(name); + } + + /// Returns the Stac JSON string for the theme [name] from the bundle, + /// or `null` if the theme is not in the bundle. + static Future getThemeJson(String name) async { + final bundle = await ensureLoaded(); + return bundle?.theme(name); + } + + /// Clears the persisted and in-memory bundle for the current project. + static Future clear() async { + _bundle = null; + return _store.clear(_projectId); + } + + /// Resets all static state; for tests only. + @visibleForTesting + static void reset() { + _bundle = null; + _hydrated = false; + _inFlightHydration = null; + _inFlightSync = null; + _dio = _createDio(); + _store = const SharedPreferencesBundleStore(); + _assetReader = rootBundle.loadString; + _updatesController.close(); + _updatesController = StreamController.broadcast(); + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_store.dart b/packages/stac/lib/src/services/stac_bundle_store.dart new file mode 100644 index 00000000..3bfaafeb --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_store.dart @@ -0,0 +1,100 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac_logger/stac_logger.dart'; + +/// Storage abstraction for persisting Stac bundles on-device. +/// +/// The default implementation is [SharedPreferencesBundleStore]; hosts and +/// tests can provide their own implementation via +/// `StacBundleService.store`. +abstract class StacBundleStore { + /// Reads the persisted bundle for [projectId]. + /// + /// Returns `null` when no bundle is stored, when the stored blob cannot + /// be parsed, or when the stored schema version does not match. + Future read(String projectId); + + /// Persists [bundle] for its project. + Future write(StacBundle bundle); + + /// Removes the persisted bundle for [projectId]. + Future clear(String projectId); +} + +/// [StacBundleStore] backed by SharedPreferences. +/// +/// Stores the bundle blob at key `stac_bundle_{projectId}` with an int +/// schema version alongside it; a schema mismatch on read is treated as +/// an empty store. +class SharedPreferencesBundleStore implements StacBundleStore { + /// Creates a [SharedPreferencesBundleStore] instance. + const SharedPreferencesBundleStore(); + + /// Schema version of the persisted bundle blob. + /// + /// Bump this when the [StacBundle] persistence format changes in a + /// backward-incompatible way; older blobs are then treated as empty. + static const int bundleSchemaVersion = 1; + + static String _bundleKey(String projectId) => 'stac_bundle_$projectId'; + + static String _schemaKey(String projectId) => + 'stac_bundle_schema_$projectId'; + + @override + Future read(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + + final schemaVersion = prefs.getInt(_schemaKey(projectId)); + if (schemaVersion != bundleSchemaVersion) { + // Missing or mismatched schema version: treat as empty. + return null; + } + + final blob = prefs.getString(_bundleKey(projectId)); + if (blob == null) { + return null; + } + + return StacBundle.fromJsonString(blob); + } catch (e) { + Log.w('StacBundleStore: Failed to read bundle for $projectId: $e'); + return null; + } + } + + @override + Future write(StacBundle bundle) async { + try { + final prefs = await SharedPreferences.getInstance(); + final wroteBlob = await prefs.setString( + _bundleKey(bundle.projectId), + bundle.toJsonString(), + ); + final wroteSchema = await prefs.setInt( + _schemaKey(bundle.projectId), + bundleSchemaVersion, + ); + return wroteBlob && wroteSchema; + } catch (e) { + Log.w( + 'StacBundleStore: Failed to write bundle for ${bundle.projectId}: $e', + ); + return false; + } + } + + @override + Future clear(String projectId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final removedBlob = await prefs.remove(_bundleKey(projectId)); + final removedSchema = await prefs.remove(_schemaKey(projectId)); + return removedBlob && removedSchema; + } catch (e) { + Log.w('StacBundleStore: Failed to clear bundle for $projectId: $e'); + return false; + } + } +} diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart new file mode 100644 index 00000000..08694d7f --- /dev/null +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; + +/// Keeps the Stac bundle fresh while the app runs. +/// +/// Registered by `StacService.initialize` when bundle mode is enabled. +/// Runs a conditional bundle sync on foreground resume (when +/// `StacBundleConfig.checkOnResume`) and on an optional periodic timer +/// (`StacBundleConfig.pollingInterval`) that only runs while the app is +/// foregrounded (cancelled on pause, restarted on resume). +/// +/// Every trigger is just the cheap conditional GET — a `304` is a no-op +/// and is not billed. +class StacBundleUpdater with WidgetsBindingObserver { + StacBundleUpdater._(); + + static StacBundleUpdater? _instance; + + /// The registered updater instance, if started; for tests only. + @visibleForTesting + static StacBundleUpdater? get instance => _instance; + + Timer? _pollingTimer; + + /// Registers the updater as a lifecycle observer and starts the polling + /// timer when configured. Does nothing if already started. + static void start() { + if (_instance != null) return; + + final updater = StacBundleUpdater._(); + WidgetsBinding.instance.addObserver(updater); + // The app starts foregrounded; arm the polling timer right away. + updater._startPollingTimer(); + _instance = updater; + } + + /// Unregisters the updater and cancels any polling timer. + static void stop() { + final updater = _instance; + if (updater == null) return; + + updater._cancelPollingTimer(); + WidgetsBinding.instance.removeObserver(updater); + _instance = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + if (StacService.bundleConfig.checkOnResume) { + unawaited(StacBundleService.sync()); + } + _startPollingTimer(); + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _cancelPollingTimer(); + case AppLifecycleState.inactive: + // Transient state (e.g. system dialogs): keep the timer running. + break; + } + } + + /// Starts the periodic conditional sync when [StacBundleConfig.pollingInterval] + /// is set and no timer is already running. + void _startPollingTimer() { + final interval = StacService.bundleConfig.pollingInterval; + if (interval == null) return; + + _pollingTimer ??= Timer.periodic(interval, (_) { + unawaited(StacBundleService.sync()); + }); + } + + void _cancelPollingTimer() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } +} diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 283516a2..6184d872 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -3,6 +3,7 @@ import 'package:stac/src/framework/stac_service.dart'; import 'package:stac/src/models/stac_artifact_type.dart'; import 'package:stac/src/models/stac_cache_config.dart'; import 'package:stac/src/models/stac_cache.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; import 'package:stac/src/services/stac_cache_service.dart'; import 'package:stac_logger/stac_logger.dart'; @@ -61,6 +62,24 @@ class StacCloud { throw Exception('StacOptions is not set'); } + // Bundle mode: serve the artifact from the cached bundle (decision 10 — + // renders never hit the network; sync keeps the bundle fresh). + if (StacService.bundleConfig.enabled) { + final bundleResponse = await _fetchArtifactFromBundle( + artifactType: artifactType, + artifactName: artifactName, + ); + if (bundleResponse != null) { + return bundleResponse; + } + // Absent from the bundle (deleted vs. not-yet-bundled is + // indistinguishable): fall through to the legacy per-artifact fetch. + Log.w( + 'StacCloud: ${artifactType.name} $artifactName not found in bundle, ' + 'falling back to per-artifact fetch', + ); + } + final cacheConfig = StacService.defaultCacheConfig; // Handle network-only strategy @@ -284,6 +303,34 @@ class StacCloud { return response; } + /// Fetches an artifact from the cached bundle (bundle mode). + /// + /// Returns a [Response] shaped exactly like [_buildArtifactCacheResponse], + /// or `null` when the artifact is absent from the bundle. + static Future _fetchArtifactFromBundle({ + required StacArtifactType artifactType, + required String artifactName, + }) async { + final stacJson = switch (artifactType) { + StacArtifactType.screen => await StacBundleService.getScreenJson( + artifactName, + ), + StacArtifactType.theme => await StacBundleService.getThemeJson( + artifactName, + ), + }; + if (stacJson == null) return null; + + return Response( + requestOptions: RequestOptions(path: _getFetchUrl(artifactType)), + data: { + 'name': artifactName, + 'stacJson': stacJson, + 'version': StacBundleService.current?.version ?? 0, + }, + ); + } + /// Builds a Response from cached artifact data. static Response _buildArtifactCacheResponse( StacArtifactType artifactType, diff --git a/packages/stac/test/models/stac_bundle_test.dart b/packages/stac/test/models/stac_bundle_test.dart new file mode 100644 index 00000000..d12a2d23 --- /dev/null +++ b/packages/stac/test/models/stac_bundle_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stac/src/models/stac_bundle.dart'; + +void main() { + final bundle = StacBundle( + projectId: 'proj_1', + version: 3, + etag: '"3"', + checksum: 'abc123', + fetchedAt: DateTime.utc(2026, 7, 3, 12), + screens: const { + 'home': '{"type":"text","data":"Home"}', + 'profile': '{"type":"text","data":"Profile"}', + }, + themes: const {'light': '{"brightness":"light"}'}, + ); + + group('StacBundle', () { + test('round-trips through toJson/fromJson', () { + final restored = StacBundle.fromJson(bundle.toJson()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips through toJsonString/fromJsonString', () { + final restored = StacBundle.fromJsonString(bundle.toJsonString()); + + expect(restored.projectId, bundle.projectId); + expect(restored.version, bundle.version); + expect(restored.etag, bundle.etag); + expect(restored.checksum, bundle.checksum); + expect(restored.fetchedAt, bundle.fetchedAt); + expect(restored.screens, bundle.screens); + expect(restored.themes, bundle.themes); + }); + + test('round-trips null etag and checksum', () { + final minimal = StacBundle( + projectId: 'proj_1', + version: 1, + fetchedAt: DateTime.utc(2026), + screens: const {}, + themes: const {}, + ); + + final restored = StacBundle.fromJsonString(minimal.toJsonString()); + + expect(restored.etag, isNull); + expect(restored.checksum, isNull); + expect(restored.screens, isEmpty); + expect(restored.themes, isEmpty); + }); + + test('screen and theme lookups return json or null', () { + expect(bundle.screen('home'), '{"type":"text","data":"Home"}'); + expect(bundle.theme('light'), '{"brightness":"light"}'); + expect(bundle.screen('missing'), isNull); + expect(bundle.theme('missing'), isNull); + }); + + test('copyWith replaces given fields and keeps the rest', () { + final copy = bundle.copyWith(version: 4, etag: '"4"'); + + expect(copy.version, 4); + expect(copy.etag, '"4"'); + expect(copy.projectId, bundle.projectId); + expect(copy.checksum, bundle.checksum); + expect(copy.fetchedAt, bundle.fetchedAt); + expect(copy.screens, bundle.screens); + expect(copy.themes, bundle.themes); + }); + }); +} diff --git a/packages/stac/test/services/stac_bundle_service_test.dart b/packages/stac/test/services/stac_bundle_service_test.dart new file mode 100644 index 00000000..abf20f42 --- /dev/null +++ b/packages/stac/test/services/stac_bundle_service_test.dart @@ -0,0 +1,589 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/framework/stac_service.dart'; +import 'package:stac/src/models/stac_bundle.dart'; +import 'package:stac/src/models/stac_bundle_config.dart'; +import 'package:stac/src/services/stac_bundle_service.dart'; +import 'package:stac/src/services/stac_bundle_store.dart'; +import 'package:stac/src/services/stac_bundle_updater.dart'; +import 'package:stac_core/stac_core.dart'; + +/// Fake [HttpClientAdapter] that records requests and answers via [handler]. +class _FakeHttpClientAdapter implements HttpClientAdapter { + ResponseBody Function(RequestOptions options) handler = (options) { + throw StateError('Unexpected network request to ${options.uri}'); + }; + + final List requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return handler(options); + } + + @override + void close({bool force = false}) {} +} + +/// [StacBundleStore] spy that counts operations and delegates to [inner]. +class _SpyBundleStore implements StacBundleStore { + final StacBundleStore inner = const SharedPreferencesBundleStore(); + int readCount = 0; + int writeCount = 0; + int clearCount = 0; + + @override + Future read(String projectId) { + readCount++; + return inner.read(projectId); + } + + @override + Future write(StacBundle bundle) { + writeCount++; + return inner.write(bundle); + } + + @override + Future clear(String projectId) { + clearCount++; + return inner.clear(projectId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const projectId = 'proj_1'; + + late _FakeHttpClientAdapter adapter; + + Map bundleBody({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return { + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'checksum-v$version', + 'screens': screens ?? {'home': '{"type":"text","data":"v$version"}'}, + 'themes': themes ?? {'light': '{"brightness":"light-v$version"}'}, + }; + } + + ResponseBody jsonResponse( + Map body, { + int statusCode = 200, + Map>? headers, + }) { + return ResponseBody.fromString( + jsonEncode(body), + statusCode, + headers: { + Headers.contentTypeHeader: ['application/json'], + ...?headers, + }, + ); + } + + ResponseBody statusResponse(int statusCode) { + return ResponseBody.fromString('', statusCode); + } + + StacBundle storedBundle({ + String projectId = projectId, + int version = 1, + Map? screens, + Map? themes, + }) { + return StacBundle( + projectId: projectId, + version: version, + etag: '"$version"', + checksum: 'checksum-v$version', + fetchedAt: DateTime(2026, 7, 1), + screens: screens ?? {'home': '{"type":"text","data":"v$version"}'}, + themes: themes ?? {'light': '{"brightness":"light-v$version"}'}, + ); + } + + Future initStac({ + String projectId = projectId, + StacBundleConfig bundleConfig = const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + ), + }) { + return StacService.initialize( + options: StacOptions(name: 'Test', projectId: projectId), + bundleConfig: bundleConfig, + ); + } + + String? headerOf(RequestOptions options, String name) { + for (final entry in options.headers.entries) { + if (entry.key.toLowerCase() == name.toLowerCase()) { + return entry.value?.toString(); + } + } + return null; + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), + }) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + StacBundleUpdater.stop(); + StacBundleService.reset(); + + adapter = _FakeHttpClientAdapter(); + StacBundleService.dio = Dio()..httpClientAdapter = adapter; + }); + + tearDown(() { + StacBundleUpdater.stop(); + StacBundleService.reset(); + }); + + group('sync', () { + test('200 parses, persists and swaps the bundle', () async { + adapter.handler = (options) => jsonResponse( + bundleBody(version: 1), + headers: { + 'etag': ['"1"'], + }, + ); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result, isNotNull); + expect(result!.version, 1); + expect(result.projectId, projectId); + expect(result.etag, '"1"'); + expect(StacBundleService.current?.version, 1); + + // Persisted blob + schema version. + final prefs = await SharedPreferences.getInstance(); + final blob = prefs.getString('stac_bundle_$projectId'); + expect(blob, isNotNull); + expect(StacBundle.fromJsonString(blob!).version, 1); + expect( + prefs.getInt('stac_bundle_schema_$projectId'), + SharedPreferencesBundleStore.bundleSchemaVersion, + ); + + // The first request is unconditional (nothing cached). + final request = adapter.requests.single; + expect(request.uri.queryParameters['projectId'], projectId); + expect(request.uri.queryParameters.containsKey('since'), isFalse); + expect(headerOf(request, 'If-None-Match'), isNull); + + // Lookups now serve from the synced bundle with no further requests. + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect(adapter.requests, hasLength(1)); + }); + + test( + 'sends If-None-Match and since; 304 keeps cache without rewrite', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final result = await StacBundleService.sync(); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(spy.writeCount, 0); + }, + ); + + test('204 keeps cache without rewrite', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + adapter.handler = (options) => statusResponse(204); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(spy.writeCount, 0); + }); + + test('version bump swaps memory and store', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await initStac(); + + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + + final result = await StacBundleService.sync(); + + expect(result?.version, 2); + expect(StacBundleService.current?.version, 2); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v2"}', + ); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 2); + }); + + test('offline sync returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => throw Exception('offline'); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + expect(await StacBundleService.getScreenJson('home'), contains('v1')); + }); + + test('server error status returns the stale bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => + jsonResponse({'error': 'limit'}, statusCode: 403); + await initStac(); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(StacBundleService.current?.version, 1); + }); + + test('updates emits on 200 and not on 304', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final events = []; + final subscription = StacBundleService.updates.listen(events.add); + addTearDown(subscription.cancel); + + adapter.handler = (options) => jsonResponse(bundleBody(version: 2)); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + expect(events.single.version, 2); + + adapter.handler = (options) => statusResponse(304); + await StacBundleService.sync(); + await Future.delayed(Duration.zero); + + expect(events, hasLength(1)); + }); + }); + + group('ensureLoaded', () { + test('concurrent calls dedupe to a single network request', () async { + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final results = await Future.wait([ + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + StacBundleService.ensureLoaded(), + ]); + + expect(adapter.requests, hasLength(1)); + for (final result in results) { + expect(result?.version, 1); + } + }); + + test('hydrated cache lookups make zero network calls', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"v1"}', + ); + expect( + await StacBundleService.getThemeJson('light'), + '{"brightness":"light-v1"}', + ); + expect(adapter.requests, isEmpty); + }); + + test('screen absent from the bundle returns null', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.getScreenJson('deleted'), isNull); + expect(await StacBundleService.getThemeJson('deleted'), isNull); + expect(adapter.requests, isEmpty); + }); + + test('stored bundle for another project is cleared', () async { + // A blob under this project's key that claims another projectId. + SharedPreferences.setMockInitialValues({ + 'stac_bundle_proj_2': storedBundle( + projectId: projectId, + version: 5, + ).toJsonString(), + 'stac_bundle_schema_proj_2': + SharedPreferencesBundleStore.bundleSchemaVersion, + }); + adapter.handler = (options) => + jsonResponse(bundleBody(projectId: 'proj_2', version: 1)); + await initStac(projectId: 'proj_2'); + + final result = await StacBundleService.ensureLoaded(); + + // The mismatched bundle was discarded, so the request is unconditional. + final request = adapter.requests.single; + expect(request.uri.queryParameters.containsKey('since'), isFalse); + + expect(result?.projectId, 'proj_2'); + expect(result?.version, 1); + + final persisted = await const SharedPreferencesBundleStore().read( + 'proj_2', + ); + expect(persisted?.projectId, 'proj_2'); + expect(persisted?.version, 1); + }); + + test('schema version mismatch reads as empty', () async { + SharedPreferences.setMockInitialValues({ + 'stac_bundle_$projectId': storedBundle(version: 5).toJsonString(), + 'stac_bundle_schema_$projectId': 999, + }); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(); + + final result = await StacBundleService.ensureLoaded(); + + expect(adapter.requests, hasLength(1)); + expect(result?.version, 1); + }); + }); + + group('seed hydration', () { + const seedConfig = StacBundleConfig( + enabled: true, + prefetchOnInit: false, + seedAsset: 'assets/stac_bundle.json', + ); + + /// Seed bundle body as written by `stac deploy` (no fetchedAt). + String seedJson({int version = 3, String projectId = projectId}) { + return jsonEncode({ + 'projectId': projectId, + 'version': version, + 'etag': '"$version"', + 'checksum': 'seed-checksum', + 'screens': {'home': '{"type":"text","data":"seed-v$version"}'}, + 'themes': {'light': '{"brightness":"seed"}'}, + }); + } + + test('empty store hydrates from the seed asset offline', () async { + StacBundleService.assetReader = (path) async { + expect(path, 'assets/stac_bundle.json'); + return seedJson(version: 3); + }; + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + // No network call was made at any point. + expect(adapter.requests, isEmpty); + + // The seed won, so it was persisted for the next launch. + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + expect(persisted?.etag, '"3"'); + }); + + test('newer stored bundle wins over an older seed', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 2), + ); + final spy = _SpyBundleStore(); + StacBundleService.store = spy; + StacBundleService.assetReader = (path) async => seedJson(version: 1); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 2); + expect(await StacBundleService.getScreenJson('home'), contains('v2')); + expect(spy.writeCount, 0); + expect(adapter.requests, isEmpty); + }); + + test('newer seed wins over an older stored bundle and is persisted', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 1), + ); + StacBundleService.assetReader = (path) async => seedJson(version: 3); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + expect(adapter.requests, isEmpty); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + }); + + test('missing seed asset is tolerated', () async { + StacBundleService.assetReader = (path) async { + throw FlutterError('Unable to load asset: $path'); + }; + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + // Falls back to a network sync as if no seed were configured. + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + + test('seed for another project is ignored', () async { + StacBundleService.assetReader = (path) async => + seedJson(version: 9, projectId: 'other_project'); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 1); + expect(adapter.requests, hasLength(1)); + }); + }); + + group('StacBundleUpdater', () { + test('resume triggers a conditional sync', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac(); + + final updater = StacBundleUpdater.instance; + expect(updater, isNotNull); + + updater!.didChangeAppLifecycleState(AppLifecycleState.resumed); + await pumpUntil(() => adapter.requests.isNotEmpty); + + final request = adapter.requests.single; + expect(request.uri.queryParameters['since'], '1'); + expect(headerOf(request, 'If-None-Match'), '"1"'); + }); + + test('resume does not sync when checkOnResume is off', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + ), + ); + + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.resumed, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(adapter.requests, isEmpty); + }); + + test('polling interval triggers conditional syncs and pause cancels them', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + pollingInterval: Duration(milliseconds: 20), + ), + ); + + await pumpUntil(() => adapter.requests.isNotEmpty); + expect(adapter.requests, isNotEmpty); + + // Pausing cancels the timer: no further requests come in. + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.paused, + ); + await Future.delayed(const Duration(milliseconds: 30)); + final countAfterPause = adapter.requests.length; + await Future.delayed(const Duration(milliseconds: 60)); + expect(adapter.requests.length, countAfterPause); + }); + }); + + group('clear', () { + test('removes the persisted and in-memory bundle', () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + expect(await StacBundleService.ensureLoaded(), isNotNull); + + await StacBundleService.clear(); + + expect(StacBundleService.current, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('stac_bundle_$projectId'), isNull); + expect(prefs.getInt('stac_bundle_schema_$projectId'), isNull); + }); + }); +} diff --git a/packages/stac_cli/CHANGELOG.md b/packages/stac_cli/CHANGELOG.md index 3f8a9ad3..9d6c6c4f 100644 --- a/packages/stac_cli/CHANGELOG.md +++ b/packages/stac_cli/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.7.0 + +- feat: Bundle deploys — `stac deploy` now publishes all screens and themes in a single atomic `POST /bundles` request (any failure exits non-zero with nothing partially applied). +- feat: Write a seed bundle to `assets/stac_bundle.json` after every successful deploy so first app launches can hydrate instantly and offline (warns when the asset is not declared in pubspec.yaml). +- feat: Add `--legacy` flag to `stac deploy` to fall back to the previous per-file screen/theme uploads. +- fix: `stac build` clears previous `screens`/`themes` outputs before writing, so deleted screens no longer leave stale JSON behind that gets re-deployed. + ## 1.6.0 - feat: Add console URL logging for successful deployments. diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart index 5d294f16..18435eec 100644 --- a/packages/stac_cli/lib/src/commands/deploy_command.dart +++ b/packages/stac_cli/lib/src/commands/deploy_command.dart @@ -31,12 +31,19 @@ class DeployCommand extends BaseCommand { help: 'Skip building before deployment (deploy existing files)', negatable: false, ); + argParser.addFlag( + 'legacy', + help: + 'Use the legacy per-file screen/theme uploads instead of the atomic bundle deploy', + negatable: false, + ); } @override Future execute() async { final projectPath = argResults?['project'] as String?; final skipBuild = argResults?['skip-build'] as bool? ?? false; + final legacy = argResults?['legacy'] as bool? ?? false; try { // Build before deploying unless --skip-build is specified @@ -58,7 +65,7 @@ class DeployCommand extends BaseCommand { } // Deploy the built files - await _deployService.deploy(projectPath: projectPath); + await _deployService.deploy(projectPath: projectPath, legacy: legacy); return 0; } catch (e) { ConsoleLogger.error('Deployment failed: $e'); diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart index c9bd20b6..01babeb0 100644 --- a/packages/stac_cli/lib/src/services/build_service.dart +++ b/packages/stac_cli/lib/src/services/build_service.dart @@ -30,10 +30,13 @@ class BuildService { final outputDirPath = path.join(projectDir, options.outputDir); await Directory(outputDirPath).create(recursive: true); - // Clear the output directory before generating new files - await _clearOutputDirectory(outputDirPath); + // Clear previous screen/theme outputs before generating new files so a + // deleted DSL screen or theme can't leave a stale JSON behind that gets + // re-deployed with every bundle. Only these two directories are cleared. final screensOutputDir = path.join(outputDirPath, 'screens'); final themesOutputDir = path.join(outputDirPath, 'themes'); + await _clearOutputDirectory(screensOutputDir); + await _clearOutputDirectory(themesOutputDir); await Directory(screensOutputDir).create(recursive: true); await Directory(themesOutputDir).create(recursive: true); diff --git a/packages/stac_cli/lib/src/services/deploy_service.dart b/packages/stac_cli/lib/src/services/deploy_service.dart index b7420f5a..a6089acf 100644 --- a/packages/stac_cli/lib/src/services/deploy_service.dart +++ b/packages/stac_cli/lib/src/services/deploy_service.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import '../config/env.dart'; @@ -10,12 +13,26 @@ import '../utils/http_client.dart'; /// Service for deploying Stac JSON files to the cloud class DeployService { - final HttpClientService _httpClient = HttpClientService.instance; + /// Creates a deploy service. + /// + /// [httpClient] is injectable for testing; when omitted the shared + /// [HttpClientService.instance] is used (resolved lazily so constructing + /// the service never touches the network configuration). + DeployService({HttpClientService? httpClient}) + : _httpClientOverride = httpClient; - /// Deploy all built JSON files (from stac/.build) to the Screens API - /// - Reads projectId from lib/default_stac_options.dart - /// - For each {name}.json in stac/.build, POST to Cloud Function "screens" - Future deploy({String? projectPath}) async { + final HttpClientService? _httpClientOverride; + + HttpClientService get _httpClient => + _httpClientOverride ?? HttpClientService.instance; + + /// Deploy all built JSON files (from stac/.build) to the cloud. + /// + /// Default: assembles all screens and themes into a single bundle and + /// publishes it atomically with one POST to the Bundles API — any failure + /// leaves the project untouched. Pass [legacy] to use the previous + /// per-file `/screens` + `/themes` uploads instead. + Future deploy({String? projectPath, bool legacy = false}) async { final projectDir = projectPath ?? Directory.current.path; // Read projectId from default_stac_options.dart @@ -35,6 +52,266 @@ class DeployService { ); } + if (legacy) { + await _deployLegacy(projectId: projectId, buildDirPath: buildDirPath); + return; + } + + await _deployBundle( + projectDir: projectDir, + projectId: projectId, + buildDirPath: buildDirPath, + ); + } + + /// Computes the advisory sha256 checksum for a bundle. + /// + /// The digest is taken over a deterministic JSON document — + /// `{projectId, screens, themes}` with fixed top-level key order and + /// screen/theme map keys sorted — so the result is stable regardless of + /// map insertion order. The server recomputes and owns the authoritative + /// checksum; this value is advisory (used for the server-side no-op check) + /// and a mismatch alone never fails a deploy. + static String computeBundleChecksum({ + required String projectId, + required Map screens, + required Map themes, + }) { + Map sortByKey(Map map) { + final keys = map.keys.toList()..sort(); + return {for (final key in keys) key: map[key]!}; + } + + final canonicalJson = jsonEncode({ + 'projectId': projectId, + 'screens': sortByKey(screens), + 'themes': sortByKey(themes), + }); + return sha256.convert(utf8.encode(canonicalJson)).toString(); + } + + /// Publish all built screens/themes as a single atomic bundle. + Future _deployBundle({ + required String projectDir, + required String projectId, + required String buildDirPath, + }) async { + ConsoleLogger.info('Deploying bundle to cloud...'); + ConsoleLogger.debug('Project ID: $projectId'); + + final screens = await _readArtifactDirectory( + path.join(buildDirPath, 'screens'), + label: 'screen', + ); + final themes = await _readArtifactDirectory( + path.join(buildDirPath, 'themes'), + label: 'theme', + ); + + if (screens.isEmpty && themes.isEmpty) { + throw StacException( + 'No built screens or themes found in $buildDirPath. Run "stac build" first.', + ); + } + + final checksum = computeBundleChecksum( + projectId: projectId, + screens: screens, + themes: themes, + ); + + final bundlesApiUrl = _resolveBundlesApiUrl(); + ConsoleLogger.debug('Bundles API: $bundlesApiUrl'); + + final payload = { + 'projectId': projectId, + 'checksum': checksum, + 'screens': screens, + 'themes': themes, + }; + final payloadBytes = utf8.encode(jsonEncode(payload)).length; + ConsoleLogger.info( + 'Bundle contents: ${screens.length} screen(s), ${themes.length} theme(s) — payload ${_formatBytes(payloadBytes)} ($payloadBytes bytes)', + ); + + Response response; + try { + response = await _httpClient.post(bundlesApiUrl, data: payload); + } on StacException { + rethrow; + } catch (e) { + throw StacException('Bundle deploy failed: $e'); + } + + final status = response.statusCode; + if (status != 200 && status != 201) { + throw StacException( + 'Bundle deploy failed: unexpected response (${status ?? 'no-status'}) from $bundlesApiUrl.', + ); + } + + final body = _decodeResponseBody(response.data); + final version = body['version']; + + // The server's checksum is authoritative — ours is advisory only. + final serverChecksum = body['checksum']?.toString(); + if (serverChecksum != null && serverChecksum != checksum) { + ConsoleLogger.debug( + 'Local checksum ($checksum) differs from server checksum ($serverChecksum). Using the server value.', + ); + } + + if (status == 201) { + ConsoleLogger.success('✓ Deployed bundle v$version'); + } else { + ConsoleLogger.info('No changes — bundle v$version already current'); + } + + await _writeSeedAsset( + projectDir: projectDir, + projectId: projectId, + responseBody: body, + screens: screens, + themes: themes, + ); + + final consoleUrl = 'https://console.stac.dev/project/$projectId'; + ConsoleLogger.info( + 'Open your project in the Stac Console to inspect your screens and themes: $consoleUrl', + ); + } + + /// Read every `*.json` file in [dirPath] into a map keyed by the file name + /// without its `.json` extension. + Future> _readArtifactDirectory( + String dirPath, { + required String label, + }) async { + final artifacts = {}; + final dir = Directory(dirPath); + if (!await dir.exists()) { + ConsoleLogger.debug('No $label output found at $dirPath. Skipping.'); + return artifacts; + } + + await for (final entity in dir.list()) { + if (entity is! File || !entity.path.endsWith('.json')) continue; + final name = path.basenameWithoutExtension(entity.path); + artifacts[name] = await entity.readAsString(); + } + return artifacts; + } + + /// Write the just-published bundle body to `/assets/stac_bundle.json` + /// so first app launches can hydrate instantly (and offline) from the seed. + /// + /// `version`/`etag`/`checksum` come from the deploy response — the server + /// is authoritative; a locally invented version is never written. + Future _writeSeedAsset({ + required String projectDir, + required String projectId, + required Map responseBody, + required Map screens, + required Map themes, + }) async { + final assetsDirPath = path.join(projectDir, 'assets'); + final seedPath = path.join(assetsDirPath, 'stac_bundle.json'); + + try { + await Directory(assetsDirPath).create(recursive: true); + final seed = { + 'projectId': projectId, + 'version': responseBody['version'], + 'etag': responseBody['etag'], + 'checksum': responseBody['checksum'], + 'screens': screens, + 'themes': themes, + }; + await File(seedPath).writeAsString(jsonEncode(seed)); + ConsoleLogger.info( + 'Seed bundle written to ${path.relative(seedPath, from: projectDir)}', + ); + } catch (e) { + // The bundle is already live server-side; a seed write failure must not + // turn a successful deploy into a failure — warn and move on. + ConsoleLogger.warning('Could not write seed bundle to $seedPath: $e'); + return; + } + + await _warnIfSeedAssetNotDeclared(projectDir); + } + + /// Warn when `assets/stac_bundle.json` is not declared under + /// `flutter/assets` in the app's pubspec.yaml. Never modifies the pubspec. + Future _warnIfSeedAssetNotDeclared(String projectDir) async { + const assetEntry = 'assets/stac_bundle.json'; + final pubspecPath = path.join(projectDir, 'pubspec.yaml'); + + var declared = false; + try { + final pubspec = await FileUtils.readYamlFile(pubspecPath); + final flutterSection = pubspec?['flutter']; + if (flutterSection is Map) { + final assets = flutterSection['assets']; + if (assets is List) { + declared = assets.any((entry) { + final value = entry?.toString().trim(); + return value == assetEntry || + value == 'assets/' || + value == 'assets'; + }); + } + } + } catch (e) { + ConsoleLogger.debug('Could not parse $pubspecPath: $e'); + } + + if (declared) return; + ConsoleLogger.warning( + 'The seed bundle is not declared as a Flutter asset in pubspec.yaml.\n' + ' Add it so first launches can render instantly (and offline):\n' + ' flutter:\n' + ' assets:\n' + ' - $assetEntry', + ); + } + + Map _decodeResponseBody(dynamic data) { + try { + dynamic decoded = data; + if (decoded is String && decoded.isNotEmpty) { + decoded = jsonDecode(decoded); + } + if (decoded is Map) { + final map = Map.from(decoded); + // Tolerate `{..., data: {...}}` envelopes. + if (map['version'] == null && map['data'] is Map) { + return Map.from(map['data'] as Map); + } + return map; + } + } catch (e) { + ConsoleLogger.debug('Could not parse bundle deploy response body: $e'); + } + return const {}; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + + // --------------------------------------------------------------------- + // Legacy per-file deploy (kept behind `stac deploy --legacy` for one + // release, then delete). + // --------------------------------------------------------------------- + + /// Deploy each built JSON file one at a time to the Screens/Themes APIs. + Future _deployLegacy({ + required String projectId, + required String buildDirPath, + }) async { ConsoleLogger.info('Deploying screens/themes to cloud...'); ConsoleLogger.debug('Project ID: $projectId'); @@ -182,6 +459,11 @@ class DeployService { return match?.group(1); } + /// Resolve Cloud Function endpoint for bundles.save + String _resolveBundlesApiUrl() { + return '${env.baseApiUrl}/bundles'; + } + /// Resolve Cloud Function endpoint for screens.save String _resolveScreensApiUrl() { // Use current environment's base URL + /screens endpoint diff --git a/packages/stac_cli/pubspec.lock b/packages/stac_cli/pubspec.lock index 4fe9fd6b..3bc09704 100644 --- a/packages/stac_cli/pubspec.lock +++ b/packages/stac_cli/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "1.15.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/packages/stac_cli/pubspec.yaml b/packages/stac_cli/pubspec.yaml index 89c523dd..0ee8d6c3 100644 --- a/packages/stac_cli/pubspec.yaml +++ b/packages/stac_cli/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: json_annotation: ^4.11.0 dotenv: ^4.2.0 cryptography: ^2.9.0 + crypto: ^3.0.3 # Executables that can be run globally executables: diff --git a/packages/stac_cli/test/services/deploy_service_test.dart b/packages/stac_cli/test/services/deploy_service_test.dart new file mode 100644 index 00000000..3123f61c --- /dev/null +++ b/packages/stac_cli/test/services/deploy_service_test.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:path/path.dart' as p; +import 'package:stac_cli/src/config/env.dart'; +import 'package:stac_cli/src/exceptions/stac_exception.dart'; +import 'package:stac_cli/src/services/deploy_service.dart'; +import 'package:stac_cli/src/utils/http_client.dart'; +import 'package:test/test.dart'; + +const _projectId = 'test-project'; + +/// Hand-rolled fake for [HttpClientService]; records every POST and answers +/// with the injected handler. +class _FakeHttpClientService implements HttpClientService { + _FakeHttpClientService(this._onPost); + + final Future> Function(String url, dynamic data) _onPost; + + final List<({String url, dynamic data})> postCalls = []; + + @override + Future> post(String path, {dynamic data}) async { + postCalls.add((url: path, data: data)); + return _onPost(path, data); + } + + @override + Future> get( + String path, { + Map? queryParameters, + }) => throw UnimplementedError('GET is not expected in these tests'); + + @override + Future> put(String path, {dynamic data}) => + throw UnimplementedError('PUT is not expected in these tests'); + + @override + Future> delete(String path) => + throw UnimplementedError('DELETE is not expected in these tests'); +} + +Response _jsonResponse( + String url, + int statusCode, + Map body, +) { + return Response( + requestOptions: RequestOptions(path: url), + statusCode: statusCode, + data: body, + ); +} + +/// Creates a temporary fixture project with `lib/default_stac_options.dart` +/// and a populated `stac/.build` directory. Returns the project path. +Future _createFixtureProject({ + Map screens = const {}, + Map themes = const {}, +}) async { + final dir = await Directory.systemTemp.createTemp('stac_cli_deploy_test_'); + final projectDir = dir.path; + + final optionsFile = File( + p.join(projectDir, 'lib', 'default_stac_options.dart'), + ); + await optionsFile.create(recursive: true); + await optionsFile.writeAsString(''' +const defaultStacOptions = StacOptions( + name: 'Test', + description: 'Test project', + projectId: '$_projectId', + sourceDir: 'stac', + outputDir: 'stac/.build', +); +'''); + + await Directory(p.join(projectDir, 'stac', '.build')).create(recursive: true); + for (final entry in screens.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'screens', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + for (final entry in themes.entries) { + final file = File( + p.join(projectDir, 'stac', '.build', 'themes', '${entry.key}.json'), + ); + await file.create(recursive: true); + await file.writeAsString(entry.value); + } + + addTearDown(() => dir.delete(recursive: true)); + return projectDir; +} + +void main() { + const screensFixture = { + 'home': '{"type":"scaffold","body":{"type":"text","data":"Home"}}', + 'profile': '{"type":"scaffold","body":{"type":"text","data":"Profile"}}', + }; + const themesFixture = {'light': '{"brightness":"light"}'}; + + setUpAll(() { + // Values are only used as fallbacks when the real variables are absent + // from the process environment (configureEnvironment keeps OS env wins). + configureEnvironment({ + 'STAC_BASE_API_URL': 'https://api.stac.test', + 'STAC_GOOGLE_CLIENT_ID': 'test-client-id', + 'STAC_FIREBASE_API_KEY': 'test-firebase-key', + }); + }); + + group('DeployService.deploy (bundle mode)', () { + test( + 'sends a single POST to /bundles with correctly assembled maps', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 3, + 'etag': '"3"', + 'checksum': 'server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final call = client.postCalls.single; + expect(call.url, '${env.baseApiUrl}/bundles'); + + final payload = call.data as Map; + expect(payload['projectId'], _projectId); + expect(payload['screens'], equals(screensFixture)); + expect(payload['themes'], equals(themesFixture)); + expect( + payload['checksum'], + DeployService.computeBundleChecksum( + projectId: _projectId, + screens: screensFixture, + themes: themesFixture, + ), + ); + }, + ); + + test('writes the seed asset using the server response version', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 7, + 'etag': '"7"', + 'checksum': 'authoritative-server-checksum', + 'deployedAt': '2026-07-03T00:00:00.000Z', + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + expect(seedFile.existsSync(), isTrue); + + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['projectId'], _projectId); + expect(seed['version'], 7); + expect(seed['etag'], '"7"'); + expect(seed['checksum'], 'authoritative-server-checksum'); + expect(seed['screens'], equals(screensFixture)); + expect(seed['themes'], equals(themesFixture)); + }); + + test( + 'treats a 200 noop response as success and refreshes the seed', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 200, { + 'projectId': _projectId, + 'version': 5, + 'etag': '"5"', + 'checksum': 'unchanged-checksum', + 'noop': true, + }), + ); + + await DeployService(httpClient: client).deploy(projectPath: projectDir); + + expect(client.postCalls, hasLength(1)); + final seedFile = File(p.join(projectDir, 'assets', 'stac_bundle.json')); + final seed = + jsonDecode(await seedFile.readAsString()) as Map; + expect(seed['version'], 5); + }, + ); + + test('throws StacException on a non-2xx response', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => + _jsonResponse(url, 500, {'error': 'internal error'}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + + // Atomic: nothing was applied, so no seed asset is written either. + expect( + File(p.join(projectDir, 'assets', 'stac_bundle.json')).existsSync(), + isFalse, + ); + }); + + test('propagates HTTP-layer failures as StacException', () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => throw StacException( + 'HTTP request failed (503) for $url: service unavailable', + ), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + }); + + test('throws StacException when there is nothing to deploy', () async { + final projectDir = await _createFixtureProject(); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, const {}), + ); + + await expectLater( + DeployService(httpClient: client).deploy(projectPath: projectDir), + throwsA(isA()), + ); + expect(client.postCalls, isEmpty); + }); + }); + + group('DeployService.computeBundleChecksum', () { + test('is stable across map insertion orders', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1', 'b': '2'}, + themes: {'x': '9', 'y': '8'}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'b': '2', 'a': '1'}, + themes: {'y': '8', 'x': '9'}, + ); + expect(a, b); + }); + + test('changes when content changes', () { + final a = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '1'}, + themes: {}, + ); + final b = DeployService.computeBundleChecksum( + projectId: _projectId, + screens: {'a': '2'}, + themes: {}, + ); + final c = DeployService.computeBundleChecksum( + projectId: 'other-project', + screens: {'a': '1'}, + themes: {}, + ); + expect(a, isNot(b)); + expect(a, isNot(c)); + }); + }); +} From e24e705602011d958dacb532945b5349d2e65688 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 15 Jul 2026 17:08:11 +0530 Subject: [PATCH 2/7] fix(bundles): ensure widgets binding before registering the bundle updater Stac.initialize runs before runApp in most apps, so WidgetsBinding.instance may not exist when bundle mode registers its lifecycle observer. Create the binding defensively (idempotent) instead of requiring every app to add WidgetsFlutterBinding.ensureInitialized before Stac.initialize. --- packages/stac/lib/src/services/stac_bundle_updater.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart index 08694d7f..7d4dfa87 100644 --- a/packages/stac/lib/src/services/stac_bundle_updater.dart +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -30,6 +30,11 @@ class StacBundleUpdater with WidgetsBindingObserver { static void start() { if (_instance != null) return; + // Stac.initialize is typically called before runApp, so the widgets + // binding may not exist yet; create it before registering a lifecycle + // observer (idempotent). Also required for seed-asset loading. + WidgetsFlutterBinding.ensureInitialized(); + final updater = StacBundleUpdater._(); WidgetsBinding.instance.addObserver(updater); // The app starts foregrounded; arm the polling timer right away. From efcbf4bfa7732fc1ac898c8accbfb3d00fe98cf9 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 21 Jul 2026 17:37:37 +0530 Subject: [PATCH 3/7] test(bundles): commit shared flexbuffers cross-language fixture (deferred migration prep) --- packages/stac/test/fixtures/bundle_fixture.json | 13 +++++++++++++ .../stac/test/fixtures/bundle_fixture_node.flx | Bin 0 -> 407 bytes 2 files changed, 13 insertions(+) create mode 100644 packages/stac/test/fixtures/bundle_fixture.json create mode 100644 packages/stac/test/fixtures/bundle_fixture_node.flx diff --git a/packages/stac/test/fixtures/bundle_fixture.json b/packages/stac/test/fixtures/bundle_fixture.json new file mode 100644 index 00000000..ceb39d22 --- /dev/null +++ b/packages/stac/test/fixtures/bundle_fixture.json @@ -0,0 +1,13 @@ +{ + "projectId": "fixture-project", + "version": 7, + "etag": "\"7\"", + "checksum": "abc123def456", + "screens": { + "home": "{\"type\":\"scaffold\",\"body\":{\"type\":\"text\",\"data\":\"Héllo — ünïcode ✓ \\\"quoted\\\"\"}}", + "settings": "{\"type\":\"scaffold\",\"body\":{\"type\":\"column\",\"children\":[{\"type\":\"text\",\"data\":\"emoji 🎬🍿\"}]}}" + }, + "themes": { + "dark": "{\"colorScheme\":{\"brightness\":\"dark\",\"primary\":\"#FFB300\"}}" + } +} diff --git a/packages/stac/test/fixtures/bundle_fixture_node.flx b/packages/stac/test/fixtures/bundle_fixture_node.flx new file mode 100644 index 0000000000000000000000000000000000000000..2ff6ba569626232f67ee37d3a6c881d6962fad17 GIT binary patch literal 407 zcmZ{fze)o^5Qk?^(Iz0sJ;1R>D}`wMgV%@_LF*KPiQr~;?rxL2+jDm#2_fJMXrrZ& zS`d5z!6Kc$MV=s!;2uc8!uFe)-#0&=An%!e1{K!fyL)wk(f+|9pk*;E(IX0lR$b)w zLcm%i2ez+q=NEzD8ZkGJs0`t9MD4IZ8dAeq6sc6uZQ4;{NW;a%;=np8m}PG7eEOQE zDwy3r2h)#y`p%WWVD|D9wCQbMSrlzb$73*Pt;}NsY?1T7fvdEi<<7wqnF@_L4O@$| zzk1A6R|fOR Date: Wed, 22 Jul 2026 15:06:00 +0530 Subject: [PATCH 4/7] fix(bundles): address code-review findings - key in-memory bundle state by projectId; a re-initialize with a different project drops the old bundle (epoch-guarded, so in-flight syncs can't resurrect stale state) - clear() re-hydrates the seed and discards racing in-flight syncs - sync(force: true) queues behind an in-flight sync instead of being silently downgraded to its conditional result - back off ensureLoaded's auto-sync for 30s after a failure and stop falling through to legacy per-screen fetches when the bundles endpoint rejected the project (404/403); memoize missing-artifact warnings per bundle version - stop the updater when re-initialized with bundle mode disabled; re-arm the polling timer on config changes; sync without options returns null instead of throwing on resume/poll ticks - mark hydration complete only on success; surface store write failures and non-network sync errors at warn level - normalize trailing slash in baseUrl - CLI: skip the seed write (loudly) when the deploy response carries no version; fix bare `assets` pubspec false-negative; correct the 1.7.0 changelog wording Adds 16 tests covering each fixed behavior. --- .../stac/lib/src/framework/stac_service.dart | 4 + .../lib/src/services/stac_bundle_service.dart | 237 +++++++++- .../lib/src/services/stac_bundle_updater.dart | 18 +- .../stac/lib/src/services/stac_cloud.dart | 62 ++- .../services/stac_bundle_service_test.dart | 422 ++++++++++++++++-- packages/stac_cli/CHANGELOG.md | 2 +- .../lib/src/services/deploy_service.dart | 47 +- .../test/services/deploy_service_test.dart | 105 +++++ 8 files changed, 804 insertions(+), 93 deletions(-) diff --git a/packages/stac/lib/src/framework/stac_service.dart b/packages/stac/lib/src/framework/stac_service.dart index d272a3ee..363d5534 100644 --- a/packages/stac/lib/src/framework/stac_service.dart +++ b/packages/stac/lib/src/framework/stac_service.dart @@ -204,6 +204,10 @@ class StacService { if (_bundleConfig.prefetchOnInit && options != null) { unawaited(StacBundleService.sync()); } + } else { + // A re-initialize can turn bundle mode off; make sure a previously + // started updater stops observing and polling. + StacBundleUpdater.stop(); } _parsers.addAll(parsers); _actionParsers.addAll(actionParsers); diff --git a/packages/stac/lib/src/services/stac_bundle_service.dart b/packages/stac/lib/src/services/stac_bundle_service.dart index 33937716..37414278 100644 --- a/packages/stac/lib/src/services/stac_bundle_service.dart +++ b/packages/stac/lib/src/services/stac_bundle_service.dart @@ -24,6 +24,10 @@ typedef StacBundleAssetReader = Future Function(String assetPath); class StacBundleService { const StacBundleService._(); + /// How long [ensureLoaded] waits after a failed sync before triggering + /// another automatic sync. Explicit [sync] calls are never backed off. + static const Duration _syncFailureBackoff = Duration(seconds: 30); + static Dio _dio = _createDio(); static Dio _createDio() { @@ -49,8 +53,13 @@ class StacBundleService { /// Overrides the asset reader used for seed bundle hydration. @visibleForTesting - static set assetReader(StacBundleAssetReader reader) => - _assetReader = reader; + static set assetReader(StacBundleAssetReader reader) => _assetReader = reader; + + static DateTime Function() _now = DateTime.now; + + /// Overrides the clock used for sync-failure backoff; for tests only. + @visibleForTesting + static set clock(DateTime Function() clock) => _now = clock; /// The bundle currently held in memory, if any. static StacBundle? _bundle; @@ -61,6 +70,36 @@ class StacBundleService { /// Whether hydration (store + seed asset) has completed. static bool _hydrated = false; + /// The projectId the in-memory state ([_bundle], [_hydrated], failure + /// tracking) belongs to. A re-initialize with a different project drops + /// all of it (see [_ensureProject]). + static String? _activeProjectId; + + /// Generation counter, bumped by [clear] and by a projectId switch. + /// In-flight hydrations/syncs capture it at start and discard their + /// results when it has changed by the time they complete. + static int _epoch = 0; + + /// When the last sync failed (non-200/304/204 status, malformed body, or + /// exception); cleared by any successful sync. Drives the [ensureLoaded] + /// backoff. + static DateTime? _lastSyncFailureAt; + + /// Whether the last sync failure was a project-level rejection + /// (HTTP 403/404 from the bundles endpoint). + static bool _projectRejected = false; + + /// Whether the last bundle sync failed because the bundles endpoint + /// rejected this project (HTTP 403/404). + /// + /// Internal signal for `StacCloud`: when no bundle exists and the project + /// was rejected, the legacy per-artifact fallback is skipped so failed + /// renders don't hammer the per-artifact endpoints. + static bool get lastSyncProjectRejected => _projectRejected; + + /// Whether the missing-options warning has been logged already. + static bool _warnedMissingOptions = false; + /// In-flight hydration, deduped across concurrent callers. static Future? _inFlightHydration; @@ -85,6 +124,54 @@ class StacBundleService { return options.projectId; } + /// The bundles endpoint URL, with any trailing slash on the configured + /// base URL normalized away. + static String get _bundlesUrl { + var baseUrl = _config.baseUrl; + while (baseUrl.endsWith('/')) { + baseUrl = baseUrl.substring(0, baseUrl.length - 1); + } + return '$baseUrl/bundles'; + } + + /// Drops all in-memory bundle state and invalidates any in-flight + /// hydration or sync (their results are discarded via the epoch guard). + static void _invalidate() { + _epoch++; + _bundle = null; + _hydrated = false; + _inFlightHydration = null; + _inFlightSync = null; + _lastSyncFailureAt = null; + _projectRejected = false; + } + + /// Re-validates the in-memory state against the current + /// `StacService.options.projectId`; a switch (re-initialize with another + /// project) drops the old project's bundle so it is never served. + static void _ensureProject() { + final projectId = _projectId; + if (_activeProjectId == projectId) return; + if (_activeProjectId != null) { + Log.d( + 'StacBundleService: Project switched from $_activeProjectId to ' + '$projectId — dropping in-memory bundle state', + ); + _invalidate(); + } + _activeProjectId = projectId; + } + + static void _recordSyncFailure({required bool projectRejected}) { + _lastSyncFailureAt = _now(); + _projectRejected = projectRejected; + } + + static void _clearSyncFailure() { + _lastSyncFailureAt = null; + _projectRejected = false; + } + /// Runs a conditional bundle version check against the server. /// /// Sends `GET {baseUrl}/bundles?projectId=..&since=` with @@ -93,24 +180,57 @@ class StacBundleService { /// current bundle; errors (offline, server failures) return the stale /// bundle. Concurrent calls are deduped onto a single in-flight request. /// + /// Returns `null` (with a single warning) when `Stac.initialize` was + /// called without options — unawaited resume/poll triggers must never + /// throw. + /// /// Set [force] to skip the conditional headers and re-download the body - /// unconditionally. + /// unconditionally. A forced call made while a conditional sync is in + /// flight runs after it completes instead of being coalesced into it. static Future sync({bool force = false}) { - return _inFlightSync ??= _syncInternal(force: force).whenComplete(() { - _inFlightSync = null; + if (StacService.options == null) { + if (!_warnedMissingOptions) { + _warnedMissingOptions = true; + Log.w( + 'StacBundleService: Skipping bundle sync — StacOptions is not set', + ); + } + return Future.value(); + } + + final inFlight = _inFlightSync; + if (inFlight != null) { + if (!force) return inFlight; + // Do not silently downgrade a forced sync into the in-flight + // conditional one: chain it after the in-flight sync completes. + return inFlight.then( + (_) => sync(force: true), + onError: (Object _) => sync(force: true), + ); + } + + late final Future future; + future = _syncInternal(force: force).whenComplete(() { + // clear()/project switches may have already detached this future. + if (identical(_inFlightSync, future)) _inFlightSync = null; }); + _inFlightSync = future; + return future; } static Future _syncInternal({required bool force}) async { + _ensureProject(); final projectId = _projectId; + final epoch = _epoch; // Hydrate first so the request can be conditional on the cached version. await _hydrate(); + if (epoch != _epoch) return null; final cached = _bundle; try { final response = await _dio.get( - '${_config.baseUrl}/bundles', + _bundlesUrl, queryParameters: { 'projectId': projectId, if (!force && cached != null) 'since': cached.version, @@ -123,12 +243,20 @@ class StacBundleService { ), ); + if (epoch != _epoch) { + // clear() or a project switch happened mid-request: the result + // belongs to state that no longer exists. + Log.d('StacBundleService: Discarding sync result from a stale epoch'); + return null; + } + final statusCode = response.statusCode; if (statusCode == 200) { final data = response.data; if (data is! Map) { Log.w('StacBundleService: Unexpected bundle response body'); + _recordSyncFailure(projectRejected: false); return cached; } @@ -139,11 +267,25 @@ class StacBundleService { ); if (bundle == null) { Log.w('StacBundleService: Bundle response is missing a version'); + _recordSyncFailure(projectRejected: false); return cached; } _bundle = bundle; - await _store.write(bundle); + _clearSyncFailure(); + final wrote = await _store.write(bundle); + if (epoch != _epoch) { + // clear()/project switch raced the store write: undo it so the + // cleared bundle is not resurrected from disk. + await _store.clear(bundle.projectId); + return null; + } + if (!wrote) { + Log.w( + 'StacBundleService: Failed to persist bundle v${bundle.version} ' + 'to the store', + ); + } _updatesController.add(bundle); Log.d('StacBundleService: Synced bundle v${bundle.version}'); return bundle; @@ -151,17 +293,32 @@ class StacBundleService { if (statusCode == 304 || statusCode == 204) { // Not modified: keep the cached bundle. + _clearSyncFailure(); return cached; } + _recordSyncFailure( + projectRejected: statusCode == 403 || statusCode == 404, + ); Log.w( 'StacBundleService: Bundle sync failed with status $statusCode, ' 'using ${cached == null ? 'no bundle' : 'stale bundle v${cached.version}'}', ); return cached; } catch (e) { + if (epoch != _epoch) return null; + _recordSyncFailure(projectRejected: false); // Offline or server error: keep serving the stale bundle. - Log.d('StacBundleService: Bundle sync failed ($e), using stale bundle'); + if (e is DioException) { + // Normal offline/server noise. + Log.d('StacBundleService: Bundle sync failed ($e), using stale bundle'); + } else { + // Anything else is a contract break (e.g. malformed 200 body). + Log.w( + 'StacBundleService: Bundle sync failed with unexpected error ($e), ' + 'using stale bundle', + ); + } return cached; } } @@ -198,22 +355,43 @@ class StacBundleService { /// /// Only when no bundle exists in either source (first launch of a /// seedless app) does this await [sync] so the first screens can render. + /// After a failed sync, the automatic sync is suppressed for + /// [_syncFailureBackoff] so widget rebuilds don't hammer the server; + /// explicit [sync] calls are never backed off. static Future ensureLoaded() async { await _hydrate(); if (_bundle != null) return _bundle; + + final failedAt = _lastSyncFailureAt; + if (failedAt != null && _now().difference(failedAt) < _syncFailureBackoff) { + return null; + } return sync(); } /// Hydrates the in-memory bundle once from the store and seed asset. static Future _hydrate() { + _ensureProject(); if (_hydrated) return Future.value(); - return _inFlightHydration ??= _hydrateInternal().whenComplete(() { - _hydrated = true; - _inFlightHydration = null; - }); + final existing = _inFlightHydration; + if (existing != null) return existing; + + final epoch = _epoch; + late final Future future; + future = _hydrateInternal(epoch) + .then((_) { + // Only a hydration that completed successfully (and still belongs + // to the current epoch) marks the state hydrated. + if (epoch == _epoch) _hydrated = true; + }) + .whenComplete(() { + if (identical(_inFlightHydration, future)) _inFlightHydration = null; + }); + _inFlightHydration = future; + return future; } - static Future _hydrateInternal() async { + static Future _hydrateInternal(int epoch) async { final projectId = _projectId; // Read the persisted bundle (schema mismatch reads as empty). @@ -231,15 +409,25 @@ class StacBundleService { // Read the seed asset shipped with the app, when configured. final seed = await _readSeed(projectId); + if (epoch != _epoch) return; + // Highest version wins (an app-store update can ship a seed newer than // the cached bundle, and vice versa). if (seed != null && (stored == null || seed.version > stored.version)) { _bundle = seed; - await _store.write(seed); + final wrote = await _store.write(seed); + if (!wrote) { + Log.w( + 'StacBundleService: Failed to persist seed bundle v${seed.version} ' + 'to the store', + ); + } Log.d('StacBundleService: Hydrated from seed bundle v${seed.version}'); } else if (stored != null) { _bundle = stored; - Log.d('StacBundleService: Hydrated from stored bundle v${stored.version}'); + Log.d( + 'StacBundleService: Hydrated from stored bundle v${stored.version}', + ); } } @@ -292,18 +480,25 @@ class StacBundleService { } /// Clears the persisted and in-memory bundle for the current project. + /// + /// Any in-flight sync result is discarded (it belongs to the state that + /// was just cleared), and the next [ensureLoaded] re-hydrates from the + /// seed asset/store as on a first launch. static Future clear() async { - _bundle = null; - return _store.clear(_projectId); + final projectId = _projectId; + _invalidate(); + _activeProjectId = projectId; + return _store.clear(projectId); } /// Resets all static state; for tests only. @visibleForTesting static void reset() { - _bundle = null; - _hydrated = false; - _inFlightHydration = null; - _inFlightSync = null; + _invalidate(); + _epoch = 0; + _activeProjectId = null; + _warnedMissingOptions = false; + _now = DateTime.now; _dio = _createDio(); _store = const SharedPreferencesBundleStore(); _assetReader = rootBundle.loadString; diff --git a/packages/stac/lib/src/services/stac_bundle_updater.dart b/packages/stac/lib/src/services/stac_bundle_updater.dart index 7d4dfa87..ce8e0a16 100644 --- a/packages/stac/lib/src/services/stac_bundle_updater.dart +++ b/packages/stac/lib/src/services/stac_bundle_updater.dart @@ -26,9 +26,18 @@ class StacBundleUpdater with WidgetsBindingObserver { Timer? _pollingTimer; /// Registers the updater as a lifecycle observer and starts the polling - /// timer when configured. Does nothing if already started. + /// timer when configured. + /// + /// Calling [start] on an already-started updater re-arms the polling + /// timer from the current `StacService.bundleConfig`, so a re-initialize + /// with a different (or removed) `pollingInterval` takes effect. static void start() { - if (_instance != null) return; + final existing = _instance; + if (existing != null) { + existing._cancelPollingTimer(); + existing._startPollingTimer(); + return; + } // Stac.initialize is typically called before runApp, so the widgets // binding may not exist yet; create it before registering a lifecycle @@ -71,12 +80,13 @@ class StacBundleUpdater with WidgetsBindingObserver { } /// Starts the periodic conditional sync when [StacBundleConfig.pollingInterval] - /// is set and no timer is already running. + /// is set, replacing any timer already running (so interval changes apply). void _startPollingTimer() { final interval = StacService.bundleConfig.pollingInterval; if (interval == null) return; - _pollingTimer ??= Timer.periodic(interval, (_) { + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic(interval, (_) { unawaited(StacBundleService.sync()); }); } diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 6184d872..b414e364 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:stac/src/framework/stac_service.dart'; import 'package:stac/src/models/stac_artifact_type.dart'; import 'package:stac/src/models/stac_cache_config.dart'; @@ -14,12 +15,27 @@ import 'package:stac_logger/stac_logger.dart'; class StacCloud { const StacCloud._(); - static final Dio _dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), - ), - ); + static Dio _dio = _createDio(); + + static Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); + } + + /// Overrides the Dio client used for per-artifact requests. + @visibleForTesting + static set dio(Dio dio) => _dio = dio; + + /// Resets the Dio client and warn-once memos; for tests only. + @visibleForTesting + static void reset() { + _dio = _createDio(); + _bundleMissWarned.clear(); + } static const String _baseUrl = 'https://api.stac.dev'; @@ -49,6 +65,11 @@ class StacCloud { StacArtifactType.theme: {}, }; + /// Bundle misses already warned about, keyed by + /// `bundleVersion:artifactType:artifactName`, so an artifact absent from + /// the bundle warns once per bundle version instead of on every rebuild. + static final Set _bundleMissWarned = {}; + /// Fetches an artifact from Stac Cloud with intelligent caching. /// /// Uses the global cache configuration from [StacService.defaultCacheConfig], @@ -72,12 +93,33 @@ class StacCloud { if (bundleResponse != null) { return bundleResponse; } + + final bundle = StacBundleService.current; + if (bundle == null && StacBundleService.lastSyncProjectRejected) { + // The bundles endpoint rejected this project (HTTP 403/404). + // Falling through would hammer the per-artifact endpoints with + // requests that fail the same way; surface the failure instead so + // the caller shows its error state. + Log.w( + 'StacCloud: bundle request for project ${options.projectId} was ' + 'rejected by the server; not falling back to per-artifact fetch', + ); + throw Exception( + 'Failed to fetch ${artifactType.name} "$artifactName": the bundle ' + 'request for project ${options.projectId} was rejected by the ' + 'server', + ); + } + // Absent from the bundle (deleted vs. not-yet-bundled is // indistinguishable): fall through to the legacy per-artifact fetch. - Log.w( - 'StacCloud: ${artifactType.name} $artifactName not found in bundle, ' - 'falling back to per-artifact fetch', - ); + final missKey = '${bundle?.version}:${artifactType.name}:$artifactName'; + if (_bundleMissWarned.add(missKey)) { + Log.w( + 'StacCloud: ${artifactType.name} $artifactName not found in bundle, ' + 'falling back to per-artifact fetch', + ); + } } final cacheConfig = StacService.defaultCacheConfig; diff --git a/packages/stac/test/services/stac_bundle_service_test.dart b/packages/stac/test/services/stac_bundle_service_test.dart index abf20f42..b7aad4f3 100644 --- a/packages/stac/test/services/stac_bundle_service_test.dart +++ b/packages/stac/test/services/stac_bundle_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -11,11 +12,12 @@ import 'package:stac/src/models/stac_bundle_config.dart'; import 'package:stac/src/services/stac_bundle_service.dart'; import 'package:stac/src/services/stac_bundle_store.dart'; import 'package:stac/src/services/stac_bundle_updater.dart'; +import 'package:stac/src/services/stac_cloud.dart'; import 'package:stac_core/stac_core.dart'; /// Fake [HttpClientAdapter] that records requests and answers via [handler]. class _FakeHttpClientAdapter implements HttpClientAdapter { - ResponseBody Function(RequestOptions options) handler = (options) { + FutureOr Function(RequestOptions options) handler = (options) { throw StateError('Unexpected network request to ${options.uri}'); }; @@ -156,6 +158,7 @@ void main() { SharedPreferences.setMockInitialValues({}); StacBundleUpdater.stop(); StacBundleService.reset(); + StacCloud.reset(); adapter = _FakeHttpClientAdapter(); StacBundleService.dio = Dio()..httpClientAdapter = adapter; @@ -164,6 +167,7 @@ void main() { tearDown(() { StacBundleUpdater.stop(); StacBundleService.reset(); + StacCloud.reset(); }); group('sync', () { @@ -288,6 +292,80 @@ void main() { expect(StacBundleService.current?.version, 1); }); + test( + 'forced sync during an in-flight sync is chained, not dropped', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await initStac(); + + final gate = Completer(); + adapter.handler = (options) async { + if (adapter.requests.length == 1) { + await gate.future; + return statusResponse(304); + } + return jsonResponse(bundleBody(version: 2)); + }; + + final first = StacBundleService.sync(); + await pumpUntil(() => adapter.requests.isNotEmpty); + + final forced = StacBundleService.sync(force: true); + // The forced sync waits for the in-flight one; only one request so far. + expect(adapter.requests, hasLength(1)); + + gate.complete(); + + expect((await first)?.version, 1); + expect((await forced)?.version, 2); + expect(adapter.requests, hasLength(2)); + + // The chained request really was forced: no conditional headers. + final forcedRequest = adapter.requests[1]; + expect(forcedRequest.uri.queryParameters.containsKey('since'), isFalse); + expect(headerOf(forcedRequest, 'If-None-Match'), isNull); + }, + ); + + test( + 'trailing slash on baseUrl is normalized in the bundles URL', + () async { + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + baseUrl: 'https://api.stac.dev/', + ), + ); + + final result = await StacBundleService.sync(); + + expect(result?.version, 1); + expect(adapter.requests.single.uri.path, '/bundles'); + }, + ); + + test('sync without options returns null instead of throwing', () async { + await StacService.initialize( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + ), + ); + + expect(await StacBundleService.sync(), isNull); + + // Resume/poll ticks go through the same entry point and must not + // produce unhandled async exceptions either. + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.resumed, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(adapter.requests, isEmpty); + }); + test('updates emits on 200 and not on 304', () async { await const SharedPreferencesBundleStore().write(storedBundle()); await initStac(); @@ -398,6 +476,86 @@ void main() { expect(adapter.requests, hasLength(1)); expect(result?.version, 1); }); + + test( + 'backs off after a failed sync; explicit sync is not backed off', + () async { + var nowValue = DateTime(2026, 7, 22, 12); + StacBundleService.clock = () => nowValue; + adapter.handler = (options) => throw Exception('offline'); + await initStac(); + + // First ensureLoaded triggers the sync, which fails. + expect(await StacBundleService.ensureLoaded(), isNull); + expect(adapter.requests, hasLength(1)); + + // Within the backoff window ensureLoaded does not re-sync. + expect(await StacBundleService.ensureLoaded(), isNull); + expect(await StacBundleService.ensureLoaded(), isNull); + expect(adapter.requests, hasLength(1)); + + // An explicit sync call is never subject to the backoff. + await StacBundleService.sync(); + expect(adapter.requests, hasLength(2)); + + // Once the backoff window has passed, ensureLoaded syncs again. + nowValue = nowValue.add(const Duration(seconds: 31)); + adapter.handler = (options) => jsonResponse(bundleBody(version: 1)); + expect((await StacBundleService.ensureLoaded())?.version, 1); + expect(adapter.requests, hasLength(3)); + + // The successful sync cleared the failure state. + expect((await StacBundleService.ensureLoaded())?.version, 1); + expect(adapter.requests, hasLength(3)); + }, + ); + }); + + group('projectId switch', () { + test( + 're-initialize with a different projectId drops the old bundle', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + await const SharedPreferencesBundleStore().write( + storedBundle(projectId: 'proj_2', version: 7), + ); + await initStac(); + + expect((await StacBundleService.ensureLoaded())?.projectId, projectId); + + await initStac(projectId: 'proj_2'); + + final result = await StacBundleService.ensureLoaded(); + expect(result?.projectId, 'proj_2'); + expect(result?.version, 7); + expect(StacBundleService.current?.projectId, 'proj_2'); + expect(adapter.requests, isEmpty); + }, + ); + + test('switching projects does not reuse the old project etag', () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 5), + ); + await initStac(); + expect((await StacBundleService.ensureLoaded())?.version, 5); + + adapter.handler = (options) => + jsonResponse(bundleBody(projectId: 'proj_2', version: 1)); + await initStac(projectId: 'proj_2'); + + final result = await StacBundleService.sync(); + + // No `since`/`If-None-Match` leaked from proj_1's bundle (etags are + // version-shaped and collide across projects). + final request = adapter.requests.single; + expect(request.uri.queryParameters['projectId'], 'proj_2'); + expect(request.uri.queryParameters.containsKey('since'), isFalse); + expect(headerOf(request, 'If-None-Match'), isNull); + + expect(result?.projectId, 'proj_2'); + expect(result?.version, 1); + }); }); group('seed hydration', () { @@ -461,27 +619,30 @@ void main() { expect(adapter.requests, isEmpty); }); - test('newer seed wins over an older stored bundle and is persisted', () async { - await const SharedPreferencesBundleStore().write( - storedBundle(version: 1), - ); - StacBundleService.assetReader = (path) async => seedJson(version: 3); - await initStac(bundleConfig: seedConfig); - - final result = await StacBundleService.ensureLoaded(); - - expect(result?.version, 3); - expect( - await StacBundleService.getScreenJson('home'), - '{"type":"text","data":"seed-v3"}', - ); - expect(adapter.requests, isEmpty); - - final persisted = await const SharedPreferencesBundleStore().read( - projectId, - ); - expect(persisted?.version, 3); - }); + test( + 'newer seed wins over an older stored bundle and is persisted', + () async { + await const SharedPreferencesBundleStore().write( + storedBundle(version: 1), + ); + StacBundleService.assetReader = (path) async => seedJson(version: 3); + await initStac(bundleConfig: seedConfig); + + final result = await StacBundleService.ensureLoaded(); + + expect(result?.version, 3); + expect( + await StacBundleService.getScreenJson('home'), + '{"type":"text","data":"seed-v3"}', + ); + expect(adapter.requests, isEmpty); + + final persisted = await const SharedPreferencesBundleStore().read( + projectId, + ); + expect(persisted?.version, 3); + }, + ); test('missing seed asset is tolerated', () async { StacBundleService.assetReader = (path) async { @@ -508,6 +669,27 @@ void main() { expect(result?.version, 1); expect(adapter.requests, hasLength(1)); }); + + test('clear() resets hydration so the seed re-hydrates', () async { + var seedReads = 0; + StacBundleService.assetReader = (path) async { + seedReads++; + return seedJson(version: 3); + }; + await initStac(bundleConfig: seedConfig); + + expect((await StacBundleService.ensureLoaded())?.version, 3); + expect(seedReads, 1); + + await StacBundleService.clear(); + expect(StacBundleService.current, isNull); + + // The next load re-hydrates from the seed — no restart, no network. + final result = await StacBundleService.ensureLoaded(); + expect(result?.version, 3); + expect(seedReads, 2); + expect(adapter.requests, isEmpty); + }); }); group('StacBundleUpdater', () { @@ -545,30 +727,85 @@ void main() { expect(adapter.requests, isEmpty); }); - test('polling interval triggers conditional syncs and pause cancels them', () async { - await const SharedPreferencesBundleStore().write(storedBundle()); - adapter.handler = (options) => statusResponse(304); - await initStac( - bundleConfig: const StacBundleConfig( - enabled: true, - prefetchOnInit: false, - checkOnResume: false, - pollingInterval: Duration(milliseconds: 20), - ), - ); + test( + 'polling interval triggers conditional syncs and pause cancels them', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + pollingInterval: Duration(milliseconds: 20), + ), + ); + + await pumpUntil(() => adapter.requests.isNotEmpty); + expect(adapter.requests, isNotEmpty); + + // Pausing cancels the timer: no further requests come in. + StacBundleUpdater.instance!.didChangeAppLifecycleState( + AppLifecycleState.paused, + ); + await Future.delayed(const Duration(milliseconds: 30)); + final countAfterPause = adapter.requests.length; + await Future.delayed(const Duration(milliseconds: 60)); + expect(adapter.requests.length, countAfterPause); + }, + ); - await pumpUntil(() => adapter.requests.isNotEmpty); - expect(adapter.requests, isNotEmpty); + test('re-initialize with bundle mode disabled stops the updater', () async { + await initStac(); + expect(StacBundleUpdater.instance, isNotNull); - // Pausing cancels the timer: no further requests come in. - StacBundleUpdater.instance!.didChangeAppLifecycleState( - AppLifecycleState.paused, - ); - await Future.delayed(const Duration(milliseconds: 30)); - final countAfterPause = adapter.requests.length; - await Future.delayed(const Duration(milliseconds: 60)); - expect(adapter.requests.length, countAfterPause); + await initStac(bundleConfig: const StacBundleConfig()); + + expect(StacBundleUpdater.instance, isNull); }); + + test( + 're-initialize re-arms the polling timer from the new config', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + adapter.handler = (options) => statusResponse(304); + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + ), + ); + await Future.delayed(const Duration(milliseconds: 40)); + expect(adapter.requests, isEmpty); + + // Re-initialize with polling on: the already-started updater must + // pick up the new interval. + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + pollingInterval: Duration(milliseconds: 20), + ), + ); + await pumpUntil(() => adapter.requests.isNotEmpty); + expect(adapter.requests, isNotEmpty); + + // Re-initialize with polling off again: the timer is disarmed. + await initStac( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + checkOnResume: false, + ), + ); + await Future.delayed(const Duration(milliseconds: 10)); + final countAfterDisable = adapter.requests.length; + await Future.delayed(const Duration(milliseconds: 60)); + expect(adapter.requests.length, countAfterDisable); + }, + ); }); group('clear', () { @@ -585,5 +822,104 @@ void main() { expect(prefs.getString('stac_bundle_$projectId'), isNull); expect(prefs.getInt('stac_bundle_schema_$projectId'), isNull); }); + + test('an in-flight sync completing after clear() is discarded', () async { + await initStac(); + + final gate = Completer(); + adapter.handler = (options) async { + await gate.future; + return jsonResponse(bundleBody(version: 5)); + }; + + final syncFuture = StacBundleService.sync(); + await pumpUntil(() => adapter.requests.isNotEmpty); + + await StacBundleService.clear(); + gate.complete(); + + // The cleared bundle is not resurrected in memory or in the store. + expect(await syncFuture, isNull); + expect(StacBundleService.current, isNull); + expect( + await const SharedPreferencesBundleStore().read(projectId), + isNull, + ); + }); + }); + + group('StacCloud bundle mode', () { + late _FakeHttpClientAdapter cloudAdapter; + + setUp(() { + cloudAdapter = _FakeHttpClientAdapter(); + StacCloud.dio = Dio()..httpClientAdapter = cloudAdapter; + }); + + test( + 'project rejection (404) does not fall through to legacy fetch', + () async { + adapter.handler = (options) => + jsonResponse({'error': 'not found'}, statusCode: 404); + await initStac(); + + await expectLater( + StacCloud.fetchScreen(routeName: 'home'), + throwsA(isA()), + ); + + // The bundles endpoint was hit once; /screens never. + expect(adapter.requests, hasLength(1)); + expect(cloudAdapter.requests, isEmpty); + + // Rebuilds keep failing fast with no further network traffic + // (ensureLoaded backoff + rejection short-circuit). + await expectLater( + StacCloud.fetchScreen(routeName: 'home'), + throwsA(isA()), + ); + expect(adapter.requests, hasLength(1)); + expect(cloudAdapter.requests, isEmpty); + }, + ); + + test( + 'project rejection (403) does not fall through to legacy fetch', + () async { + adapter.handler = (options) => + jsonResponse({'error': 'forbidden'}, statusCode: 403); + await initStac(); + + await expectLater( + StacCloud.fetchScreen(routeName: 'home'), + throwsA(isA()), + ); + expect(cloudAdapter.requests, isEmpty); + }, + ); + + test( + 'artifact missing from a healthy bundle falls back to legacy fetch', + () async { + await const SharedPreferencesBundleStore().write(storedBundle()); + cloudAdapter.handler = (options) => jsonResponse({ + 'name': 'missing', + 'stacJson': '{"type":"text","data":"legacy"}', + 'version': 9, + }); + await initStac(); + + final response = await StacCloud.fetchScreen(routeName: 'missing'); + + expect(response, isNotNull); + expect(response!.data['stacJson'], '{"type":"text","data":"legacy"}'); + + final request = cloudAdapter.requests.single; + expect(request.uri.path, '/screens'); + expect(request.uri.queryParameters['screenName'], 'missing'); + // The bundle was served from hydration; no bundle sync happened. + expect(adapter.requests, isEmpty); + }, + ); }); } diff --git a/packages/stac_cli/CHANGELOG.md b/packages/stac_cli/CHANGELOG.md index 9d6c6c4f..1a26e394 100644 --- a/packages/stac_cli/CHANGELOG.md +++ b/packages/stac_cli/CHANGELOG.md @@ -3,7 +3,7 @@ - feat: Bundle deploys — `stac deploy` now publishes all screens and themes in a single atomic `POST /bundles` request (any failure exits non-zero with nothing partially applied). - feat: Write a seed bundle to `assets/stac_bundle.json` after every successful deploy so first app launches can hydrate instantly and offline (warns when the asset is not declared in pubspec.yaml). - feat: Add `--legacy` flag to `stac deploy` to fall back to the previous per-file screen/theme uploads. -- fix: `stac build` clears previous `screens`/`themes` outputs before writing, so deleted screens no longer leave stale JSON behind that gets re-deployed. +- refactor: `stac build` now clears only the `screens`/`themes` outputs inside `stac/.build` before writing (previously the whole `.build` directory was cleared), preserving any other files kept in `.build`. ## 1.6.0 diff --git a/packages/stac_cli/lib/src/services/deploy_service.dart b/packages/stac_cli/lib/src/services/deploy_service.dart index a6089acf..7785bc99 100644 --- a/packages/stac_cli/lib/src/services/deploy_service.dart +++ b/packages/stac_cli/lib/src/services/deploy_service.dart @@ -161,19 +161,38 @@ class DeployService { ); } - if (status == 201) { - ConsoleLogger.success('✓ Deployed bundle v$version'); + if (version is! int) { + // The deploy succeeded server-side, but the response carries no + // usable version. Never write a seed with a null/invalid version — + // the client rejects it, silently breaking offline first launches. + if (status == 201) { + ConsoleLogger.success( + '✓ Deployed bundle (server did not return a version)', + ); + } else { + ConsoleLogger.info( + 'No changes — bundle already current (server did not return a version)', + ); + } + ConsoleLogger.warning( + 'The deploy succeeded, but the server response did not include a bundle version. ' + 'Skipping the seed bundle write (assets/stac_bundle.json was not updated).', + ); } else { - ConsoleLogger.info('No changes — bundle v$version already current'); - } + if (status == 201) { + ConsoleLogger.success('✓ Deployed bundle v$version'); + } else { + ConsoleLogger.info('No changes — bundle v$version already current'); + } - await _writeSeedAsset( - projectDir: projectDir, - projectId: projectId, - responseBody: body, - screens: screens, - themes: themes, - ); + await _writeSeedAsset( + projectDir: projectDir, + projectId: projectId, + responseBody: body, + screens: screens, + themes: themes, + ); + } final consoleUrl = 'https://console.stac.dev/project/$projectId'; ConsoleLogger.info( @@ -256,9 +275,9 @@ class DeployService { if (assets is List) { declared = assets.any((entry) { final value = entry?.toString().trim(); - return value == assetEntry || - value == 'assets/' || - value == 'assets'; + // Note: a bare `assets` entry (no trailing slash) is NOT a + // directory include in Flutter, so it does not count. + return value == assetEntry || value == 'assets/'; }); } } diff --git a/packages/stac_cli/test/services/deploy_service_test.dart b/packages/stac_cli/test/services/deploy_service_test.dart index 3123f61c..75652f51 100644 --- a/packages/stac_cli/test/services/deploy_service_test.dart +++ b/packages/stac_cli/test/services/deploy_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -96,6 +97,19 @@ const defaultStacOptions = StacOptions( return projectDir; } +/// Runs [body] capturing everything printed through [ConsoleLogger] +/// (which uses `print` under the hood) and returns the captured lines. +Future> _capturePrints(Future Function() body) async { + final lines = []; + await runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) => lines.add(line), + ), + ); + return lines; +} + void main() { const screensFixture = { 'home': '{"type":"scaffold","body":{"type":"text","data":"Home"}}', @@ -248,6 +262,97 @@ void main() { ); }); + test( + 'versionless success response warns and skips the seed write', + () async { + final projectDir = await _createFixtureProject( + screens: screensFixture, + themes: themesFixture, + ); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, {'status': 'ok'}), + ); + + final prints = await _capturePrints( + () => + DeployService(httpClient: client).deploy(projectPath: projectDir), + ); + + // The deploy itself is still a success and never logs "vnull". + final output = prints.join('\n'); + expect(output, isNot(contains('vnull'))); + expect( + output, + contains('✓ Deployed bundle (server did not return a version)'), + ); + expect(output, contains('[WARN]')); + expect(output, contains('did not include a bundle version')); + + // No seed with a null version is ever written. + expect( + File(p.join(projectDir, 'assets', 'stac_bundle.json')).existsSync(), + isFalse, + ); + }, + ); + + test( + 'warns when the seed is declared as bare "assets" (no trailing slash)', + () async { + final projectDir = await _createFixtureProject(screens: screensFixture); + // Flutter only directory-includes `assets/`; a bare `assets` entry + // does not cover assets/stac_bundle.json. + File(p.join(projectDir, 'pubspec.yaml')).writeAsStringSync(''' +name: fixture +flutter: + assets: + - assets +'''); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 1, + 'etag': '"1"', + 'checksum': 'c', + }), + ); + + final prints = await _capturePrints( + () => + DeployService(httpClient: client).deploy(projectPath: projectDir), + ); + + expect(prints.join('\n'), contains('not declared as a Flutter asset')); + }, + ); + + test('accepts "assets/" directory entry as declared', () async { + final projectDir = await _createFixtureProject(screens: screensFixture); + File(p.join(projectDir, 'pubspec.yaml')).writeAsStringSync(''' +name: fixture +flutter: + assets: + - assets/ +'''); + final client = _FakeHttpClientService( + (url, data) async => _jsonResponse(url, 201, { + 'projectId': _projectId, + 'version': 1, + 'etag': '"1"', + 'checksum': 'c', + }), + ); + + final prints = await _capturePrints( + () => DeployService(httpClient: client).deploy(projectPath: projectDir), + ); + + expect( + prints.join('\n'), + isNot(contains('not declared as a Flutter asset')), + ); + }); + test('throws StacException when there is nothing to deploy', () async { final projectDir = await _createFixtureProject(); final client = _FakeHttpClientService( From 60d54c54009c2a42a91e8ed8910ecbcb5d1e21f1 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 22 Jul 2026 15:16:48 +0530 Subject: [PATCH 5/7] fix(bundles): fail fast when bundle mode is enabled without StacOptions Bundle mode fetches a project's bundle and cannot function without a projectId. Validate at the top of initialize (before any state is mutated) instead of starting an updater whose resume and poll ticks can only warn. With the guarantee in place, the prefetch no longer needs its own null check. sync() keeps returning null for a missing-options call as defense in depth, since it is public API and reachable without a successful initialize. --- .../stac/lib/src/framework/stac_service.dart | 14 ++++++++- .../services/stac_bundle_service_test.dart | 29 ++++++++++++------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/stac/lib/src/framework/stac_service.dart b/packages/stac/lib/src/framework/stac_service.dart index 363d5534..0f88908b 100644 --- a/packages/stac/lib/src/framework/stac_service.dart +++ b/packages/stac/lib/src/framework/stac_service.dart @@ -192,6 +192,17 @@ class StacService { StacCacheConfig? cacheConfig, StacBundleConfig? bundleConfig, }) async { + // Bundle mode fetches a project's bundle, so it cannot do anything + // without a projectId. Reject the misconfiguration here — before any + // state is mutated — instead of starting an updater that can only warn + // on every resume and poll tick. + if ((bundleConfig ?? _bundleConfig).enabled && options == null) { + throw ArgumentError( + 'Bundle mode is enabled but StacOptions is null. Pass options with ' + 'the projectId whose bundle should be fetched to Stac.initialize.', + ); + } + _options = options; if (cacheConfig != null) { _defaultCacheConfig = cacheConfig; @@ -200,8 +211,9 @@ class StacService { _bundleConfig = bundleConfig; } if (_bundleConfig.enabled) { + // Options are guaranteed non-null here by the validation above. StacBundleUpdater.start(); - if (_bundleConfig.prefetchOnInit && options != null) { + if (_bundleConfig.prefetchOnInit) { unawaited(StacBundleService.sync()); } } else { diff --git a/packages/stac/test/services/stac_bundle_service_test.dart b/packages/stac/test/services/stac_bundle_service_test.dart index b7aad4f3..4e095cd2 100644 --- a/packages/stac/test/services/stac_bundle_service_test.dart +++ b/packages/stac/test/services/stac_bundle_service_test.dart @@ -346,23 +346,30 @@ void main() { }, ); - test('sync without options returns null instead of throwing', () async { - await StacService.initialize( - bundleConfig: const StacBundleConfig( - enabled: true, - prefetchOnInit: false, + test('enabling bundle mode without options fails fast', () async { + await expectLater( + StacService.initialize( + bundleConfig: const StacBundleConfig( + enabled: true, + prefetchOnInit: false, + ), ), + throwsArgumentError, ); - expect(await StacBundleService.sync(), isNull); + // Nothing was started, so no resume/poll tick can fire a sync. + expect(StacBundleUpdater.instance, isNull); + expect(adapter.requests, isEmpty); + }); - // Resume/poll ticks go through the same entry point and must not - // produce unhandled async exceptions either. - StacBundleUpdater.instance!.didChangeAppLifecycleState( - AppLifecycleState.resumed, + test('sync without options returns null instead of throwing', () async { + // Defense in depth: sync() is public API and may be called directly + // before (or without) a successful initialize. + await StacService.initialize( + bundleConfig: const StacBundleConfig(prefetchOnInit: false), ); - await Future.delayed(const Duration(milliseconds: 20)); + expect(await StacBundleService.sync(), isNull); expect(adapter.requests, isEmpty); }); From 83a8918e716267f870ba624ed462521c42c27f88 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Wed, 22 Jul 2026 16:31:53 +0530 Subject: [PATCH 6/7] style(bundles): apply dart format to stac_bundle_store --- packages/stac/lib/src/services/stac_bundle_store.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stac/lib/src/services/stac_bundle_store.dart b/packages/stac/lib/src/services/stac_bundle_store.dart index 3bfaafeb..833bf06e 100644 --- a/packages/stac/lib/src/services/stac_bundle_store.dart +++ b/packages/stac/lib/src/services/stac_bundle_store.dart @@ -38,8 +38,7 @@ class SharedPreferencesBundleStore implements StacBundleStore { static String _bundleKey(String projectId) => 'stac_bundle_$projectId'; - static String _schemaKey(String projectId) => - 'stac_bundle_schema_$projectId'; + static String _schemaKey(String projectId) => 'stac_bundle_schema_$projectId'; @override Future read(String projectId) async { From d46d4e480d35ce5c9d55b70f9e50c5071f7915d4 Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Thu, 23 Jul 2026 19:09:16 +0530 Subject: [PATCH 7/7] chore(movie_app): save WIP bundle testing setup Enable StacBundleConfig with a seed asset, add the generated stac_bundle.json, install agent skills into the example, migrate iOS Runner to the scene-delegate lifecycle, and bump stac to 1.5.0. --- .../skills/stac-custom-extensions/SKILL.md | 51 +++++++ .../agents/interface.yaml | 5 + .../assets/templates/custom_action.dart.tmpl | 22 +++ .../templates/custom_action_parser.dart.tmpl | 21 +++ .../assets/templates/custom_widget.dart.tmpl | 22 +++ .../templates/custom_widget_parser.dart.tmpl | 19 +++ .../references/converters-guide.md | 13 ++ .../references/custom-action-checklist.md | 9 ++ .../references/custom-widget-checklist.md | 13 ++ .../references/parser-registration.md | 27 ++++ .../scripts/check_parser_registration.py | 54 ++++++++ .../scripts/scaffold_custom_action.py | 96 +++++++++++++ .../scripts/scaffold_custom_widget.py | 94 +++++++++++++ .../.agents/skills/stac-quickstart/SKILL.md | 42 ++++++ .../stac-quickstart/agents/interface.yaml | 5 + .../references/cli-workflow.md | 23 ++++ .../references/project-layout.md | 26 ++++ .../references/setup-checklist.md | 36 +++++ .../scripts/check_environment.sh | 46 +++++++ .../scripts/validate_project_layout.py | 79 +++++++++++ .../skills/stac-screen-builder/SKILL.md | 48 +++++++ .../stac-screen-builder/agents/interface.yaml | 5 + .../references/action-recipes.md | 24 ++++ .../references/navigation-patterns.md | 28 ++++ .../references/style-recipes.md | 22 +++ .../references/widget-selector.md | 22 +++ .../stac-screen-builder/scripts/new_screen.py | 81 +++++++++++ .../scripts/new_theme_ref.py | 63 +++++++++ .../skills/stac-troubleshooter/SKILL.md | 42 ++++++ .../stac-troubleshooter/agents/interface.yaml | 5 + .../references/cache-debug.md | 25 ++++ .../references/error-playbooks.md | 19 +++ .../references/known-gotchas.md | 7 + .../references/migration-cheatsheet.md | 14 ++ .../references/navigation-debug.md | 15 ++ .../scripts/check_build_outputs.py | 46 +++++++ .../scripts/stac_doctor.py | 129 ++++++++++++++++++ examples/movie_app/assets/stac_bundle.json | 1 + .../ios/Flutter/AppFrameworkInfo.plist | 2 - examples/movie_app/ios/Podfile.lock | 21 --- .../ios/Runner.xcodeproj/project.pbxproj | 42 +++--- .../xcshareddata/xcschemes/Runner.xcscheme | 18 +++ .../movie_app/ios/Runner/AppDelegate.swift | 7 +- examples/movie_app/ios/Runner/Info.plist | 29 +++- .../movie_app/lib/default_stac_options.dart | 6 +- examples/movie_app/lib/main.dart | 26 ++++ examples/movie_app/pubspec.yaml | 3 +- examples/movie_app/stac/hello_world.dart | 8 ++ 48 files changed, 1410 insertions(+), 51 deletions(-) create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/SKILL.md create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/agents/interface.yaml create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action.dart.tmpl create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action_parser.dart.tmpl create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget.dart.tmpl create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget_parser.dart.tmpl create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/references/converters-guide.md create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-action-checklist.md create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-widget-checklist.md create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/references/parser-registration.md create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/scripts/check_parser_registration.py create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_action.py create mode 100644 examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_widget.py create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/SKILL.md create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/agents/interface.yaml create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/references/cli-workflow.md create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/references/project-layout.md create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/references/setup-checklist.md create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/scripts/check_environment.sh create mode 100644 examples/movie_app/.agents/skills/stac-quickstart/scripts/validate_project_layout.py create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/SKILL.md create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/agents/interface.yaml create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/references/action-recipes.md create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/references/navigation-patterns.md create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/references/style-recipes.md create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/references/widget-selector.md create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_screen.py create mode 100644 examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_theme_ref.py create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/SKILL.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/agents/interface.yaml create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/references/cache-debug.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/references/error-playbooks.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/references/known-gotchas.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/references/migration-cheatsheet.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/references/navigation-debug.md create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/scripts/check_build_outputs.py create mode 100644 examples/movie_app/.agents/skills/stac-troubleshooter/scripts/stac_doctor.py create mode 100644 examples/movie_app/assets/stac_bundle.json create mode 100644 examples/movie_app/stac/hello_world.dart diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/SKILL.md b/examples/movie_app/.agents/skills/stac-custom-extensions/SKILL.md new file mode 100644 index 00000000..640bc544 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/SKILL.md @@ -0,0 +1,51 @@ +--- +name: stac-custom-extensions +description: Scaffold and integrate custom Stac widgets and actions with parsers and registration checks. Use when users ask to build new StacParser or StacActionParser implementations, generate custom model classes, or verify parser registration inside Stac.initialize. +--- + +# Stac Custom Extensions + +## Overview + +Use this skill to add custom widgets/actions that serialize cleanly and register correctly in a Stac app. + +## Workflow + +1. Choose extension type: widget or action. +2. Scaffold model and parser files with scripts. +3. Add generated files to app codebase and run codegen. +4. Verify parser registration in `main.dart`. +5. Validate runtime wiring with a minimal usage example. + +## Required Inputs + +- PascalCase extension name. +- Runtime type id (`type` or `actionType`). +- Output directory for generated files. +- Path to `main.dart` for registration check. + +## Output Contract + +- Produce model + parser pair with consistent type ids. +- Include registration snippet for `Stac.initialize`. +- Include `build_runner` command when json serialization is used. + +## References + +- Read `references/custom-widget-checklist.md` for widget model/parser flow. +- Read `references/custom-action-checklist.md` for action model/parser flow. +- Read `references/parser-registration.md` for initialization wiring. +- Read `references/converters-guide.md` for converter usage patterns. + +## Scripts + +- `scripts/scaffold_custom_widget.py --name --type --out-dir ` +- `scripts/scaffold_custom_action.py --name --action-type --out-dir ` +- `scripts/check_parser_registration.py --main-dart --parser-class ` + +## Templates + +- `assets/templates/custom_widget.dart.tmpl` +- `assets/templates/custom_widget_parser.dart.tmpl` +- `assets/templates/custom_action.dart.tmpl` +- `assets/templates/custom_action_parser.dart.tmpl` diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/agents/interface.yaml b/examples/movie_app/.agents/skills/stac-custom-extensions/agents/interface.yaml new file mode 100644 index 00000000..6ba003e1 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/agents/interface.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Stac Custom Extensions" + short_description: "Build custom Stac widgets/actions with parsers" + brand_color: "#15803D" + default_prompt: "Use $stac-custom-extensions to scaffold a custom widget or action parser for my Stac app." diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action.dart.tmpl b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action.dart.tmpl new file mode 100644 index 00000000..c2183052 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action.dart.tmpl @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stac_core/stac_core.dart'; + +part '__FILE_BASENAME__.g.dart'; + +@JsonSerializable() +class __CLASS_NAME__ extends StacAction { + const __CLASS_NAME__({ + this.message, + }); + + final String? message; + + @override + String get actionType => '__ACTION_TYPE__'; + + factory __CLASS_NAME__.fromJson(Map json) => + _$__CLASS_NAME__FromJson(json); + + @override + Map toJson() => _$__CLASS_NAME__ToJson(this); +} diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action_parser.dart.tmpl b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action_parser.dart.tmpl new file mode 100644 index 00000000..278f93dd --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_action_parser.dart.tmpl @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:stac_framework/stac_framework.dart'; +import '__FILE_BASENAME__.dart'; + +class __PARSER_CLASS__ implements StacActionParser<__CLASS_NAME__> { + const __PARSER_CLASS__(); + + @override + String get actionType => '__ACTION_TYPE__'; + + @override + __CLASS_NAME__ getModel(Map json) => + __CLASS_NAME__.fromJson(json); + + @override + FutureOr onCall(BuildContext context, __CLASS_NAME__ model) { + debugPrint(model.message ?? '__CLASS_NAME__ called'); + // TODO: Implement action logic + return null; + } +} diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget.dart.tmpl b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget.dart.tmpl new file mode 100644 index 00000000..28a82e60 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget.dart.tmpl @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stac_core/stac_core.dart'; + +part '__FILE_BASENAME__.g.dart'; + +@JsonSerializable() +class __CLASS_NAME__ extends StacWidget { + const __CLASS_NAME__({ + this.label, + }); + + final String? label; + + @override + String get type => '__WIDGET_TYPE__'; + + factory __CLASS_NAME__.fromJson(Map json) => + _$__CLASS_NAME__FromJson(json); + + @override + Map toJson() => _$__CLASS_NAME__ToJson(this); +} diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget_parser.dart.tmpl b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget_parser.dart.tmpl new file mode 100644 index 00000000..dbf2a550 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/assets/templates/custom_widget_parser.dart.tmpl @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:stac_framework/stac_framework.dart'; +import '__FILE_BASENAME__.dart'; + +class __PARSER_CLASS__ extends StacParser<__CLASS_NAME__> { + const __PARSER_CLASS__(); + + @override + String get type => '__WIDGET_TYPE__'; + + @override + __CLASS_NAME__ getModel(Map json) => + __CLASS_NAME__.fromJson(json); + + @override + Widget parse(BuildContext context, __CLASS_NAME__ model) { + return Text(model.label ?? '__CLASS_NAME__'); + } +} diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/references/converters-guide.md b/examples/movie_app/.agents/skills/stac-custom-extensions/references/converters-guide.md new file mode 100644 index 00000000..31705099 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/references/converters-guide.md @@ -0,0 +1,13 @@ +# Converters Guide + +## DoubleConverter + +Use when JSON may send integer values for `double` fields. + +## StacWidgetConverter + +Use for child widget fields in custom widget models. + +## General Rule + +Use converters only when field serialization would otherwise fail or lose fidelity. diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-action-checklist.md b/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-action-checklist.md new file mode 100644 index 00000000..21e4240b --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-action-checklist.md @@ -0,0 +1,9 @@ +# Custom Action Checklist + +1. Define action model extending `StacAction`. +2. Set unique `actionType` string. +3. Add `@JsonSerializable()` and `part` file. +4. Implement `fromJson` and `toJson`. +5. Create parser implementing `StacActionParser`. +6. Register parser in `Stac.initialize(actionParsers: [...])`. +7. Trigger action from widget callback (`onPressed`, `onTap`, etc.). diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-widget-checklist.md b/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-widget-checklist.md new file mode 100644 index 00000000..7cef0d13 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/references/custom-widget-checklist.md @@ -0,0 +1,13 @@ +# Custom Widget Checklist + +1. Define widget model extending `StacWidget`. +2. Set unique `type` string. +3. Add `@JsonSerializable()` and `part` file. +4. Implement `fromJson` and `toJson`. +5. Create parser extending `StacParser`. +6. Register parser in `Stac.initialize(parsers: [...])`. +7. Run: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/references/parser-registration.md b/examples/movie_app/.agents/skills/stac-custom-extensions/references/parser-registration.md new file mode 100644 index 00000000..7deeda44 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/references/parser-registration.md @@ -0,0 +1,27 @@ +# Parser Registration + +## Widget Parser + +```dart +await Stac.initialize( + options: defaultStacOptions, + parsers: const [ + MyWidgetParser(), + ], +); +``` + +## Action Parser + +```dart +await Stac.initialize( + options: defaultStacOptions, + actionParsers: const [ + MyActionParser(), + ], +); +``` + +## Validation Rule + +- Parser class name should appear in the same file that calls `Stac.initialize`. diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/check_parser_registration.py b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/check_parser_registration.py new file mode 100644 index 00000000..8ad3b6cb --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/check_parser_registration.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Check that a parser class appears in the same file as Stac.initialize.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate parser registration in a main.dart file." + ) + parser.add_argument("--main-dart", required=True, help="Path to main.dart") + parser.add_argument( + "--parser-class", required=True, help="Parser class to locate in file" + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + main_dart = Path(args.main_dart).expanduser().resolve() + + if not main_dart.exists(): + print(f"[FAIL] File not found: {main_dart}") + return 1 + + content = main_dart.read_text(encoding="utf-8", errors="ignore") + init_index = content.find("Stac.initialize(") + parser_index = content.find(args.parser_class) + + if init_index < 0: + print("[FAIL] Stac.initialize(...) call not found") + return 1 + + if parser_index < 0: + print(f"[FAIL] Parser class not found: {args.parser_class}") + return 1 + + if parser_index < init_index: + print( + "[WARN] Parser class appears before initialize call; verify registration location manually" + ) + else: + print(f"[OK] Found parser class near/after initialize call: {args.parser_class}") + + print("Registration check completed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_action.py b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_action.py new file mode 100644 index 00000000..14b0ac2d --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_action.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Scaffold custom action model and parser files from templates.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +def to_snake_case(name: str) -> str: + return re.sub(r"(? argparse.Namespace: + parser = argparse.ArgumentParser(description="Scaffold custom Stac action files.") + parser.add_argument("--name", required=True, help="PascalCase class name.") + parser.add_argument( + "--action-type", required=True, help="Stac action type identifier." + ) + parser.add_argument("--out-dir", required=True, help="Output directory.") + parser.add_argument("--force", action="store_true", help="Overwrite existing files.") + return parser.parse_args() + + +def render(template: str, mapping: dict[str, str]) -> str: + content = template + for key, value in mapping.items(): + content = content.replace(key, value) + return content + + +def write_file(path: Path, content: str, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"File already exists: {path}") + path.write_text(content, encoding="utf-8") + + +def main() -> int: + args = parse_args() + + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", args.name): + print("[FAIL] --name must be PascalCase.") + return 1 + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", args.action_type): + print("[FAIL] --action-type must start with a letter and use alphanumeric/_ only.") + return 1 + + skill_root = Path(__file__).resolve().parents[1] + model_template = (skill_root / "assets/templates/custom_action.dart.tmpl").read_text( + encoding="utf-8" + ) + parser_template = ( + skill_root / "assets/templates/custom_action_parser.dart.tmpl" + ).read_text(encoding="utf-8") + + file_basename = f"{to_snake_case(args.name)}_action" + parser_class = f"Stac{args.name}ActionParser" + mapping = { + "__CLASS_NAME__": args.name, + "__ACTION_TYPE__": args.action_type, + "__FILE_BASENAME__": file_basename, + "__PARSER_CLASS__": parser_class, + } + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + model_file = out_dir / f"{file_basename}.dart" + parser_file = out_dir / f"{file_basename}_parser.dart" + + # Pre-check both files before writing to avoid partial creation + if not args.force: + if model_file.exists(): + print(f"[FAIL] File already exists: {model_file}") + return 1 + if parser_file.exists(): + print(f"[FAIL] File already exists: {parser_file}") + return 1 + + try: + write_file(model_file, render(model_template, mapping), args.force) + write_file(parser_file, render(parser_template, mapping), args.force) + except FileExistsError as exc: + # This should not happen due to pre-check, but handle it anyway + print(f"[FAIL] {exc}") + return 1 + + print(f"[OK] Created {model_file}") + print(f"[OK] Created {parser_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_widget.py b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_widget.py new file mode 100644 index 00000000..41280d86 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-custom-extensions/scripts/scaffold_custom_widget.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Scaffold custom widget model and parser files from templates.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +def to_snake_case(name: str) -> str: + return re.sub(r"(? argparse.Namespace: + parser = argparse.ArgumentParser(description="Scaffold custom Stac widget files.") + parser.add_argument("--name", required=True, help="PascalCase class name.") + parser.add_argument("--type", required=True, help="Stac widget type identifier.") + parser.add_argument("--out-dir", required=True, help="Output directory.") + parser.add_argument("--force", action="store_true", help="Overwrite existing files.") + return parser.parse_args() + + +def render(template: str, mapping: dict[str, str]) -> str: + content = template + for key, value in mapping.items(): + content = content.replace(key, value) + return content + + +def write_file(path: Path, content: str, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"File already exists: {path}") + path.write_text(content, encoding="utf-8") + + +def main() -> int: + args = parse_args() + + if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", args.name): + print("[FAIL] --name must be PascalCase.") + return 1 + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", args.type): + print("[FAIL] --type must start with a letter and use alphanumeric/_ only.") + return 1 + + skill_root = Path(__file__).resolve().parents[1] + model_template = (skill_root / "assets/templates/custom_widget.dart.tmpl").read_text( + encoding="utf-8" + ) + parser_template = ( + skill_root / "assets/templates/custom_widget_parser.dart.tmpl" + ).read_text(encoding="utf-8") + + file_basename = to_snake_case(args.name) + parser_class = f"Stac{args.name}Parser" + mapping = { + "__CLASS_NAME__": args.name, + "__WIDGET_TYPE__": args.type, + "__FILE_BASENAME__": file_basename, + "__PARSER_CLASS__": parser_class, + } + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + model_file = out_dir / f"{file_basename}.dart" + parser_file = out_dir / f"{file_basename}_parser.dart" + + # Pre-check both files before writing to avoid partial creation + if not args.force: + if model_file.exists(): + print(f"[FAIL] File already exists: {model_file}") + return 1 + if parser_file.exists(): + print(f"[FAIL] File already exists: {parser_file}") + return 1 + + try: + write_file(model_file, render(model_template, mapping), args.force) + write_file(parser_file, render(parser_template, mapping), args.force) + except FileExistsError as exc: + # This should not happen due to pre-check, but handle it anyway + print(f"[FAIL] {exc}") + return 1 + + print(f"[OK] Created {model_file}") + print(f"[OK] Created {parser_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-quickstart/SKILL.md b/examples/movie_app/.agents/skills/stac-quickstart/SKILL.md new file mode 100644 index 00000000..ea95cc2c --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/SKILL.md @@ -0,0 +1,42 @@ +--- +name: stac-quickstart +description: Help initialize and validate a Stac-enabled Flutter project and ship a first server-driven screen. Use when users ask to set up Stac CLI, run stac init/build/deploy, verify project prerequisites, or troubleshoot first-run setup and missing configuration files. +--- + +# Stac Quickstart + +## Overview + +Use this skill to set up Stac in a Flutter project, verify required files, and complete the first build/deploy loop safely. + +## Workflow + +1. Run `scripts/check_environment.sh` to verify local tooling. +2. Run `scripts/validate_project_layout.py --project-root ` to confirm Stac project structure. +3. Apply the setup flow from `references/setup-checklist.md`. +4. Execute the command sequence in `references/cli-workflow.md`. +5. Confirm required file locations from `references/project-layout.md`. + +## Required Inputs + +- Flutter project root path. +- Whether Stac CLI is already installed. +- Whether user wants a new or existing Stac Cloud project. + +## Output Contract + +- Provide exact commands to run in order. +- Include expected artifacts and verification checks. +- Flag blocking errors and the precise fix. +- Keep recommendations aligned with current Stac docs in this repository. + +## References + +- Read `references/setup-checklist.md` when beginning a fresh setup. +- Read `references/cli-workflow.md` when sequencing `stac` commands. +- Read `references/project-layout.md` when validating missing files or wrong structure. + +## Scripts + +- `scripts/check_environment.sh`: verifies `flutter`, `dart`, and `stac` availability. +- `scripts/validate_project_layout.py`: validates required Stac project files. diff --git a/examples/movie_app/.agents/skills/stac-quickstart/agents/interface.yaml b/examples/movie_app/.agents/skills/stac-quickstart/agents/interface.yaml new file mode 100644 index 00000000..b9e19831 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/agents/interface.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Stac Quickstart" + short_description: "Set up and ship your first Stac screen" + brand_color: "#15803D" + default_prompt: "Use $stac-quickstart to initialize my Flutter app with Stac and deploy a first screen." diff --git a/examples/movie_app/.agents/skills/stac-quickstart/references/cli-workflow.md b/examples/movie_app/.agents/skills/stac-quickstart/references/cli-workflow.md new file mode 100644 index 00000000..937294b9 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/references/cli-workflow.md @@ -0,0 +1,23 @@ +# CLI Workflow + +## Core Commands + +```bash +stac login +stac status +stac init +stac build --verbose +stac deploy --verbose +``` + +## Fast Verification Loop + +1. Run `stac status` and confirm authenticated state. +2. Run `stac build` and verify generated json files. +3. Run `stac deploy` and verify uploaded screen count. + +## Common Recovery Steps + +- Login issues: `stac logout && stac login` +- No screens found: ensure `@StacScreen` annotations exist under `stac/`. +- Project mismatch: re-run `stac init` in correct root. diff --git a/examples/movie_app/.agents/skills/stac-quickstart/references/project-layout.md b/examples/movie_app/.agents/skills/stac-quickstart/references/project-layout.md new file mode 100644 index 00000000..63a19dcc --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/references/project-layout.md @@ -0,0 +1,26 @@ +# Project Layout + +## Minimum Expected Structure + +```text +/ + lib/ + main.dart + default_stac_options.dart + stac/ + hello_world.dart + pubspec.yaml +``` + +## Required Signals + +- `main.dart` calls `Stac.initialize(...)`. +- `stac/` contains at least one file with `@StacScreen(...)`. +- `default_stac_options.dart` defines `StacOptions` with project details. + +## Recommended Generated Output + +```text +stac/.build/ + .json +``` diff --git a/examples/movie_app/.agents/skills/stac-quickstart/references/setup-checklist.md b/examples/movie_app/.agents/skills/stac-quickstart/references/setup-checklist.md new file mode 100644 index 00000000..c3e101ca --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/references/setup-checklist.md @@ -0,0 +1,36 @@ +# Setup Checklist + +## Prerequisites + +1. Confirm Flutter SDK is installed (`flutter --version`). +2. Confirm Dart SDK is installed (`dart --version`). +3. Confirm project has `pubspec.yaml`. +4. Confirm Stac CLI is installed (`stac --version`) or install it first. + +## Install Stac CLI + +macOS/Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/StacDev/install/main/install.sh | bash +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/StacDev/install/main/install.ps1 | iex +``` + +## First-Time Setup Flow + +1. `stac login` +2. `stac init` +3. Add or update `stac/*.dart` screen definitions. +4. `stac build` +5. `stac deploy` + +## Success Criteria + +- `lib/default_stac_options.dart` exists and contains a project id. +- `stac/` directory exists with at least one `@StacScreen` function. +- Build output is generated in `stac/.build`. diff --git a/examples/movie_app/.agents/skills/stac-quickstart/scripts/check_environment.sh b/examples/movie_app/.agents/skills/stac-quickstart/scripts/check_environment.sh new file mode 100644 index 00000000..9b46693b --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/scripts/check_environment.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: check_environment.sh + +Checks whether flutter, dart, and stac commands are available. +Exits non-zero if required tools are missing. +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +missing=0 + +check_cmd() { + local cmd="$1" + local required="$2" + + if command -v "$cmd" >/dev/null 2>&1; then + printf "[OK] %s found: %s\n" "$cmd" "$(command -v "$cmd")" + "$cmd" --version 2>/dev/null | head -n 1 || true + else + if [[ "$required" == "required" ]]; then + printf "[FAIL] %s not found\n" "$cmd" + missing=1 + else + printf "[WARN] %s not found\n" "$cmd" + fi + fi +} + +check_cmd flutter required +check_cmd dart required +check_cmd stac optional + +if [[ "$missing" -ne 0 ]]; then + echo "Environment check failed: install missing required tools first." + exit 1 +fi + +echo "Environment check passed." diff --git a/examples/movie_app/.agents/skills/stac-quickstart/scripts/validate_project_layout.py b/examples/movie_app/.agents/skills/stac-quickstart/scripts/validate_project_layout.py new file mode 100644 index 00000000..0f36b739 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-quickstart/scripts/validate_project_layout.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Validate required project structure for a Stac-enabled Flutter app.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate Stac project layout and required files.", + ) + parser.add_argument( + "--project-root", + required=True, + help="Path to Flutter project root.", + ) + return parser.parse_args() + + +def has_stac_screen(stac_dir: Path) -> bool: + for file in stac_dir.rglob("*.dart"): + text = file.read_text(encoding="utf-8", errors="ignore") + if re.search(r"@StacScreen\s*\(", text): + return True + return False + + +def check_file(path: Path, label: str, failures: list[str]) -> None: + if path.exists(): + print(f"[OK] {label}: {path}") + else: + print(f"[FAIL] Missing {label}: {path}") + failures.append(label) + + +def main() -> int: + args = parse_args() + root = Path(args.project_root).expanduser().resolve() + + failures: list[str] = [] + + if not root.exists() or not root.is_dir(): + print(f"[FAIL] Project root is not a directory: {root}") + return 1 + + check_file(root / "pubspec.yaml", "pubspec.yaml", failures) + check_file(root / "lib" / "main.dart", "lib/main.dart", failures) + check_file( + root / "lib" / "default_stac_options.dart", + "lib/default_stac_options.dart", + failures, + ) + + stac_dir = root / "stac" + if stac_dir.exists() and stac_dir.is_dir(): + print(f"[OK] stac directory: {stac_dir}") + if has_stac_screen(stac_dir): + print("[OK] Found at least one @StacScreen annotation") + else: + print("[FAIL] No @StacScreen annotations found under stac/") + failures.append("stac-screen-annotation") + else: + print(f"[FAIL] Missing stac directory: {stac_dir}") + failures.append("stac-directory") + + if failures: + print(f"\nValidation failed with {len(failures)} issue(s): {', '.join(failures)}") + return 1 + + print("\nValidation passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/SKILL.md b/examples/movie_app/.agents/skills/stac-screen-builder/SKILL.md new file mode 100644 index 00000000..96ed5c51 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/SKILL.md @@ -0,0 +1,48 @@ +--- +name: stac-screen-builder +description: Build Stac DSL screens and themes from product requirements with safe defaults and reusable templates. Use when users ask to create or refactor StacScreen files, map UI requirements to Stac widgets/actions/styles, or scaffold new screen/theme files. +--- + +# Stac Screen Builder + +## Overview + +Use this skill to convert feature requirements into maintainable Stac DSL screens and theme definitions. + +## Workflow + +1. Translate user requirements into route names, states, and actions. +2. Select widgets using `references/widget-selector.md`. +3. Select actions using `references/action-recipes.md`. +4. Apply style patterns from `references/style-recipes.md`. +5. Apply route semantics from `references/navigation-patterns.md`. +6. Scaffold files using scripts when requested. + +## Required Inputs + +- Target screen name (`snake_case` recommended). +- Desired interactions (navigation, network, forms, state changes). +- Whether a theme reference is needed. + +## Output Contract + +- Return valid Stac DSL snippets with `@StacScreen` or `@StacThemeRef`. +- Keep generated screen names stable and explicit. +- Use built-in Stac widgets/actions first, then custom extensions if needed. + +## References + +- Read `references/widget-selector.md` to choose layout and interactive widgets. +- Read `references/action-recipes.md` for navigation/form/network/state actions. +- Read `references/style-recipes.md` for color, spacing, and text style patterns. +- Read `references/navigation-patterns.md` for stack-safe navigation actions. + +## Scripts + +- `scripts/new_screen.py --screen-name --out-dir [--with-navigation]` +- `scripts/new_theme_ref.py --theme-name --out-file ` + +## Templates + +- `assets/templates/screen.dart.tmpl` +- `assets/templates/theme_ref.dart.tmpl` diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/agents/interface.yaml b/examples/movie_app/.agents/skills/stac-screen-builder/agents/interface.yaml new file mode 100644 index 00000000..ac5a6c6c --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/agents/interface.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Stac Screen Builder" + short_description: "Design and scaffold Stac DSL screens fast" + brand_color: "#15803D" + default_prompt: "Use $stac-screen-builder to turn product requirements into Stac DSL screens and themes." diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/references/action-recipes.md b/examples/movie_app/.agents/skills/stac-screen-builder/references/action-recipes.md new file mode 100644 index 00000000..167b1c6c --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/references/action-recipes.md @@ -0,0 +1,24 @@ +# Action Recipes + +## Navigation + +- Preferred: use `StacNavigator` helpers. +- Push Stac screen: `StacNavigator.pushStac('screen_name')` +- Pop current route: `StacNavigator.pop()` +- Replace route: `StacNavigator.pushReplacementStac('screen_name')` + +## Forms + +- Validate before submit: `StacFormValidate` +- Read values: `StacGetFormValue` +- Update state: `StacSetValueAction` + +## Network + +- Request API data: `StacNetworkRequest` +- Pair with `StacDynamicView` for templated list rendering. + +## Utilities + +- Sequence actions: `StacMultiAction` +- Add delay for staged transitions: `StacDelayAction` diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/references/navigation-patterns.md b/examples/movie_app/.agents/skills/stac-screen-builder/references/navigation-patterns.md new file mode 100644 index 00000000..32fd52bf --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/references/navigation-patterns.md @@ -0,0 +1,28 @@ +# Navigation Patterns + +## Pattern: List to Detail + +- In list item tap: + +```dart +StacNavigator.pushStac('detail_screen', arguments: {'id': '{{id}}'}) +``` + +## Pattern: Auth Redirect + +- After successful login: + +```dart +StacNavigator.pushAndRemoveAllStac('home_screen') +``` + +## Pattern: Modal Close with Result + +```dart +StacNavigator.pop(result: {'saved': true}) +``` + +## Guardrails + +- Use `pushReplacement*` only when previous screen must not remain in back stack. +- Use `pushAndRemoveAll*` only for root-flow transitions. diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/references/style-recipes.md b/examples/movie_app/.agents/skills/stac-screen-builder/references/style-recipes.md new file mode 100644 index 00000000..b366b84e --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/references/style-recipes.md @@ -0,0 +1,22 @@ +# Style Recipes + +## Spacing + +- Use `StacEdgeInsets.all(16)` as default content padding. +- Keep vertical rhythm with `StacSizedBox(height: 8|12|16|24)`. + +## Text + +- Use theme styles before hardcoded values: + - `StacThemeData.textTheme.titleLarge` + - `StacThemeData.textTheme.bodyMedium` + +## Color + +- Prefer theme tokens (`primary`, `onSurface`) over hardcoded hex. +- Use explicit hex only for one-off decorative elements. + +## Containers + +- Use `StacBoxDecoration` only when style intent is clear. +- Keep border radius consistent across cards/buttons. diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/references/widget-selector.md b/examples/movie_app/.agents/skills/stac-screen-builder/references/widget-selector.md new file mode 100644 index 00000000..dfc0993e --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/references/widget-selector.md @@ -0,0 +1,22 @@ +# Widget Selector + +## Layout-First Mapping + +- Page shell: `StacScaffold` +- Vertical composition: `StacColumn` +- Horizontal composition: `StacRow` +- Scrollable content: `StacListView` or `StacSingleChildScrollView` +- Section spacing: `StacPadding` and `StacSizedBox` + +## Interaction Mapping + +- Primary button CTA: `StacFilledButton` or `StacElevatedButton` +- Icon action: `StacIconButton` +- Form group: `StacForm` + `StacTextFormField` +- Conditional UI: `StacConditional` + +## Data Mapping + +- Remote content list: `StacDynamicView` +- Loading state: `StacCircularProgressIndicator` +- Empty state: `StacCenter` + `StacText` diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_screen.py b/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_screen.py new file mode 100644 index 00000000..51509c97 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_screen.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Scaffold a Stac screen file from template.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +def snake_to_camel(name: str) -> str: + parts = [p for p in name.split("_") if p] + if not parts: + return name + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create a Stac screen Dart file.") + parser.add_argument("--screen-name", required=True, help="Screen name in snake_case.") + parser.add_argument("--out-dir", required=True, help="Output directory for dart file.") + parser.add_argument( + "--with-navigation", + action="store_true", + help="Include an example navigation button block.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite file if it already exists.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + screen_name = args.screen_name.strip() + + if not re.fullmatch(r"[a-z][a-z0-9_]*", screen_name): + print("[FAIL] --screen-name must start with a lowercase letter and contain only [a-z0-9_]") + return 1 + + skill_root = Path(__file__).resolve().parents[1] + template_path = skill_root / "assets" / "templates" / "screen.dart.tmpl" + template = template_path.read_text(encoding="utf-8") + + function_name = snake_to_camel(screen_name) + title = " ".join(part.capitalize() for part in screen_name.split("_")) + + if args.with_navigation: + navigation_block = """ StacSizedBox(height: 16), + StacFilledButton( + onPressed: StacNavigator.pushStac('detail_screen'), + child: StacText(data: 'Go to Detail'), + ),""" + else: + navigation_block = " // Add interactive widgets here." + + content = ( + template.replace("__SCREEN_NAME__", screen_name) + .replace("__FUNCTION_NAME__", function_name) + .replace("__TITLE__", title) + .replace("__NAVIGATION_BLOCK__", navigation_block) + ) + + out_dir = Path(args.out_dir).expanduser().resolve() + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / f"{screen_name}.dart" + + if out_file.exists() and not args.force: + print(f"[FAIL] File already exists: {out_file} (use --force to overwrite)") + return 1 + + out_file.write_text(content, encoding="utf-8") + print(f"[OK] Created {out_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_theme_ref.py b/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_theme_ref.py new file mode 100644 index 00000000..a6e781bc --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-screen-builder/scripts/new_theme_ref.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Scaffold a Stac theme reference file from template.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys + + +def to_getter_name(theme_name: str) -> str: + parts = [p for p in re.split(r"[_\-\s]+", theme_name) if p] + if not parts: + return "appTheme" + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create a Stac theme ref Dart file.") + parser.add_argument("--theme-name", required=True, help="Theme name identifier.") + parser.add_argument("--out-file", required=True, help="Target output .dart file path.") + parser.add_argument( + "--force", + action="store_true", + help="Overwrite file if it already exists.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + theme_name = args.theme_name.strip() + + if not re.fullmatch(r"[A-Za-z0-9_\-]+", theme_name): + print("[FAIL] --theme-name must be alphanumeric with _ or -") + return 1 + + skill_root = Path(__file__).resolve().parents[1] + template_path = skill_root / "assets" / "templates" / "theme_ref.dart.tmpl" + template = template_path.read_text(encoding="utf-8") + + getter_name = to_getter_name(theme_name) + + content = ( + template.replace("__THEME_NAME__", theme_name) + .replace("__GETTER_NAME__", getter_name) + ) + + out_file = Path(args.out_file).expanduser().resolve() + out_file.parent.mkdir(parents=True, exist_ok=True) + + if out_file.exists() and not args.force: + print(f"[FAIL] File already exists: {out_file} (use --force to overwrite)") + return 1 + + out_file.write_text(content, encoding="utf-8") + print(f"[OK] Created {out_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/SKILL.md b/examples/movie_app/.agents/skills/stac-troubleshooter/SKILL.md new file mode 100644 index 00000000..dd0e01a3 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/SKILL.md @@ -0,0 +1,42 @@ +--- +name: stac-troubleshooter +description: Diagnose Stac build, deploy, rendering, caching, and navigation issues using repeatable checks. Use when users report stac build finding no screens, deploy mismatches, runtime unknown widget/action errors, cache staleness, or migration regressions. +--- + +# Stac Troubleshooter + +## Overview + +Use this skill to run structured diagnostics and return precise fixes for Stac project issues. + +## Workflow + +1. Run `scripts/stac_doctor.py --project-root ` for baseline checks. +2. Run `scripts/check_build_outputs.py --project-root --expected-dir stac/.build`. +3. Triage using focused playbooks under `references/`. +4. Return root cause, fix commands, and post-fix verification steps. + +## Required Inputs + +- Project root path. +- Failing command and output (if available). +- Affected route/screen name (if runtime issue). + +## Output Contract + +- Classify issue as setup/build/deploy/runtime/cache/navigation. +- Provide exact remedial commands and expected outcomes. +- Include one verification command or file check per fix. + +## References + +- Read `references/error-playbooks.md` for common Stac errors and fixes. +- Read `references/cache-debug.md` for stale content and cache strategy checks. +- Read `references/navigation-debug.md` for route/action stack behavior. +- Read `references/migration-cheatsheet.md` for JSON-to-Dart migration mapping. +- Read `references/known-gotchas.md` for recurring pitfalls. + +## Scripts + +- `scripts/stac_doctor.py --project-root [--json]` +- `scripts/check_build_outputs.py --project-root --expected-dir stac/.build` diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/agents/interface.yaml b/examples/movie_app/.agents/skills/stac-troubleshooter/agents/interface.yaml new file mode 100644 index 00000000..ce714117 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/agents/interface.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Stac Troubleshooter" + short_description: "Diagnose Stac build, deploy, and runtime issues" + brand_color: "#15803D" + default_prompt: "Use $stac-troubleshooter to diagnose why my Stac screen is not building, deploying, or rendering." diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/references/cache-debug.md b/examples/movie_app/.agents/skills/stac-troubleshooter/references/cache-debug.md new file mode 100644 index 00000000..d64c186b --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/references/cache-debug.md @@ -0,0 +1,25 @@ +# Cache Debug + +## Symptoms + +- Deployed screen does not update in app. +- App shows stale content after successful deploy. + +## Checks + +1. Confirm active cache strategy in `Stac.initialize(cacheConfig: ...)`. +2. Prefer `networkFirst` while validating updates. +3. Clear cache when necessary: + +```dart +// Clear a specific screen cache +StacCloud.clearScreenCache('/home'); + +// Or clear all cached screens +StacCloud.clearAllCache(); +``` + +## Recovery + +- Restart app process after cache clear. +- Re-run `stac deploy` and re-open route. diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/references/error-playbooks.md b/examples/movie_app/.agents/skills/stac-troubleshooter/references/error-playbooks.md new file mode 100644 index 00000000..e5c617a4 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/references/error-playbooks.md @@ -0,0 +1,19 @@ +# Error Playbooks + +## `stac: command not found` + +- Install CLI. +- Restart shell. +- Verify with `stac --version`. + +## `stac build` finds no files + +- Confirm `stac/` directory exists. +- Confirm `.dart` files exist under `stac/`. +- Confirm at least one function uses `@StacScreen(...)`. + +## Unknown widget/action type at runtime + +- Confirm type spelling. +- Confirm custom parser is registered in `Stac.initialize`. +- Confirm generated json contains expected `type`/`actionType`. diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/references/known-gotchas.md b/examples/movie_app/.agents/skills/stac-troubleshooter/references/known-gotchas.md new file mode 100644 index 00000000..b762bbbd --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/references/known-gotchas.md @@ -0,0 +1,7 @@ +# Known Gotchas + +- Missing `lib/default_stac_options.dart` after partial init. +- Missing `@StacScreen` annotation in otherwise valid Dart file. +- Parser class implemented but not passed into `Stac.initialize`. +- Cache strategy hides recent cloud updates during testing. +- Generated `stac/.build` expected by workflow but not created yet. diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/references/migration-cheatsheet.md b/examples/movie_app/.agents/skills/stac-troubleshooter/references/migration-cheatsheet.md new file mode 100644 index 00000000..2e637e53 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/references/migration-cheatsheet.md @@ -0,0 +1,14 @@ +# Migration Cheatsheet + +## JSON to Dart Mapping + +- `type: "text"` -> `StacText(...)` +- `type: "container"` -> `StacContainer(...)` +- `actionType: "navigate"` -> `StacNavigateAction(...)` +- `actionType: "setValue"` -> `StacSetValueAction(...)` + +## Strategy + +1. Migrate one screen at a time. +2. Keep generated json under version control only when required. +3. Run `stac build` after each migrated screen. diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/references/navigation-debug.md b/examples/movie_app/.agents/skills/stac-troubleshooter/references/navigation-debug.md new file mode 100644 index 00000000..429b15b6 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/references/navigation-debug.md @@ -0,0 +1,15 @@ +# Navigation Debug + +## Quick Checks + +- Validate `navigationStyle` matches intent (`push`, `pop`, `pushReplacement`, etc.). +- Validate route source: Stac route, Flutter route, asset, or network. +- Validate `routeName` exists in target system. + +## Preferred API + +Use `StacNavigator` helpers for clarity: + +- `StacNavigator.pushStac('screen_name')` +- `StacNavigator.pushReplacementStac('screen_name')` +- `StacNavigator.pop()` diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/check_build_outputs.py b/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/check_build_outputs.py new file mode 100644 index 00000000..58f15cf6 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/check_build_outputs.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Check that expected build output directory exists and has JSON files.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate Stac build output directory.") + parser.add_argument("--project-root", required=True, help="Project root path") + parser.add_argument( + "--expected-dir", + required=True, + help="Expected build output directory, relative to project root", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = Path(args.project_root).expanduser().resolve() + + if not root.exists() or not root.is_dir(): + print(f"[FAIL] Invalid project root: {root}") + return 1 + + expected = root / args.expected_dir + if not expected.exists() or not expected.is_dir(): + print(f"[FAIL] Build output directory not found: {expected}") + return 1 + + json_files = sorted(expected.rglob("*.json")) + if not json_files: + print(f"[FAIL] No JSON build artifacts found under: {expected}") + return 1 + + print(f"[OK] Build output directory: {expected}") + print(f"[OK] JSON artifacts found: {len(json_files)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/stac_doctor.py b/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/stac_doctor.py new file mode 100644 index 00000000..67da2490 --- /dev/null +++ b/examples/movie_app/.agents/skills/stac-troubleshooter/scripts/stac_doctor.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Run baseline health checks for Stac projects.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import sys + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run diagnostics for a Stac project.") + parser.add_argument("--project-root", required=True, help="Path to project root") + parser.add_argument("--json", action="store_true", help="Emit JSON output") + return parser.parse_args() + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="ignore") + + +def has_annotation(stac_dir: Path) -> bool: + for dart_file in stac_dir.rglob("*.dart"): + if re.search(r"@StacScreen\s*\(", read(dart_file)): + return True + return False + + +def add_result(results: list[dict[str, str]], status: str, check: str, detail: str) -> None: + results.append({"status": status, "check": check, "detail": detail}) + + +def main() -> int: + args = parse_args() + root = Path(args.project_root).expanduser().resolve() + results: list[dict[str, str]] = [] + + if not root.exists() or not root.is_dir(): + add_result(results, "fail", "project-root", f"Not a directory: {root}") + if args.json: + print(json.dumps({"results": results}, indent=2)) + else: + print(f"[FAIL] Not a directory: {root}") + return 1 + + pubspec = root / "pubspec.yaml" + main_dart = root / "lib" / "main.dart" + options_dart = root / "lib" / "default_stac_options.dart" + stac_dir = root / "stac" + gitignore = root / ".gitignore" + build_dir = root / "stac" / ".build" + + add_result( + results, + "pass" if pubspec.exists() else "fail", + "pubspec", + str(pubspec), + ) + add_result( + results, + "pass" if main_dart.exists() else "fail", + "main.dart", + str(main_dart), + ) + add_result( + results, + "pass" if options_dart.exists() else "fail", + "default_stac_options.dart", + str(options_dart), + ) + + if stac_dir.exists() and stac_dir.is_dir(): + add_result(results, "pass", "stac-directory", str(stac_dir)) + found = has_annotation(stac_dir) + add_result( + results, + "pass" if found else "fail", + "stac-screen-annotation", + "Found @StacScreen in stac/" if found else "No @StacScreen found in stac/", + ) + else: + add_result(results, "fail", "stac-directory", str(stac_dir)) + add_result(results, "fail", "stac-screen-annotation", "stac/ missing") + + if main_dart.exists(): + has_initialize = "Stac.initialize(" in read(main_dart) + add_result( + results, + "pass" if has_initialize else "fail", + "stac-initialize", + "Stac.initialize call found" if has_initialize else "Stac.initialize call missing", + ) + + if gitignore.exists(): + ignores_build = ".build" in read(gitignore) + add_result( + results, + "pass" if ignores_build else "warn", + "build-ignore", + ".build ignore rule present" if ignores_build else "Consider ignoring stac/.build", + ) + else: + add_result(results, "warn", "build-ignore", ".gitignore not found") + + if build_dir.exists() and build_dir.is_dir(): + artifact_count = len(list(build_dir.rglob("*.json"))) + add_result( + results, + "pass" if artifact_count > 0 else "warn", + "build-artifacts", + f"JSON artifacts: {artifact_count}", + ) + else: + add_result(results, "warn", "build-artifacts", f"Build output missing: {build_dir}") + + if args.json: + print(json.dumps({"results": results}, indent=2)) + else: + for entry in results: + print(f"[{entry['status'].upper()}] {entry['check']}: {entry['detail']}") + + has_fail = any(item["status"] == "fail" for item in results) + return 1 if has_fail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/movie_app/assets/stac_bundle.json b/examples/movie_app/assets/stac_bundle.json new file mode 100644 index 00000000..d7a623df --- /dev/null +++ b/examples/movie_app/assets/stac_bundle.json @@ -0,0 +1 @@ +{"projectId":"s6ibzYZdLBkNsrqdAQGv","version":2,"etag":"\"2\"","checksum":"8caf15a4d833b4716795497b0d11895ecaf77ff942171db58a2a1c5c71b10744","screens":{"hello_world":"{\n \"body\": {\n \"child\": {\n \"data\": \"Hello, world!\",\n \"type\": \"text\"\n },\n \"type\": \"center\"\n },\n \"type\": \"scaffold\"\n}","detail_screen":"{\n \"appBar\": {\n \"leading\": {\n \"onPressed\": {\n \"actionType\": \"navigate\",\n \"navigationStyle\": \"pop\"\n },\n \"style\": {\n \"backgroundColor\": \"#50050608\",\n \"fixedSize\": {\n \"width\": 36.0,\n \"height\": 36.0\n }\n },\n \"icon\": {\n \"icon\": \"chevron_left\",\n \"iconType\": \"material\",\n \"color\": \"onSurface\",\n \"type\": \"icon\"\n },\n \"type\": \"iconButton\"\n },\n \"backgroundColor\": \"transparent\",\n \"type\": \"appBar\"\n },\n \"body\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/{{movie_id}}?language=en-US\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"template\": {\n \"child\": {\n \"children\": [\n {\n \"children\": [\n {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"width\": 1.7976931348623157e+308,\n \"height\": 480.0,\n \"fit\": \"cover\",\n \"type\": \"image\"\n },\n {\n \"left\": 0.0,\n \"right\": 0.0,\n \"bottom\": 0.0,\n \"child\": {\n \"decoration\": {\n \"gradient\": {\n \"gradientType\": \"linear\",\n \"colors\": [\n \"#00050608\",\n \"#050608\"\n ],\n \"stops\": [\n 0.0,\n 1.0\n ],\n \"begin\": \"topCenter\",\n \"end\": \"bottomCenter\"\n }\n },\n \"height\": 240.0,\n \"type\": \"container\"\n },\n \"type\": \"positioned\"\n }\n ],\n \"type\": \"stack\"\n },\n {\n \"padding\": {\n \"left\": 16.0,\n \"right\": 16.0\n },\n \"child\": {\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"child\": {\n \"data\": \"{{title}}\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"headlineMedium\"\n },\n \"overflow\": \"ellipsis\",\n \"type\": \"text\"\n },\n \"type\": \"expanded\"\n },\n {\n \"decoration\": {\n \"color\": \"primary\",\n \"borderRadius\": {\n \"topLeft\": 4.0,\n \"topRight\": 4.0,\n \"bottomLeft\": 4.0,\n \"bottomRight\": 4.0\n }\n },\n \"height\": 24.0,\n \"child\": {\n \"children\": [\n {\n \"width\": 6.0,\n \"type\": \"sizedBox\"\n },\n {\n \"icon\": \"star_rounded\",\n \"iconType\": \"material\",\n \"size\": 14.0,\n \"color\": \"onPrimary\",\n \"type\": \"icon\"\n },\n {\n \"width\": 2.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"{{vote_average}}\",\n \"style\": {\n \"type\": \"custom\",\n \"color\": \"onPrimary\",\n \"fontSize\": 14.0\n },\n \"type\": \"text\"\n },\n {\n \"width\": 6.0,\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"container\"\n }\n ],\n \"type\": \"row\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"data\": \"{{release_date}} · {{runtime}} mins\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"bodySmall\"\n },\n \"textAlign\": \"left\",\n \"type\": \"text\"\n },\n {\n \"type\": \"divider\"\n },\n {\n \"children\": [\n {\n \"child\": {\n \"child\": {\n \"mainAxisAlignment\": \"center\",\n \"children\": [\n {\n \"icon\": \"play_circle_filled\",\n \"iconType\": \"material\",\n \"size\": 24.0,\n \"type\": \"icon\"\n },\n {\n \"width\": 6.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"Watch Trailer\",\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"filledButton\"\n },\n \"type\": \"expanded\"\n },\n {\n \"width\": 16.0,\n \"type\": \"sizedBox\"\n },\n {\n \"child\": {\n \"mainAxisAlignment\": \"center\",\n \"children\": [\n {\n \"icon\": \"favorite_outline\",\n \"iconType\": \"material\",\n \"size\": 24.0,\n \"type\": \"icon\"\n },\n {\n \"width\": 6.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"Add to Watchlist\",\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"outlinedButton\"\n }\n ],\n \"type\": \"row\"\n },\n {\n \"height\": 24.0,\n \"type\": \"sizedBox\"\n },\n {\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"data\": \"About\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"bodyMedium\"\n },\n \"type\": \"text\"\n },\n {\n \"height\": 4.0,\n \"type\": \"sizedBox\"\n },\n {\n \"color\": \"primary\",\n \"width\": 24.0,\n \"height\": 2.0,\n \"type\": \"container\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"height\": 20.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"{{overview}}\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"bodyMedium\"\n },\n \"type\": \"text\"\n },\n {\n \"height\": 24.0,\n \"type\": \"sizedBox\"\n },\n {\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"data\": \"Cast\",\n \"style\": {\n \"type\": \"custom\",\n \"color\": \"onSurfaceVariant\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w600\",\n \"height\": 1.3\n },\n \"type\": \"text\"\n },\n {\n \"height\": 10.0,\n \"type\": \"sizedBox\"\n },\n {\n \"height\": 146.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/{{movie_id}}/credits?language=en-US\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"cast\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 16.0,\n \"type\": \"sizedBox\"\n },\n \"itemTemplate\": {\n \"width\": 80.0,\n \"child\": {\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{profile_path}}\",\n \"width\": 80.0,\n \"height\": 96.0,\n \"fit\": \"cover\",\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n {\n \"height\": 8.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"{{name}}\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"titleSmall\"\n },\n \"overflow\": \"ellipsis\",\n \"type\": \"text\"\n },\n {\n \"data\": \"{{character}}\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"bodySmall\"\n },\n \"overflow\": \"ellipsis\",\n \"type\": \"text\"\n }\n ],\n \"type\": \"column\"\n },\n \"type\": \"sizedBox\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"height\": 24.0,\n \"type\": \"sizedBox\"\n },\n {\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"data\": \"Similar Movies\",\n \"style\": {\n \"type\": \"custom\",\n \"color\": \"onSurfaceVariant\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w600\",\n \"height\": 1.3\n },\n \"type\": \"text\"\n },\n {\n \"height\": 10.0,\n \"type\": \"sizedBox\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/{{movie_id}}/similar?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{data.poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{data.id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"resultTarget\": \"data\",\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"height\": 80.0,\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n \"type\": \"padding\"\n }\n ],\n \"type\": \"column\"\n },\n \"type\": \"singleChildScrollView\"\n },\n \"type\": \"dynamicView\"\n },\n \"extendBodyBehindAppBar\": true,\n \"type\": \"scaffold\"\n}","home_screen":"{\n \"length\": 3,\n \"child\": {\n \"body\": {\n \"children\": [\n {\n \"padding\": {\n \"left\": 0.0,\n \"top\": 0.0,\n \"right\": 0.0,\n \"bottom\": 0.0\n },\n \"children\": [\n {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/trending/movie/day?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"type\": \"movieCarousel\"\n },\n {\n \"children\": [\n {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 24.0,\n \"right\": 16.0,\n \"bottom\": 10.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"data\": \"Now Playing\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"labelLarge\"\n },\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"padding\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/now_playing?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"padding\": {\n \"left\": 16.0\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"children\": [\n {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 24.0,\n \"right\": 16.0,\n \"bottom\": 10.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"data\": \"Popular Movies\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"labelLarge\"\n },\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"padding\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/popular?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"padding\": {\n \"left\": 16.0\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"children\": [\n {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 24.0,\n \"right\": 16.0,\n \"bottom\": 10.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"data\": \"Trending Movies\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"labelLarge\"\n },\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"padding\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/trending/movie/day?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"padding\": {\n \"left\": 16.0\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"children\": [\n {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 24.0,\n \"right\": 16.0,\n \"bottom\": 10.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"data\": \"Top Rated\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"labelLarge\"\n },\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"padding\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/top_rated?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"padding\": {\n \"left\": 16.0\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"children\": [\n {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 24.0,\n \"right\": 16.0,\n \"bottom\": 10.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"spaceBetween\",\n \"children\": [\n {\n \"data\": \"Upcoming Movies\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"labelLarge\"\n },\n \"type\": \"text\"\n }\n ],\n \"type\": \"row\"\n },\n \"type\": \"padding\"\n },\n {\n \"height\": 164.0,\n \"child\": {\n \"request\": {\n \"url\": \"https://api.themoviedb.org/3/movie/upcoming?language=en-US&page=1\",\n \"method\": \"get\",\n \"actionType\": \"networkRequest\"\n },\n \"targetPath\": \"results\",\n \"template\": {\n \"type\": \"listView\",\n \"scrollDirection\": \"horizontal\",\n \"shrinkWrap\": true,\n \"separator\": {\n \"width\": 8.0,\n \"type\": \"sizedBox\"\n },\n \"padding\": {\n \"left\": 16.0\n },\n \"itemTemplate\": {\n \"child\": {\n \"borderRadius\": {\n \"topLeft\": 6.0,\n \"topRight\": 6.0,\n \"bottomLeft\": 6.0,\n \"bottomRight\": 6.0\n },\n \"child\": {\n \"src\": \"https://media.themoviedb.org/t/p/w440_and_h660_face/{{poster_path}}\",\n \"imageType\": \"network\",\n \"width\": 108.0,\n \"height\": 164.0,\n \"type\": \"image\"\n },\n \"type\": \"clipRRect\"\n },\n \"onTap\": {\n \"values\": [\n {\n \"key\": \"movie_id\",\n \"value\": \"{{id}}\"\n }\n ],\n \"action\": {\n \"routeName\": \"detail_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"actionType\": \"setValue\"\n },\n \"type\": \"gestureDetector\"\n }\n },\n \"type\": \"dynamicView\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n {\n \"height\": 80.0,\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"listView\"\n },\n {\n \"child\": {\n \"data\": \"Search\",\n \"type\": \"text\"\n },\n \"type\": \"center\"\n },\n {\n \"child\": {\n \"data\": \"Profile\",\n \"type\": \"text\"\n },\n \"type\": \"center\"\n }\n ],\n \"type\": \"bottomNavigationView\"\n },\n \"bottomNavigationBar\": {\n \"items\": [\n {\n \"icon\": {\n \"icon\": \"home_outlined\",\n \"iconType\": \"material\",\n \"type\": \"icon\"\n },\n \"label\": \"Home\"\n },\n {\n \"icon\": {\n \"icon\": \"search_outlined\",\n \"iconType\": \"material\",\n \"type\": \"icon\"\n },\n \"label\": \"Search\"\n },\n {\n \"icon\": {\n \"icon\": \"person_outlined\",\n \"iconType\": \"material\",\n \"type\": \"icon\"\n },\n \"label\": \"Profile\"\n }\n ],\n \"type\": \"bottomNavigationBar\"\n },\n \"extendBodyBehindAppBar\": true,\n \"type\": \"scaffold\"\n },\n \"type\": \"defaultBottomNavigationController\"\n}","onboarding_screen":"{\n \"body\": {\n \"children\": [\n {\n \"src\": \"assets/images/image.png\",\n \"imageType\": \"asset\",\n \"width\": 1.7976931348623157e+308,\n \"height\": 1.7976931348623157e+308,\n \"fit\": \"cover\",\n \"type\": \"image\"\n },\n {\n \"left\": 0.0,\n \"top\": 0.0,\n \"right\": 0.0,\n \"bottom\": 0.0,\n \"child\": {\n \"decoration\": {\n \"gradient\": {\n \"gradientType\": \"linear\",\n \"colors\": [\n \"#00050608\",\n \"#050608\",\n \"#050608\"\n ],\n \"stops\": [\n 0.0,\n 0.8,\n 1.0\n ],\n \"begin\": \"topCenter\",\n \"end\": \"bottomCenter\"\n }\n },\n \"width\": 1.7976931348623157e+308,\n \"height\": 500.0,\n \"child\": {\n \"padding\": {\n \"left\": 16.0,\n \"top\": 48.0,\n \"right\": 16.0,\n \"bottom\": 48.0\n },\n \"child\": {\n \"mainAxisAlignment\": \"end\",\n \"crossAxisAlignment\": \"start\",\n \"children\": [\n {\n \"data\": \"Movie \",\n \"children\": [\n {\n \"text\": \"\\nDatabase\",\n \"style\": {\n \"type\": \"custom\",\n \"color\": \"primary\"\n }\n }\n ],\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"displayMedium\"\n },\n \"type\": \"text\"\n },\n {\n \"height\": 24.0,\n \"type\": \"sizedBox\"\n },\n {\n \"data\": \"Watch & enjoy a variety of award winning TV shows, movies, anime, and a lot more\",\n \"style\": {\n \"type\": \"theme\",\n \"textTheme\": \"bodyMedium\"\n },\n \"type\": \"text\"\n },\n {\n \"height\": 64.0,\n \"type\": \"sizedBox\"\n },\n {\n \"width\": 1.7976931348623157e+308,\n \"height\": 48.0,\n \"child\": {\n \"onPressed\": {\n \"routeName\": \"home_screen\",\n \"navigationStyle\": \"push\",\n \"actionType\": \"navigate\"\n },\n \"child\": {\n \"data\": \"Get Started\",\n \"type\": \"text\"\n },\n \"type\": \"filledButton\"\n },\n \"type\": \"sizedBox\"\n }\n ],\n \"type\": \"column\"\n },\n \"type\": \"padding\"\n },\n \"type\": \"container\"\n },\n \"type\": \"positioned\"\n }\n ],\n \"type\": \"stack\"\n },\n \"type\": \"scaffold\"\n}"},"themes":{"movie_app_dark":"{\n \"colorScheme\": {\n \"brightness\": \"dark\",\n \"primary\": \"#95E183\",\n \"onPrimary\": \"#050608\",\n \"secondary\": \"#95E183\",\n \"onSecondary\": \"#FFFFFF\",\n \"error\": \"#FF6565\",\n \"onError\": \"#050608\",\n \"surface\": \"#050608\",\n \"onSurface\": \"#FFFFFF\",\n \"onSurfaceVariant\": \"#65FFFFFF\",\n \"outline\": \"#08FFFFFF\"\n },\n \"brightness\": \"dark\",\n \"textTheme\": {\n \"displayLarge\": {\n \"type\": \"custom\",\n \"fontSize\": 48.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.1\n },\n \"displayMedium\": {\n \"type\": \"custom\",\n \"fontSize\": 40.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.1\n },\n \"displaySmall\": {\n \"type\": \"custom\",\n \"fontSize\": 34.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.1\n },\n \"headlineLarge\": {\n \"type\": \"custom\",\n \"fontSize\": 30.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.3\n },\n \"headlineMedium\": {\n \"type\": \"custom\",\n \"fontSize\": 26.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.3\n },\n \"headlineSmall\": {\n \"type\": \"custom\",\n \"fontSize\": 23.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.3\n },\n \"titleLarge\": {\n \"type\": \"custom\",\n \"fontSize\": 20.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n },\n \"titleMedium\": {\n \"type\": \"custom\",\n \"fontSize\": 18.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n },\n \"titleSmall\": {\n \"type\": \"custom\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n },\n \"bodyLarge\": {\n \"type\": \"custom\",\n \"fontSize\": 18.0,\n \"fontWeight\": \"w400\",\n \"height\": 1.5\n },\n \"bodyMedium\": {\n \"type\": \"custom\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w400\",\n \"height\": 1.5\n },\n \"bodySmall\": {\n \"type\": \"custom\",\n \"fontSize\": 14.0,\n \"fontWeight\": \"w400\",\n \"height\": 1.5\n },\n \"labelLarge\": {\n \"type\": \"custom\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w700\",\n \"height\": 1.3\n },\n \"labelMedium\": {\n \"type\": \"custom\",\n \"fontSize\": 14.0,\n \"fontWeight\": \"w600\",\n \"height\": 1.3\n },\n \"labelSmall\": {\n \"type\": \"custom\",\n \"fontSize\": 12.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n }\n },\n \"dividerTheme\": {\n \"color\": \"#24FFFFFF\",\n \"thickness\": 1.0\n },\n \"filledButtonTheme\": {\n \"textStyle\": {\n \"type\": \"custom\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n },\n \"padding\": {\n \"left\": 10.0,\n \"top\": 8.0,\n \"right\": 10.0,\n \"bottom\": 8.0\n },\n \"minimumSize\": {\n \"width\": 120.0,\n \"height\": 40.0\n },\n \"shape\": {\n \"type\": \"roundedRectangleBorder\",\n \"borderRadius\": {\n \"topLeft\": 8.0,\n \"topRight\": 8.0,\n \"bottomLeft\": 8.0,\n \"bottomRight\": 8.0\n }\n }\n },\n \"outlinedButtonTheme\": {\n \"textStyle\": {\n \"type\": \"custom\",\n \"fontSize\": 16.0,\n \"fontWeight\": \"w500\",\n \"height\": 1.3\n },\n \"padding\": {\n \"left\": 10.0,\n \"top\": 8.0,\n \"right\": 10.0,\n \"bottom\": 8.0\n },\n \"minimumSize\": {\n \"width\": 120.0,\n \"height\": 40.0\n },\n \"side\": {\n \"color\": \"#95E183\",\n \"width\": 1.0\n },\n \"shape\": {\n \"type\": \"roundedRectangleBorder\",\n \"borderRadius\": {\n \"topLeft\": 8.0,\n \"topRight\": 8.0,\n \"bottomLeft\": 8.0,\n \"bottomRight\": 8.0\n }\n }\n }\n}"}} \ No newline at end of file diff --git a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist +++ b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/examples/movie_app/ios/Podfile.lock b/examples/movie_app/ios/Podfile.lock index efc2a468..64cf43cf 100644 --- a/examples/movie_app/ios/Podfile.lock +++ b/examples/movie_app/ios/Podfile.lock @@ -1,36 +1,15 @@ PODS: - Flutter (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS DEPENDENCIES: - Flutter (from `Flutter`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/examples/movie_app/ios/Runner.xcodeproj/project.pbxproj b/examples/movie_app/ios/Runner.xcodeproj/project.pbxproj index 1c4bc28a..6b2066d6 100644 --- a/examples/movie_app/ios/Runner.xcodeproj/project.pbxproj +++ b/examples/movie_app/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 71DB6342EA3CC33466A1A767 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C5C37AE1E8860E5DA28E0F69 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -50,6 +51,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -72,6 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 71DB6342EA3CC33466A1A767 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -112,6 +115,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -198,13 +202,15 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 4345C4B27295EFE60E2ED410 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +244,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -286,23 +295,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 4345C4B27295EFE60E2ED410 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 762D547BF4F79909FA8EC25A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -726,6 +718,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/examples/movie_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/movie_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42..c3fedb29 100644 --- a/examples/movie_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/examples/movie_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/examples/movie_app/ios/Runner/Info.plist b/examples/movie_app/ios/Runner/Info.plist index a257724c..d3235fe4 100644 --- a/examples/movie_app/ios/Runner/Info.plist +++ b/examples/movie_app/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/examples/movie_app/lib/default_stac_options.dart b/examples/movie_app/lib/default_stac_options.dart index 6e62e90b..4ce029d1 100644 --- a/examples/movie_app/lib/default_stac_options.dart +++ b/examples/movie_app/lib/default_stac_options.dart @@ -19,7 +19,7 @@ import 'package:stac/stac_core.dart'; /// } /// ``` StacOptions get defaultStacOptions => StacOptions( - name: 'movie_app', - description: '', - projectId: 'pha1PAyoVRqREK5M2k3E', + name: 'Movie App', + description: 'null', + projectId: 's6ibzYZdLBkNsrqdAQGv', ); diff --git a/examples/movie_app/lib/main.dart b/examples/movie_app/lib/main.dart index df21e93a..ce803305 100644 --- a/examples/movie_app/lib/main.dart +++ b/examples/movie_app/lib/main.dart @@ -22,6 +22,10 @@ void main() async { options: defaultStacOptions, dio: dio, parsers: [MovieCarouselParser()], + bundleConfig: const StacBundleConfig( + enabled: true, + seedAsset: 'assets/stac_bundle.json', + ), ); runApp(const MyApp()); @@ -41,3 +45,25 @@ class MyApp extends StatelessWidget { ); } } + +class NewScreen extends StatefulWidget { + const NewScreen({super.key}); + + @override + State createState() => _NewScreenState(); +} + +class _NewScreenState extends State { + int a = 2; + a = "deed"; + @override + void initState() { + // TODO: implement initState + super.initState(); + } + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/examples/movie_app/pubspec.yaml b/examples/movie_app/pubspec.yaml index 69d4082b..c9dd44e3 100644 --- a/examples/movie_app/pubspec.yaml +++ b/examples/movie_app/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - stac: ^1.3.1 + stac: ^1.5.0 dio: ^5.8.0+1 smooth_page_indicator: ^1.2.1 @@ -65,6 +65,7 @@ flutter: uses-material-design: true assets: - assets/images/ + - assets/stac_bundle.json # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/examples/movie_app/stac/hello_world.dart b/examples/movie_app/stac/hello_world.dart new file mode 100644 index 00000000..5c727c66 --- /dev/null +++ b/examples/movie_app/stac/hello_world.dart @@ -0,0 +1,8 @@ +import 'package:stac/stac_core.dart'; + +@StacScreen(screenName: "hello_world") +StacWidget helloWorld() { + return StacScaffold( + body: StacCenter(child: StacText(data: 'Hello, rahul!')), + ); +}