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', + ); + }); + }); +}