Ashlar UI Ashlar UI ashlarui

Appearance Picker

System, light or dark, picked from three small screens drawn in their own colours.

Appearance Picker, as it looks in an app Appearance Picker, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/app.dart';
import 'ui/appearance_picker.dart';

class AppearancePickerDemo extends StatefulWidget {
  const AppearancePickerDemo({super.key});

  @override
  State<AppearancePickerDemo> createState() => _AppearancePickerDemoState();
}

class _AppearancePickerDemoState extends State<AppearancePickerDemo> {
  var mode = AshlarThemeMode.system;

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 380,
    child: AppearancePicker(value: mode, onChanged: (m) => setState(() => mode = m)),
  );
}

Add it

In your Flutter project:

$ ashlar add appearance-picker

It writes theme.dart, palette.dart, group.dart, pressable.dart, selection.dart, app.dart and appearance_picker.dart into lib/ui, keeping any you already have. It needs pressable and app, which come with it.

API

Read from the source above: each parameter with its type, its default, and what it is for.

AppearancePickerclass

System, light or dark, chosen from three small screens, each drawn in the colours it stands for; System is the light screen and the dark one, cut on a slant. To a screen reader it is three choices, one of them chosen.

Give it the app's own themes, so the screens show the app's colours.

Parameter Type Default What it is for
valuerequired AshlarThemeMode The appearance chosen.
onChangedrequired ValueChanged<AshlarThemeMode> Told the appearance picked.
light AshlarThemeData? The themes the small screens are drawn in: Ashlar's own when not given.
dark AshlarThemeData? The themes the small screens are drawn in: Ashlar's own when not given.
systemLabel String 'System' What each is called, under its screen.
lightLabel String 'Light' What each is called, under its screen.
darkLabel String 'Dark' What each is called, under its screen.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/appearance_picker.dart 247 lines
// Ashlar UI: Appearance Picker. Copied into your app by `ashlar add appearance-picker`; it
// is yours to change.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'app.dart';
import 'pressable.dart';
import 'theme.dart';

/// System, light or dark, chosen from three small screens, each drawn in the colours it
/// stands for; System is the light screen and the dark one, cut on a slant. To a screen
/// reader it is three choices, one of them chosen.
///
/// Give it the app's own themes, so the screens show the app's colours.
///
/// ```dart
/// AppearancePicker(
///   value: mode,
///   onChanged: (m) => setState(() => mode = m),
///   light: appTheme,
///   dark: appDarkTheme,
/// )
/// ```
class AppearancePicker extends StatelessWidget {
  const AppearancePicker({
    super.key,
    required this.value,
    required this.onChanged,
    this.light,
    this.dark,
    this.systemLabel = 'System',
    this.lightLabel = 'Light',
    this.darkLabel = 'Dark',
  });

  /// The appearance chosen.
  final AshlarThemeMode value;

  /// Told the appearance picked.
  final ValueChanged<AshlarThemeMode> onChanged;

  /// The themes the small screens are drawn in: Ashlar's own when not given.
  final AshlarThemeData? light, dark;

