From b4766cd7e5be9a0290d04858d2837106171ab54a Mon Sep 17 00:00:00 2001 From: Divyanshu Bhargava Date: Tue, 25 Nov 2025 17:59:04 +0530 Subject: [PATCH] feat: Add caching mechanism for Stac JSONs. --- docs/concepts/caching.mdx | 232 ++++++++++++++++ docs/docs.json | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + examples/counter_example/pubspec.lock | 67 ++++- examples/movie_app/ios/Podfile.lock | 7 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + examples/movie_app/pubspec.lock | 65 ++++- .../Flutter/GeneratedPluginRegistrant.swift | 2 + examples/stac_gallery/pubspec.lock | 67 ++++- packages/stac/lib/src/framework/stac.dart | 238 ++++++++++++++++- packages/stac/lib/src/models/models.dart | 2 + .../lib/src/models/stac_cache_config.dart | 119 +++++++++ .../lib/src/models/stac_screen_cache.dart | 86 ++++++ .../lib/src/models/stac_screen_cache.g.dart | 23 ++ packages/stac/lib/src/services/services.dart | 1 + .../lib/src/services/stac_cache_service.dart | 128 +++++++++ .../stac/lib/src/services/stac_cloud.dart | 247 +++++++++++++++++- packages/stac/lib/stac.dart | 1 + packages/stac/pubspec.yaml | 1 + 19 files changed, 1276 insertions(+), 15 deletions(-) create mode 100644 docs/concepts/caching.mdx create mode 100644 packages/stac/lib/src/models/models.dart create mode 100644 packages/stac/lib/src/models/stac_cache_config.dart create mode 100644 packages/stac/lib/src/models/stac_screen_cache.dart create mode 100644 packages/stac/lib/src/models/stac_screen_cache.g.dart create mode 100644 packages/stac/lib/src/services/stac_cache_service.dart diff --git a/docs/concepts/caching.mdx b/docs/concepts/caching.mdx new file mode 100644 index 000000000..010a1df9f --- /dev/null +++ b/docs/concepts/caching.mdx @@ -0,0 +1,232 @@ +--- +title: "Caching & Offline Support" +description: "Learn how Stac intelligently caches screens for offline access, faster loading, and smarter network usage" +--- + +Stac includes a powerful caching system that stores screens locally, enabling offline access, instant loading, and more efficient network usage. The caching layer works automatically with the `Stac` widget and can be customized for different use cases. + +## How Caching Works + +When you use the `Stac` widget to fetch screens from Stac Cloud: + +1. **First Load**: The screen is fetched from the network and stored in local cache +2. **Subsequent Loads**: Based on your cache strategy, Stac returns cached data and/or fetches fresh data +3. **Version Tracking**: Each cached screen stores its version number, enabling smart updates +4. **Background Updates**: Fresh data can be fetched in the background without blocking the UI + +## Cache Strategies + +Stac provides five caching strategies to fit different use cases: + +### 1. Optimistic (Default) + +Returns cached data immediately while fetching updates in the background. Best for fast perceived performance. + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.optimistic, + ), +) +``` + +**Behavior:** +- ✅ Returns cached data instantly (even if expired) +- ✅ Fetches fresh data in background +- ✅ Updates cache for next load +- ⚡ Fastest perceived loading + +**Best for:** UI layouts, content screens, any screen where instant loading matters more than showing the absolute latest data. + +### 2. Cache First + +Uses cached data if available and valid, only fetches from network when cache is invalid or missing. + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: Duration(hours: 24), + ), +) +``` + +**Behavior:** +- ✅ Uses cache if valid (not expired) +- ✅ Fetches from network only when cache is invalid +- ✅ Optionally refreshes in background +- 📱 Great for offline-first apps + +**Best for:** Offline-first apps, content that doesn't change frequently, reducing network usage. + +### 3. Network First + +Always tries the network first, falls back to cache if network fails. + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.networkFirst, + ), +) +``` + +**Behavior:** +- ✅ Always fetches fresh data first +- ✅ Falls back to cache on network error +- ✅ Ensures latest content when online +- 🌐 Requires network for best experience + +**Best for:** Real-time data, frequently changing content, screens where freshness is critical. + +### 4. Cache Only + +Only uses cached data, never makes network requests. Throws an error if no cache exists. + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheOnly, + ), +) +``` + +**Behavior:** +- ✅ Never makes network requests +- ✅ Instant loading from cache +- ❌ Fails if no cached data exists +- 📴 Perfect for offline mode + +**Best for:** Offline-only mode, airplane mode, when you've pre-cached screens. + +### 5. Network Only + +Always fetches from network, never uses or updates cache. + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.networkOnly, + ), +) +``` + +**Behavior:** +- ✅ Always fresh data +- ❌ Fails without network +- ❌ No offline support +- 🔒 Good for sensitive data + +**Best for:** Sensitive data that shouldn't be cached, real-time dashboards, authentication screens. + +## Cache Configuration + +### StacCacheConfig Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `strategy` | `StacCacheStrategy` | `optimistic` | The caching strategy to use | +| `maxAge` | `Duration?` | `null` | Maximum age before cache is considered expired. `null` means no time-based expiration | +| `refreshInBackground` | `bool` | `true` | Whether to fetch fresh data in background when cache is valid | +| `staleWhileRevalidate` | `bool` | `false` | Whether to use expired cache while fetching fresh data | + +### Setting Cache Duration + +Control how long cached data remains valid: + +```dart +// Cache valid for 1 hour +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: Duration(hours: 1), + ), +) + +// Cache valid for 7 days +Stac( + routeName: '/settings', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: Duration(days: 7), + ), +) + +// Cache never expires (only version-based updates) +Stac( + routeName: '/static-page', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: null, // No time-based expiration + ), +) +``` + +### Background Refresh + +Keep cache fresh without blocking the UI: + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: Duration(hours: 1), + refreshInBackground: true, // Fetch updates silently + ), +) +``` + +When `refreshInBackground` is `true`: +- Valid cache is returned immediately +- Fresh data is fetched in background +- Cache is updated for next load +- User sees instant loading with eventual consistency + +### Stale While Revalidate + +Show expired cache while fetching fresh data: + +```dart +Stac( + routeName: '/home', + cacheConfig: StacCacheConfig( + strategy: StacCacheStrategy.cacheFirst, + maxAge: Duration(hours: 1), + staleWhileRevalidate: true, // Use expired cache while fetching + ), +) +``` + +This is useful when: +- You prefer showing something over a loading spinner +- Content staleness is acceptable for a brief period +- Network is slow or unreliable + +## Strategy Comparison + +| Strategy | Initial Load | Subsequent Load | Offline Support | Best For | +|----------|--------------|-----------------|-----------------|----------| +| `optimistic` | Network → Cache | Cache (bg update) | ✅ Yes | Fast UX | +| `cacheFirst` | Cache or Network | Cache | ✅ Yes | Offline apps | +| `networkFirst` | Network | Network (cache fallback) | ⚠️ Fallback only | Fresh data | +| `cacheOnly` | Cache only | Cache only | ✅ Yes | Offline mode | +| `networkOnly` | Network only | Network only | ❌ No | Sensitive data | + +## Version-Based Updates + +Stac tracks version numbers for each cached screen. When you deploy updates to Stac Cloud: + +1. The server assigns a new version number +2. Background fetches detect the new version +3. Cache is updated with new content +4. Next load shows updated screen + +This ensures users get updates without manual intervention while maintaining fast loading times. + diff --git a/docs/docs.json b/docs/docs.json index b4c8050c5..8214a5a38 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -38,6 +38,7 @@ "icon": "book", "pages": [ "concepts/rendering_stac_widgets", + "concepts/caching", "concepts/custom_widgets", "concepts/custom_actions" ] diff --git a/examples/counter_example/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/counter_example/macos/Flutter/GeneratedPluginRegistrant.swift index 252c0044e..d0e7d180c 100644 --- a/examples/counter_example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/counter_example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import path_provider_foundation +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/examples/counter_example/pubspec.lock b/examples/counter_example/pubspec.lock index 5b4f4c8c5..205ed32fd 100644 --- a/examples/counter_example/pubspec.lock +++ b/examples/counter_example/pubspec.lock @@ -299,6 +299,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" freezed: dependency: "direct dev" description: @@ -627,6 +632,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "07d552dbe8e71ed720e5205e760438ff4ecfb76ec3b32ea664350e2ca4b0c43b" + url: "https://pub.dev" + source: hosted + version: "2.4.16" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -726,7 +787,7 @@ packages: path: "../../packages/stac" relative: true source: path - version: "1.1.0" + version: "1.1.2" stac_core: dependency: "direct overridden" description: @@ -925,5 +986,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/examples/movie_app/ios/Podfile.lock b/examples/movie_app/ios/Podfile.lock index f2b82f708..efc2a4685 100644 --- a/examples/movie_app/ios/Podfile.lock +++ b/examples/movie_app/ios/Podfile.lock @@ -3,6 +3,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS @@ -10,6 +13,7 @@ PODS: 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: @@ -17,12 +21,15 @@ EXTERNAL SOURCES: :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/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/movie_app/macos/Flutter/GeneratedPluginRegistrant.swift index 252c0044e..d0e7d180c 100644 --- a/examples/movie_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/movie_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import path_provider_foundation +import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/examples/movie_app/pubspec.lock b/examples/movie_app/pubspec.lock index 3ee864a26..00d7faa48 100644 --- a/examples/movie_app/pubspec.lock +++ b/examples/movie_app/pubspec.lock @@ -267,6 +267,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -547,6 +552,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "07d552dbe8e71ed720e5205e760438ff4ecfb76ec3b32ea664350e2ca4b0c43b" + url: "https://pub.dev" + source: hosted + version: "2.4.16" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -654,7 +715,7 @@ packages: path: "../../packages/stac" relative: true source: path - version: "1.1.0" + version: "1.1.2" stac_core: dependency: "direct main" description: @@ -846,4 +907,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.9.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.35.0" diff --git a/examples/stac_gallery/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/stac_gallery/macos/Flutter/GeneratedPluginRegistrant.swift index 9f8f6a2f6..896f34b37 100644 --- a/examples/stac_gallery/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/stac_gallery/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import path_provider_foundation +import shared_preferences_foundation import sqflite_darwin import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/examples/stac_gallery/pubspec.lock b/examples/stac_gallery/pubspec.lock index 229528570..e4b11ea88 100644 --- a/examples/stac_gallery/pubspec.lock +++ b/examples/stac_gallery/pubspec.lock @@ -299,6 +299,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" freezed: dependency: "direct dev" description: @@ -627,6 +632,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "07d552dbe8e71ed720e5205e760438ff4ecfb76ec3b32ea664350e2ca4b0c43b" + url: "https://pub.dev" + source: hosted + version: "2.4.16" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -726,7 +787,7 @@ packages: path: "../../packages/stac" relative: true source: path - version: "1.1.0" + version: "1.1.2" stac_core: dependency: "direct main" description: @@ -964,5 +1025,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/packages/stac/lib/src/framework/stac.dart b/packages/stac/lib/src/framework/stac.dart index 7c606bc71..8d64f9a28 100644 --- a/packages/stac/lib/src/framework/stac.dart +++ b/packages/stac/lib/src/framework/stac.dart @@ -5,14 +5,21 @@ 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_cache_config.dart'; import 'package:stac/src/services/stac_cloud.dart'; import 'package:stac_core/actions/network_request/stac_network_request.dart'; import 'package:stac_core/core/stac_options.dart'; import 'package:stac_framework/stac_framework.dart'; +/// Builder function for displaying errors in Stac widgets. +/// +/// Called when a Stac widget encounters an error during loading or parsing. typedef ErrorWidgetBuilder = Widget Function(BuildContext context, dynamic error); +/// Builder function for displaying loading states in Stac widgets. +/// +/// Called while a Stac widget is fetching data from the network or cache. typedef LoadingWidgetBuilder = Widget Function(BuildContext context); /// Global parse-error widget builder for Stac. @@ -32,18 +39,171 @@ typedef LoadingWidgetBuilder = Widget Function(BuildContext context); typedef StacErrorWidgetBuilder = Widget Function(BuildContext context, StacError errorDetails); +/// The main entry point for rendering Server-Driven UI from Stac Cloud. +/// +/// [Stac] is a widget that fetches screen definitions from Stac Cloud +/// and renders them as Flutter widgets. It supports intelligent caching, +/// offline access, and background updates. +/// +/// ## Basic Usage +/// +/// ```dart +/// // First, initialize Stac in your app's main function +/// void main() async { +/// WidgetsFlutterBinding.ensureInitialized(); +/// await Stac.initialize( +/// options: StacOptions(projectId: 'your-project-id'), +/// ); +/// runApp(MyApp()); +/// } +/// +/// // Then use Stac widget to render server-driven screens +/// Stac(routeName: '/home') +/// ``` +/// +/// ## Caching +/// +/// By default, Stac uses an optimistic caching strategy that returns +/// cached data immediately while fetching updates in the background. +/// +/// ```dart +/// Stac( +/// routeName: '/home', +/// cacheConfig: StacCacheConfig( +/// strategy: StacCacheStrategy.cacheFirst, +/// maxAge: Duration(hours: 24), +/// ), +/// ) +/// ``` +/// +/// ## Custom Loading and Error States +/// +/// ```dart +/// Stac( +/// routeName: '/home', +/// loadingWidget: Center(child: CircularProgressIndicator()), +/// errorWidget: Center(child: Text('Failed to load')), +/// ) +/// ``` +/// +/// ## Static Methods +/// +/// Stac also provides static methods for rendering widgets from various sources: +/// - [fromJson] - Render from a JSON map +/// - [fromAssets] - Render from a local asset file +/// - [fromNetwork] - Render from a custom network request +/// +/// See also: +/// - [StacCacheConfig] for cache configuration options +/// - [StacOptions] for initialization options class Stac extends StatelessWidget { + /// Creates a Stac widget that renders a screen from Stac Cloud. + /// + /// The [routeName] identifies which screen to fetch from the cloud. + /// This should match the screen name configured in your Stac Cloud project. + /// + /// Optionally provide [loadingWidget] and [errorWidget] to customize + /// the loading and error states. If not provided, defaults are used. + /// + /// The [cacheConfig] controls caching behavior. Defaults to optimistic + /// caching which returns cached data immediately and updates in background. const Stac({ super.key, required this.routeName, this.loadingWidget, this.errorWidget, + this.cacheConfig = const StacCacheConfig( + strategy: StacCacheStrategy.optimistic, + ), }); + /// The route name identifying the screen to fetch from Stac Cloud. + /// + /// This should match the screen name configured in your Stac Cloud project. + /// For example: `/home`, `/profile`, `/settings`. final String routeName; + + /// Widget to display while the screen is loading. + /// + /// If `null`, a default loading indicator is shown (centered + /// [CircularProgressIndicator] in a [Scaffold]). final Widget? loadingWidget; + + /// Widget to display when an error occurs. + /// + /// If `null`, an empty [SizedBox] is shown on error. final Widget? errorWidget; + /// Configuration for cache behavior. + /// + /// Controls cache strategy, TTL, background refresh, and more. + /// + /// Defaults to optimistic caching strategy. + /// + /// Example: + /// ```dart + /// Stac( + /// routeName: '/home', + /// cacheConfig: StacCacheConfig( + /// maxAge: Duration(hours: 24), + /// strategy: StacCacheStrategy.optimistic, + /// ), + /// ) + /// ``` + /// + /// Or use presets: + /// ```dart + /// Stac( + /// routeName: '/home', + /// cacheConfig: StacCacheConfig.cacheFirst, + /// ) + /// ``` + final StacCacheConfig cacheConfig; + + /// Initializes Stac with the provided configuration. + /// + /// This must be called before using any Stac widgets, typically in + /// your app's `main` function after `WidgetsFlutterBinding.ensureInitialized()`. + /// + /// ## Parameters + /// + /// - [options]: Configuration containing your Stac Cloud project ID. + /// Required for fetching screens from Stac Cloud. + /// + /// - [parsers]: Custom widget parsers for extending Stac with custom widgets. + /// These are merged with the built-in parsers. + /// + /// - [actionParsers]: Custom action parsers for extending Stac with custom actions. + /// These are merged with the built-in action parsers. + /// + /// - [dio]: Custom Dio instance for network requests. If not provided, + /// a default instance is used. + /// + /// - [override]: If `true`, allows re-initialization. Useful for testing. + /// Defaults to `false`. + /// + /// - [showErrorWidgets]: If `true`, shows error widgets when parsing fails. + /// If `false`, errors are silent. Defaults to `true`. + /// + /// - [logStackTraces]: If `true`, logs stack traces for debugging. + /// Defaults to `true`. + /// + /// - [errorWidgetBuilder]: Custom builder for error widgets shown when + /// parsing fails. + /// + /// ## Example + /// + /// ```dart + /// void main() async { + /// WidgetsFlutterBinding.ensureInitialized(); + /// await Stac.initialize( + /// options: StacOptions(projectId: 'your-project-id'), + /// parsers: [MyCustomWidgetParser()], + /// actionParsers: [MyCustomActionParser()], + /// ); + /// runApp(MyApp()); + /// } + /// ``` static Future initialize({ StacOptions? options, List parsers = const [], @@ -72,13 +232,44 @@ class Stac extends StatelessWidget { routeName: routeName, loadingWidget: loadingWidget, errorWidget: errorWidget, + cacheConfig: cacheConfig, ); } + /// Converts a JSON map to a Flutter widget. + /// + /// Use this method to render a Stac widget definition that you already + /// have as a JSON map (e.g., from a local file or custom API). + /// + /// Returns `null` if the JSON is `null` or cannot be parsed. + /// + /// ## Example + /// + /// ```dart + /// final json = { + /// 'type': 'text', + /// 'data': 'Hello, World!', + /// }; + /// final widget = Stac.fromJson(json, context); + /// ``` static Widget? fromJson(Map? json, BuildContext context) { return StacService.fromJson(json, context); } + /// Loads and renders a Stac widget from a local asset file. + /// + /// The [assetPath] should point to a JSON file in your assets folder + /// containing a valid Stac widget definition. + /// + /// ## Example + /// + /// ```dart + /// Stac.fromAssets( + /// 'assets/screens/home.json', + /// loadingWidget: (context) => CircularProgressIndicator(), + /// errorWidget: (context, error) => Text('Error: $error'), + /// ) + /// ``` static Widget fromAssets( String assetPath, { LoadingWidgetBuilder? loadingWidget, @@ -91,6 +282,26 @@ class Stac extends StatelessWidget { ); } + /// Loads and renders a Stac widget from a custom network request. + /// + /// Use this when you need to fetch Stac widget definitions from your + /// own API instead of Stac Cloud. + /// + /// The [request] defines the network request configuration including + /// URL, method, headers, and body. + /// + /// ## Example + /// + /// ```dart + /// Stac.fromNetwork( + /// context: context, + /// request: StacNetworkRequest( + /// url: 'https://api.example.com/screens/home', + /// method: 'GET', + /// ), + /// loadingWidget: (context) => CircularProgressIndicator(), + /// ) + /// ``` static Widget fromNetwork({ required BuildContext context, required StacNetworkRequest request, @@ -105,6 +316,22 @@ class Stac extends StatelessWidget { ); } + /// Executes a Stac action from a JSON definition. + /// + /// Use this to programmatically trigger Stac actions (like navigation, + /// network requests, or custom actions) from JSON definitions. + /// + /// Returns the result of the action, which varies by action type. + /// + /// ## Example + /// + /// ```dart + /// final actionJson = { + /// 'actionType': 'navigate', + /// 'routeName': '/details', + /// }; + /// await Stac.onCallFromJson(actionJson, context); + /// ``` static FutureOr onCallFromJson( Map? json, BuildContext context, @@ -113,16 +340,19 @@ class Stac extends StatelessWidget { } } +/// Internal stateless widget that handles fetching and rendering Stac screens. class _StacView extends StatelessWidget { const _StacView({ required this.routeName, this.loadingWidget, this.errorWidget, + required this.cacheConfig, }); final String routeName; final Widget? loadingWidget; final Widget? errorWidget; + final StacCacheConfig cacheConfig; @override Widget build(BuildContext context) { @@ -132,7 +362,10 @@ class _StacView extends StatelessWidget { } return FutureBuilder( - future: StacCloud.fetchScreen(context: context, routeName: routeName), + future: StacCloud.fetchScreen( + routeName: routeName, + cacheConfig: cacheConfig, + ), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return loadingWidget ?? const _LoadingWidget(); @@ -151,11 +384,12 @@ class _StacView extends StatelessWidget { } } +/// Default loading widget shown when no custom loading widget is provided. class _LoadingWidget extends StatelessWidget { const _LoadingWidget(); @override Widget build(BuildContext context) { - return Scaffold(body: const Center(child: CircularProgressIndicator())); + return const Material(child: Center(child: CircularProgressIndicator())); } } diff --git a/packages/stac/lib/src/models/models.dart b/packages/stac/lib/src/models/models.dart new file mode 100644 index 000000000..845acf51a --- /dev/null +++ b/packages/stac/lib/src/models/models.dart @@ -0,0 +1,2 @@ +export 'package:stac/src/models/stac_cache_config.dart'; +export 'package:stac/src/models/stac_screen_cache.dart'; diff --git a/packages/stac/lib/src/models/stac_cache_config.dart b/packages/stac/lib/src/models/stac_cache_config.dart new file mode 100644 index 000000000..9a6052aa1 --- /dev/null +++ b/packages/stac/lib/src/models/stac_cache_config.dart @@ -0,0 +1,119 @@ +/// Defines different cache strategies for Stac screens. +enum StacCacheStrategy { + /// Always fetch from network, update cache in background. + /// Best for real-time data. + networkFirst, + + /// Use cache if available and valid, fallback to network. + /// Best for offline-first apps. + cacheFirst, + + /// Return cache immediately, update in background. + /// Best for fast loading with eventual consistency. + optimistic, + + /// Only use cache, never fetch from network. + /// Best for offline-only mode. + cacheOnly, + + /// Only use network, never cache. + /// Best for sensitive data that shouldn't be cached. + networkOnly, +} + +/// Configuration for Stac screen caching behavior. +/// +/// This class allows fine-grained control over how screens are cached, +/// when they expire, and how updates are handled. +/// +/// Example: +/// ```dart +/// Stac( +/// routeName: '/home', +/// cacheConfig: StacCacheConfig( +/// maxAge: Duration(hours: 24), +/// strategy: StacCacheStrategy.optimistic, +/// ), +/// ) +/// ``` +class StacCacheConfig { + /// Creates a [StacCacheConfig] instance. + const StacCacheConfig({ + this.maxAge, + this.strategy = StacCacheStrategy.optimistic, + this.refreshInBackground = true, + this.staleWhileRevalidate = false, + }); + + /// Maximum age of cached data before it's considered expired. + /// + /// When `null`, cache never expires based on time (only version updates matter). + /// + /// Examples: + /// - `Duration(hours: 1)` - Cache expires after 1 hour + /// - `Duration(days: 7)` - Cache expires after 7 days + /// - `Duration(minutes: 30)` - Cache expires after 30 minutes + final Duration? maxAge; + + /// The caching strategy to use. + /// + /// Defaults to [StacCacheStrategy.optimistic]. + final StacCacheStrategy strategy; + + /// Whether to refresh cache in the background when data is stale but valid. + /// + /// Only applies to [StacCacheStrategy.optimistic] and [StacCacheStrategy.cacheFirst]. + /// + /// When `true`: Shows cached data, fetches updates in background + /// When `false`: Only updates when cache is completely invalid + final bool refreshInBackground; + + /// Use stale cache while revalidating (fetch in background). + /// + /// When `true`: Even expired cache will be shown while fetching fresh data. + /// When `false`: Expired cache is treated as invalid. + /// + /// Useful for providing instant UI even when cache is expired. + final bool staleWhileRevalidate; + + /// Creates a copy of this config with the given fields replaced. + StacCacheConfig copyWith({ + Duration? maxAge, + StacCacheStrategy? strategy, + bool? refreshInBackground, + bool? staleWhileRevalidate, + }) { + return StacCacheConfig( + maxAge: maxAge ?? this.maxAge, + strategy: strategy ?? this.strategy, + refreshInBackground: refreshInBackground ?? this.refreshInBackground, + staleWhileRevalidate: staleWhileRevalidate ?? this.staleWhileRevalidate, + ); + } + + @override + String toString() { + return 'StacCacheConfig(maxAge: $maxAge, strategy: $strategy, refreshInBackground: $refreshInBackground, staleWhileRevalidate: $staleWhileRevalidate)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StacCacheConfig && + other.maxAge == maxAge && + other.strategy == strategy && + other.refreshInBackground == refreshInBackground && + other.staleWhileRevalidate == staleWhileRevalidate; + } + + @override + int get hashCode { + return Object.hash( + maxAge, + strategy, + refreshInBackground, + staleWhileRevalidate, + ); + } +} diff --git a/packages/stac/lib/src/models/stac_screen_cache.dart b/packages/stac/lib/src/models/stac_screen_cache.dart new file mode 100644 index 000000000..e63d5a38a --- /dev/null +++ b/packages/stac/lib/src/models/stac_screen_cache.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +part 'stac_screen_cache.g.dart'; + +/// Model representing a cached screen from Stac Cloud. +/// +/// This model stores the screen data along with metadata for caching purposes. +@JsonSerializable() +class StacScreenCache { + /// Creates a [StacScreenCache] instance. + const StacScreenCache({ + required this.name, + required this.stacJson, + required this.version, + required this.cachedAt, + }); + + /// The screen name/route identifier. + final String name; + + /// The JSON string containing the Stac widget definition. + final String stacJson; + + /// The version number of the screen. + final int version; + + /// The timestamp when this screen was cached. + final DateTime cachedAt; + + /// Creates a [StacScreenCache] from a JSON map. + factory StacScreenCache.fromJson(Map json) => + _$StacScreenCacheFromJson(json); + + /// Converts this [StacScreenCache] to a JSON map. + Map toJson() => _$StacScreenCacheToJson(this); + + /// Creates a [StacScreenCache] from a JSON string. + factory StacScreenCache.fromJsonString(String jsonString) { + return StacScreenCache.fromJson( + jsonDecode(jsonString) as Map, + ); + } + + /// Converts this [StacScreenCache] to a JSON string. + String toJsonString() { + return jsonEncode(toJson()); + } + + /// Creates a copy of this [StacScreenCache] with the given fields replaced. + StacScreenCache copyWith({ + String? name, + String? stacJson, + int? version, + DateTime? cachedAt, + }) { + return StacScreenCache( + name: name ?? this.name, + stacJson: stacJson ?? this.stacJson, + version: version ?? this.version, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + String toString() { + return 'StacScreenCache(name: $name, version: $version, cachedAt: $cachedAt)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StacScreenCache && + other.name == name && + other.stacJson == stacJson && + other.version == version && + other.cachedAt == cachedAt; + } + + @override + int get hashCode { + return Object.hash(name, stacJson, version, cachedAt); + } +} diff --git a/packages/stac/lib/src/models/stac_screen_cache.g.dart b/packages/stac/lib/src/models/stac_screen_cache.g.dart new file mode 100644 index 000000000..be1b97e89 --- /dev/null +++ b/packages/stac/lib/src/models/stac_screen_cache.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stac_screen_cache.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StacScreenCache _$StacScreenCacheFromJson(Map json) => + StacScreenCache( + name: json['name'] as String, + stacJson: json['stacJson'] as String, + version: (json['version'] as num).toInt(), + cachedAt: DateTime.parse(json['cachedAt'] as String), + ); + +Map _$StacScreenCacheToJson(StacScreenCache instance) => + { + 'name': instance.name, + 'stacJson': instance.stacJson, + 'version': instance.version, + 'cachedAt': instance.cachedAt.toIso8601String(), + }; diff --git a/packages/stac/lib/src/services/services.dart b/packages/stac/lib/src/services/services.dart index 8d8f30e15..c9fdb6246 100644 --- a/packages/stac/lib/src/services/services.dart +++ b/packages/stac/lib/src/services/services.dart @@ -1 +1,2 @@ +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_cache_service.dart b/packages/stac/lib/src/services/stac_cache_service.dart new file mode 100644 index 000000000..4737ca200 --- /dev/null +++ b/packages/stac/lib/src/services/stac_cache_service.dart @@ -0,0 +1,128 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stac/src/models/stac_screen_cache.dart'; + +/// Service for managing cached Stac screens. +/// +/// This service uses SharedPreferences to persist screen 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; + + /// Gets the SharedPreferences instance, caching it for subsequent calls. + static Future get _sharedPrefs async { + return _prefs ??= await SharedPreferences.getInstance(); + } + + /// Gets a cached screen by its name. + /// + /// Returns `null` if the screen is not cached. + static Future getCachedScreen(String screenName) async { + try { + final prefs = await _sharedPrefs; + final cacheKey = _getCacheKey(screenName); + final cachedData = prefs.getString(cacheKey); + + if (cachedData == null) { + return null; + } + + 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. + /// + /// If a screen with the same name already exists, it will be overwritten. + static Future saveScreen({ + required String name, + required String stacJson, + required int version, + }) async { + try { + final prefs = await _sharedPrefs; + final cacheKey = _getCacheKey(name); + + final screenCache = StacScreenCache( + name: name, + stacJson: stacJson, + version: version, + cachedAt: DateTime.now(), + ); + + return prefs.setString(cacheKey, screenCache.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 { + try { + final prefs = await _sharedPrefs; + final cacheKey = _getCacheKey(screenName); + return prefs.remove(cacheKey); + } catch (e) { + return false; + } + } + + /// Clears all cached screens. + static Future clearAllScreens() async { + try { + final prefs = await _sharedPrefs; + final keys = prefs.getKeys(); + 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; + } catch (e) { + return false; + } + } + + /// Generates a cache key for a screen name. + static String _getCacheKey(String screenName) { + return '$_cachePrefix$screenName'; + } +} diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart index 2582b377b..c95cf5049 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -1,23 +1,172 @@ import 'package:dio/dio.dart'; -import 'package:flutter/widgets.dart'; import 'package:stac/src/framework/stac_service.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'; +import 'package:stac_logger/stac_logger.dart'; +/// Service for fetching screens from Stac Cloud with caching support. +/// +/// This service automatically caches screens and compares versions +/// to avoid unnecessary network requests. class StacCloud { const StacCloud._(); - static final Dio _dio = Dio(); + static final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); - static const String _fetchUrl = 'http://api.stac.dev/screens'; + static const String _fetchUrl = 'https://api.stac.dev/screens'; + /// Tracks screens currently being fetched in background to prevent duplicates. + static final Set _backgroundFetchInProgress = {}; + + /// 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 BuildContext context, required String routeName, - }) { + StacCacheConfig cacheConfig = const StacCacheConfig( + strategy: StacCacheStrategy.optimistic, + ), + }) async { final options = StacService.options; if (options == null) { throw Exception('StacOptions is not set'); } + // Handle network-only strategy + if (cacheConfig.strategy == StacCacheStrategy.networkOnly) { + return _fetchFromNetwork(routeName, saveToCache: false); + } + + // Get cached screen + final cachedScreen = await StacCacheService.getCachedScreen(routeName); + + // Handle cache-only strategy + if (cacheConfig.strategy == StacCacheStrategy.cacheOnly) { + if (cachedScreen != null) { + return _buildCacheResponse(cachedScreen); + } + throw Exception( + 'No cached data available for $routeName (cache-only mode)', + ); + } + + // Check if cache is valid based on maxAge (sync to avoid double cache read) + final isCacheValid = StacCacheService.isCacheValidSync( + cachedScreen, + cacheConfig.maxAge, + ); + + // Handle different strategies + switch (cacheConfig.strategy) { + case StacCacheStrategy.networkFirst: + return _handleNetworkFirst(routeName, cachedScreen); + + case StacCacheStrategy.cacheFirst: + return _handleCacheFirst( + routeName, + cachedScreen, + isCacheValid, + cacheConfig, + ); + + case StacCacheStrategy.optimistic: + return _handleOptimistic( + routeName, + cachedScreen, + isCacheValid, + cacheConfig, + ); + + case StacCacheStrategy.cacheOnly: + case StacCacheStrategy.networkOnly: + // Already handled above + return _fetchFromNetwork(routeName, saveToCache: false); + } + } + + /// Handles network-first strategy: Try network, fallback to cache. + static Future _handleNetworkFirst( + String routeName, + StacScreenCache? cachedScreen, + ) async { + try { + return await _fetchFromNetwork(routeName, 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); + } + rethrow; + } + } + + /// Handles cache-first strategy: Use valid cache, fallback to network. + static Future _handleCacheFirst( + String routeName, + StacScreenCache? cachedScreen, + bool isCacheValid, + StacCacheConfig config, + ) async { + // If cache is valid and exists, use it + if (cachedScreen != null && isCacheValid) { + // Optionally refresh in background + if (config.refreshInBackground) { + _fetchAndUpdateInBackground(routeName, cachedScreen.version); + } + return _buildCacheResponse(cachedScreen); + } + + // Cache invalid or doesn't exist, fetch from network + try { + return await _fetchFromNetwork(routeName, saveToCache: true); + } catch (e) { + // Network failed, use stale cache if available and staleWhileRevalidate is true + if (cachedScreen != null && config.staleWhileRevalidate) { + Log.d( + 'StacCloud: Using stale cache for $routeName due to network error', + ); + return _buildCacheResponse(cachedScreen); + } + rethrow; + } + } + + /// Handles optimistic strategy: Return cache immediately, update in background. + static Future _handleOptimistic( + String routeName, + StacScreenCache? cachedScreen, + bool isCacheValid, + StacCacheConfig config, + ) async { + // If cache exists and is valid (or staleWhileRevalidate is true) + if (cachedScreen != null && (isCacheValid || config.staleWhileRevalidate)) { + // Update in background if configured + if (config.refreshInBackground || !isCacheValid) { + _fetchAndUpdateInBackground(routeName, cachedScreen.version); + } + return _buildCacheResponse(cachedScreen); + } + + // No valid cache, must fetch from network + return _fetchFromNetwork(routeName, saveToCache: true); + } + + /// Makes a network request to fetch screen data. + static Future _makeRequest(String routeName) { + final options = StacService.options!; return _dio.get( _fetchUrl, queryParameters: { @@ -26,4 +175,92 @@ class StacCloud { }, ); } + + /// Fetches screen data from network and optionally saves to cache. + static Future _fetchFromNetwork( + String routeName, { + required bool saveToCache, + }) async { + final response = await _makeRequest(routeName); + + // Save to cache if enabled and response is valid + if (saveToCache && response.data != null) { + final version = response.data['version'] as int?; + final stacJson = response.data['stacJson'] as String?; + final name = response.data['name'] as String?; + + if (version != null && stacJson != null && name != null) { + await StacCacheService.saveScreen( + name: name, + stacJson: stacJson, + version: version, + ); + } + } + + return response; + } + + /// Builds a Response from cached screen data. + static Response _buildCacheResponse(StacScreenCache cachedScreen) { + return Response( + requestOptions: RequestOptions(path: _fetchUrl), + data: { + 'name': cachedScreen.name, + 'stacJson': cachedScreen.stacJson, + 'version': cachedScreen.version, + }, + ); + } + + /// Fetches the latest version in background and updates cache if newer. + /// + /// 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; + + try { + final response = await _makeRequest(routeName); + + if (response.data != null) { + final serverVersion = response.data['version'] as int?; + final serverStacJson = response.data['stacJson'] as String?; + final name = response.data['name'] as String?; + + // Only update if server has newer version + if (serverVersion != null && + serverStacJson != null && + name != null && + serverVersion > cachedVersion) { + // Update cache with new version for next load + await StacCacheService.saveScreen( + name: name, + stacJson: serverStacJson, + version: serverVersion, + ); + } + } + } catch (e) { + // Silently fail - background update is optional + Log.d('StacCloud: Background update failed for $routeName: $e'); + } finally { + _backgroundFetchInProgress.remove(routeName); + } + } + + /// Clears the cache for a specific screen. + static Future clearScreenCache(String routeName) { + return StacCacheService.removeScreen(routeName); + } + + /// Clears all cached screens. + static Future clearAllCache() { + return StacCacheService.clearAllScreens(); + } } diff --git a/packages/stac/lib/stac.dart b/packages/stac/lib/stac.dart index 1f3b5e094..749fd39aa 100644 --- a/packages/stac/lib/stac.dart +++ b/packages/stac/lib/stac.dart @@ -1,4 +1,5 @@ export 'package:stac/src/framework/framework.dart'; +export 'package:stac/src/models/models.dart'; export 'package:stac/src/parsers/actions/actions.dart'; export 'package:stac/src/parsers/parsers.dart'; export 'package:stac/src/services/services.dart'; diff --git a/packages/stac/pubspec.yaml b/packages/stac/pubspec.yaml index e59053deb..231980b08 100644 --- a/packages/stac/pubspec.yaml +++ b/packages/stac/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: flutter_svg: ^2.2.2 stac_logger: ^1.1.0 stac_core: ^1.1.0 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: