material-foundation / flutter-packages

A collection of useful Material Design packages
https://pub.dev/publishers/material.io
Apache License 2.0
824 stars 144 forks source link

Improvements in the implementation of dynamic colors #579

Open IlluminatiWave opened 2 months ago

IlluminatiWave commented 2 months ago

Package

dynamic_color

Description

Is your feature request related to a problem? Please describe.

The color rendering in the plugin for non-Android operating systems needs significant improvements. Currently, the color palette is limited and the documentation isn't clear enough.

Describe the solution you'd like

  1. Enhanced support for color palette on other operating systems: I request improved color palette support for operating systems other than Android, allowing for greater customization.

  2. Improved documentation and example files: More detailed documentation and better designed examples are needed to facilitate understanding and use of the plugin.

  3. Real-time update of the color palette: I wish the plugin could update the color palette in real-time, similar to how Flutter is able to dynamically switch between light/dark themes depending on the current device theme. It would be ideal to integrate this functionality as part of native material.dart.

Additional context

Consider including functionality such as ColorScheme.fromImageProvider or DynamicColorPlugin.getAccentColor for more accurate color rendering and integration with dynamic system themes, such as those that Flutter is able to detect (light/dark), for a more consistent experience.


Describe the solution you'd like While Windows currently uses a SystemAccentColorLight/Dark 1/2/3 system, it is not entirely useful for the material theme system.

Currently, Flutter doesn't dynamically adapt to Windows theming, which limits its ability to reflect changes in accent color and light/dark themes. I propose to update the Flutter plugin to allow dynamic response to these changes in the first instance, focusing on improving the functionality of DynamicColorPlugin.getAccentColor, which currently works statically. I have implemented code that improves this functionality, making it dynamic.

void updateColor() async {
  Duration delay = const Duration(/*seconds: 1*/);
  isDynamicColor = true;
  while (isDynamicColor) {
    final newColor = await DynamicColorPlugin.getAccentColor();
    if (newColor != currentColor) {
      setState(() {
        currentColor = newColor;
      });
    }
    await Future.delayed(delay);
  }
}
Form of use ```dart class _MyAppState extends State { Color? currentColor; bool isDynamicColor = true; @override void initState() { super.initState(); updateColor(); } Widget build(BuildContext context) { final color = currentColor ?? Colors.blue; return MaterialApp( themeMode: ThemeMode.system, theme: ThemeData.light().copyWith( colorScheme: ColorScheme.fromSeed(seedColor: color, brightness: Brightness.light), ), darkTheme: ThemeData.dark().copyWith( colorScheme: ColorScheme.fromSeed(seedColor: color, brightness: Brightness.dark), ), debugShowCheckedModeBanner: false, home: const MyHomePage(), ); } } /* Then you can use the theme colors automatically, it is compatible with Material3. Dynamic color is disabled when setting isDynamicColor = false; and activating it by calling the updateColor(); function. */ ```
Real Code (Windows) ```dart import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; void main() { runApp(const DynamicColorExample()); } class DynamicColorExample extends StatefulWidget { const DynamicColorExample({super.key}); @override // ignore: library_private_types_in_public_api _DynamicColorExampleState createState() => _DynamicColorExampleState(); } class _DynamicColorExampleState extends State { Color? currentColor; bool isDynamicColor = true; @override void initState() { super.initState(); updateColor(); } void updateColor() async { Duration delay = const Duration(/*seconds: 1*/); isDynamicColor = true; while (isDynamicColor) { final newColor = await DynamicColorPlugin.getAccentColor(); if (newColor != currentColor) { setState(() { currentColor = newColor; }); } await Future.delayed(delay); } } @override Widget build(BuildContext context) { final color = currentColor ?? Colors.blue; return MaterialApp( themeMode: ThemeMode.system, theme: ThemeData.light().copyWith( colorScheme: ColorScheme.fromSeed( seedColor: color, brightness: Brightness.light), ), darkTheme: ThemeData.dark().copyWith( colorScheme: ColorScheme.fromSeed(seedColor: color, brightness: Brightness.dark), ), debugShowCheckedModeBanner: false, home: const MyHomePage(), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Testapp'), backgroundColor: Theme.of(context).colorScheme.primaryContainer, ), body: const Center( child: Text('Hello World!'), ), backgroundColor: Theme.of(context).colorScheme.background, drawer: Drawer( child: ListView( children: [ DrawerHeader( decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, ), child: const Text('Drawer'), ), ListTile( leading: const Icon(Icons.home), title: const Text('Home'), onTap: () {}, ), ListTile( leading: const Icon(Icons.settings), title: const Text('Settings'), onTap: () {}, ), ], ), ), ); } } ```

