diff --git a/docs/concepts/theming.mdx b/docs/concepts/theming.mdx index 1a1b65f8..e97589c0 100644 --- a/docs/concepts/theming.mdx +++ b/docs/concepts/theming.mdx @@ -1,17 +1,21 @@ --- title: "Theming" -description: "Documentation for Theming" +description: "Learn how to implement and manage themes in Stac applications using DSL + Cloud (recommended), network, or JSON sources" --- -Theming is an essential part of any application, ensuring a consistent look and feel across the entire app. Stac offers a powerful way to update the theme of your application dynamically using JSON. -Stac theming functions similarly to Flutter's built-in theming. You define the theme in JSON and apply it to your application using the StacTheme widget. This allows for a centralized and easily maintainable approach to managing your app's visual style. +Theming is an essential part of any application, ensuring a consistent look and feel across the entire app. Stac offers flexible theming options that allow you to fetch themes from Stac Cloud (recommended), load them over the network, or parse them from JSON. -## Implementing Stac Theming +## Overview -To implement theming in Stac, follow these steps: +Stac theming works similarly to Flutter's built-in theming system. You define themes using `StacTheme` objects and apply them to your application using `StacApp`. The framework supports multiple ways to load themes: -1. **Replace MaterialApp with StacApp**: Start by replacing your `MaterialApp` with `StacApp` -2. **Pass the StacTheme to StacApp**: Apply the theme by passing the `StacTheme` widget to the `StacApp`. The StacTheme widget takes a `StacTheme` object as a parameter, which is constructed from your JSON theme definition. +1. **Cloud Themes (Recommended)** - Fetch themes from Stac Cloud by name +2. **Network Themes** - Load themes over HTTP using `StacNetworkRequest` +3. **JSON Themes** - Parse themes from JSON data + +## Using StacApp with Themes + +To use themes in your Stac application, replace `MaterialApp` with `StacApp` and pass your theme using the `StacAppTheme` wrapper: ```dart import 'package:flutter/material.dart'; @@ -23,39 +27,206 @@ void main() async { } class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); + const MyApp({super.key}); @override Widget build(BuildContext context) { return StacApp( - theme: StacTheme.fromJson(themeJson), + title: 'My App', + theme: StacAppTheme(name: "light_theme"), // Cloud theme (recommended) + darkTheme: StacAppTheme(name: "dark_theme"), // Cloud theme (recommended) homeBuilder: (context) => const HomeScreen(), ); } - - Map themeJson = { - "brightness": "light", - "disabledColor": "#60FEF7FF", - "fontFamily": "Handjet", - "colorScheme": { - "brightness": "light", - "primary": "#6750a4", - "onPrimary": "#FFFFFF", - "secondary": "#615B6F", - "onSecondary": "#FFFFFF", - "surface": "#FEFBFF", - "onSurface": "#1C1B1E", - "background": "#FEFBFF", - "onBackground": "#1C1B1E", - "surfaceVariant": "#E6E0EA", - "onSurfaceVariant": "#48454D", - "error": "#AB2D25", - "onError": "#FFFFFF", - "success": "#27BA62", - "onSuccess": "#FFFFFF" - } - }; } ``` -For more details check out [StacTheme](https://github.com/StacDev/stac/blob/dev/packages/stac/lib/src/parsers/theme/stac_theme/stac_theme.dart) class. +## Theme Sources + +### 1. Cloud Themes (Recommended) + +Fetch themes from Stac Cloud by name. This is the recommended approach as it allows themes to be managed server-side and updated without app updates. Themes are cached locally for offline access and performance. + +#### Workflow: Generate and Deploy Themes + +To use Cloud themes, you first need to: + +1. **Define your themes** in your `stac/` directory using `@StacThemeRef` annotation +2. **Generate JSON** from your DSL themes using the Stac CLI +3. **Deploy to Stac Cloud** using `stac deploy` + +Here's the complete workflow: + +**Step 1: Define your theme** in `stac/app_theme.dart`: + +```dart +import 'package:stac_core/stac_core.dart'; + +@StacThemeRef(name: "movie_app_dark") +StacTheme get darkTheme => _buildTheme( + brightness: StacBrightness.dark, + colorScheme: StacColorScheme( + brightness: StacBrightness.dark, + primary: '#95E183', + // ... other properties + ), +); +``` + +**Step 2: Generate and deploy** using the CLI: + +```bash +stac deploy +``` + +This command will: +- Build your project +- Process all `@StacScreen` annotated screens and `@StacThemeRef` annotated themes +- Generate JSON files for each screen and theme +- Upload all generated files to Stac Cloud + +Example output: +``` +[INFO] Building project before deployment... +[INFO] Found 4 @StacScreen annotated function(s) +[SUCCESS] ✓ Generated screen: onboarding_screen.json +[SUCCESS] ✓ Generated screen: home_screen.json +[SUCCESS] ✓ Generated screen: detail_screen.json +[INFO] Found 1 @StacThemeRef definition(s) in stac\app_theme.dart +[SUCCESS] ✓ Generated theme: movie_app_dark.json +[SUCCESS] Build completed successfully! +[INFO] Deploying screens/themes to cloud... +[SUCCESS] Uploaded screen: onboarding_screen.json +[SUCCESS] Uploaded screen: home_screen.json +[SUCCESS] Uploaded screen: detail_screen.json +[SUCCESS] Uploaded theme: movie_app_dark.json +[SUCCESS] Deployment completed successfully! +``` + +**Step 3: Use in your app**: + +```dart +StacApp( + theme: StacAppTheme(name: "movie_app_light"), + darkTheme: StacAppTheme(name: "movie_app_dark"), + // ... +) +``` + +The `StacAppTheme` wrapper automatically fetches the theme from Stac Cloud using the provided name. Themes are cached intelligently to minimize network requests. + +> You can also use DSL themes directly by passing a `StacTheme` object to `StacAppTheme.dsl(theme: myTheme)`. When using themes directly, the `@StacThemeRef` annotation is not needed. The annotation is only required when you want to deploy themes to Stac Cloud using `stac deploy`. + +### 2. Network Themes + +Load themes over HTTP using a `StacNetworkRequest`. This allows you to fetch themes from any API endpoint: + +```dart +StacApp( + theme: StacAppTheme.network( + context: context, + request: StacNetworkRequest( + url: 'https://api.example.com/themes/light', + method: Method.get, + ), + ), + // ... +) +``` + +### 3. JSON Themes + +Parse themes directly from JSON data. Useful when themes are stored locally or received from other sources: + +```dart +final themeJson = { + "brightness": "dark", + "colorScheme": { + "brightness": "dark", + "primary": "#95E183", + "onPrimary": "#050608", + // ... other color scheme properties + }, + "textTheme": { + // ... text theme properties + } +}; + +StacApp( + theme: StacAppTheme.json(payload: themeJson), + // ... +) +``` + +## Theme Structure + +A `StacTheme` consists of several components: + +### Color Scheme + +The color scheme defines the primary colors used throughout your app: + +```dart +StacColorScheme( + brightness: StacBrightness.dark, + primary: '#95E183', + onPrimary: '#050608', + secondary: '#95E183', + onSecondary: '#FFFFFF', + surface: '#050608', + onSurface: '#FFFFFF', + onSurfaceVariant: '#65FFFFFF', + error: '#FF6565', + onError: '#050608', + outline: '#08FFFFFF', +) +``` + +### Text Theme + +Define typography styles for different text elements: + +```dart +StacTextTheme( + displayLarge: StacCustomTextStyle( + fontSize: 48, + fontWeight: StacFontWeight.w700, + height: 1.1, + ), + headlineLarge: StacCustomTextStyle( + fontSize: 30, + fontWeight: StacFontWeight.w700, + height: 1.3, + ), + bodyLarge: StacCustomTextStyle( + fontSize: 18, + fontWeight: StacFontWeight.w400, + height: 1.5, + ), + // ... other text styles +) +``` + +### Button Themes + +Customize button appearances: + +```dart +// Filled button theme +StacButtonStyle( + minimumSize: StacSize(120, 40), + textStyle: StacCustomTextStyle( + fontSize: 16, + fontWeight: StacFontWeight.w500, + ), + padding: StacEdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8), + shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), +) + +// Outlined button theme +StacButtonStyle( + minimumSize: StacSize(120, 40), + side: StacBorderSide(color: '#95E183', width: 1.0), + shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), +) +``` diff --git a/docs/docs.json b/docs/docs.json index 26a49426..f2c1724b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,7 +40,8 @@ "concepts/rendering_stac_widgets", "concepts/caching", "concepts/custom_widgets", - "concepts/custom_actions" + "concepts/custom_actions", + "concepts/theming" ] } ] diff --git a/examples/movie_app/lib/main.dart b/examples/movie_app/lib/main.dart index 3ec8688a..df21e93a 100644 --- a/examples/movie_app/lib/main.dart +++ b/examples/movie_app/lib/main.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:movie_app/default_stac_options.dart'; -import 'package:movie_app/themes/app_theme.dart'; import 'package:movie_app/widgets/movie_carousel/movie_carousel_parser.dart'; import 'package:stac/stac.dart'; @@ -35,7 +34,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return StacApp( title: 'Flutter Demo', - theme: darkTheme, + theme: StacAppTheme(name: "movie_app_dark"), homeBuilder: (_) { return Stac(routeName: 'onboarding_screen'); }, diff --git a/examples/movie_app/lib/themes/app_theme.dart b/examples/movie_app/lib/themes/app_theme.dart deleted file mode 100644 index 84e694bc..00000000 --- a/examples/movie_app/lib/themes/app_theme.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:stac_core/stac_core.dart'; // Hide StacTheme from stac_core to use the one from stac - -/// Dark theme for the Movie App. -StacTheme get darkTheme { - return StacTheme( - brightness: StacBrightness.dark, - colorScheme: StacColorScheme( - brightness: StacBrightness.dark, - primary: '#95E183', - onPrimary: '#050608', - secondary: '#95E183', - onSecondary: '#FFFFFF', - surface: '#050608', - onSurface: '#FFFFFF', - onSurfaceVariant: '#65FFFFFF', - error: '#FF6565', - onError: '#050608', - outline: '#08FFFFFF', - ), - textTheme: StacTextTheme( - displayLarge: StacCustomTextStyle( - fontSize: 48, - fontWeight: StacFontWeight.w700, - height: 1.1, - ), - displayMedium: StacCustomTextStyle( - fontSize: 40, - fontWeight: StacFontWeight.w700, - height: 1.1, - ), - displaySmall: StacCustomTextStyle( - fontSize: 34, - fontWeight: StacFontWeight.w700, - height: 1.1, - ), - headlineLarge: StacCustomTextStyle( - fontSize: 30, - fontWeight: StacFontWeight.w700, - height: 1.3, - ), - headlineMedium: StacCustomTextStyle( - fontSize: 26, - fontWeight: StacFontWeight.w700, - height: 1.3, - ), - headlineSmall: StacCustomTextStyle( - fontSize: 23, - fontWeight: StacFontWeight.w700, - height: 1.3, - ), - titleLarge: StacCustomTextStyle( - fontSize: 20, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - titleMedium: StacCustomTextStyle( - fontSize: 18, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - titleSmall: StacCustomTextStyle( - fontSize: 16, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - labelLarge: StacCustomTextStyle( - fontSize: 16, - fontWeight: StacFontWeight.w700, - height: 1.3, - ), - labelMedium: StacCustomTextStyle( - fontSize: 14, - fontWeight: StacFontWeight.w600, - height: 1.3, - ), - labelSmall: StacCustomTextStyle( - fontSize: 12, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - bodyLarge: StacCustomTextStyle( - fontSize: 18, - fontWeight: StacFontWeight.w400, - height: 1.5, - ), - bodyMedium: StacCustomTextStyle( - fontSize: 16, - fontWeight: StacFontWeight.w400, - height: 1.5, - ), - bodySmall: StacCustomTextStyle( - fontSize: 14, - fontWeight: StacFontWeight.w400, - height: 1.5, - ), - ), - filledButtonTheme: StacButtonStyle( - minimumSize: StacSize(120, 40), - textStyle: StacCustomTextStyle( - fontSize: 16, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - padding: StacEdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8), - shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), - ), - outlinedButtonTheme: StacButtonStyle( - minimumSize: StacSize(120, 40), - textStyle: StacCustomTextStyle( - fontSize: 16, - fontWeight: StacFontWeight.w500, - height: 1.3, - ), - padding: StacEdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8), - side: StacBorderSide(color: '#95E183', width: 1.0), - shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), - ), - dividerTheme: StacDividerThemeData(color: '#24FFFFFF', thickness: 1), - ); -} diff --git a/examples/movie_app/stac/app_theme.dart b/examples/movie_app/stac/app_theme.dart new file mode 100644 index 00000000..08ff2494 --- /dev/null +++ b/examples/movie_app/stac/app_theme.dart @@ -0,0 +1,158 @@ +import 'package:stac_core/stac_core.dart'; + +@StacThemeRef(name: "movie_app_dark") +StacTheme get darkTheme => _buildTheme( + brightness: StacBrightness.dark, + colorScheme: StacColorScheme( + brightness: StacBrightness.dark, + primary: '#95E183', + onPrimary: '#050608', + secondary: '#95E183', + onSecondary: '#FFFFFF', + surface: '#050608', + onSurface: '#FFFFFF', + onSurfaceVariant: '#65FFFFFF', + error: '#FF6565', + onError: '#050608', + outline: '#08FFFFFF', + ), +); + +StacTheme _buildTheme({ + required StacBrightness brightness, + required StacColorScheme colorScheme, +}) { + return StacTheme( + brightness: brightness, + colorScheme: colorScheme, + textTheme: _buildTextTheme(), + filledButtonTheme: _buildFilledButtonTheme(), + outlinedButtonTheme: _buildOutlinedButtonTheme(), + dividerTheme: _buildDividerTheme(), + ); +} + +StacTextTheme _buildTextTheme() { + return StacTextTheme( + displayLarge: _textStyle( + fontSize: 48, + fontWeight: StacFontWeight.w700, + height: 1.1, + ), + displayMedium: _textStyle( + fontSize: 40, + fontWeight: StacFontWeight.w700, + height: 1.1, + ), + displaySmall: _textStyle( + fontSize: 34, + fontWeight: StacFontWeight.w700, + height: 1.1, + ), + headlineLarge: _textStyle( + fontSize: 30, + fontWeight: StacFontWeight.w700, + height: 1.3, + ), + headlineMedium: _textStyle( + fontSize: 26, + fontWeight: StacFontWeight.w700, + height: 1.3, + ), + headlineSmall: _textStyle( + fontSize: 23, + fontWeight: StacFontWeight.w700, + height: 1.3, + ), + titleLarge: _textStyle( + fontSize: 20, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + titleMedium: _textStyle( + fontSize: 18, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + titleSmall: _textStyle( + fontSize: 16, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + labelLarge: _textStyle( + fontSize: 16, + fontWeight: StacFontWeight.w700, + height: 1.3, + ), + labelMedium: _textStyle( + fontSize: 14, + fontWeight: StacFontWeight.w600, + height: 1.3, + ), + labelSmall: _textStyle( + fontSize: 12, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + bodyLarge: _textStyle( + fontSize: 18, + fontWeight: StacFontWeight.w400, + height: 1.5, + ), + bodyMedium: _textStyle( + fontSize: 16, + fontWeight: StacFontWeight.w400, + height: 1.5, + ), + bodySmall: _textStyle( + fontSize: 14, + fontWeight: StacFontWeight.w400, + height: 1.5, + ), + ); +} + +StacButtonStyle _buildFilledButtonTheme() { + return StacButtonStyle( + minimumSize: StacSize(120, 40), + textStyle: _textStyle( + fontSize: 16, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + padding: StacEdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8), + shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), + ); +} + +StacButtonStyle _buildOutlinedButtonTheme() { + return StacButtonStyle( + minimumSize: StacSize(120, 40), + textStyle: _textStyle( + fontSize: 16, + fontWeight: StacFontWeight.w500, + height: 1.3, + ), + padding: StacEdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8), + side: StacBorderSide(color: '#95E183', width: 1.0), + shape: StacRoundedRectangleBorder(borderRadius: StacBorderRadius.all(8)), + ); +} + +StacDividerThemeData _buildDividerTheme() { + return StacDividerThemeData(color: '#24FFFFFF', thickness: 1); +} + +StacCustomTextStyle _textStyle({ + required double fontSize, + required StacFontWeight fontWeight, + required double height, + double? letterSpacing, +}) { + return StacCustomTextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + height: height, + letterSpacing: letterSpacing, + ); +} diff --git a/examples/stac_gallery/lib/main.dart b/examples/stac_gallery/lib/main.dart index 15c7e44c..b25e6a20 100644 --- a/examples/stac_gallery/lib/main.dart +++ b/examples/stac_gallery/lib/main.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:stac/stac.dart' show Stac, StacApp; +import 'package:stac/stac.dart'; import 'package:stac_core/stac_core.dart'; import 'package:stac_gallery/app/details/details_screen.dart'; import 'package:stac_gallery/app/example/example_screen_parser.dart'; @@ -41,8 +41,8 @@ class MyApp extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return StacApp( - theme: state.lightTheme, - darkTheme: state.darkTheme, + theme: StacAppTheme.json(payload: state.lightTheme), + darkTheme: StacAppTheme.json(payload: state.darkTheme), themeMode: state.themeMode, homeBuilder: (context) => HomeScreen(), title: 'Stac Gallery', 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..9c4a6b32 100644 --- a/packages/stac/lib/src/framework/stac_app.dart +++ b/packages/stac/lib/src/framework/stac_app.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:stac/src/framework/stac_app_theme.dart'; import 'package:stac/src/parsers/theme/themes.dart'; +import 'package:stac_logger/stac_logger.dart'; -class StacApp extends StatelessWidget { +class StacApp extends StatefulWidget { const StacApp({ super.key, this.navigatorKey, @@ -91,6 +93,9 @@ class StacApp extends StatelessWidget { routes = null, initialRoute = null; + @override + State createState() => _StacAppState(); + final GlobalKey? navigatorKey; final GlobalKey? scaffoldMessengerKey; final Widget? Function(BuildContext)? homeBuilder; @@ -108,8 +113,8 @@ class StacApp extends StatelessWidget { final TransitionBuilder? builder; final String title; final GenerateAppTitle? onGenerateTitle; - final StacTheme? theme; - final StacTheme? darkTheme; + final StacAppTheme? theme; + final StacAppTheme? darkTheme; final ThemeData? highContrastTheme; final ThemeData? highContrastDarkTheme; final ThemeMode? themeMode; @@ -132,96 +137,164 @@ class StacApp extends StatelessWidget { final ScrollBehavior? scrollBehavior; final bool debugShowMaterialGrid; final bool useInheritedMediaQuery; +} + +class _StacAppState extends State { + Future<_ResolvedStacThemes>? _themesFuture; + _ResolvedStacThemes? _resolvedThemes; + + @override + void initState() { + super.initState(); + _themesFuture = _resolveThemes(); + _themesFuture! + .then((themes) { + if (mounted) { + setState(() { + _resolvedThemes = themes; + }); + } + }) + .catchError((error) { + if (mounted) { + Log.w('Failed to resolve theme: $error'); + setState(() { + _resolvedThemes = (theme: null, darkTheme: null); + }); + } + }); + } @override Widget build(BuildContext context) { - if (routerDelegate != null || routerConfig != null) { - return _materialRouterApp(context); + if (_resolvedThemes == null) { + return const _LoadingWidget(); + } + + if (widget.routerDelegate != null || widget.routerConfig != null) { + return _buildMaterialAppRouter(context, _resolvedThemes!); } - return _materialApp(context); + return _buildMaterialApp(context, _resolvedThemes!); } - Widget _materialApp(BuildContext context) { + Widget _buildMaterialApp(BuildContext context, _ResolvedStacThemes themes) { return MaterialApp( - navigatorKey: navigatorKey, - scaffoldMessengerKey: scaffoldMessengerKey, + navigatorKey: widget.navigatorKey, + scaffoldMessengerKey: widget.scaffoldMessengerKey, home: Builder( builder: (context) { - if (homeBuilder != null) { - return homeBuilder!(context) ?? const SizedBox(); + if (widget.homeBuilder != null) { + return widget.homeBuilder!(context) ?? const SizedBox(); } return const SizedBox(); }, ), - routes: routes ?? {}, - initialRoute: initialRoute, - onGenerateRoute: onGenerateRoute, - onGenerateInitialRoutes: onGenerateInitialRoutes, - onUnknownRoute: onUnknownRoute, - navigatorObservers: navigatorObservers ?? [], - builder: builder, - title: title, - onGenerateTitle: onGenerateTitle, - theme: theme?.parse(context), - darkTheme: darkTheme?.parse(context), - highContrastTheme: highContrastTheme, - highContrastDarkTheme: highContrastDarkTheme, - themeMode: themeMode, - themeAnimationDuration: themeAnimationDuration, - themeAnimationCurve: themeAnimationCurve, - color: color, - locale: locale, - localizationsDelegates: localizationsDelegates, - localeListResolutionCallback: localeListResolutionCallback, - localeResolutionCallback: localeResolutionCallback, - supportedLocales: supportedLocales, - showPerformanceOverlay: showPerformanceOverlay, - checkerboardRasterCacheImages: checkerboardRasterCacheImages, - checkerboardOffscreenLayers: checkerboardOffscreenLayers, - showSemanticsDebugger: showSemanticsDebugger, - debugShowCheckedModeBanner: debugShowCheckedModeBanner, - shortcuts: shortcuts, - actions: actions, - restorationScopeId: restorationScopeId, - scrollBehavior: scrollBehavior, - debugShowMaterialGrid: debugShowMaterialGrid, + routes: widget.routes ?? {}, + initialRoute: widget.initialRoute, + onGenerateRoute: widget.onGenerateRoute, + onGenerateInitialRoutes: widget.onGenerateInitialRoutes, + onUnknownRoute: widget.onUnknownRoute, + navigatorObservers: widget.navigatorObservers ?? [], + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + theme: themes.theme?.parse(context), + darkTheme: themes.darkTheme?.parse(context), + highContrastTheme: widget.highContrastTheme, + highContrastDarkTheme: widget.highContrastDarkTheme, + themeMode: widget.themeMode, + themeAnimationDuration: widget.themeAnimationDuration, + themeAnimationCurve: widget.themeAnimationCurve, + color: widget.color, + locale: widget.locale, + localizationsDelegates: widget.localizationsDelegates, + localeListResolutionCallback: widget.localeListResolutionCallback, + localeResolutionCallback: widget.localeResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + scrollBehavior: widget.scrollBehavior, + debugShowMaterialGrid: widget.debugShowMaterialGrid, ); } - Widget _materialRouterApp(BuildContext context) { + Widget _buildMaterialAppRouter( + BuildContext context, + _ResolvedStacThemes themes, + ) { return MaterialApp.router( - scaffoldMessengerKey: scaffoldMessengerKey, - routeInformationProvider: routeInformationProvider, - routeInformationParser: routeInformationParser, - routerDelegate: routerDelegate, - routerConfig: routerConfig, - backButtonDispatcher: backButtonDispatcher, - builder: builder, - title: title, - onGenerateTitle: onGenerateTitle, - color: color, - theme: theme?.parse(context), - darkTheme: darkTheme?.parse(context), - highContrastTheme: highContrastTheme, - highContrastDarkTheme: highContrastDarkTheme, - themeMode: themeMode, - themeAnimationDuration: themeAnimationDuration, - themeAnimationCurve: themeAnimationCurve, - locale: locale, - localizationsDelegates: localizationsDelegates, - localeListResolutionCallback: localeListResolutionCallback, - localeResolutionCallback: localeResolutionCallback, - supportedLocales: supportedLocales, - debugShowMaterialGrid: debugShowMaterialGrid, - showPerformanceOverlay: showPerformanceOverlay, - checkerboardRasterCacheImages: checkerboardRasterCacheImages, - checkerboardOffscreenLayers: checkerboardOffscreenLayers, - showSemanticsDebugger: showSemanticsDebugger, - debugShowCheckedModeBanner: debugShowCheckedModeBanner, - shortcuts: shortcuts, - actions: actions, - restorationScopeId: restorationScopeId, - scrollBehavior: scrollBehavior, + scaffoldMessengerKey: widget.scaffoldMessengerKey, + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + routerConfig: widget.routerConfig, + backButtonDispatcher: widget.backButtonDispatcher, + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + color: widget.color, + theme: themes.theme?.parse(context), + darkTheme: themes.darkTheme?.parse(context), + highContrastTheme: widget.highContrastTheme, + highContrastDarkTheme: widget.highContrastDarkTheme, + themeMode: widget.themeMode, + themeAnimationDuration: widget.themeAnimationDuration, + themeAnimationCurve: widget.themeAnimationCurve, + locale: widget.locale, + localizationsDelegates: widget.localizationsDelegates, + localeListResolutionCallback: widget.localeListResolutionCallback, + localeResolutionCallback: widget.localeResolutionCallback, + supportedLocales: widget.supportedLocales, + debugShowMaterialGrid: widget.debugShowMaterialGrid, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + scrollBehavior: widget.scrollBehavior, ); } + + Future<_ResolvedStacThemes> _resolveThemes() { + final themeInput = widget.theme; + final darkThemeInput = widget.darkTheme; + + // Both themes are optional, so we need to handle null cases + final Future? themeFuture = themeInput?.resolve(); + final Future? darkThemeFuture = darkThemeInput?.resolve(); + + // If both are null, return immediately with null themes + if (themeFuture == null && darkThemeFuture == null) { + return Future.value((theme: null, darkTheme: null)); + } + + return Future<_ResolvedStacThemes>(() async { + final resolvedTheme = + await (themeFuture ?? Future.value(null)); + final resolvedDarkTheme = + await (darkThemeFuture ?? Future.value(null)); + + return (theme: resolvedTheme, darkTheme: resolvedDarkTheme); + }); + } +} + +typedef _ResolvedStacThemes = ({StacTheme? theme, StacTheme? darkTheme}); + +class _LoadingWidget extends StatelessWidget { + const _LoadingWidget(); + + @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..40484c32 --- /dev/null +++ b/packages/stac/lib/src/framework/stac_app_theme.dart @@ -0,0 +1,166 @@ +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'; +import 'package:stac_logger/stac_logger.dart'; + +/// Provides helpers to load [StacTheme] definitions for [StacApp]. +/// +/// Can be used as a wrapper to fetch themes from different sources: +/// ```dart +/// // From DSL (StacTheme object) +/// StacAppTheme.dsl(theme: myTheme) +/// +/// // From cloud +/// StacAppTheme(name: "xyz") +/// +/// // From network +/// StacAppTheme.network(context: context, request: request) +/// +/// // From JSON +/// StacAppTheme.json(payload: jsonData) +/// ``` +class StacAppTheme { + /// Creates a [StacAppTheme] wrapper for using a DSL theme. + /// + /// The [theme] should be a `StacTheme` object defined with `@StacThemeRef` annotation. + const StacAppTheme.dsl({required StacTheme theme}) + : _source = _ThemeSource.dsl, + name = null, + _context = null, + _request = null, + _jsonPayload = null, + _dslTheme = theme; + + /// Creates a [StacAppTheme] wrapper for fetching a theme from the cloud by [name]. + const StacAppTheme({required this.name}) + : _source = _ThemeSource.cloud, + _context = null, + _request = null, + _jsonPayload = null, + _dslTheme = null; + + /// Creates a [StacAppTheme] wrapper for fetching a theme from network. + const StacAppTheme.network({ + required BuildContext context, + required StacNetworkRequest request, + }) : _source = _ThemeSource.network, + name = null, + _context = context, + _request = request, + _jsonPayload = null, + _dslTheme = null; + + /// Creates a [StacAppTheme] wrapper for creating a theme from JSON. + const StacAppTheme.json({required dynamic payload}) + : _source = _ThemeSource.json, + name = null, + _context = null, + _request = null, + _jsonPayload = payload, + _dslTheme = null; + + /// The name of the theme to fetch from cloud (only used for cloud source). + final String? name; + + final _ThemeSource _source; + final BuildContext? _context; + final StacNetworkRequest? _request; + final Object? _jsonPayload; + final StacTheme? _dslTheme; + + /// Resolves the theme based on the configured source. + /// + /// Returns `null` if the fetch/parse fails or the payload is malformed. + Future resolve() async { + switch (_source) { + case _ThemeSource.dsl: + return _dslTheme; + case _ThemeSource.cloud: + return fromCloud(themeName: name!); + case _ThemeSource.network: + return fromNetwork(context: _context!, request: _request!); + case _ThemeSource.json: + return fromJson(_jsonPayload); + } + } + + /// 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 = _themeJsonDynamicToMap(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 = _themeJsonDynamicToMap(payload); + if (themePayload == null) { + return null; + } + return StacTheme.fromJson(themePayload); + } + + static Map? _themeJsonDynamicToMap(dynamic payload) { + if (payload == null) { + return null; + } + if (payload is Map && payload['stacJson'] != null) { + return _themeJsonDynamicToMap(payload['stacJson']); + } + if (payload is Map) { + return payload; + } + if (payload is String) { + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return decoded; + } + } catch (e) { + Log.w('Unexpected error parsing theme JSON: $e'); + return null; + } + } + return null; + } +} + +enum _ThemeSource { dsl, cloud, network, json } diff --git a/packages/stac/lib/src/models/models.dart b/packages/stac/lib/src/models/models.dart index 845acf51..34b2f513 100644 --- a/packages/stac/lib/src/models/models.dart +++ b/packages/stac/lib/src/models/models.dart @@ -1,2 +1,2 @@ export 'package:stac/src/models/stac_cache_config.dart'; -export 'package:stac/src/models/stac_screen_cache.dart'; +export 'package:stac/src/models/stac_cache.dart'; 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/models/stac_screen_cache.dart b/packages/stac/lib/src/models/stac_cache.dart similarity index 59% rename from packages/stac/lib/src/models/stac_screen_cache.dart rename to packages/stac/lib/src/models/stac_cache.dart index e63d5a38..8cc8ca60 100644 --- a/packages/stac/lib/src/models/stac_screen_cache.dart +++ b/packages/stac/lib/src/models/stac_cache.dart @@ -2,15 +2,15 @@ import 'dart:convert'; import 'package:json_annotation/json_annotation.dart'; -part 'stac_screen_cache.g.dart'; +part 'stac_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({ +class StacCache { + /// Creates a [StacCache] instance. + const StacCache({ required this.name, required this.stacJson, required this.version, @@ -29,33 +29,31 @@ class StacScreenCache { /// The timestamp when this screen was cached. final DateTime cachedAt; - /// Creates a [StacScreenCache] from a JSON map. - factory StacScreenCache.fromJson(Map json) => - _$StacScreenCacheFromJson(json); + /// Creates a [StacCache] from a JSON map. + factory StacCache.fromJson(Map json) => + _$StacCacheFromJson(json); - /// Converts this [StacScreenCache] to a JSON map. - Map toJson() => _$StacScreenCacheToJson(this); + /// Converts this [StacCache] to a JSON map. + Map toJson() => _$StacCacheToJson(this); - /// Creates a [StacScreenCache] from a JSON string. - factory StacScreenCache.fromJsonString(String jsonString) { - return StacScreenCache.fromJson( - jsonDecode(jsonString) as Map, - ); + /// Creates a [StacCache] from a JSON string. + factory StacCache.fromJsonString(String jsonString) { + return StacCache.fromJson(jsonDecode(jsonString) as Map); } - /// Converts this [StacScreenCache] to a JSON string. + /// Converts this [StacCache] to a JSON string. String toJsonString() { return jsonEncode(toJson()); } - /// Creates a copy of this [StacScreenCache] with the given fields replaced. - StacScreenCache copyWith({ + /// Creates a copy of this [StacCache] with the given fields replaced. + StacCache copyWith({ String? name, String? stacJson, int? version, DateTime? cachedAt, }) { - return StacScreenCache( + return StacCache( name: name ?? this.name, stacJson: stacJson ?? this.stacJson, version: version ?? this.version, @@ -65,14 +63,14 @@ class StacScreenCache { @override String toString() { - return 'StacScreenCache(name: $name, version: $version, cachedAt: $cachedAt)'; + return 'StacCache(name: $name, version: $version, cachedAt: $cachedAt)'; } @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is StacScreenCache && + return other is StacCache && other.name == name && other.stacJson == stacJson && other.version == version && diff --git a/packages/stac/lib/src/models/stac_cache.g.dart b/packages/stac/lib/src/models/stac_cache.g.dart new file mode 100644 index 00000000..a5371ab8 --- /dev/null +++ b/packages/stac/lib/src/models/stac_cache.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stac_cache.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StacCache _$StacCacheFromJson(Map json) => StacCache( + name: json['name'] as String, + stacJson: json['stacJson'] as String, + version: (json['version'] as num).toInt(), + cachedAt: DateTime.parse(json['cachedAt'] as String), +); + +Map _$StacCacheToJson(StacCache instance) => { + 'name': instance.name, + 'stacJson': instance.stacJson, + 'version': instance.version, + 'cachedAt': instance.cachedAt.toIso8601String(), +}; diff --git a/packages/stac/lib/src/models/stac_screen_cache.g.dart b/packages/stac/lib/src/models/stac_screen_cache.g.dart deleted file mode 100644 index be1b97e8..00000000 --- a/packages/stac/lib/src/models/stac_screen_cache.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// 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/stac_cache_service.dart b/packages/stac/lib/src/services/stac_cache_service.dart index 4737ca20..6a5ea882 100644 --- a/packages/stac/lib/src/services/stac_cache_service.dart +++ b/packages/stac/lib/src/services/stac_cache_service.dart @@ -1,15 +1,15 @@ import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stac/src/models/stac_screen_cache.dart'; +import 'package:stac/src/models/stac_cache.dart'; +import 'package:stac/src/models/stac_artifact_type.dart'; +import 'package:stac_logger/stac_logger.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,101 +18,92 @@ 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(StacArtifactType artifactType) { + switch (artifactType) { + case StacArtifactType.screen: + return 'stac_screen_cache_'; + case StacArtifactType.theme: + return 'stac_theme_cache_'; + } + } + + /// 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, + StacArtifactType 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) { return null; } - return StacScreenCache.fromJsonString(cachedData); + return StacCache.fromJsonString(cachedData); } catch (e) { - // If there's an error reading from cache, return null - // and let the app fetch from network + Log.w( + 'Failed to get cached artifact $artifactName (${artifactType.name}): $e', + ); 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 StacArtifactType artifactType, }) async { try { final prefs = await _sharedPrefs; - final cacheKey = _getCacheKey(name); + final cachePrefix = _getCachePrefix(artifactType); + final cacheKey = '$cachePrefix$name'; - final screenCache = StacScreenCache( + final artifactCache = StacCache( 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, + StacArtifactType 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(StacArtifactType 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 +112,17 @@ 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 bool isCacheValid(StacCache? 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..65f7a676 100644 --- a/packages/stac/lib/src/services/stac_cloud.dart +++ b/packages/stac/lib/src/services/stac_cloud.dart @@ -1,7 +1,8 @@ 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/models/stac_cache.dart'; import 'package:stac/src/services/stac_cache_service.dart'; import 'package:stac_logger/stac_logger.dart'; @@ -19,12 +20,35 @@ class StacCloud { ), ); - static const String _fetchUrl = 'https://api.stac.dev/screens'; + static const String _baseUrl = 'https://api.stac.dev'; - /// 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'; + } + } + + /// 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 +57,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, ), @@ -46,142 +71,217 @@ class StacCloud { // 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, + artifactType, + ); // 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, + final isCacheValid = StacCacheService.isCacheValid( + 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, + StacCache? 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, + StacCache? 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, + StacCache? 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 +290,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: artifactType, ); } } @@ -201,14 +302,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, + StacCache 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 +322,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 +349,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: 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, StacArtifactType.screen); } /// Clears all cached screens. static Future clearAllCache() { - return StacCacheService.clearAllScreens(); + return StacCacheService.clearAllArtifacts(StacArtifactType.screen); + } + + /// Clears the cache for a specific theme. + static Future clearThemeCache(String themeName) { + return StacCacheService.removeArtifact(themeName, StacArtifactType.theme); + } + + /// Clears all cached themes. + static Future clearAllThemeCache() { + return StacCacheService.clearAllArtifacts(StacArtifactType.theme); } } diff --git a/packages/stac_core/lib/annotations/annotations.dart b/packages/stac_core/lib/annotations/annotations.dart index 0b3ece7b..cb160149 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_theme_ref.dart'; diff --git a/packages/stac_core/lib/annotations/stac_theme_ref.dart b/packages/stac_core/lib/annotations/stac_theme_ref.dart new file mode 100644 index 00000000..9eba335d --- /dev/null +++ b/packages/stac_core/lib/annotations/stac_theme_ref.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 +/// @StacThemeConfig(themeName: 'darkTheme') +/// ThemeData buildDarkTheme() { +/// return ThemeData.dark(); +/// } +/// ``` +class StacThemeRef { + /// Creates a [StacThemeRef] with the given theme name. + const StacThemeRef({required this.name}); + + /// The identifier for this theme. + final String name; +}