  /// What each is called, under its screen.
  final String systemLabel, lightLabel, darkLabel;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final lightTheme = light ?? AshlarThemeData.light();
    final darkTheme = dark ?? AshlarThemeData.dark();
    Widget screen(AshlarThemeMode mode) => switch (mode) {
      AshlarThemeMode.light => _Screen(lightTheme.colors),
      AshlarThemeMode.dark => _Screen(darkTheme.colors),
      AshlarThemeMode.system => Stack(
        children: [
          _Screen(lightTheme.colors),
          ClipPath(clipper: const _Slant(), child: _Screen(darkTheme.colors)),
        ],
      ),
    };
    return Semantics(
      container: true,
      explicitChildNodes: true,
      child: Row(
        children: [
          for (final (i, mode) in const [
            AshlarThemeMode.system,
            AshlarThemeMode.light,
            AshlarThemeMode.dark,
          ].indexed) ...[
            if (i > 0) const SizedBox(width: 12),
            Expanded(
              child: Pressable(
                semanticLabel: switch (mode) {
                  AshlarThemeMode.system => systemLabel,
                  AshlarThemeMode.light => lightLabel,
                  AshlarThemeMode.dark => darkLabel,
                },
                checked: mode == value,
                exclusive: true,
                onPressed: () => onChanged(mode),
                builder: (context, state) {
                  final on = mode == value;
                  return Column(
                    children: [
                      AnimatedContainer(
                        duration: t.duration,
                        padding: const EdgeInsets.all(3),
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(t.radiusLg + 4),
                          border: Border.all(
                            color: on
                                ? c.foreground
                                : (state.focused
                                      ? c.ring
                                      : (state.hovered ? c.ring : c.border)),
                            width: 2,
                          ),
                        ),
                        child: Stack(
                          children: [
                            ClipRRect(
                              borderRadius: BorderRadius.circular(t.radiusLg),
                              child: screen(mode),
                            ),
                            if (on)
                              PositionedDirectional(
                                end: 6,
                                bottom: 6,
                                child: Container(
                                  width: 22,
                                  height: 22,
                                  decoration: BoxDecoration(
                                    color: c.foreground,
                                    shape: BoxShape.circle,
                                  ),
                                  child: Center(
                                    child: AshlarIcon(
                                      AshlarIconsRoundedStroke.check,
                                      size: 14,
                                      color: c.background,
                                    ),
                                  ),
                                ),
                              ),
                          ],
                        ),
                      ),
                      const SizedBox(height: 8),
                      Text(
                        switch (mode) {
                          AshlarThemeMode.system => systemLabel,
                          AshlarThemeMode.light => lightLabel,
                          AshlarThemeMode.dark => darkLabel,
                        },
                        style: t.text.sm.copyWith(
                          color: c.foreground,
                          fontWeight: on ? FontWeight.w600 : FontWeight.w400,
                        ),
                      ),
                    ],
                  );
                },
              ),
            ),
          ],
        ],
      ),
    );
  }
}

/// A tiny screen in [colors]: a bar along the top, a card with a picture, and a tab bar.
class _Screen extends StatelessWidget {
  const _Screen(this.colors);

  final AshlarColors colors;

  @override
  Widget build(BuildContext context) => Container(
    height: 110,
    padding: const EdgeInsets.all(8),
    color: colors.background,
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Container(
          height: 12,
          decoration: BoxDecoration(
            color: colors.card,
            borderRadius: BorderRadius.circular(6),
            border: Border.all(color: colors.border),
          ),
        ),
        const SizedBox(height: 6),
        Container(
          height: 44,
          decoration: BoxDecoration(
            color: colors.card,
            borderRadius: BorderRadius.circular(8),
            border: Border.all(color: colors.border),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Expanded(
                child: Container(
                  decoration: BoxDecoration(
                    color: colors.muted,
                    borderRadius: const BorderRadius.vertical(
                      top: Radius.circular(7),
                    ),
                  ),
                ),
              ),
              Padding(
                padding: const EdgeInsetsDirectional.fromSTEB(6, 4, 24, 4),
                child: Container(height: 4, color: colors.mutedForeground),
              ),
            ],
          ),
        ),
        const Spacer(),
        Center(
          child: Container(
            width: 48,
            height: 12,
            padding: const EdgeInsets.all(2),
            alignment: AlignmentDirectional.centerStart,
            decoration: BoxDecoration(
              color: colors.card,
              borderRadius: BorderRadius.circular(6),
              border: Border.all(color: colors.border),
            ),
            child: Container(
              width: 16,
              decoration: BoxDecoration(
                color: colors.primary,
                borderRadius: BorderRadius.circular(4),
              ),
            ),
          ),
        ),
      ],
    ),
  );
}

/// The right of a screen, cut on a slant: System's dark side.
class _Slant extends CustomClipper<Path> {
  const _Slant();

  @override
  Path getClip(Size size) => Path()
    ..moveTo(size.width * .62, 0)
    ..lineTo(size.width, 0)
    ..lineTo(size.width, size.height)
    ..lineTo(size.width * .38, size.height)
    ..close();

  @override
  bool shouldReclip(_Slant old) => false;
}