https://github.com/material-foundation/flutter-packages/assets/42191299/286ac360-20a6-4b0f-822e-9695b4477270

With this change, the accent color in Flutter now dynamically adapts to the system accent color in Windows.

In addition, the code provided has been tested and works correctly on Windows. It is worth noting that the dynamic theming has not been tested on other platforms such as MacOS or Linux (it should work). However, a static blue theme is generated by default for the web platform where dynamic theming isn't supported.

Furthermore, this implementation could potentially serve as the default color scheme for desktop platforms (material.dart).

Adaptive/Responsive Color Scheme.

Android's Material You and Material Design include adaptive theming based on the wallpaper. Meanwhile, Windows mainly relies on accent color changes based on the wallpaper. However, achieving similar results on other platforms is complex due to factors such as the macOS system or the way wallpapers are stored in various Linux desktop environments. The code provided, while primarily focused on Windows, uses a modified version of DynamicColorExample that uses ColorScheme.fromImageProvider to obtain a color palette from an image. While not identical to native Android theming, it achieves a similar result by dynamically adapting the theme based on the wallpaper.

That is why, in order to achieve similar performance, I have focused on 3 goals:

  1. Get the wallpaper path (only works on Windows at the moment): To adapt the theme according to the wallpaper, it is crucial to get the path of the wallpaper image file in use.

  2. Listen for possible changes in the wallpaper: The ability to detect changes in the wallpaper is essential to dynamically update the color scheme in response to changes in the system wallpaper.

  3. Set the theme whenever a change in the wallpaper is detected: Once a change in the wallpaper is detected, it is necessary to automatically update the application theme to reflect the new wallpaper colors.


Goal 1: Obtain the path to the wallpaper (only works on Windows at the moment).

The following code is responsible for obtaining an image file that represents the wallpaper, depending on the operating system in use. Although it works effectively in Windows, it has conditions of use for when it is possible to find a way to obtain the image of the wallpaper in other operating systems.

import 'dart:io';

File getWallpaperFile() {
  String wallpaperPath = '';
  if (Platform.isWindows) {
    wallpaperPath =
        '${Platform.environment['APPDATA']}\\Microsoft\\Windows\\Themes\\transcodedWallpaper';
  } else if (Platform.isMacOS) {
    /*wallpaperPath =
        '${Platform.environment['HOME']}/Library/Application Support/Dock/desktoppicture.db';*/
    // Additional research is needed to extract the real-time image on MacOS
  } else if (Platform.isLinux) {
    // Code to get the background on Linux platform (Pending implementation)
  } else if (Platform.isAndroid) {
    // Code to get the background on Android platform (Pending implementation)
  } else if (Platform.isIOS) {
    // Code to get the background on iOS platform (Pending implementation)
  } else if (Platform.isFuchsia) {
    // Code to get the background on Fuchsia platform (Pending implementation)
  }

  if (wallpaperPath.isNotEmpty) {
    return File(wallpaperPath);
  } else {
    return File('');
  }
}

As mentioned, the code currently only works on Windows. Additional research is needed to determine how to get the real-time wallpaper image on macOS, as well as implement the functionality for other platforms.

Goal 2: Listen for possible changes in the wallpaper.

