From 08b8d7f629d9d394cfa307dd07767087d6f89fa6 Mon Sep 17 00:00:00 2001 From: Michael Lazebny <62852417+hawkkiller@users.noreply.github.com> Date: Thu, 17 Aug 2023 10:43:26 +0200 Subject: [PATCH] Theme Scope (#238) * WIP on 231-fr-implement-themescope * Reworked brightness * Add paddings * Move system theme * update schema * update schema * Added `theme_scope` to README. --- README.md | 4 + .../generated/intl/messages_en.dart | 6 +- .../generated/intl/messages_es.dart | 6 +- lib/src/core/localization/generated/l10n.dart | 32 ++- .../localization/translations/intl_en.arb | 7 +- .../localization/translations/intl_es.arb | 7 +- lib/src/core/utils/logger.dart | 2 +- lib/src/core/utils/pattern_match.dart | 2 + .../feature/app/data/theme_datasource.dart | 217 ++++++++++++++++++ .../feature/app/data/theme_repository.dart | 30 +++ lib/src/feature/app/logic/theme_bloc.dart | 187 +++++++++++++++ lib/src/feature/app/model/app_theme.dart | 167 ++++++++++++++ lib/src/feature/app/widget/app.dart | 6 + .../feature/app/widget/material_context.dart | 32 +-- lib/src/feature/app/widget/theme_scope.dart | 127 ++++++++++ lib/src/feature/home/widget/home_screen.dart | 132 ++++++++++- .../logic/initialization_steps.dart | 10 + .../initialization/model/dependencies.dart | 18 ++ .../widget/dependencies_scope.dart | 5 +- pubspec.yaml | 2 +- 20 files changed, 953 insertions(+), 46 deletions(-) create mode 100644 lib/src/core/utils/pattern_match.dart create mode 100644 lib/src/feature/app/data/theme_datasource.dart create mode 100644 lib/src/feature/app/data/theme_repository.dart create mode 100644 lib/src/feature/app/logic/theme_bloc.dart create mode 100644 lib/src/feature/app/model/app_theme.dart create mode 100644 lib/src/feature/app/widget/theme_scope.dart diff --git a/README.md b/README.md index 277ee6b..e5e4443 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ Color scheme is generated by [Material Theme Builder](https://www.figma.com/comm - Easy to change the color scheme - All Material widgets that come with Flutter are already supported and use the color scheme +### Theme Scope + +`ThemeScope` is an inherited widget that enables users to operate themes. It supports custom themes and system themes as well as the standard ones. The chosen theme is saved in the local memory (shared_preferences) See material_context, theme_scope, app_theme and home_screen for more details. + ## Database `Sizzle starter` comes with drift and its executors preinstalled in `packages/database`. It is **recommended** to use drift for database management. diff --git a/lib/src/core/localization/generated/intl/messages_en.dart b/lib/src/core/localization/generated/intl/messages_en.dart index 4c30216..c926c39 100644 --- a/lib/src/core/localization/generated/intl/messages_en.dart +++ b/lib/src/core/localization/generated/intl/messages_en.dart @@ -20,11 +20,11 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'en'; - static String m0(value) => "Sample placeholder ${value}"; - final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "appTitle": MessageLookupByLibrary.simpleMessage("sizzle_starter"), - "samplePlaceholder": m0 + "dark_themes": MessageLookupByLibrary.simpleMessage("Dark Themes"), + "light_themes": MessageLookupByLibrary.simpleMessage("Light Themes"), + "system_theme": MessageLookupByLibrary.simpleMessage("System Theme") }; } diff --git a/lib/src/core/localization/generated/intl/messages_es.dart b/lib/src/core/localization/generated/intl/messages_es.dart index 7a87301..6f379db 100644 --- a/lib/src/core/localization/generated/intl/messages_es.dart +++ b/lib/src/core/localization/generated/intl/messages_es.dart @@ -20,11 +20,11 @@ typedef String MessageIfAbsent(String messageStr, List args); class MessageLookup extends MessageLookupByLibrary { String get localeName => 'es'; - static String m0(value) => "Espacio reservado de muestra ${value}"; - final messages = _notInlinedMessages(_notInlinedMessages); static Map _notInlinedMessages(_) => { "appTitle": MessageLookupByLibrary.simpleMessage("sizzle_starter"), - "samplePlaceholder": m0 + "dark_themes": MessageLookupByLibrary.simpleMessage("Tema oscuro"), + "light_themes": MessageLookupByLibrary.simpleMessage("Tema claro"), + "system_theme": MessageLookupByLibrary.simpleMessage("Tema del sistema") }; } diff --git a/lib/src/core/localization/generated/l10n.dart b/lib/src/core/localization/generated/l10n.dart index dfda285..6253a08 100644 --- a/lib/src/core/localization/generated/l10n.dart +++ b/lib/src/core/localization/generated/l10n.dart @@ -61,13 +61,33 @@ class GeneratedLocalization { ); } - /// `Sample placeholder {value}` - String samplePlaceholder(Object value) { + /// `Light Themes` + String get light_themes { return Intl.message( - 'Sample placeholder $value', - name: 'samplePlaceholder', - desc: 'Sample placeholder', - args: [value], + 'Light Themes', + name: 'light_themes', + desc: '', + args: [], + ); + } + + /// `Dark Themes` + String get dark_themes { + return Intl.message( + 'Dark Themes', + name: 'dark_themes', + desc: '', + args: [], + ); + } + + /// `System Theme` + String get system_theme { + return Intl.message( + 'System Theme', + name: 'system_theme', + desc: '', + args: [], ); } } diff --git a/lib/src/core/localization/translations/intl_en.arb b/lib/src/core/localization/translations/intl_en.arb index ac5b3bd..56cc634 100644 --- a/lib/src/core/localization/translations/intl_en.arb +++ b/lib/src/core/localization/translations/intl_en.arb @@ -3,8 +3,7 @@ "@appTitle": { "description": "The title of the application" }, - "samplePlaceholder": "Sample placeholder {value}", - "@samplePlaceholder": { - "description": "Sample placeholder" - } + "light_themes": "Light Themes", + "dark_themes": "Dark Themes", + "system_theme": "System Theme" } \ No newline at end of file diff --git a/lib/src/core/localization/translations/intl_es.arb b/lib/src/core/localization/translations/intl_es.arb index dc088ef..3598528 100644 --- a/lib/src/core/localization/translations/intl_es.arb +++ b/lib/src/core/localization/translations/intl_es.arb @@ -3,8 +3,7 @@ "@appTitle": { "description": "El título de la aplicación" }, - "samplePlaceholder": "Espacio reservado de muestra {value}", - "@samplePlaceholder": { - "description": "Espacio reservado de muestra" - } + "light_themes": "Tema claro", + "dark_themes": "Tema oscuro", + "system_theme": "Tema del sistema" } \ No newline at end of file diff --git a/lib/src/core/utils/logger.dart b/lib/src/core/utils/logger.dart index 79c9e92..3c8e516 100644 --- a/lib/src/core/utils/logger.dart +++ b/lib/src/core/utils/logger.dart @@ -239,7 +239,7 @@ final class AppLogger$Logging extends Logger { extension on DateTime { /// Transforms DateTime to String with format: 00:00:00 String formatTime() => - [hour, minute, second].map((i) => i.toString().padLeft(2)).join(':'); + [hour, minute, second].map((i) => i.toString().padLeft(2, '0')).join(':'); } extension on logging.Level { diff --git a/lib/src/core/utils/pattern_match.dart b/lib/src/core/utils/pattern_match.dart new file mode 100644 index 0000000..2209221 --- /dev/null +++ b/lib/src/core/utils/pattern_match.dart @@ -0,0 +1,2 @@ +/// A function that takes value of type [S] and returns a value of type [T]. +typedef PatternMatch = T Function(S value); diff --git a/lib/src/feature/app/data/theme_datasource.dart b/lib/src/feature/app/data/theme_datasource.dart new file mode 100644 index 0000000..aeb6ebe --- /dev/null +++ b/lib/src/feature/app/data/theme_datasource.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sizzle_starter/src/feature/app/model/app_theme.dart'; + +/// {@template theme_datasource} +/// [ThemeDataSource] is an entry point to the theme data layer. +/// +/// This is used to set and get theme. +/// {@endtemplate} +abstract interface class ThemeDataSource { + /// Set theme + Future setTheme(AppTheme theme); + + /// Get current theme from cache + AppTheme? loadThemeFromCache(); +} + +/// {@macro theme_datasource} +final class ThemeDataSourceImpl implements ThemeDataSource { + /// {@macro theme_datasource} + const ThemeDataSourceImpl({ + required SharedPreferences sharedPreferences, + }) : _sharedPreferences = sharedPreferences; + + final SharedPreferences _sharedPreferences; + + static const _prefix = 'theme_'; + + @override + Future setTheme(AppTheme theme) async { + await _sharedPreferences.setString( + '$_prefix.theme', + _themeCodec.encode(theme), + ); + + return; + } + + @override + AppTheme? loadThemeFromCache() { + final theme = _sharedPreferences.getString('$_prefix.theme'); + + if (theme != null) { + return _themeCodec.decode(theme); + } + + return null; + } +} + +const _themeCodec = _AppThemeCodec(); + +final class _AppThemeCodec extends Codec { + const _AppThemeCodec(); + + @override + Converter get decoder => const _AppThemeDecoder(); + + @override + Converter get encoder => const _AppThemeEncoder(); +} + +final class _AppThemeDecoder extends Converter { + const _AppThemeDecoder(); + + @pragma('vm:prefer-inline') + static Color? _colorOrNull(int? value) => value != null ? Color(value) : null; + + @override + AppTheme convert(String input) { + final json = jsonDecode(input) as Map; + + if (json + case { + 'type': final String type, + 'colorScheme': { + 'brightness': final String? brightness, + 'primary': final int? primary, + 'primaryContainer': final int? primaryContainer, + 'onPrimary': final int? onPrimary, + 'onPrimaryContainer': final int? onPrimaryContainer, + 'secondary': final int? secondary, + 'secondaryContainer': final int? secondaryContainer, + 'onSecondary': final int? onSecondary, + 'onSecondaryContainer': final int? onSecondaryContainer, + 'tertiary': final int? tertiary, + 'onTertiary': final int? onTertiary, + 'tertiaryContainer': final int? tertiaryContainer, + 'onTertiaryContainer': final int? onTertiaryContainer, + 'surface': final int? surface, + 'onSurface': final int? onSurface, + 'background': final int? background, + 'onBackground': final int? onBackground, + 'error': final int? error, + 'onError': final int? onError, + 'errorContainer': final int? errorContainer, + 'onErrorContainer': final int? onErrorContainer, + 'surfaceVariant': final int? surfaceVariant, + 'onSurfaceVariant': final int? onSurfaceVariant, + 'outline': final int? outline, + 'outlineVariant': final int? outlineVariant, + 'shadow': final int? shadow, + 'scrim': final int? scrim, + 'inverseSurface': final int? inverseSurface, + 'onInverseSurface': final int? onInverseSurface, + 'inversePrimary': final int? inversePrimary, + 'surfaceTint': final int? surfaceTint, + }, + }) { + if (primary == null || + secondary == null || + surface == null || + background == null || + error == null || + onPrimary == null || + onSecondary == null || + onSurface == null || + onBackground == null || + onError == null || + brightness == null) { + return AppTheme.create( + type: AppColorSchemeType.fromString(type), + ); + } + return AppTheme.create( + type: AppColorSchemeType.fromString(type), + colorScheme: ColorScheme( + primary: Color(primary), + secondary: Color(secondary), + surface: Color(surface), + background: Color(background), + error: Color(error), + onPrimary: Color(onPrimary), + onSecondary: Color(onSecondary), + onSurface: Color(onSurface), + onBackground: Color(onBackground), + onError: Color(onError), + primaryContainer: _colorOrNull(primaryContainer), + secondaryContainer: _colorOrNull(secondaryContainer), + errorContainer: _colorOrNull(errorContainer), + onPrimaryContainer: _colorOrNull(onPrimaryContainer), + onSecondaryContainer: _colorOrNull(onSecondaryContainer), + onErrorContainer: _colorOrNull(onErrorContainer), + tertiary: _colorOrNull(tertiary), + onTertiary: _colorOrNull(onTertiary), + tertiaryContainer: _colorOrNull(tertiaryContainer), + onTertiaryContainer: _colorOrNull(onTertiaryContainer), + surfaceVariant: _colorOrNull(surfaceVariant), + onSurfaceVariant: _colorOrNull(onSurfaceVariant), + outline: _colorOrNull(outline), + outlineVariant: _colorOrNull(outlineVariant), + shadow: _colorOrNull(shadow), + scrim: _colorOrNull(scrim), + inverseSurface: _colorOrNull(inverseSurface), + onInverseSurface: _colorOrNull(onInverseSurface), + inversePrimary: _colorOrNull(inversePrimary), + surfaceTint: _colorOrNull(surfaceTint), + brightness: switch (brightness) { + 'light' => Brightness.light, + 'dark' => Brightness.dark, + _ => throw Exception('Unknown brightness: $brightness'), + }, + ), + ); + } + throw Exception('Unknown json: $json'); + } +} + +final class _AppThemeEncoder extends Converter { + const _AppThemeEncoder(); + + @override + String convert(AppTheme input) { + final json = { + 'type': input.type.toString(), + 'colorScheme': { + 'brightness': input.colorScheme?.brightness.name, + 'primary': input.colorScheme?.primary.value, + 'onPrimary': input.colorScheme?.onPrimary.value, + 'primaryContainer': input.colorScheme?.primaryContainer.value, + 'onPrimaryContainer': input.colorScheme?.onPrimaryContainer.value, + 'secondary': input.colorScheme?.secondary.value, + 'onSecondary': input.colorScheme?.onSecondary.value, + 'secondaryContainer': input.colorScheme?.secondaryContainer.value, + 'onSecondaryContainer': input.colorScheme?.onSecondaryContainer.value, + 'surface': input.colorScheme?.surface.value, + 'onSurface': input.colorScheme?.onSurface.value, + 'background': input.colorScheme?.background.value, + 'onBackground': input.colorScheme?.onBackground.value, + 'error': input.colorScheme?.error.value, + 'onError': input.colorScheme?.onError.value, + 'errorContainer': input.colorScheme?.errorContainer.value, + 'onErrorContainer': input.colorScheme?.onErrorContainer.value, + 'tertiary': input.colorScheme?.tertiary.value, + 'onTertiary': input.colorScheme?.onTertiary.value, + 'tertiaryContainer': input.colorScheme?.tertiaryContainer.value, + 'onTertiaryContainer': input.colorScheme?.onTertiaryContainer.value, + 'surfaceVariant': input.colorScheme?.surfaceVariant.value, + 'onSurfaceVariant': input.colorScheme?.onSurfaceVariant.value, + 'outline': input.colorScheme?.outline.value, + 'outlineVariant': input.colorScheme?.outlineVariant.value, + 'shadow': input.colorScheme?.shadow.value, + 'scrim': input.colorScheme?.scrim.value, + 'inverseSurface': input.colorScheme?.inverseSurface.value, + 'onInverseSurface': input.colorScheme?.onInverseSurface.value, + 'inversePrimary': input.colorScheme?.inversePrimary.value, + 'surfaceTint': input.colorScheme?.surfaceTint.value, + } + }; + + return jsonEncode(json); + } +} diff --git a/lib/src/feature/app/data/theme_repository.dart b/lib/src/feature/app/data/theme_repository.dart new file mode 100644 index 0000000..1dbccd9 --- /dev/null +++ b/lib/src/feature/app/data/theme_repository.dart @@ -0,0 +1,30 @@ +import 'dart:async'; + +import 'package:sizzle_starter/src/feature/app/data/theme_datasource.dart'; +import 'package:sizzle_starter/src/feature/app/model/app_theme.dart'; + +/// {@template theme_repository} +/// Repository which manages the current theme. +/// {@endtemplate} +abstract interface class ThemeRepository { + /// Set theme + Future setTheme(AppTheme theme); + + /// Observe current theme changes + AppTheme loadAppThemeFromCache(); +} + +/// {@macro theme_repository} +final class ThemeRepositoryImpl implements ThemeRepository { + final ThemeDataSource _dataSource; + + /// {@macro theme_repository} + const ThemeRepositoryImpl(this._dataSource); + + @override + Future setTheme(AppTheme theme) => _dataSource.setTheme(theme); + + @override + AppTheme loadAppThemeFromCache() => + _dataSource.loadThemeFromCache() ?? AppTheme.systemScheme; +} diff --git a/lib/src/feature/app/logic/theme_bloc.dart b/lib/src/feature/app/logic/theme_bloc.dart new file mode 100644 index 0000000..495c551 --- /dev/null +++ b/lib/src/feature/app/logic/theme_bloc.dart @@ -0,0 +1,187 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:meta/meta.dart'; +import 'package:sizzle_starter/src/core/utils/logger.dart'; +import 'package:sizzle_starter/src/core/utils/pattern_match.dart'; +import 'package:sizzle_starter/src/feature/app/data/theme_repository.dart'; +import 'package:sizzle_starter/src/feature/app/model/app_theme.dart'; +import 'package:sizzle_starter/src/feature/app/widget/theme_scope.dart'; + +/// {@template theme_event} +/// Theme event +/// {@endtemplate} +@immutable +sealed class ThemeEvent with _ThemeEvent { + /// {@macro theme_event} + const ThemeEvent(); + + /// Update the theme + const factory ThemeEvent.update(AppTheme theme) = _ThemeEventUpdate; +} + +final class _ThemeEventUpdate extends ThemeEvent { + final AppTheme theme; + + const _ThemeEventUpdate(this.theme); + + @override + String toString() => 'ThemeEvent.update(theme: $theme)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _ThemeEventUpdate && + runtimeType == other.runtimeType && + theme == other.theme); + + @override + int get hashCode => theme.hashCode; +} + +abstract base mixin class _ThemeEvent { + const _ThemeEvent(); + + T map({ + required PatternMatch update, + }) => + switch (this) { + final _ThemeEventUpdate event => update(event), + _ => throw AssertionError('Unknown event: $this'), + }; + + T maybeMap({ + required PatternMatch? update, + required T orElse, + }) => + map( + update: update ?? (_) => orElse, + ); +} + +/// {@template theme_state} +/// Theme state +/// {@endtemplate} +@immutable +sealed class ThemeState with _ThemeState { + /// {@macro theme_state} + const ThemeState(); + + /// Idle state + const factory ThemeState.idle(AppTheme theme) = _ThemeStateIdle; + + /// In Progress state + const factory ThemeState.inProgress(AppTheme theme) = _ThemeStateInProgress; +} + +final class _ThemeStateIdle extends ThemeState { + @override + final AppTheme theme; + + const _ThemeStateIdle(this.theme); + + @override + String toString() => 'ThemeState.idle(theme: $theme)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _ThemeStateIdle && + runtimeType == other.runtimeType && + theme == other.theme); + + @override + int get hashCode => theme.hashCode; +} + +final class _ThemeStateInProgress extends ThemeState { + @override + final AppTheme theme; + + const _ThemeStateInProgress(this.theme); + + @override + String toString() => 'ThemeState.inProgress(theme: $theme)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is _ThemeStateInProgress && + runtimeType == other.runtimeType && + theme == other.theme); + + @override + int get hashCode => theme.hashCode; +} + +abstract base mixin class _ThemeState { + const _ThemeState(); + + /// Current theme + AppTheme get theme; + + T map({ + required PatternMatch idle, + required PatternMatch inProgress, + }) => + switch (this) { + final _ThemeStateIdle state => idle(state), + final _ThemeStateInProgress state => inProgress(state), + _ => throw AssertionError('Unknown state: $this'), + }; + + T maybeMap({ + required PatternMatch? idle, + required PatternMatch? inProgress, + required T orElse, + }) => + map( + idle: idle ?? (_) => orElse, + inProgress: inProgress ?? (_) => orElse, + ); +} + +/// {@template theme_bloc} +/// Business logic components that can switch themes. +/// +/// It communicates with provided repository to persist the theme. +/// +/// Should not be used directly, instead use [ThemeScope]. +/// It operates ThemeBloc under the hood. +/// {@endtemplate} +final class ThemeBloc extends Bloc { + final ThemeRepository _themeRepository; + + /// {@macro theme_bloc} + ThemeBloc(this._themeRepository) + : super( + ThemeState.idle( + _themeRepository.loadAppThemeFromCache(), + ), + ) { + on( + (event, emit) => event.map( + update: (e) => _update(e, emit), + ), + ); + } + + Future _update( + _ThemeEventUpdate event, + Emitter emit, + ) async { + final oldTheme = state.map( + idle: (state) => state.theme, + inProgress: (state) => state.theme, + ); + try { + emit(ThemeState.inProgress(event.theme)); + await _themeRepository.setTheme(event.theme); + emit(ThemeState.idle(event.theme)); + } catch (e) { + logger.warning( + 'Failed to update theme to $event, reverting to $oldTheme', + ); + emit(ThemeState.idle(oldTheme)); + rethrow; + } + } +} diff --git a/lib/src/feature/app/model/app_theme.dart b/lib/src/feature/app/model/app_theme.dart new file mode 100644 index 0000000..9431a6c --- /dev/null +++ b/lib/src/feature/app/model/app_theme.dart @@ -0,0 +1,167 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// {@template app_theme_type} +/// The type of theme to use. +/// {@endtemplate} +enum AppColorSchemeType { + /// Light theme. + light, + + /// Dark theme. + dark, + + /// Custom theme. + custom, + + /// System theme. + system; + + /// Whether this is a system theme. + bool get isSystem => switch (this) { + AppColorSchemeType.system => true, + _ => false, + }; + + @override + String toString() => switch (this) { + AppColorSchemeType.light => 'light', + AppColorSchemeType.dark => 'dark', + AppColorSchemeType.system => 'system', + AppColorSchemeType.custom => 'custom', + }; + + /// Creates a [AppColorSchemeType] from a [String]. + static AppColorSchemeType fromString(String value) => switch (value) { + 'light' => AppColorSchemeType.light, + 'dark' => AppColorSchemeType.dark, + 'custom' => AppColorSchemeType.custom, + 'system' => AppColorSchemeType.system, + _ => throw Exception('Unknown AppColorSchemeType: $value'), + }; +} + +/// {@template app_theme} +/// An immutable class that just holds the [ColorScheme]. +/// {@endtemplate} +@immutable +final class AppTheme with Diagnosticable { + /// The [ColorScheme] for this theme. + final ColorScheme? colorScheme; + + /// The type of theme to use. + final AppColorSchemeType type; + + /// {@macro app_theme} + const AppTheme.create({ + required this.type, + this.colorScheme, + }); + + /// Creates a [AppTheme] from a [Color] seed. + factory AppTheme.fromSeed( + Color seed, [ + Brightness brightness = Brightness.light, + ]) => + AppTheme.create( + colorScheme: ColorScheme.fromSeed( + seedColor: seed, + brightness: brightness, + ), + type: AppColorSchemeType.custom, + ); + + /// Light theme. + static final lightScheme = AppTheme.create( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink), + type: AppColorSchemeType.light, + ); + + /// Dark theme. + static final darkScheme = AppTheme.create( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.pink, + brightness: Brightness.dark, + ), + type: AppColorSchemeType.dark, + ); + + /// System theme. + static const systemScheme = AppTheme.create( + type: AppColorSchemeType.system, + ); + + /// All the light [AppTheme]s. + static final lightValues = [ + ...List.generate( + Colors.primaries.length, + (index) => AppTheme.fromSeed( + Colors.primaries[index], + ), + ), + ]; + + /// All the dark [AppTheme]s. + static final darkValues = [ + ...List.generate( + Colors.primaries.length, + (index) => AppTheme.fromSeed( + Colors.primaries[index], + Brightness.dark, + ), + ), + ]; + + @pragma('vm:prefer-inline') + AppTheme _systemOr(AppTheme other) => type.isSystem ? other : this; + + /// Get the dark [ThemeData] for this [AppTheme]. + ThemeData get darkTheme { + final schema = _systemOr(darkScheme).colorScheme; + return ThemeData( + colorScheme: schema, + brightness: schema?.brightness, + ); + } + + /// Get the light [ThemeData] for this [AppTheme]. + ThemeData get lightTheme { + final schema = _systemOr(lightScheme).colorScheme; + return ThemeData( + colorScheme: schema, + brightness: schema?.brightness, + ); + } + + /// Copy this [AppTheme] with the given parameters. + AppTheme copyWith({ + ColorScheme? colorScheme, + AppColorSchemeType? type, + }) => + AppTheme.create( + colorScheme: colorScheme ?? this.colorScheme, + type: type ?? this.type, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('colorScheme', colorScheme), + ); + properties.add(EnumProperty('type', type)); + properties.add(DiagnosticsProperty('lightTheme', lightTheme)); + properties.add(DiagnosticsProperty('darkTheme', darkTheme)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppTheme && + runtimeType == other.runtimeType && + colorScheme == other.colorScheme && + type == other.type; + + @override + int get hashCode => colorScheme.hashCode ^ type.hashCode; +} diff --git a/lib/src/feature/app/widget/app.dart b/lib/src/feature/app/widget/app.dart index 1231e96..4c80cd2 100644 --- a/lib/src/feature/app/widget/app.dart +++ b/lib/src/feature/app/widget/app.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sizzle_starter/src/core/widget/scope_widgets.dart'; import 'package:sizzle_starter/src/feature/app/widget/material_context.dart'; +import 'package:sizzle_starter/src/feature/app/widget/theme_scope.dart'; import 'package:sizzle_starter/src/feature/initialization/logic/initialization_processor.dart'; import 'package:sizzle_starter/src/feature/initialization/model/dependencies.dart'; import 'package:sizzle_starter/src/feature/initialization/widget/dependencies_scope.dart'; @@ -50,6 +51,11 @@ class App extends StatelessWidget { child: child, ), ), + ScopeProvider( + buildScope: (child) => ThemeScope( + child: child, + ), + ), ], child: const MaterialContext(), ), diff --git a/lib/src/feature/app/widget/material_context.dart b/lib/src/feature/app/widget/material_context.dart index 0aaff83..352fae4 100644 --- a/lib/src/feature/app/widget/material_context.dart +++ b/lib/src/feature/app/widget/material_context.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:sizzle_starter/src/core/localization/localization.dart'; -import 'package:sizzle_starter/src/core/theme/theme.dart'; +import 'package:sizzle_starter/src/feature/app/widget/theme_scope.dart'; import 'package:sizzle_starter/src/feature/home/widget/home_screen.dart'; /// {@template material_context} @@ -19,17 +19,21 @@ class MaterialContext extends StatefulWidget { class _MaterialContextState extends State { @override - Widget build(BuildContext context) => MaterialApp( - supportedLocales: Localization.supportedLocales, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - Localization.localizationDelegate, - ], - theme: defaultLightThemeData, - darkTheme: defaultDarkThemeData, - locale: const Locale('en'), - home: const HomeScreen(), - ); + Widget build(BuildContext context) { + final theme = ThemeScope.of(context).theme; + return MaterialApp( + debugShowCheckedModeBanner: false, + supportedLocales: Localization.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + Localization.localizationDelegate, + ], + theme: theme.lightTheme, + darkTheme: theme.darkTheme, + locale: const Locale('en'), + home: const HomeScreen(), + ); + } } diff --git a/lib/src/feature/app/widget/theme_scope.dart b/lib/src/feature/app/widget/theme_scope.dart new file mode 100644 index 0000000..7f084c3 --- /dev/null +++ b/lib/src/feature/app/widget/theme_scope.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:sizzle_starter/src/core/utils/mixin/scope_mixin.dart'; +import 'package:sizzle_starter/src/feature/app/logic/theme_bloc.dart'; +import 'package:sizzle_starter/src/feature/app/model/app_theme.dart'; +import 'package:sizzle_starter/src/feature/initialization/widget/dependencies_scope.dart'; + +/// {@template theme_controller} +/// A controller that holds and operates the app theme. +/// {@endtemplate} +abstract interface class ThemeController { + /// Get the current theme. + /// + /// This is handy to be obtained in the [MaterialApp]. + AppTheme get theme; + + /// Set the theme to [theme]. + void setTheme(AppTheme theme); +} + +/// {@template theme_scope} +/// Theme scope is responsible for handling theme-related stuff. +/// +/// See [ThemeController] for more info. +/// {@endtemplate} +class ThemeScope extends StatefulWidget { + /// {@macro theme_scope} + const ThemeScope({ + required this.child, + super.key, + }); + + /// The child widget. + final Widget child; + + /// Get the [ThemeController] of the closest [ThemeScope] ancestor. + static ThemeController of(BuildContext context, {bool listen = true}) => + ScopeMixin.scopeOf<_ThemeInherited>(context, listen: listen).controller; + + @override + State createState() => _ThemeScopeState(); +} + +class _ThemeScopeState extends State implements ThemeController { + @override + void setTheme(AppTheme theme) => _bloc.add( + ThemeEvent.update(theme), + ); + + @override + AppTheme get theme => _state.theme; + + late ThemeState _state; + + late final ThemeBloc _bloc; + + StreamSubscription? _subscription; + + void _listener(ThemeState state) { + if (_state == state) return; + + setState(() => _state = state); + } + + @override + void initState() { + _bloc = ThemeBloc( + DependenciesScope.of(context, listen: false).themeRepository, + ); + + _state = _bloc.state; + + _subscription = _bloc.stream.listen(_listener); + super.initState(); + } + + @override + void dispose() { + _bloc.close(); + _subscription?.cancel(); + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('theme', theme)); + } + + @override + Widget build(BuildContext context) => _ThemeInherited( + controller: this, + state: _state, + child: widget.child, + ); +} + +class _ThemeInherited extends InheritedWidget { + const _ThemeInherited({ + required this.controller, + required this.state, + required super.child, + }); + + /// {@macro theme_scope} + final ThemeController controller; + + /// The current theme state. + final ThemeState state; + + @override + bool updateShouldNotify(covariant _ThemeInherited oldWidget) => + oldWidget.state != state; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller), + ); + properties.add( + DiagnosticsProperty('themeState', state), + ); + } +} diff --git a/lib/src/feature/home/widget/home_screen.dart b/lib/src/feature/home/widget/home_screen.dart index 030dae4..aea942a 100644 --- a/lib/src/feature/home/widget/home_screen.dart +++ b/lib/src/feature/home/widget/home_screen.dart @@ -1,24 +1,140 @@ import 'package:flutter/material.dart'; import 'package:sizzle_starter/src/core/localization/localization.dart'; +import 'package:sizzle_starter/src/feature/app/model/app_theme.dart'; +import 'package:sizzle_starter/src/feature/app/widget/theme_scope.dart'; /// {@template sample_page} /// SamplePage widget /// {@endtemplate} -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { /// {@macro sample_page} const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(Localization.of(context).appTitle), - ), - body: Column( - children: [ - Text( - Localization.of(context).samplePlaceholder('Sizzle Starter'), + backgroundColor: Theme.of(context).colorScheme.background, + body: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(Localization.of(context).appTitle), + ), + SliverList( + delegate: SliverChildListDelegate.fixed([ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + Localization.of(context).system_theme, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ), + const _ThemeSelector([AppTheme.systemScheme]), + ]), + ), + SliverList( + delegate: SliverChildListDelegate.fixed([ + Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: Text( + Localization.of(context).light_themes, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ), + _ThemeSelector(AppTheme.lightValues), + ]), + ), + SliverList( + delegate: SliverChildListDelegate.fixed([ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + Localization.of(context).dark_themes, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + ), + _ThemeSelector(AppTheme.darkValues), + ]), + ), + SliverToBoxAdapter( + child: Center( + child: SizedBox( + height: 100, + width: 100, + child: Card( + color: Theme.of(context).colorScheme.primaryContainer, + margin: const EdgeInsets.all(8), + ), + ), + ), ), ], ), ); } + +class _ThemeSelector extends StatelessWidget { + const _ThemeSelector(this._themes); + + final List _themes; + + @override + Widget build(BuildContext context) => SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _themes.length, + itemBuilder: (context, index) { + final theme = _themes[index]; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: _ThemeCard(theme), + ); + }, + ), + ); +} + +class _ThemeCard extends StatelessWidget { + const _ThemeCard(this._theme); + + final AppTheme _theme; + + @override + Widget build(BuildContext context) => Card( + child: DecoratedBox( + decoration: BoxDecoration( + color: _theme.colorScheme?.primary, + borderRadius: BorderRadius.circular(4), + ), + child: InkWell( + onTap: () => ThemeScope.of(context).setTheme(_theme), + borderRadius: BorderRadius.circular(4), + child: SizedBox( + width: 64, + child: Center( + child: Text( + _theme.type.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _theme.colorScheme?.onPrimary, + ), + ), + ), + ), + ), + ), + ); +} diff --git a/lib/src/feature/initialization/logic/initialization_steps.dart b/lib/src/feature/initialization/logic/initialization_steps.dart index 64f0ca2..e991280 100644 --- a/lib/src/feature/initialization/logic/initialization_steps.dart +++ b/lib/src/feature/initialization/logic/initialization_steps.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sizzle_starter/src/feature/app/data/theme_datasource.dart'; +import 'package:sizzle_starter/src/feature/app/data/theme_repository.dart'; import 'package:sizzle_starter/src/feature/initialization/model/dependencies.dart'; import 'package:sizzle_starter/src/feature/initialization/model/initialization_progress.dart'; @@ -19,5 +21,13 @@ mixin InitializationSteps { final sharedPreferences = await SharedPreferences.getInstance(); progress.dependencies.sharedPreferences = sharedPreferences; }, + 'Theme Repository': (progress) async { + final sharedPreferences = progress.dependencies.sharedPreferences; + final themeDataSource = ThemeDataSourceImpl( + sharedPreferences: sharedPreferences, + ); + final themeRepository = ThemeRepositoryImpl(themeDataSource); + progress.dependencies.themeRepository = themeRepository; + }, }; } diff --git a/lib/src/feature/initialization/model/dependencies.dart b/lib/src/feature/initialization/model/dependencies.dart index b44b903..a36ab00 100644 --- a/lib/src/feature/initialization/model/dependencies.dart +++ b/lib/src/feature/initialization/model/dependencies.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sizzle_starter/src/feature/app/data/theme_repository.dart'; /// {@template dependencies} /// Dependencies container @@ -11,6 +12,9 @@ abstract base class Dependencies with Diagnosticable { /// Shared preferences abstract final SharedPreferences sharedPreferences; + /// Theme repository + abstract final ThemeRepository themeRepository; + /// Freeze dependencies, so they cannot be modified Dependencies freeze(); @@ -23,6 +27,12 @@ abstract base class Dependencies with Diagnosticable { sharedPreferences, ), ); + properties.add( + DiagnosticsProperty( + 'themeRepository', + themeRepository, + ), + ); } } @@ -38,9 +48,13 @@ final class DependenciesMutable extends Dependencies { @override late SharedPreferences sharedPreferences; + @override + late ThemeRepository themeRepository; + @override Dependencies freeze() => _DependenciesImmutable( sharedPreferences: sharedPreferences, + themeRepository: themeRepository, ); } @@ -53,11 +67,15 @@ final class _DependenciesImmutable extends Dependencies { /// {@macro immutable_dependencies} const _DependenciesImmutable({ required this.sharedPreferences, + required this.themeRepository, }); @override final SharedPreferences sharedPreferences; + @override + final ThemeRepository themeRepository; + @override Dependencies freeze() => this; } diff --git a/lib/src/feature/initialization/widget/dependencies_scope.dart b/lib/src/feature/initialization/widget/dependencies_scope.dart index b61da1e..21adf1c 100644 --- a/lib/src/feature/initialization/widget/dependencies_scope.dart +++ b/lib/src/feature/initialization/widget/dependencies_scope.dart @@ -19,8 +19,9 @@ class DependenciesScope extends InheritedWidget { final Dependencies dependencies; /// Get the dependencies from the [context]. - static Dependencies dependenciesOf(BuildContext context) => - ScopeMixin.scopeOf(context).dependencies; + static Dependencies of(BuildContext context, {bool listen = true}) => + ScopeMixin.scopeOf(context, listen: listen) + .dependencies; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { diff --git a/pubspec.yaml b/pubspec.yaml index a0aa82a..bc4b076 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,7 +60,7 @@ dev_dependencies: sdk: flutter # Lints - sizzle_lints: 2.0.1 + sizzle_lints: 2.0.2 # Utils flutter_gen_runner: 5.3.1