-
-
Notifications
You must be signed in to change notification settings - Fork 82
feat: Add theme support to Stac framework with cloud loading #393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:flutter/material.dart'; | ||
| import 'package:stac/src/parsers/theme/themes.dart'; | ||
|
|
||
|
|
@@ -108,8 +110,8 @@ class StacApp extends StatelessWidget { | |
| final TransitionBuilder? builder; | ||
| final String title; | ||
| final GenerateAppTitle? onGenerateTitle; | ||
| final StacTheme? theme; | ||
| final StacTheme? darkTheme; | ||
| final FutureOr<StacTheme?>? theme; | ||
| final FutureOr<StacTheme?>? darkTheme; | ||
| final ThemeData? highContrastTheme; | ||
| final ThemeData? highContrastDarkTheme; | ||
| final ThemeMode? themeMode; | ||
|
|
@@ -142,6 +144,22 @@ class StacApp extends StatelessWidget { | |
| } | ||
|
|
||
| Widget _materialApp(BuildContext context) { | ||
| return _withResolvedThemes( | ||
| context, | ||
| (resolvedContext, resolved) => | ||
| _buildMaterialApp(resolvedContext, resolved), | ||
| ); | ||
| } | ||
|
|
||
| Widget _materialRouterApp(BuildContext context) { | ||
| return _withResolvedThemes( | ||
| context, | ||
| (resolvedContext, resolved) => | ||
| _buildMaterialAppRouter(resolvedContext, resolved), | ||
| ); | ||
| } | ||
|
|
||
| Widget _buildMaterialApp(BuildContext context, _ResolvedStacThemes themes) { | ||
| return MaterialApp( | ||
| navigatorKey: navigatorKey, | ||
| scaffoldMessengerKey: scaffoldMessengerKey, | ||
|
|
@@ -162,8 +180,8 @@ class StacApp extends StatelessWidget { | |
| builder: builder, | ||
| title: title, | ||
| onGenerateTitle: onGenerateTitle, | ||
| theme: theme?.parse(context), | ||
| darkTheme: darkTheme?.parse(context), | ||
| theme: themes.theme?.parse(context), | ||
| darkTheme: themes.darkTheme?.parse(context), | ||
| highContrastTheme: highContrastTheme, | ||
| highContrastDarkTheme: highContrastDarkTheme, | ||
| themeMode: themeMode, | ||
|
|
@@ -188,7 +206,10 @@ class StacApp extends StatelessWidget { | |
| ); | ||
| } | ||
|
|
||
| Widget _materialRouterApp(BuildContext context) { | ||
| Widget _buildMaterialAppRouter( | ||
| BuildContext context, | ||
| _ResolvedStacThemes themes, | ||
| ) { | ||
| return MaterialApp.router( | ||
| scaffoldMessengerKey: scaffoldMessengerKey, | ||
| routeInformationProvider: routeInformationProvider, | ||
|
|
@@ -200,8 +221,8 @@ class StacApp extends StatelessWidget { | |
| title: title, | ||
| onGenerateTitle: onGenerateTitle, | ||
| color: color, | ||
| theme: theme?.parse(context), | ||
| darkTheme: darkTheme?.parse(context), | ||
| theme: themes.theme?.parse(context), | ||
| darkTheme: themes.darkTheme?.parse(context), | ||
| highContrastTheme: highContrastTheme, | ||
| highContrastDarkTheme: highContrastDarkTheme, | ||
| themeMode: themeMode, | ||
|
|
@@ -224,4 +245,80 @@ class StacApp extends StatelessWidget { | |
| scrollBehavior: scrollBehavior, | ||
| ); | ||
| } | ||
|
|
||
| FutureOr<_ResolvedStacThemes> _resolveThemes() { | ||
| final themeInput = theme; | ||
| final darkThemeInput = darkTheme; | ||
|
|
||
| final Future<StacTheme?>? themeFuture = themeInput is Future<StacTheme?> | ||
| ? themeInput | ||
| : null; | ||
| final Future<StacTheme?>? darkThemeFuture = | ||
| darkThemeInput is Future<StacTheme?> ? darkThemeInput : null; | ||
|
|
||
| final StacTheme? themeValue = themeFuture == null | ||
| ? themeInput as StacTheme? | ||
| : null; | ||
| final StacTheme? darkThemeValue = darkThemeFuture == null | ||
| ? darkThemeInput as StacTheme? | ||
| : null; | ||
|
|
||
| if (themeFuture == null && darkThemeFuture == null) { | ||
| return _ResolvedStacThemes(theme: themeValue, darkTheme: darkThemeValue); | ||
| } | ||
|
|
||
| return Future<_ResolvedStacThemes>(() async { | ||
| final resolvedTheme = | ||
| await (themeFuture ?? Future<StacTheme?>.value(themeValue)); | ||
| final resolvedDarkTheme = | ||
| await (darkThemeFuture ?? Future<StacTheme?>.value(darkThemeValue)); | ||
|
|
||
| return _ResolvedStacThemes( | ||
| theme: resolvedTheme, | ||
| darkTheme: resolvedDarkTheme, | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| Widget _withResolvedThemes( | ||
| BuildContext context, | ||
| Widget Function(BuildContext, _ResolvedStacThemes) builder, | ||
| ) { | ||
| final resolved = _resolveThemes(); | ||
| if (resolved is Future<_ResolvedStacThemes>) { | ||
| return FutureBuilder<_ResolvedStacThemes>( | ||
| future: resolved, | ||
| builder: (futureContext, snapshot) { | ||
| if (snapshot.connectionState == ConnectionState.waiting) { | ||
| return const _ThemeFutureLoading(); | ||
| } | ||
| if (snapshot.hasError) { | ||
| return const _ThemeFutureLoading(); | ||
| } | ||
| final themes = snapshot.data; | ||
| if (themes == null) { | ||
| return const _ThemeFutureLoading(); | ||
| } | ||
| return builder(futureContext, themes); | ||
| }, | ||
| ); | ||
| } | ||
| return builder(context, resolved); | ||
| } | ||
|
Comment on lines
+283
to
+307
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Future is recreated on every widget rebuild.
Consider caching the resolved future in a stateful widget or using -class StacApp extends StatelessWidget {
+class StacApp extends StatefulWidget {
const StacApp({
...
});
+
+ @override
+ State<StacApp> createState() => _StacAppState();
+}
+
+class _StacAppState extends State<StacApp> {
+ late final FutureOr<_ResolvedStacThemes> _resolvedThemes;
+
+ @override
+ void initState() {
+ super.initState();
+ _resolvedThemes = _resolveThemes();
+ }
+
+ // Move build logic here, using _resolvedThemes instead of calling _resolveThemes()
|
||
| } | ||
|
|
||
| class _ResolvedStacThemes { | ||
| const _ResolvedStacThemes({required this.theme, required this.darkTheme}); | ||
|
|
||
| final StacTheme? theme; | ||
| final StacTheme? darkTheme; | ||
| } | ||
|
|
||
| class _ThemeFutureLoading extends StatelessWidget { | ||
| const _ThemeFutureLoading(); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return const Material(child: Center(child: CircularProgressIndicator())); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||||||||||||||||||||||||||
| import 'dart:convert'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import 'package:flutter/widgets.dart'; | ||||||||||||||||||||||||||||||||||
| import 'package:stac/src/services/stac_cloud.dart'; | ||||||||||||||||||||||||||||||||||
| import 'package:stac/src/services/stac_network_service.dart'; | ||||||||||||||||||||||||||||||||||
| import 'package:stac_core/actions/network_request/stac_network_request.dart'; | ||||||||||||||||||||||||||||||||||
| import 'package:stac_core/foundation/theme/stac_theme/stac_theme.dart'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /// Provides helpers to load [StacTheme] definitions for [StacApp]. | ||||||||||||||||||||||||||||||||||
| class StacAppTheme { | ||||||||||||||||||||||||||||||||||
| const StacAppTheme._(); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /// Fetches a theme from the `/themes` endpoint by [themeName]. | ||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||
| /// Returns `null` if the network call fails or the payload is malformed. | ||||||||||||||||||||||||||||||||||
| static Future<StacTheme?> 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<String, dynamic>) { | ||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| final themePayload = _normalizeThemeJson(rawData['stacJson']); | ||||||||||||||||||||||||||||||||||
| if (themePayload == null) { | ||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return StacTheme.fromJson(themePayload); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /// Fetches a theme over HTTP using a [StacNetworkRequest]. | ||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||
| /// Mirrors [Stac.fromNetwork], allowing callers to reuse existing request | ||||||||||||||||||||||||||||||||||
| /// builders and middleware. | ||||||||||||||||||||||||||||||||||
| static Future<StacTheme?> 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<String, dynamic>` or a JSON `String`. Returns `null` | ||||||||||||||||||||||||||||||||||
| /// when the payload cannot be parsed into a valid [StacTheme]. | ||||||||||||||||||||||||||||||||||
| static StacTheme? fromJson(dynamic payload) { | ||||||||||||||||||||||||||||||||||
| final themePayload = _normalizeThemeJson(payload); | ||||||||||||||||||||||||||||||||||
| if (themePayload == null) { | ||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| return StacTheme.fromJson(themePayload); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| static Map<String, dynamic>? _normalizeThemeJson(dynamic payload) { | ||||||||||||||||||||||||||||||||||
| if (payload == null) { | ||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if (payload is Map<String, dynamic> && payload['stacJson'] != null) { | ||||||||||||||||||||||||||||||||||
| return _normalizeThemeJson(payload['stacJson']); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if (payload is Map<String, dynamic>) { | ||||||||||||||||||||||||||||||||||
| return payload; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if (payload is String) { | ||||||||||||||||||||||||||||||||||
| final decoded = jsonDecode(payload); | ||||||||||||||||||||||||||||||||||
| if (decoded is Map<String, dynamic>) { | ||||||||||||||||||||||||||||||||||
| return decoded; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If if (payload is String) {
- final decoded = jsonDecode(payload);
- if (decoded is Map<String, dynamic>) {
- return decoded;
+ try {
+ final decoded = jsonDecode(payload);
+ if (decoded is Map<String, dynamic>) {
+ return decoded;
+ }
+ } on FormatException {
+ return null;
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /// Type of artifact that can be fetched from Stac Cloud. | ||
| enum StacArtifactType { | ||
| /// A screen artifact. | ||
| screen, | ||
|
|
||
| /// A theme artifact. | ||
| theme, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error state is silently swallowed with no feedback.
When theme loading fails, the same loading indicator is shown indefinitely. Consider logging the error and/or falling back to a default theme rather than showing a perpetual loading state.
if (snapshot.hasError) { + // Log error for debugging + debugPrint('Theme loading failed: ${snapshot.error}'); - return const _ThemeFutureLoading(); + // Fall back to no theme rather than infinite loading + return builder(futureContext, const _ResolvedStacThemes(theme: null, darkTheme: null)); }🤖 Prompt for AI Agents