diff --git a/packages/stac/lib/src/framework/framework.dart b/packages/stac/lib/src/framework/framework.dart index 66a085da..5085f31d 100644 --- a/packages/stac/lib/src/framework/framework.dart +++ b/packages/stac/lib/src/framework/framework.dart @@ -2,3 +2,4 @@ export 'stac.dart'; export 'stac_app.dart'; export 'stac_registry.dart'; export 'stac_service.dart'; +export 'stac_app_theme.dart'; diff --git a/packages/stac/lib/src/framework/stac_app.dart b/packages/stac/lib/src/framework/stac_app.dart index 01e337a5..74b4cca3 100644 --- a/packages/stac/lib/src/framework/stac_app.dart +++ b/packages/stac/lib/src/framework/stac_app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:stac/src/parsers/theme/themes.dart'; @@ -108,8 +110,8 @@ class StacApp extends StatelessWidget { final TransitionBuilder? builder; final String title; final GenerateAppTitle? onGenerateTitle; - final StacTheme? theme; - final StacTheme? darkTheme; + final FutureOr? theme; + final FutureOr? darkTheme; final ThemeData? highContrastTheme; final ThemeData? highContrastDarkTheme; final ThemeMode? themeMode; @@ -142,6 +144,22 @@ class StacApp extends StatelessWidget { } Widget _materialApp(BuildContext context) { + return _withResolvedThemes( + context, + (resolvedContext, resolved) => + _buildMaterialApp(resolvedContext, resolved), + ); + } + + Widget _materialRouterApp(BuildContext context) { + return _withResolvedThemes( + context, + (resolvedContext, resolved) => + _buildMaterialAppRouter(resolvedContext, resolved), + ); + } + + Widget _buildMaterialApp(BuildContext context, _ResolvedStacThemes themes) { return MaterialApp( navigatorKey: navigatorKey, scaffoldMessengerKey: scaffoldMessengerKey, @@ -162,8 +180,8 @@ class StacApp extends StatelessWidget { builder: builder, title: title, onGenerateTitle: onGenerateTitle, - theme: theme?.parse(context), - darkTheme: darkTheme?.parse(context), + theme: themes.theme?.parse(context), + darkTheme: themes.darkTheme?.parse(context), highContrastTheme: highContrastTheme, highContrastDarkTheme: highContrastDarkTheme, themeMode: themeMode, @@ -188,7 +206,10 @@ class StacApp extends StatelessWidget { ); } - Widget _materialRouterApp(BuildContext context) { + Widget _buildMaterialAppRouter( + BuildContext context, + _ResolvedStacThemes themes, + ) { return MaterialApp.router( scaffoldMessengerKey: scaffoldMessengerKey, routeInformationProvider: routeInformationProvider, @@ -200,8 +221,8 @@ class StacApp extends StatelessWidget { title: title, onGenerateTitle: onGenerateTitle, color: color, - theme: theme?.parse(context), - darkTheme: darkTheme?.parse(context), + theme: themes.theme?.parse(context), + darkTheme: themes.darkTheme?.parse(context), highContrastTheme: highContrastTheme, highContrastDarkTheme: highContrastDarkTheme, themeMode: themeMode, @@ -224,4 +245,80 @@ class StacApp extends StatelessWidget { scrollBehavior: scrollBehavior, ); } + + FutureOr<_ResolvedStacThemes> _resolveThemes() { + final themeInput = theme; + final darkThemeInput = darkTheme; + + final Future? themeFuture = themeInput is Future + ? themeInput + : null; + final Future? darkThemeFuture = + darkThemeInput is Future ? darkThemeInput : null; + + final StacTheme? themeValue = themeFuture == null + ? themeInput as StacTheme? + : null; + final StacTheme? darkThemeValue = darkThemeFuture == null + ? darkThemeInput as StacTheme? + : null; + + if (themeFuture == null && darkThemeFuture == null) { + return _ResolvedStacThemes(theme: themeValue, darkTheme: darkThemeValue); + } + + return Future<_ResolvedStacThemes>(() async { + final resolvedTheme = + await (themeFuture ?? Future.value(themeValue)); + final resolvedDarkTheme = + await (darkThemeFuture ?? Future.value(darkThemeValue)); + + return _ResolvedStacThemes( + theme: resolvedTheme, + darkTheme: resolvedDarkTheme, + ); + }); + } + + Widget _withResolvedThemes( + BuildContext context, + Widget Function(BuildContext, _ResolvedStacThemes) builder, + ) { + final resolved = _resolveThemes(); + if (resolved is Future<_ResolvedStacThemes>) { + return FutureBuilder<_ResolvedStacThemes>( + future: resolved, + builder: (futureContext, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _ThemeFutureLoading(); + } + if (snapshot.hasError) { + return const _ThemeFutureLoading(); + } + final themes = snapshot.data; + if (themes == null) { + return const _ThemeFutureLoading(); + } + return builder(futureContext, themes); + }, + ); + } + return builder(context, resolved); + } +} + +class _ResolvedStacThemes { + const _ResolvedStacThemes({required this.theme, required this.darkTheme}); + + final StacTheme? theme; + final StacTheme? darkTheme; +} + +class _ThemeFutureLoading extends StatelessWidget { + const _ThemeFutureLoading(); + + @override + Widget build(BuildContext context) { + return const Material(child: Center(child: CircularProgressIndicator())); + } } diff --git a/packages/stac/lib/src/framework/stac_app_theme.dart b/packages/stac/lib/src/framework/stac_app_theme.dart new file mode 100644 index 00000000..bf78e314 --- /dev/null +++ b/packages/stac/lib/src/framework/stac_app_theme.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:stac/src/services/stac_cloud.dart'; +import 'package:stac/src/services/stac_network_service.dart'; +import 'package:stac_core/actions/network_request/stac_network_request.dart'; +import 'package:stac_core/foundation/theme/stac_theme/stac_theme.dart'; + +/// Provides helpers to load [StacTheme] definitions for [StacApp]. +class StacAppTheme { + const StacAppTheme._(); + + /// Fetches a theme from the `/themes` endpoint by [themeName]. + /// + /// Returns `null` if the network call fails or the payload is malformed. + static Future fromCloud({required String themeName}) async { + final response = await StacCloud.fetchTheme(themeName: themeName); + if (response == null) { + return null; + } + + final rawData = response.data; + if (rawData is! Map) { + return null; + } + + final themePayload = _normalizeThemeJson(rawData['stacJson']); + if (themePayload == null) { + return null; + } + + return StacTheme.fromJson(themePayload); + } + + /// Fetches a theme over HTTP using a [StacNetworkRequest]. + /// + /// Mirrors [Stac.fromNetwork], allowing callers to reuse existing request + /// builders and middleware. + static Future fromNetwork({ + required BuildContext context, + required StacNetworkRequest request, + }) async { + final response = await StacNetworkService.request(context, request); + if (response == null) { + return null; + } + + return fromJson(response.data); + } + + /// Creates a [StacTheme] from raw JSON payloads. + /// + /// Accepts either a `Map` or a JSON `String`. Returns `null` + /// when the payload cannot be parsed into a valid [StacTheme]. + static StacTheme? fromJson(dynamic payload) { + final themePayload = _normalizeThemeJson(payload); + if (themePayload == null) { + return null; + } + return StacTheme.fromJson(themePayload); + } + + static Map? _normalizeThemeJson(dynamic payload) { + if (payload == null) { + return null; + } + if (payload is Map && payload['stacJson'] != null) { + return _normalizeThemeJson(payload['stacJson']); + } + if (payload is Map) { + return payload; + } + if (payload is String) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return decoded; + } + } + return null; + } +} diff --git a/packages/stac/lib/src/models/stac_artifact_type.dart b/packages/stac/lib/src/models/stac_artifact_type.dart new file mode 100644 index 00000000..6bcc7683 --- /dev/null +++ b/packages/stac/lib/src/models/stac_artifact_type.dart @@ -0,0 +1,8 @@ +/// Type of artifact that can be fetched from Stac Cloud. +enum StacArtifactType { + /// A screen artifact. + screen, + + /// A theme artifact. + theme, +} diff --git a/packages/stac/lib/src/services/stac_cache_service.dart b/packages/stac/lib/src/services/stac_cache_service.dart index 4737ca20..20f2e4f3 100644 --- a/packages/stac/lib/src/services/stac_cache_service.dart +++ b/packages/stac/lib/src/services/stac_cache_service.dart @@ -1,15 +1,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:stac/src/models/stac_screen_cache.dart'; -/// Service for managing cached Stac screens. +/// Service for managing cached Stac artifacts (screens, themes, etc.). /// -/// This service uses SharedPreferences to persist screen data locally, +/// This service uses SharedPreferences to persist artifact data locally, /// enabling offline access and reducing unnecessary network requests. class StacCacheService { StacCacheService._(); - static const String _cachePrefix = 'stac_screen_cache_'; - /// Cached SharedPreferences instance for better performance. static SharedPreferences? _prefs; @@ -18,13 +16,29 @@ class StacCacheService { return _prefs ??= await SharedPreferences.getInstance(); } - /// Gets a cached screen by its name. + /// Gets the cache prefix for a given artifact type. + static String _getCachePrefix(String artifactType) { + switch (artifactType) { + case 'screen': + return 'stac_screen_cache_'; + case 'theme': + return 'stac_theme_cache_'; + default: + throw ArgumentError('Unknown artifact type: $artifactType'); + } + } + + /// Gets a cached artifact by its name and type. /// - /// Returns `null` if the screen is not cached. - static Future getCachedScreen(String screenName) async { + /// Returns `null` if the artifact is not cached. + static Future getCachedArtifact( + String artifactName, + String artifactType, + ) async { try { final prefs = await _sharedPrefs; - final cacheKey = _getCacheKey(screenName); + final cachePrefix = _getCachePrefix(artifactType); + final cacheKey = '$cachePrefix$artifactName'; final cachedData = prefs.getString(cacheKey); if (cachedData == null) { @@ -33,86 +47,60 @@ class StacCacheService { return StacScreenCache.fromJsonString(cachedData); } catch (e) { - // If there's an error reading from cache, return null - // and let the app fetch from network return null; } } - /// Saves a screen to the cache. + /// Saves an artifact to the cache. /// - /// If a screen with the same name already exists, it will be overwritten. - static Future saveScreen({ + /// If an artifact with the same name already exists, it will be overwritten. + static Future saveArtifact({ required String name, required String stacJson, required int version, + required String artifactType, }) async { try { final prefs = await _sharedPrefs; - final cacheKey = _getCacheKey(name); + final cachePrefix = _getCachePrefix(artifactType); + final cacheKey = '$cachePrefix$name'; - final screenCache = StacScreenCache( + final artifactCache = StacScreenCache( name: name, stacJson: stacJson, version: version, cachedAt: DateTime.now(), ); - return prefs.setString(cacheKey, screenCache.toJsonString()); + return prefs.setString(cacheKey, artifactCache.toJsonString()); } catch (e) { - // If there's an error saving to cache, return false - // but don't throw - the app should still work without cache return false; } } - /// Checks if a cached screen is still valid based on its age. - /// - /// Returns `true` if the cache is valid (not expired). - /// Returns `false` if the cache is expired or doesn't exist. - /// - /// If [maxAge] is `null`, cache is considered valid (no time-based expiration). - static Future isCacheValid({ - required String screenName, - Duration? maxAge, - }) async { - final cachedScreen = await getCachedScreen(screenName); - return isCacheValidSync(cachedScreen, maxAge); - } - - /// Synchronous version of [isCacheValid] for when you already have the cache. - /// - /// Use this to avoid re-fetching the cache when you already have it. - static bool isCacheValidSync( - StacScreenCache? cachedScreen, - Duration? maxAge, - ) { - if (cachedScreen == null) return false; - if (maxAge == null) return true; - - final age = DateTime.now().difference(cachedScreen.cachedAt); - return age <= maxAge; - } - - /// Removes a specific screen from the cache. - static Future removeScreen(String screenName) async { + /// Removes a specific artifact from the cache. + static Future removeArtifact( + String artifactName, + String artifactType, + ) async { try { final prefs = await _sharedPrefs; - final cacheKey = _getCacheKey(screenName); + final cachePrefix = _getCachePrefix(artifactType); + final cacheKey = '$cachePrefix$artifactName'; return prefs.remove(cacheKey); } catch (e) { return false; } } - /// Clears all cached screens. - static Future clearAllScreens() async { + /// Clears all cached artifacts of a specific type. + static Future clearAllArtifacts(String artifactType) async { try { final prefs = await _sharedPrefs; final keys = prefs.getKeys(); - final cacheKeys = keys.where((key) => key.startsWith(_cachePrefix)); + final cachePrefix = _getCachePrefix(artifactType); + final cacheKeys = keys.where((key) => key.startsWith(cachePrefix)); - // Use Future.wait for parallel deletion instead of sequential awaits await Future.wait(cacheKeys.map(prefs.remove)); return true; @@ -121,8 +109,32 @@ class StacCacheService { } } - /// Generates a cache key for a screen name. - static String _getCacheKey(String screenName) { - return '$_cachePrefix$screenName'; + /// Checks if a cached artifact is still valid based on its age. + /// + /// Returns `true` if the cache is valid (not expired). + /// Returns `false` if the cache is expired or doesn't exist. + /// + /// If [maxAge] is `null`, cache is considered valid (no time-based expiration). + static Future isCacheValid({ + required String artifactName, + required String artifactType, + Duration? maxAge, + }) async { + final cachedArtifact = await getCachedArtifact(artifactName, artifactType); + return isCacheValidSync(cachedArtifact, maxAge); + } + + /// Synchronous version of [isCacheValid] for when you already have the cache. + /// + /// Use this to avoid re-fetching the cache when you already have it. + static bool isCacheValidSync( + StacScreenCache? cachedArtifact, + Duration? maxAge, + ) { + if (cachedArtifact == null) return false; + if (maxAge == null) return true; + + final age = DateTime.now().difference(cachedArtifact.cachedAt); + return age <= maxAge; } } diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index c95cf504..62b7142e 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.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'; import 'package:stac/src/models/stac_screen_cache.dart'; import 'package:stac/src/services/stac_cache_service.dart'; @@ -19,12 +20,41 @@ class StacCloud { ), ); - static const String _fetchUrl = 'https://api.stac.dev/screens'; + static const String _baseUrl = + 'https://us-central1-stac-dev-9eff0.cloudfunctions.net'; - /// Tracks screens currently being fetched in background to prevent duplicates. - static final Set _backgroundFetchInProgress = {}; + /// Gets the fetch URL for a given artifact type. + static String _getFetchUrl(StacArtifactType artifactType) { + switch (artifactType) { + case StacArtifactType.screen: + return '$_baseUrl/screens'; + case StacArtifactType.theme: + return '$_baseUrl/themes'; + } + } - /// Fetches a screen from Stac Cloud with intelligent caching. + /// Gets the query parameter name for a given artifact type. + static String _getQueryParamName(StacArtifactType artifactType) { + switch (artifactType) { + case StacArtifactType.screen: + return 'screenName'; + case StacArtifactType.theme: + return 'themeName'; + } + } + + /// Gets the artifact type string for cache operations. + static String _getArtifactTypeString(StacArtifactType artifactType) { + return artifactType.name; + } + + /// Tracks artifacts currently being fetched in background to prevent duplicates. + static final Map> _backgroundFetchInProgress = { + StacArtifactType.screen: {}, + StacArtifactType.theme: {}, + }; + + /// Fetches an artifact from Stac Cloud with intelligent caching. /// /// The [cacheConfig] parameter controls caching behavior: /// - Strategy: How to handle cache vs network @@ -33,8 +63,9 @@ class StacCloud { /// - staleWhileRevalidate: Use expired cache while fetching fresh data /// /// Defaults to [StacCacheConfig.optimistic] if not provided. - static Future fetchScreen({ - required String routeName, + static Future _fetchArtifact({ + required StacArtifactType artifactType, + required String artifactName, StacCacheConfig cacheConfig = const StacCacheConfig( strategy: StacCacheStrategy.optimistic, ), @@ -44,144 +75,221 @@ class StacCloud { throw Exception('StacOptions is not set'); } + final artifactTypeString = _getArtifactTypeString(artifactType); + // Handle network-only strategy if (cacheConfig.strategy == StacCacheStrategy.networkOnly) { - return _fetchFromNetwork(routeName, saveToCache: false); + return _fetchArtifactFromNetwork( + artifactType: artifactType, + artifactName: artifactName, + saveToCache: false, + ); } - // Get cached screen - final cachedScreen = await StacCacheService.getCachedScreen(routeName); + // Get cached artifact + final cachedArtifact = await StacCacheService.getCachedArtifact( + artifactName, + artifactTypeString, + ); // Handle cache-only strategy if (cacheConfig.strategy == StacCacheStrategy.cacheOnly) { - if (cachedScreen != null) { - return _buildCacheResponse(cachedScreen); + if (cachedArtifact != null) { + return _buildArtifactCacheResponse(artifactType, cachedArtifact); } throw Exception( - 'No cached data available for $routeName (cache-only mode)', + 'No cached data available for $artifactType $artifactName (cache-only mode)', ); } // Check if cache is valid based on maxAge (sync to avoid double cache read) final isCacheValid = StacCacheService.isCacheValidSync( - cachedScreen, + cachedArtifact, cacheConfig.maxAge, ); // Handle different strategies switch (cacheConfig.strategy) { case StacCacheStrategy.networkFirst: - return _handleNetworkFirst(routeName, cachedScreen); + return _handleArtifactNetworkFirst( + artifactType: artifactType, + artifactName: artifactName, + cachedArtifact: cachedArtifact, + ); case StacCacheStrategy.cacheFirst: - return _handleCacheFirst( - routeName, - cachedScreen, - isCacheValid, - cacheConfig, + return _handleArtifactCacheFirst( + artifactType: artifactType, + artifactName: artifactName, + cachedArtifact: cachedArtifact, + isCacheValid: isCacheValid, + config: cacheConfig, ); case StacCacheStrategy.optimistic: - return _handleOptimistic( - routeName, - cachedScreen, - isCacheValid, - cacheConfig, + return _handleArtifactOptimistic( + artifactType: artifactType, + artifactName: artifactName, + cachedArtifact: cachedArtifact, + isCacheValid: isCacheValid, + config: cacheConfig, ); case StacCacheStrategy.cacheOnly: case StacCacheStrategy.networkOnly: // Already handled above - return _fetchFromNetwork(routeName, saveToCache: false); + return _fetchArtifactFromNetwork( + artifactType: artifactType, + artifactName: artifactName, + saveToCache: false, + ); } } + /// Fetches a screen from Stac Cloud with intelligent caching. + /// + /// The [cacheConfig] parameter controls caching behavior: + /// - Strategy: How to handle cache vs network + /// - maxAge: How long cache is valid + /// - refreshInBackground: Whether to update stale cache in background + /// - staleWhileRevalidate: Use expired cache while fetching fresh data + /// + /// Defaults to [StacCacheConfig.optimistic] if not provided. + static Future fetchScreen({ + required String routeName, + StacCacheConfig cacheConfig = const StacCacheConfig( + strategy: StacCacheStrategy.optimistic, + ), + }) async { + return _fetchArtifact( + artifactType: StacArtifactType.screen, + artifactName: routeName, + cacheConfig: cacheConfig, + ); + } + /// Handles network-first strategy: Try network, fallback to cache. - static Future _handleNetworkFirst( - String routeName, - StacScreenCache? cachedScreen, - ) async { + static Future _handleArtifactNetworkFirst({ + required StacArtifactType artifactType, + required String artifactName, + StacScreenCache? cachedArtifact, + }) async { try { - return await _fetchFromNetwork(routeName, saveToCache: true); + return await _fetchArtifactFromNetwork( + artifactType: artifactType, + artifactName: artifactName, + saveToCache: true, + ); } catch (e) { // Network failed, use cache as fallback - if (cachedScreen != null) { - Log.d('StacCloud: Network failed, using cached data for $routeName'); - return _buildCacheResponse(cachedScreen); + if (cachedArtifact != null) { + Log.d( + 'StacCloud: Network failed, using cached data for ${artifactType.name} $artifactName', + ); + return _buildArtifactCacheResponse(artifactType, cachedArtifact); } rethrow; } } /// Handles cache-first strategy: Use valid cache, fallback to network. - static Future _handleCacheFirst( - String routeName, - StacScreenCache? cachedScreen, - bool isCacheValid, - StacCacheConfig config, - ) async { + static Future _handleArtifactCacheFirst({ + required StacArtifactType artifactType, + required String artifactName, + StacScreenCache? cachedArtifact, + required bool isCacheValid, + required StacCacheConfig config, + }) async { // If cache is valid and exists, use it - if (cachedScreen != null && isCacheValid) { + if (cachedArtifact != null && isCacheValid) { // Optionally refresh in background if (config.refreshInBackground) { - _fetchAndUpdateInBackground(routeName, cachedScreen.version); + _fetchAndUpdateArtifactInBackground( + artifactType: artifactType, + artifactName: artifactName, + cachedVersion: cachedArtifact.version, + ); } - return _buildCacheResponse(cachedScreen); + return _buildArtifactCacheResponse(artifactType, cachedArtifact); } // Cache invalid or doesn't exist, fetch from network try { - return await _fetchFromNetwork(routeName, saveToCache: true); + return await _fetchArtifactFromNetwork( + artifactType: artifactType, + artifactName: artifactName, + saveToCache: true, + ); } catch (e) { // Network failed, use stale cache if available and staleWhileRevalidate is true - if (cachedScreen != null && config.staleWhileRevalidate) { + if (cachedArtifact != null && config.staleWhileRevalidate) { Log.d( - 'StacCloud: Using stale cache for $routeName due to network error', + 'StacCloud: Using stale cache for ${artifactType.name} $artifactName due to network error', ); - return _buildCacheResponse(cachedScreen); + return _buildArtifactCacheResponse(artifactType, cachedArtifact); } rethrow; } } /// Handles optimistic strategy: Return cache immediately, update in background. - static Future _handleOptimistic( - String routeName, - StacScreenCache? cachedScreen, - bool isCacheValid, - StacCacheConfig config, - ) async { + static Future _handleArtifactOptimistic({ + required StacArtifactType artifactType, + required String artifactName, + StacScreenCache? cachedArtifact, + required bool isCacheValid, + required StacCacheConfig config, + }) async { // If cache exists and is valid (or staleWhileRevalidate is true) - if (cachedScreen != null && (isCacheValid || config.staleWhileRevalidate)) { + if (cachedArtifact != null && + (isCacheValid || config.staleWhileRevalidate)) { // Update in background if configured if (config.refreshInBackground || !isCacheValid) { - _fetchAndUpdateInBackground(routeName, cachedScreen.version); + _fetchAndUpdateArtifactInBackground( + artifactType: artifactType, + artifactName: artifactName, + cachedVersion: cachedArtifact.version, + ); } - return _buildCacheResponse(cachedScreen); + return _buildArtifactCacheResponse(artifactType, cachedArtifact); } // No valid cache, must fetch from network - return _fetchFromNetwork(routeName, saveToCache: true); + return _fetchArtifactFromNetwork( + artifactType: artifactType, + artifactName: artifactName, + saveToCache: true, + ); } - /// Makes a network request to fetch screen data. - static Future _makeRequest(String routeName) { + /// Makes a network request to fetch artifact data. + static Future _makeArtifactRequest({ + required StacArtifactType artifactType, + required String artifactName, + }) { final options = StacService.options!; + final fetchUrl = _getFetchUrl(artifactType); + final queryParamName = _getQueryParamName(artifactType); + return _dio.get( - _fetchUrl, + fetchUrl, queryParameters: { 'projectId': options.projectId, - 'screenName': routeName, + queryParamName: artifactName, }, ); } - /// Fetches screen data from network and optionally saves to cache. - static Future _fetchFromNetwork( - String routeName, { + /// Fetches artifact data from network and optionally saves to cache. + static Future _fetchArtifactFromNetwork({ + required StacArtifactType artifactType, + required String artifactName, required bool saveToCache, }) async { - final response = await _makeRequest(routeName); + final response = await _makeArtifactRequest( + artifactType: artifactType, + artifactName: artifactName, + ); // Save to cache if enabled and response is valid if (saveToCache && response.data != null) { @@ -190,10 +298,11 @@ class StacCloud { final name = response.data['name'] as String?; if (version != null && stacJson != null && name != null) { - await StacCacheService.saveScreen( + await StacCacheService.saveArtifact( name: name, stacJson: stacJson, version: version, + artifactType: _getArtifactTypeString(artifactType), ); } } @@ -201,14 +310,18 @@ class StacCloud { return response; } - /// Builds a Response from cached screen data. - static Response _buildCacheResponse(StacScreenCache cachedScreen) { + /// Builds a Response from cached artifact data. + static Response _buildArtifactCacheResponse( + StacArtifactType artifactType, + StacScreenCache cachedArtifact, + ) { + final fetchUrl = _getFetchUrl(artifactType); return Response( - requestOptions: RequestOptions(path: _fetchUrl), + requestOptions: RequestOptions(path: fetchUrl), data: { - 'name': cachedScreen.name, - 'stacJson': cachedScreen.stacJson, - 'version': cachedScreen.version, + 'name': cachedArtifact.name, + 'stacJson': cachedArtifact.stacJson, + 'version': cachedArtifact.version, }, ); } @@ -217,16 +330,21 @@ class StacCloud { /// /// This method runs asynchronously without blocking the UI. /// If a newer version is found, it updates the cache for the next load. - /// Prevents duplicate fetches for the same screen. - static Future _fetchAndUpdateInBackground( - String routeName, - int cachedVersion, - ) async { - // Prevent duplicate background fetches for the same screen - if (!_backgroundFetchInProgress.add(routeName)) return; + /// Prevents duplicate fetches for the same artifact. + static Future _fetchAndUpdateArtifactInBackground({ + required StacArtifactType artifactType, + required String artifactName, + required int cachedVersion, + }) async { + final inProgressSet = _backgroundFetchInProgress[artifactType]!; + // Prevent duplicate background fetches for the same artifact + if (!inProgressSet.add(artifactName)) return; try { - final response = await _makeRequest(routeName); + final response = await _makeArtifactRequest( + artifactType: artifactType, + artifactName: artifactName, + ); if (response.data != null) { final serverVersion = response.data['version'] as int?; @@ -239,28 +357,63 @@ class StacCloud { name != null && serverVersion > cachedVersion) { // Update cache with new version for next load - await StacCacheService.saveScreen( + await StacCacheService.saveArtifact( name: name, stacJson: serverStacJson, version: serverVersion, + artifactType: _getArtifactTypeString(artifactType), ); } } } catch (e) { // Silently fail - background update is optional - Log.d('StacCloud: Background update failed for $routeName: $e'); + Log.d( + 'StacCloud: Background update failed for ${artifactType.name} $artifactName: $e', + ); } finally { - _backgroundFetchInProgress.remove(routeName); + inProgressSet.remove(artifactName); } } + /// Fetches a theme from Stac Cloud with intelligent caching. + /// + /// The [cacheConfig] parameter controls caching behavior: + /// - Strategy: How to handle cache vs network + /// - maxAge: How long cache is valid + /// - refreshInBackground: Whether to update stale cache in background + /// - staleWhileRevalidate: Use expired cache while fetching fresh data + /// + /// Defaults to [StacCacheConfig.optimistic] if not provided. + static Future fetchTheme({ + required String themeName, + StacCacheConfig cacheConfig = const StacCacheConfig( + strategy: StacCacheStrategy.optimistic, + ), + }) async { + return _fetchArtifact( + artifactType: StacArtifactType.theme, + artifactName: themeName, + cacheConfig: cacheConfig, + ); + } + /// Clears the cache for a specific screen. static Future clearScreenCache(String routeName) { - return StacCacheService.removeScreen(routeName); + return StacCacheService.removeArtifact(routeName, 'screen'); } /// Clears all cached screens. static Future clearAllCache() { - return StacCacheService.clearAllScreens(); + return StacCacheService.clearAllArtifacts('screen'); + } + + /// Clears the cache for a specific theme. + static Future clearThemeCache(String themeName) { + return StacCacheService.removeArtifact(themeName, 'theme'); + } + + /// Clears all cached themes. + static Future clearAllThemeCache() { + return StacCacheService.clearAllArtifacts('theme'); } } diff --git a/packages/stac_core/lib/annotations/annotations.dart b/packages/stac_core/lib/annotations/annotations.dart index 0b3ece7b..3a83c0ec 100644 --- a/packages/stac_core/lib/annotations/annotations.dart +++ b/packages/stac_core/lib/annotations/annotations.dart @@ -1,3 +1,4 @@ library; export 'stac_screen.dart'; +export 'stac_cloud_theme.dart'; diff --git a/packages/stac_core/lib/annotations/stac_cloud_theme.dart b/packages/stac_core/lib/annotations/stac_cloud_theme.dart new file mode 100644 index 00000000..259921d7 --- /dev/null +++ b/packages/stac_core/lib/annotations/stac_cloud_theme.dart @@ -0,0 +1,19 @@ +/// Annotation to mark methods that return theme definitions. +/// +/// This annotation is used to identify Stac theme builders so the framework can +/// register them and apply the correct theme at runtime. +/// +/// Example usage: +/// ```dart +/// @StacThemeAnnotation(themeName: 'darkTheme') +/// ThemeData buildDarkTheme() { +/// return ThemeData.dark(); +/// } +/// ``` +class StacCloudTheme { + /// Creates a [StacCloudTheme] with the given theme name. + const StacCloudTheme({required this.themeName}); + + /// The identifier for this theme. + final String themeName; +}