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
57 changes: 47 additions & 10 deletions .github/workflows/pr_verification_pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@ on:
pull_request:
branches: [ "main" ]
jobs:

analyze-code:
dart_format_and_analyze:
name: dart format and analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Flutter
uses: subosito/flutter-action@v2
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get


- name: Run pub get
run: dart pub get

- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .

- name: Running Flutter analysis on package code
run: flutter analyze --no-fatal-infos --no-fatal-warnings --write=analysis_report.txt
run: flutter analyze --no-fatal-warnings --write=analysis_report.txt

- name: Artifact analysis report
uses: actions/upload-artifact@v4
Expand All @@ -24,4 +27,38 @@ jobs:
name: analysis-report
path: |
analysis_report.txt
retention-days: 15
retention-days: 15

unit-test:
needs: dart_format_and_analyze
name: Run unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: stable

- name: Install dependencies
run: flutter pub get

- name: Run unit tests
run: flutter test

dry-run-upload:
needs: unit-test
name: Dry run upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Flutter
uses: subosito/flutter-action@v2
with:
channel: stable

- name: Install dependencies
run: flutter pub get

- name: Check Publish Warnings
run: dart pub publish --dry-run
1 change: 1 addition & 0 deletions lib/constants/fskin_constants.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
class FskinConstants {
static const String baseUrl = 'fskinbackend-production.up.railway.app';
static const String keyRegex = r'^fsk_[a-f0-9]{64}$';
}
30 changes: 26 additions & 4 deletions lib/flutter_skin.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart' show ThemeData, ColorScheme;
import 'package:flutter/widgets.dart';
import 'package:flutter_skin/constants/fskin_constants.dart';
import 'package:flutter_skin/models/project_config.dart';
import 'package:flutter_skin/remote/fskin_remote_config.dart';
import 'package:flutter_skin/services/fskin_logger.dart';
Expand All @@ -10,7 +11,8 @@ class FlutterSkin with WidgetsBindingObserver {

late String apiKey;
static final FskinSubscriber _sse = FskinSubscriber();
static Stream<ThemeData> get onSkinChanged => FskinRemoteConfig.onSkinChanged;
static FskinRemoteConfig remoteConfig = FskinRemoteConfig.singleton;
static Stream<ThemeData> get onSkinChanged => remoteConfig.onSkinChanged;
static final FskinLogger _logger = FskinLogger();

// Private constructor
Expand All @@ -20,7 +22,11 @@ class FlutterSkin with WidgetsBindingObserver {
if (apiKey.trim().isEmpty) {
_logger.logError('apiKey must not be empty');
throw ArgumentError.value(apiKey, 'apiKey', 'apiKey must not be empty');
} else if (RegExp(FskinConstants.keyRegex).hasMatch(apiKey) == false) {
Comment thread
koukibadr marked this conversation as resolved.
_logger.logError('apiKey is not valid');
throw ArgumentError.value(apiKey, 'apiKey', 'apiKey is not valid');
}

if (_instance == null) {
_instance = FlutterSkin._();
WidgetsBinding.instance.addObserver(_instance!);
Expand Down Expand Up @@ -48,7 +54,7 @@ class FlutterSkin with WidgetsBindingObserver {
);
_sse.listen(
apiKey: _instance!.apiKey,
onSkinUpdated: FskinRemoteConfig.singleton.fetchConfig,
onSkinUpdated: remoteConfig.fetchConfig,
);
}

Expand All @@ -58,7 +64,7 @@ class FlutterSkin with WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) {
try {
_logger.logMessage('Fetching latest config and restarting stream.');
await FskinRemoteConfig.singleton.fetchConfig();
await remoteConfig.fetchConfig();
} finally {
_startStream();
}
Expand All @@ -73,7 +79,7 @@ class FlutterSkin with WidgetsBindingObserver {
/// 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}) {
ProjectConfig? config = FskinRemoteConfig.projectConfig;
ProjectConfig? config = remoteConfig.projectConfig;
ColorScheme? colors = config?.skin?.colors;
ThemeData remoteTheme = ThemeData(colorScheme: colors);
if (colors == null) {
Expand All @@ -91,6 +97,22 @@ class FlutterSkin with WidgetsBindingObserver {
/// Query current active theme from remote config and return as ThemeData.
/// When there's no active theme, the result is null.
static ThemeData? get theme {
if (_instance == null) {
throw Exception(
'FlutterSkin must be initialized with FlutterSkin.init()',
);
}
return toThemeData();
}

@visibleForTesting
static void resetInstance() {
_instance = null;
FlutterSkin.remoteConfig = FskinRemoteConfig.singleton;
}

@visibleForTesting
void setRemoteConfig(FskinRemoteConfig remoteConfig) {
FlutterSkin.remoteConfig = remoteConfig;
}
}
4 changes: 2 additions & 2 deletions lib/remote/fskin_remote_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ class FskinRemoteConfig {
late String apiKey;
ProjectConfig? _cachedConfig;

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

static ProjectConfig? get projectConfig {
ProjectConfig? get projectConfig {
if (_instance == null) {
throw Exception(
'FskinRemoteConfig must be initialized with FskinRemoteConfig.init()',
Expand Down
2 changes: 1 addition & 1 deletion lib/services/skin_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class SkinService {
body: jsonEncode({'apiKey': apiKey}),
)
.timeout(const Duration(seconds: 5));

_logger.logMessage(
'Received response with status code: ${response.statusCode}',
);
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
mockito: ^5.5.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
Expand Down
68 changes: 68 additions & 0 deletions test/flutter_skin/flutter_skin_init_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_skin/flutter_skin.dart';
import 'package:flutter_skin/models/project_config.dart';
import 'package:flutter_skin/remote/fskin_remote_config.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

import '../mocks/skin_model_mocks.dart';

class MockFskinRemoteConfig extends Mock implements FskinRemoteConfig {}

class MockProjectConfig extends Mock implements ProjectConfig {}

void main() async {
group('FlutterSkin Initialization Tests', () {
final apiKey =
'fsk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';

setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
await FlutterSkin.init(apiKey: apiKey);
});

test('fetches and applies skin on successful response', () async {
// Testing with fake api key will result in null theme.
await FlutterSkin.init(apiKey: apiKey);

final skin = FlutterSkin.theme;
final instance = FlutterSkin.singleton;

expect(instance.apiKey, apiKey);
expect(skin, null);
});

test('applies skin from remote config', () async {
final instance = FlutterSkin.singleton;

instance.setRemoteConfig(MockFskinRemoteConfig());
when(
FlutterSkin.remoteConfig.projectConfig,
).thenReturn(ProjectConfig(skin: skinModelMock));

final skin = FlutterSkin.theme;
expect(instance.apiKey, apiKey);
expect(skin, isA<ThemeData>());
expect(skin?.colorScheme.primary, skinModelMock.colors?.primary);
});

test('project config returns a nullable skin', () async {
final instance = FlutterSkin.singleton;

instance.setRemoteConfig(MockFskinRemoteConfig());
when(
FlutterSkin.remoteConfig.projectConfig,
).thenReturn(MockProjectConfig());

var skin = FlutterSkin.theme;
expect(instance.apiKey, apiKey);
expect(skin, isA<Null>());

skin = FlutterSkin.toThemeData(
fallbackTheme: ThemeData(primaryColor: Colors.red),
);
expect(skin, isA<ThemeData>());
expect(skin?.brightness, Brightness.light);
});
Comment thread
koukibadr marked this conversation as resolved.
});
}
34 changes: 34 additions & 0 deletions test/flutter_skin/initialization_scenario_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:flutter_skin/flutter_skin.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('Initialization test across different scenarios', () {
test(
'should throw an exception if init is called with an empty apiKey',
() async {
expect(
() => FlutterSkin.init(apiKey: ''),
throwsA(isA<ArgumentError>()),
);
},
);

test(
'should throw an exception if init is called with an invalid apiKey',
() async {
expect(
() => FlutterSkin.init(apiKey: 'my_api_key'),
throwsA(isA<ArgumentError>()),
);
},
);

test('should throw an exception if singleton is accessed before init', () {
expect(() => FlutterSkin.singleton, throwsA(isA<Exception>()));
});

test('should throw an exception if theme is accessed before init', () {
expect(() => FlutterSkin.theme, throwsA(isA<Exception>()));
});
});
}
5 changes: 0 additions & 5 deletions test/flutter_skin_test.dart

This file was deleted.

22 changes: 22 additions & 0 deletions test/mocks/skin_model_mocks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// ignore_for_file: deprecated_member_use

import 'package:flutter/material.dart';
import 'package:flutter_skin/models/skin_model.dart';

var skinModelMock = SkinModel(
colors: const ColorScheme.light(
primary: Color(0xFF6200EE),
secondary: Color(0xFF03DAC6),
background: Color(0xFFFFFFFF),
surface: Color(0xFFFFFFFF),
onPrimary: Color(0xFFFFFFFF),
onSecondary: Color(0xFF000000),
onBackground: Color(0xFF000000),
onSurface: Color(0xFF000000),
),
id: 'mock_id',
projectId: 'mock_project_id',
isActive: true,
version: 1,
createdAt: DateTime.now(),
);
Loading