The following function, wallpaperwatcher, is in charge of monitoring a file representing the wallpaper. This function takes as parameter a File object, which is the same file obtained by the getWallpaperFile() function.

import 'dart:io';

Future<void> wallpaperwatcher(File file) async {
  DateTime lastModified = file.statSync().modified;
  Duration delay = const Duration(milliseconds: 100);

  while (true) {
    final currentModified = file.statSync().modified;
    if (currentModified != lastModified) {
      lastModified = currentModified;
      while (true) {
        await Future.delayed(delay); // Prevents system overload if the file doesn't change
        try {
          RandomAccessFile raf = await file.open(mode: FileMode.read);
          await raf.close(); // Avoid Windows background file usage problems
          break;
        } catch (e) {
          // In case of error detection, create a console.log(e);
        }
      }

      await (widget.imageKey.currentWidget as Image).image.evict();

      // Update the desktop wallpaper usage with a new widget Image
      ImageProvider<Object>? result;
      if (forceLoad) {
        result = MemoryImage(imagePath.readAsBytesSync());
      } else {
        result = FileImage(file);
      }

      setState(() {
        forceLoad = true;
        widget.images = result!;
        _updateImage(widget.images /*[selectedImage]*/); // single image
      });
    }
    await Future.delayed(delay);
  }
}

This function uses the timestamp of the file to detect changes in the wallpaper. If a change is detected, it dynamically updates the wallpaper image in the user interface. In addition, it includes error handling to avoid file overuse problems and controls the delay time to avoid excessive system calls.

Goal 3: Update the color scheme accordingly.

Although the Wallpaperwatcher function is responsible for monitoring possible changes to the wallpaper, its main function is to initialize the update process. Once a change in the wallpaper is detected, this function calls _updateImage(), which in turn is responsible for updating the color scheme and loading the new wallpaper image using the buildImage() function.

Future<void> _updateImage(ImageProvider provider) async {
  final ColorScheme newColorScheme = await ColorScheme.fromImageProvider(
    provider: provider,
    brightness: isLight ? Brightness.light : Brightness.dark,
  );
  setState(() {
    buildImage(); // Update the wallpaper image
    currentColorScheme = newColorScheme; // Update the color scheme
  });
}

The buildImage() function is responsible for refreshing the background, setting a new key for Flutter to remove the current background from the cache and refresh it with the new image.

Widget buildImage() {
  ImageProvider<Object> result = forceLoad
    ? MemoryImage(imagePath.readAsBytesSync()) as ImageProvider<Object>
    : FileImage(imagePath) as ImageProvider<Object>;
  return Image(
    image: result,
    key: widget.imageKey, // Sets a new key to refresh the wallpaper
  );
}

So, goal 3 actually focuses on the buildImage() function, which uses _updateImage() and Wallpaperwatcher() as intermediaries to accomplish the color scheme and wallpaper update.

Based on the code provided by the flutter documentation, I made an adaptation of it that is able to change the color scheme dynamically (the commented code is part of the original code, I have kept it as a reference).

