diff --git a/CHANGELOG.md b/CHANGELOG.md
index f37f00a..db50c4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# 1.4.0+5
+
+- Implement `ToolbarLogo` and `ThemeSwitcher`
+ - feat: added `ToolbarLogo` and `ThemeSwitcher` as reusable components with UI implementation, tests, and documentation [#35](https://github.com/pactus-project/pactus-gui/pull/36)
+
# 1.3.0+4
- Implement Theme Management
diff --git a/assets/icons/ic_dark_mode.svg b/assets/icons/ic_dark_mode.svg
new file mode 100644
index 0000000..42e03cb
--- /dev/null
+++ b/assets/icons/ic_dark_mode.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/assets/icons/ic_light_mode.svg b/assets/icons/ic_light_mode.svg
new file mode 100644
index 0000000..bb49a19
--- /dev/null
+++ b/assets/icons/ic_light_mode.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/ic_logo_dark.svg b/assets/icons/ic_logo_dark.svg
new file mode 100644
index 0000000..c0f8611
--- /dev/null
+++ b/assets/icons/ic_logo_dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/ic_logo_light.svg b/assets/icons/ic_logo_light.svg
new file mode 100644
index 0000000..59503f3
--- /dev/null
+++ b/assets/icons/ic_logo_light.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/moon.png b/assets/icons/moon.png
deleted file mode 100644
index 871abcb..0000000
Binary files a/assets/icons/moon.png and /dev/null differ
diff --git a/assets/icons/sun.png b/assets/icons/sun.png
deleted file mode 100644
index 445b445..0000000
Binary files a/assets/icons/sun.png and /dev/null differ
diff --git a/lib/main.dart b/lib/main.dart
index f1cebdc..fe2a75c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -32,6 +32,7 @@ class MyApp extends StatelessWidget {
return BlocBuilder(
builder: (context, themeState) {
return MaterialApp(
+ debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: themeState.themeData,
locale: languageState.selectedLanguage.value,
diff --git a/lib/src/core/common/colors/app_colors.dart b/lib/src/core/common/colors/app_colors.dart
new file mode 100644
index 0000000..98f70b3
--- /dev/null
+++ b/lib/src/core/common/colors/app_colors.dart
@@ -0,0 +1,6 @@
+import 'package:flutter/material.dart' show Color;
+
+class AppColors {
+ AppColors._();
+ static const primaryDark = Color(0xFF242424);
+}
diff --git a/lib/src/core/common/widgets/theme_switcher.dart b/lib/src/core/common/widgets/theme_switcher.dart
new file mode 100644
index 0000000..98c452e
--- /dev/null
+++ b/lib/src/core/common/widgets/theme_switcher.dart
@@ -0,0 +1,86 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:gui/src/core/common/colors/app_colors.dart';
+import 'package:gui/src/core/enums/theme_modes.dart';
+import 'package:gui/src/core/utils/assets/assets.gen.dart';
+import 'package:gui/src/features/main/theme/bloc/theme_bloc.dart';
+
+/// ### [ThemeSwitcher] Documentation
+/// A widget that toggles between light and dark themes using a animated switch.
+///
+/// - Uses `Theme.of(context)` to determine the current theme.
+/// - Displays different icons for light and dark modes with animations.
+/// - Provides a switch that triggers a theme change by `ThemeBloc` when tapped.
+///
+class ThemeSwitcher extends StatelessWidget {
+ const ThemeSwitcher({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isLightTheme = theme.brightness == Brightness.light;
+ const duration = Duration(milliseconds: 200);
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ AnimatedOpacity(
+ curve: Curves.easeIn,
+ opacity: isLightTheme ? 0.0 : 1.0,
+ duration: duration,
+ child: SvgPicture.asset(
+ Assets.icons.icLightMode,
+ ),
+ ),
+ // Switch
+ GestureDetector(
+ onTap: () {
+ context.read().add(
+ ThemeChanged(
+ theme: isLightTheme ? ThemeModes.dark : ThemeModes.light,
+ ),
+ );
+ },
+ child: AnimatedContainer(
+ margin: EdgeInsets.symmetric(horizontal: 4),
+ width: 40, // Total width of the switch
+ height: 20, // Total height of the switch
+ duration: duration * 2,
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: AppColors.primaryDark,
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 3),
+ child: AnimatedAlign(
+ duration: Duration(milliseconds: 100),
+ alignment:
+ isLightTheme ? Alignment.centerRight : Alignment.centerLeft,
+ child: Container(
+ width: 14, // Thumb width
+ height: 14, // Thumb height
+ decoration: BoxDecoration(
+ color: AppColors.primaryDark,
+ borderRadius: BorderRadius.circular(7),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ // Sun icon (right side)
+ AnimatedOpacity(
+ curve: Curves.easeIn,
+ opacity: isLightTheme ? 1.0 : 0.0,
+ duration: duration,
+ child: SvgPicture.asset(
+ Assets.icons.icDarkMode,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/src/core/common/widgets/toolbar_logo.dart b/lib/src/core/common/widgets/toolbar_logo.dart
new file mode 100644
index 0000000..e61c255
--- /dev/null
+++ b/lib/src/core/common/widgets/toolbar_logo.dart
@@ -0,0 +1,22 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:gui/src/core/utils/assets/assets.gen.dart';
+
+/// # [ToolbarLogo] Documentation
+/// A stateless widget that displays the app's toolbar logo.
+/// Automatically adapts to the current theme (light or dark) and selects the
+/// appropriate logo asset.
+///
+class ToolbarLogo extends StatelessWidget {
+ const ToolbarLogo({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final isLightTheme = Theme.of(context).brightness == Brightness.light;
+ return SvgPicture.asset(
+ width: 25,
+ height: 25,
+ isLightTheme ? Assets.icons.icLogoLight : Assets.icons.icLogoDark,
+ );
+ }
+}
diff --git a/lib/gen/assets.gen.dart b/lib/src/core/utils/assets/assets.gen.dart
similarity index 86%
rename from lib/gen/assets.gen.dart
rename to lib/src/core/utils/assets/assets.gen.dart
index 97ba016..c0636b9 100644
--- a/lib/gen/assets.gen.dart
+++ b/lib/src/core/utils/assets/assets.gen.dart
@@ -16,20 +16,27 @@ class $AssetsIconsGen {
AssetGenImage get clipboard =>
const AssetGenImage('assets/icons/clipboard.png');
+ /// File path: assets/icons/ic_dark_mode.svg
+ String get icDarkMode => 'assets/icons/ic_dark_mode.svg';
+
+ /// File path: assets/icons/ic_light_mode.svg
+ String get icLightMode => 'assets/icons/ic_light_mode.svg';
+
+ /// File path: assets/icons/ic_logo_dark.svg
+ String get icLogoDark => 'assets/icons/ic_logo_dark.svg';
+
+ /// File path: assets/icons/ic_logo_light.svg
+ String get icLogoLight => 'assets/icons/ic_logo_light.svg';
+
/// File path: assets/icons/lock.png
AssetGenImage get lock => const AssetGenImage('assets/icons/lock.png');
/// File path: assets/icons/logo.png
AssetGenImage get logo => const AssetGenImage('assets/icons/logo.png');
- /// File path: assets/icons/moon.png
- AssetGenImage get moon => const AssetGenImage('assets/icons/moon.png');
-
- /// File path: assets/icons/sun.png
- AssetGenImage get sun => const AssetGenImage('assets/icons/sun.png');
-
/// List of all assets
- List get values => [clipboard, lock, logo, moon, sun];
+ List get values =>
+ [clipboard, icDarkMode, icLightMode, icLogoDark, icLogoLight, lock, logo];
}
class $AssetsImagesGen {
diff --git a/lib/gen/fonts.gen.dart b/lib/src/core/utils/assets/fonts.gen.dart
similarity index 100%
rename from lib/gen/fonts.gen.dart
rename to lib/src/core/utils/assets/fonts.gen.dart
diff --git a/lib/src/features/main/theme/theme_data/app_theme_data.dart b/lib/src/features/main/theme/theme_data/app_theme_data.dart
index 9e630b9..f704767 100644
--- a/lib/src/features/main/theme/theme_data/app_theme_data.dart
+++ b/lib/src/features/main/theme/theme_data/app_theme_data.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import 'package:gui/gen/fonts.gen.dart';
import 'package:gui/src/core/enums/theme_modes.dart';
+import 'package:gui/src/core/utils/assets/fonts.gen.dart';
import 'package:gui/src/features/main/theme/theme_data/pallets/on_surface_pallet.dart';
import 'package:gui/src/features/main/theme/theme_data/pallets/surface_pallet.dart';
diff --git a/lib/src/features/splash_screen/presentation/screen/home_page.dart b/lib/src/features/splash_screen/presentation/screen/home_page.dart
index 61cfec0..acde13e 100644
--- a/lib/src/features/splash_screen/presentation/screen/home_page.dart
+++ b/lib/src/features/splash_screen/presentation/screen/home_page.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:gui/src/core/common/widgets/theme_switcher.dart';
+import 'package:gui/src/core/common/widgets/toolbar_logo.dart';
import 'package:gui/src/features/main/language/presentation/widget/language_widget.dart';
import 'package:gui/src/features/main/theme/presentation/widgets/theme_selector.dart';
import 'package:gui/src/features/main/theme/theme_data/pallets/on_surface_pallet.dart';
@@ -23,6 +25,7 @@ class _MyHomePageState extends State {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
+ actions: [ToolbarLogo(), ThemeSwitcher()],
backgroundColor: theme.colorScheme.inversePrimary,
title: Text(AppLocalizations.of(context)!.title),
),
diff --git a/pubspec.yaml b/pubspec.yaml
index 5a9c5f3..8fb1a08 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gui
description: "Pactus Flutter GUI"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
-version: 1.3.0+4
+version: 1.4.0+5
environment:
sdk: ^3.5.3
@@ -17,9 +17,9 @@ dependencies:
flutter_gen_runner: ^5.3.1
flutter_localizations:
sdk: flutter
+ flutter_svg: ^2.0.16
freezed: ^2.5.7
freezed_annotation: ^2.4.4
-
get_it: ^8.0.2
intl: ^0.19.0
json_annotation: ^4.9.0
@@ -31,6 +31,10 @@ dev_dependencies:
sdk: flutter
json_serializable: ^6.5.0
+flutter_gen:
+ output: lib/src/core/utils/assets # Optional (default: libf/gen/)
+ line_length: 80 # Optional (default: 80)
+
flutter:
generate: true
uses-material-design: true
diff --git a/test/src/core/common/widgets/theme_switcher_test.dart b/test/src/core/common/widgets/theme_switcher_test.dart
new file mode 100644
index 0000000..845a4c2
--- /dev/null
+++ b/test/src/core/common/widgets/theme_switcher_test.dart
@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_svg/svg.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gui/src/core/common/widgets/theme_switcher.dart';
+import 'package:gui/src/core/utils/assets/assets.gen.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ /// Checks if the asset exists by attempting to load it.
+ Future isAssetValid(String assetPath) async {
+ try {
+ // Attempt to load the asset from the root bundle
+ final assetData = await rootBundle.load(assetPath);
+ return assetData.lengthInBytes > 0;
+ } on Exception catch (_) {
+ return false;
+ }
+ }
+
+ group('Asset Validation Tests', () {
+ test('Light mode asset exists', () async {
+ final assetPath = Assets.icons.icLightMode;
+ expect(
+ await isAssetValid(assetPath),
+ isTrue,
+ reason: 'The light mode asset should exist in the project assets.',
+ );
+ });
+
+ test('Dark mode asset exists', () async {
+ final assetPath = Assets.icons.icDarkMode;
+ expect(
+ await isAssetValid(assetPath),
+ isTrue,
+ reason: 'The dark mode asset should exist in the project assets.',
+ );
+ });
+ });
+ group('ThemeSwitcher Tests', () {
+ testWidgets('should detect light theme in UI', (WidgetTester tester) async {
+ // Create a light theme context
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.light(),
+ home: const ThemeSwitcher(),
+ ),
+ );
+
+ // Verify light theme-specific icon (e.g., dark mode icon) is visible
+ final lightModeIcon = find.byType(SvgPicture).first;
+ expect(lightModeIcon, findsOneWidget);
+
+ // Verify the opacity for the sun icon is visible (light theme icon)
+ final sunIconOpacity = tester.widget(
+ find.byType(AnimatedOpacity).last,
+ );
+ expect(sunIconOpacity.opacity, equals(1.0));
+ });
+
+ testWidgets('should detect dark theme in UI', (WidgetTester tester) async {
+ // Create a dark theme context
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.dark(),
+ home: const ThemeSwitcher(),
+ ),
+ );
+
+ // Verify dark theme-specific icon (e.g., light mode icon) is visible
+ final darkModeIcon = find.byType(SvgPicture).last;
+ expect(darkModeIcon, findsOneWidget);
+
+ // Verify the opacity for the moon icon is visible (dark theme icon)
+ final moonIconOpacity = tester.widget(
+ find.byType(AnimatedOpacity).first,
+ );
+ expect(moonIconOpacity.opacity, equals(1.0));
+ });
+ });
+}
diff --git a/test/src/core/common/widgets/toolbar_logo_widget_test.dart b/test/src/core/common/widgets/toolbar_logo_widget_test.dart
new file mode 100644
index 0000000..10c6b60
--- /dev/null
+++ b/test/src/core/common/widgets/toolbar_logo_widget_test.dart
@@ -0,0 +1,89 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_svg/svg.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gui/src/core/common/widgets/toolbar_logo.dart';
+import 'package:gui/src/core/utils/assets/assets.gen.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ /// Checks if the asset exists by attempting to load it.
+ Future isAssetValid(String assetPath) async {
+ try {
+ // Attempt to load the asset from the root bundle
+ final assetData = await rootBundle.load(assetPath);
+ return assetData.lengthInBytes > 0;
+ } on Exception catch (_) {
+ return false;
+ }
+ }
+
+ group('Asset Validation Tests', () {
+ test('Light logo asset exists', () async {
+ final assetPath = Assets.icons.icLogoLight;
+ expect(
+ await isAssetValid(assetPath),
+ isTrue,
+ reason: 'The light logo asset should exist in the project assets.',
+ );
+ });
+
+ test('Dark logo asset exists', () async {
+ final assetPath = Assets.icons.icLogoDark;
+ expect(
+ await isAssetValid(assetPath),
+ isTrue,
+ reason: 'The dark logo asset should exist in the project assets.',
+ );
+ });
+ });
+
+ group('Theme Switching Tests', () {
+ testWidgets('Displays light logo in light theme', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.light(),
+ home: const Scaffold(
+ body: ToolbarLogo(),
+ ),
+ ),
+ );
+
+ // Find the SvgPicture widget
+ final finder = find.byType(SvgPicture);
+ expect(finder, findsOneWidget, reason: 'SvgPicture should be present.');
+
+ // Verify the asset path indirectly
+ final svgWidget = tester.widget(finder);
+ expect(
+ svgWidget.toString().contains(Assets.icons.icLogoLight),
+ isTrue,
+ reason: 'The light logo asset should be used in a light theme',
+ );
+ });
+
+ testWidgets('Displays dark logo in dark theme', (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.dark(),
+ home: const Scaffold(
+ body: ToolbarLogo(),
+ ),
+ ),
+ );
+
+ // Find the SvgPicture widget
+ final finder = find.byType(SvgPicture);
+ expect(finder, findsOneWidget, reason: 'SvgPicture should be present.');
+
+ // Verify the asset path indirectly
+ final svgWidget = tester.widget(finder);
+ expect(
+ svgWidget.toString().contains(Assets.icons.icLogoDark),
+ isTrue,
+ reason: 'The dark logo asset should be used in a dark theme',
+ );
+ });
+ });
+}