Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:label="example"
android:name="${applicationName}"
Expand All @@ -17,12 +19,12 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
Expand All @@ -38,8 +40,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>
17 changes: 15 additions & 2 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlutterSkin.init(
apiKey:
"fsk_ca8f01785a2bac063eb06d8895c76e021e18554af328248d8e2ab0d0a96f0cdc",
"fsk_b4331e99326b000434d601a80284abf89b8425e5f246f3b76a924c9d04486012",
);
Comment thread
koukibadr marked this conversation as resolved.
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
FlutterSkin.onSkinChanged.listen((_) {
setState(() {});
});
}
Comment thread
koukibadr marked this conversation as resolved.

@override
Widget build(BuildContext context) {
return MaterialApp(
Expand Down
4 changes: 2 additions & 2 deletions example/lib/widgets/movie_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ class _MovieCardState extends State<MovieCard> {
),
),
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,
Comment thread
koukibadr marked this conversation as resolved.
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
Expand Down
3 changes: 3 additions & 0 deletions lib/constants/fskin_constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class FskinConstants {
static const String baseUrl = 'fskinbackend-production.up.railway.app';
}
39 changes: 34 additions & 5 deletions lib/flutter_skin.dart
Original file line number Diff line number Diff line change
@@ -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<ThemeData> get onSkinChanged => FskinRemoteConfig.onSkinChanged;

// Private constructor
FlutterSkin._();

// Factory method to initialize and get the singleton instance
static Future<FlutterSkin> 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);
Expand All @@ -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}) {
Expand Down
10 changes: 10 additions & 0 deletions lib/remote/fskin_remote_config.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -6,10 +9,14 @@ import 'package:flutter_skin/services/skin_service.dart';
/// subsequent access.
class FskinRemoteConfig {
static FskinRemoteConfig? _instance;
static final StreamController<ThemeData> _skinController =
StreamController<ThemeData>.broadcast();

late String apiKey;
ProjectConfig? _cachedConfig;

static Stream<ThemeData> get onSkinChanged => _skinController.stream;

static ProjectConfig? get projectConfig {
if (_instance == null) {
throw Exception(
Expand Down Expand Up @@ -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));
}
Comment thread
koukibadr marked this conversation as resolved.
}
}
69 changes: 69 additions & 0 deletions lib/services/fskin_subscriber.dart
Original file line number Diff line number Diff line change
@@ -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'),
);
Comment thread
koukibadr marked this conversation as resolved.

_client
.send(request)
.then((response) {
_retryCount = 0;
_subscription = response.stream
Comment thread
koukibadr marked this conversation as resolved.
.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),
Comment thread
koukibadr marked this conversation as resolved.
);
}

void dispose() {
_disposed = true;
_subscription?.cancel();
_client.close();
}
}
3 changes: 2 additions & 1 deletion lib/services/skin_service.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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}),
)
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading