From 39e52cf31738b9b103dfa9a2b3522d5e6fec8310 Mon Sep 17 00:00:00 2001 From: koukibadr Date: Wed, 24 Jun 2026 14:36:18 +0300 Subject: [PATCH] feat: update broadcast stream for skins and projects --- CHANGELOG.md | 17 ++++- README.md | 2 +- .../android/app/src/main/AndroidManifest.xml | 18 ++--- example/lib/main.dart | 17 ++++- example/lib/widgets/movie_card.dart | 4 +- lib/constants/fskin_constants.dart | 3 + lib/flutter_skin.dart | 39 +++++++++-- lib/remote/fskin_remote_config.dart | 10 +++ lib/services/fskin_subscriber.dart | 69 +++++++++++++++++++ lib/services/skin_service.dart | 3 +- pubspec.yaml | 2 +- 11 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 lib/constants/fskin_constants.dart create mode 100644 lib/services/fskin_subscriber.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e91589a..7a8e774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ -## 0.0.1-alpha.1 - 2026-06-21 +## 0.0.2 - 2026-06-24 + +- Live skin updates. + +### Added + +- Implement `FlutterSkin.onSkinChanged` — a broadcast stream that emits the new Skin Tokens whenever the active skin or the project skin changes. +- `FlutterSkin.init()` now opens a persistent Server-Sent Events connection to the FSkin backend alongside the initial skin fetch. +- The SSE connection is automatically paused when the app is backgrounded and resumed when it returns to the foreground. + +### Changed + +- `FlutterSkin.init()` is now the complete setup — it handles the initial fetch, SSE connection, and lifecycle observer in a single call. No additional configuration required. + + +## 0.0.1 - 2026-06-21 > 🎉 First alpha release of `flutter_skin` — the remote skin engine for Flutter. diff --git a/README.md b/README.md index c6b5dc6..ac2d298 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Sign up at [app.fskin.dev](https://app.fskin.dev), create a project, and copy yo ```yaml dependencies: - flutter_skin: ^0.0.1 + flutter_skin: ^0.0.2 ``` ```bash diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 74a78b9..e45e75b 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" + /> - - + + - - + + - + \ No newline at end of file diff --git a/example/lib/main.dart b/example/lib/main.dart index 8b28907..0d63e83 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,14 +6,27 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await FlutterSkin.init( apiKey: - "fsk_ca8f01785a2bac063eb06d8895c76e021e18554af328248d8e2ab0d0a96f0cdc", + "fsk_b4331e99326b000434d601a80284abf89b8425e5f246f3b76a924c9d04486012", ); runApp(const MyApp()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + FlutterSkin.onSkinChanged.listen((_) { + setState(() {}); + }); + } + @override Widget build(BuildContext context) { return MaterialApp( diff --git a/example/lib/widgets/movie_card.dart b/example/lib/widgets/movie_card.dart index a75f77e..95ff21d 100644 --- a/example/lib/widgets/movie_card.dart +++ b/example/lib/widgets/movie_card.dart @@ -98,8 +98,8 @@ class _MovieCardState extends State { ), ), Text( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore", - maxLines: 2, + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod", + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10, diff --git a/lib/constants/fskin_constants.dart b/lib/constants/fskin_constants.dart new file mode 100644 index 0000000..e7da73a --- /dev/null +++ b/lib/constants/fskin_constants.dart @@ -0,0 +1,3 @@ +class FskinConstants { + static const String baseUrl = 'fskinbackend-production.up.railway.app'; +} diff --git a/lib/flutter_skin.dart b/lib/flutter_skin.dart index 8c166ac..7d59f14 100644 --- a/lib/flutter_skin.dart +++ b/lib/flutter_skin.dart @@ -1,22 +1,27 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ThemeData, ColorScheme; +import 'package:flutter/widgets.dart'; import 'package:flutter_skin/models/project_config.dart'; import 'package:flutter_skin/remote/fskin_remote_config.dart'; +import 'package:flutter_skin/services/fskin_subscriber.dart'; -class FlutterSkin { +class FlutterSkin with WidgetsBindingObserver { static FlutterSkin? _instance; - /// The API key for accessing the remote configuration. This is set during initialization. late String apiKey; + static final FskinSubscriber _sse = FskinSubscriber(); + static Stream get onSkinChanged => FskinRemoteConfig.onSkinChanged; // Private constructor FlutterSkin._(); - // Factory method to initialize and get the singleton instance static Future init({required String apiKey}) async { if (apiKey.trim().isEmpty) { throw ArgumentError.value(apiKey, 'apiKey', 'apiKey must not be empty'); } - _instance ??= FlutterSkin._(); + if (_instance == null) { + _instance = FlutterSkin._(); + WidgetsBinding.instance.addObserver(_instance!); + } _instance!.apiKey = apiKey; await FskinRemoteConfig.init(apiKey: apiKey); @@ -33,6 +38,30 @@ class FlutterSkin { return _instance!; } + /// Start listening to the backend stream for skin and projects updates + static void _startStream() { + _sse.listen( + apiKey: _instance!.apiKey, + onSkinUpdated: FskinRemoteConfig.singleton.fetchConfig, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + // When the app is resumed, fetch the latest config and restart the stream. + if (state == AppLifecycleState.resumed) { + try { + await FskinRemoteConfig.singleton.fetchConfig(); + } finally { + _startStream(); + } + + // When the app is paused, dispose the stream to save resources. + } else if (state == AppLifecycleState.paused) { + _sse.dispose(); + } + } + /// Query current active theme from remote config and return as ThemeData. /// When there's no active theme, the result is null or fallbackTheme if provided. static ThemeData? toThemeData({ThemeData? fallbackTheme}) { diff --git a/lib/remote/fskin_remote_config.dart b/lib/remote/fskin_remote_config.dart index f4cf0e2..9848543 100644 --- a/lib/remote/fskin_remote_config.dart +++ b/lib/remote/fskin_remote_config.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; import 'package:flutter_skin/models/project_config.dart'; import 'package:flutter_skin/services/skin_service.dart'; @@ -6,10 +9,14 @@ import 'package:flutter_skin/services/skin_service.dart'; /// subsequent access. class FskinRemoteConfig { static FskinRemoteConfig? _instance; + static final StreamController _skinController = + StreamController.broadcast(); late String apiKey; ProjectConfig? _cachedConfig; + static Stream get onSkinChanged => _skinController.stream; + static ProjectConfig? get projectConfig { if (_instance == null) { throw Exception( @@ -43,5 +50,8 @@ class FskinRemoteConfig { // Call the skin service to fetch skin for developer and project //final skin = await SkinService().getSkin(apiKey); _cachedConfig = await SkinService().fetchData(apiKey); + if (_cachedConfig != null) { + _skinController.add(ThemeData(colorScheme: _cachedConfig!.skin?.colors)); + } } } diff --git a/lib/services/fskin_subscriber.dart b/lib/services/fskin_subscriber.dart new file mode 100644 index 0000000..355068d --- /dev/null +++ b/lib/services/fskin_subscriber.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; +import 'package:flutter_skin/constants/fskin_constants.dart'; +import 'package:http/http.dart' as http; + +class FskinSubscriber { + StreamSubscription? _subscription; + late http.Client _client; + int _retryCount = 0; + bool _disposed = false; + + void listen({required String apiKey, required VoidCallback onSkinUpdated}) { + _disposed = false; + _client = http.Client(); + _connect(apiKey, FskinConstants.baseUrl, onSkinUpdated); + } + + void _connect(String apiKey, String baseUrl, VoidCallback onSkinUpdated) { + if (_disposed) return; + + final request = http.Request( + 'GET', + Uri.parse('https://$baseUrl/fskin/stream?apiKey=$apiKey'), + ); + + _client + .send(request) + .then((response) { + _retryCount = 0; + _subscription = response.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + if (line.startsWith('event: skin_updated') || + line.startsWith('event: project_updated')) { + onSkinUpdated(); + } + }, + onDone: () { + _retry(apiKey, baseUrl, onSkinUpdated); + }, + onError: (_) { + _retry(apiKey, baseUrl, onSkinUpdated); + }, + ); + }) + .catchError((_) { + _retry(apiKey, baseUrl, onSkinUpdated); + }); + } + + void _retry(String apiKey, String baseUrl, VoidCallback onSkinUpdated) { + if (_disposed) return; + _retryCount++; + final seconds = (_retryCount * 2).clamp(2, 30); + Future.delayed( + Duration(seconds: seconds), + () => _connect(apiKey, baseUrl, onSkinUpdated), + ); + } + + void dispose() { + _disposed = true; + _subscription?.cancel(); + _client.close(); + } +} diff --git a/lib/services/skin_service.dart b/lib/services/skin_service.dart index 01a87f6..65e78d2 100644 --- a/lib/services/skin_service.dart +++ b/lib/services/skin_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter_skin/constants/fskin_constants.dart'; import 'package:flutter_skin/models/project_config.dart'; import 'package:http/http.dart' as http; @@ -19,7 +20,7 @@ class SkinService { try { var response = await client .post( - Uri.https('fskin-backend.vercel.app', 'fskin/skin'), + Uri.https(FskinConstants.baseUrl, 'fskin/skin'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'apiKey': apiKey}), ) diff --git a/pubspec.yaml b/pubspec.yaml index 39cc550..c0a7ceb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_skin description: A runtime skin engine for Flutter projects, allowing dynamic theme changes without the need for app restarts. -version: 0.0.1 +version: 0.0.2 homepage: https://github.com/koukibadr/flutter_skin screenshots: - description: A runtime skin engine for Flutter projects, allowing dynamic theme