Real Code (Windows) ```dart import 'dart:io'; import 'package:flutter/material.dart'; /// Flutter code sample for [ColorScheme.fromImageProvider] with content-based dynamic color. const Widget divider = SizedBox(height: 10); const double narrowScreenWidthThreshold = 400; File getWallpaperFile() { String wallpaperPath = ''; if (Platform.isWindows) { wallpaperPath = '${Platform.environment['APPDATA']}\\Microsoft\\Windows\\Themes\\transcodedWallpaper'; } else if (Platform.isMacOS) { wallpaperPath = '${Platform.environment['HOME']}/Library/Application Support/Dock/desktoppicture.db'; } else if (Platform.isLinux) { // Código para obtener el fondo en plataforma Linux } else if (Platform.isAndroid) { // Código para obtener el fondo en plataforma Android } else if (Platform.isIOS) { // Código para obtener el fondo en plataforma iOS } else if (Platform.isFuchsia) { // Código para obtener el fondo en plataforma Fuschia } if (wallpaperPath.isNotEmpty) { return File(wallpaperPath); } else { return File(''); } } void main() { runApp(DynamicColorExample()); } class DynamicColorExample extends StatefulWidget { /* final List images = [ const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_1.png'), const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_2.png'), const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_3.png'), const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_4.png'), const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_5.png'), const NetworkImage( 'https://flutter.github.io/assets-for-api-docs/assets/material/content_based_color_scheme_6.png'), ]; */ late ImageProvider images; late GlobalKey imageKey; DynamicColorExample({super.key}); @override State createState() => _DynamicColorExampleState(); } class _DynamicColorExampleState extends State { late ColorScheme currentColorScheme; String currentHyperlinkImage = ''; late int selectedImage; late bool isLight; late bool isLoading; bool forceLoad = false; late File imagePath; @override void initState() { super.initState(); widget.imageKey = GlobalKey(); File wallpaperFile = getWallpaperFile(); imagePath = getWallpaperFile(); wallpaperwatcher(imagePath); widget.images = FileImage(wallpaperFile); selectedImage = 0; isLight = true; isLoading = true; currentColorScheme = const ColorScheme.light(); WidgetsBinding.instance.addPostFrameCallback((_) { _updateImage(widget.images /*[selectedImage]*/); // single image isLoading = false; }); } @override Widget build(BuildContext context) { final ColorScheme colorScheme = currentColorScheme; final Color selectedColor = currentColorScheme.primary; final ThemeData lightTheme = ThemeData( colorSchemeSeed: selectedColor, brightness: Brightness.light, useMaterial3: false, ); final ThemeData darkTheme = ThemeData( colorSchemeSeed: selectedColor, brightness: Brightness.dark, useMaterial3: false, ); Widget schemeLabel(String brightness, ColorScheme colorScheme) { return Padding( padding: const EdgeInsets.symmetric(vertical: 15), child: Text( brightness, style: TextStyle( fontWeight: FontWeight.bold, color: colorScheme.onSecondaryContainer), ), ); } Widget schemeView(ThemeData theme) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: ColorSchemeView(colorScheme: theme.colorScheme), ); } return MaterialApp( theme: ThemeData(useMaterial3: true, colorScheme: colorScheme), debugShowCheckedModeBanner: false, home: Builder( builder: (BuildContext context) => Scaffold( appBar: AppBar( title: const Text('Content Based Dynamic Color'), backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, actions: [ const Icon(Icons.light_mode), Switch( activeColor: colorScheme.primary, activeTrackColor: colorScheme.surface, inactiveTrackColor: colorScheme.onSecondary, value: isLight, onChanged: (bool value) { setState(() { isLight = value; _updateImage( widget.images /*[selectedImage]*/); // single image }); }) ], ), body: Center( child: isLoading ? const CircularProgressIndicator() : ColoredBox( color: colorScheme.secondaryContainer, child: Column( children: [ divider, /*_imagesRow*/ _imageWidget( context, widget.images, colorScheme, ), divider, Expanded( child: ColoredBox( color: colorScheme.background, child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { if (constraints.maxWidth < narrowScreenWidthThreshold) { return SingleChildScrollView( child: Column( children: [ divider, schemeLabel( 'Light ColorScheme', colorScheme), schemeView(lightTheme), divider, divider, schemeLabel( 'Dark ColorScheme', colorScheme), schemeView(darkTheme), ], ), ); } else { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(top: 5), child: Column( children: [ Row( children: [ Expanded( child: Column( children: [ schemeLabel( 'Light ColorScheme', colorScheme), schemeView(lightTheme), ], ), ), Expanded( child: Column( children: [ schemeLabel( 'Dark ColorScheme', colorScheme), schemeView(darkTheme), ], ), ), ], ), ], ), ), ); } }), ), ), ], ), ), ), ), ), ); } Future _updateImage(ImageProvider provider) async { final ColorScheme newColorScheme = await ColorScheme.fromImageProvider( provider: provider, brightness: isLight ? Brightness.light : Brightness.dark); setState(() { // selectedImage = widget.images.indexOf(provider);// Disabled for single image buildImage(); currentColorScheme = newColorScheme; }); } // For small screens, have two rows of image selection. For wide screens, // fit them onto one row. /* Widget _imagesRow(BuildContext context, List images, ColorScheme colorScheme) { final double windowHeight = MediaQuery.of(context).size.height; final double windowWidth = MediaQuery.of(context).size.width; return Padding( padding: const EdgeInsets.all(8.0), child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { if (constraints.maxWidth > 800) { return _adaptiveLayoutImagesRow(images, colorScheme, windowHeight); } else { return Column(children: [ _adaptiveLayoutImagesRow( images.sublist(0, 3), colorScheme, windowWidth), _adaptiveLayoutImagesRow( images.sublist(3), colorScheme, windowWidth), ]); } }), ); } */ Widget _imageWidget( BuildContext context, ImageProvider image, ColorScheme colorScheme) { final double windowWidth = MediaQuery.of(context).size.width; return Padding( padding: const EdgeInsets.all(8.0), child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return ConstrainedBox( constraints: BoxConstraints(maxWidth: windowWidth * 0.25), child: Card( color: colorScheme.primaryContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), child: Padding( padding: const EdgeInsets.all(5.0), child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: Image( key: widget.imageKey, image: image, ), ), ), ), ); }, ), ); } Widget buildImage() { ImageProvider result = forceLoad ? MemoryImage(imagePath.readAsBytesSync()) as ImageProvider : FileImage(imagePath) as ImageProvider; return Image( image: result, key: widget.imageKey, ); } Future wallpaperwatcher(File file) async { DateTime lastModified = file.statSync().modified; Duration delay = const Duration(milliseconds: 100); while (true) { final currentModified = file.statSync().modified; if (currentModified != lastModified) { lastModified = currentModified; while (true) { await Future.delayed(delay); try { RandomAccessFile raf = await file.open(mode: FileMode.read); await raf.close(); break; } catch (e) { // print("Archivo en uso, esperando..."); } } await (widget.imageKey.currentWidget as Image).image.evict(); ImageProvider? result; if (forceLoad) { result = MemoryImage(imagePath.readAsBytesSync()); } else { result = FileImage(file); } setState(() { forceLoad = true; widget.images = result!; _updateImage(widget.images /*[selectedImage]*/); // single image }); } await Future.delayed(delay); } } /* Widget _adaptiveLayoutImagesRow( List images, ColorScheme colorScheme, double windowWidth) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: images .map( (ImageProvider image) => Flexible( flex: (images.length / 3).floor(), child: GestureDetector( onTap: () => _updateImage(image), child: Card( color: widget.images.indexOf(image) == selectedImage ? colorScheme.primaryContainer : colorScheme.background, child: Padding( padding: const EdgeInsets.all(5.0), child: ConstrainedBox( constraints: BoxConstraints(maxWidth: windowWidth * .25), child: ClipRRect( borderRadius: BorderRadius.circular(8.0), child: Image(image: image), ), ), ), ), ), ), ) .toList(), ); } */ } class ColorSchemeView extends StatelessWidget { const ColorSchemeView({super.key, required this.colorScheme}); final ColorScheme colorScheme; @override Widget build(BuildContext context) { return Column( children: [ ColorGroup(children: [ ColorChip( label: 'primary', color: colorScheme.primary, onColor: colorScheme.onPrimary), ColorChip( label: 'onPrimary', color: colorScheme.onPrimary, onColor: colorScheme.primary), ColorChip( label: 'primaryContainer', color: colorScheme.primaryContainer, onColor: colorScheme.onPrimaryContainer), ColorChip( label: 'onPrimaryContainer', color: colorScheme.onPrimaryContainer, onColor: colorScheme.primaryContainer), ]), divider, ColorGroup(children: [ ColorChip( label: 'secondary', color: colorScheme.secondary, onColor: colorScheme.onSecondary), ColorChip( label: 'onSecondary', color: colorScheme.onSecondary, onColor: colorScheme.secondary), ColorChip( label: 'secondaryContainer', color: colorScheme.secondaryContainer, onColor: colorScheme.onSecondaryContainer), ColorChip( label: 'onSecondaryContainer', color: colorScheme.onSecondaryContainer, onColor: colorScheme.secondaryContainer), ]), divider, ColorGroup( children: [ ColorChip( label: 'tertiary', color: colorScheme.tertiary, onColor: colorScheme.onTertiary), ColorChip( label: 'onTertiary', color: colorScheme.onTertiary, onColor: colorScheme.tertiary), ColorChip( label: 'tertiaryContainer', color: colorScheme.tertiaryContainer, onColor: colorScheme.onTertiaryContainer), ColorChip( label: 'onTertiaryContainer', color: colorScheme.onTertiaryContainer, onColor: colorScheme.tertiaryContainer), ], ), divider, ColorGroup( children: [ ColorChip( label: 'error', color: colorScheme.error, onColor: colorScheme.onError), ColorChip( label: 'onError', color: colorScheme.onError, onColor: colorScheme.error), ColorChip( label: 'errorContainer', color: colorScheme.errorContainer, onColor: colorScheme.onErrorContainer), ColorChip( label: 'onErrorContainer', color: colorScheme.onErrorContainer, onColor: colorScheme.errorContainer), ], ), divider, ColorGroup( children: [ ColorChip( label: 'background', color: colorScheme.background, onColor: colorScheme.onBackground), ColorChip( label: 'onBackground', color: colorScheme.onBackground, onColor: colorScheme.background), ], ), divider, ColorGroup( children: [ ColorChip( label: 'surface', color: colorScheme.surface, onColor: colorScheme.onSurface), ColorChip( label: 'onSurface', color: colorScheme.onSurface, onColor: colorScheme.surface), ColorChip( label: 'surfaceVariant', color: colorScheme.surfaceVariant, onColor: colorScheme.onSurfaceVariant), ColorChip( label: 'onSurfaceVariant', color: colorScheme.onSurfaceVariant, onColor: colorScheme.surfaceVariant), ], ), divider, ColorGroup( children: [ ColorChip(label: 'outline', color: colorScheme.outline), ColorChip(label: 'shadow', color: colorScheme.shadow), ColorChip( label: 'inverseSurface', color: colorScheme.inverseSurface, onColor: colorScheme.onInverseSurface), ColorChip( label: 'onInverseSurface', color: colorScheme.onInverseSurface, onColor: colorScheme.inverseSurface), ColorChip( label: 'inversePrimary', color: colorScheme.inversePrimary, onColor: colorScheme.primary), ], ), ], ); } } class ColorGroup extends StatelessWidget { const ColorGroup({super.key, required this.children}); final List children; @override Widget build(BuildContext context) { return RepaintBoundary( child: Card(clipBehavior: Clip.antiAlias, child: Column(children: children)), ); } } class ColorChip extends StatelessWidget { const ColorChip({ super.key, required this.color, required this.label, this.onColor, }); final Color color; final Color? onColor; final String label; static Color contrastColor(Color color) { final Brightness brightness = ThemeData.estimateBrightnessForColor(color); return switch (brightness) { Brightness.dark => Colors.white, Brightness.light => Colors.black, }; } @override Widget build(BuildContext context) { final Color labelColor = onColor ?? contrastColor(color); return ColoredBox( color: color, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded(child: Text(label, style: TextStyle(color: labelColor))), ], ), ), ); } } ```

https://github.com/material-foundation/flutter-packages/assets/42191299/4ef7a2c7-caea-433e-a830-862238b6e30d


Describe alternatives you've considered I couldn't find any alternative that meets the "dynamic theme" requirement. The closest we have is dynamicColors for android12 or higher, but it's not available for other platforms :L

Additional context The only current usage scenario is on windows for dynamicColor, it still needs to be implemented for the other platforms.