ML-18 Implement primary color picker (#19)

* wip

* hide `DynamicColorListTile` if unavailable

* added color animation for `AnimatedDialog`

* adjusted some colors

* sync `AnimatedDialog` insets with material

* scroll to selected color
This commit is contained in:
Vadim 2023-02-01 00:24:26 +03:00 committed by GitHub
parent cb4e91cb0a
commit 9cfffc3377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 369 additions and 138 deletions

View file

@ -9,8 +9,6 @@
},
"dart.lineLength": 100,
"[dart]": {
"editor.formatOnSave": true,
"editor.formatOnType": true,
"editor.rulers": [
100,
120,

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/ev_source_type.dart';
@ -15,6 +16,7 @@ class UserPreferencesService {
static const _hapticsKey = "haptics";
static const _themeTypeKey = "themeType";
static const _primaryColorKey = "primaryColor";
static const _dynamicColorKey = "dynamicColor";
final SharedPreferences _sharedPreferences;
@ -42,6 +44,9 @@ class UserPreferencesService {
ThemeType get themeType => ThemeType.values[_sharedPreferences.getInt(_themeTypeKey) ?? 0];
set themeType(ThemeType value) => _sharedPreferences.setInt(_themeTypeKey, value.index);
Color get primaryColor => Color(_sharedPreferences.getInt(_primaryColorKey) ?? 0xff2196f3);
set primaryColor(Color value) => _sharedPreferences.setInt(_primaryColorKey, value.value);
bool get dynamicColor => _sharedPreferences.getBool(_dynamicColorKey) ?? false;
set dynamicColor(bool value) => _sharedPreferences.setBool(_dynamicColorKey, value);
}

View file

@ -36,10 +36,12 @@
"haptics": "Haptics",
"theme": "Theme",
"chooseTheme": "Choose theme",
"dynamicColor": "Dynamic color",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystemDefault": "System default",
"dynamicColor": "Dynamic color",
"primaryColor": "Primary color",
"choosePrimaryColor": "Choose primary color",
"about": "About",
"sourceCode": "Source code",
"reportIssue": "Report an issue",

View file

@ -4,6 +4,7 @@ import 'package:flutter/scheduler.dart';
import 'package:lightmeter/data/models/dynamic_colors_state.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:provider/provider.dart';
@ -20,6 +21,25 @@ class ThemeProvider extends StatefulWidget {
return context.findAncestorStateOfType<ThemeProviderState>()!;
}
static const primaryColorsList = [
Color(0xfff44336),
Color(0xffe91e63),
Color(0xff9c27b0),
Color(0xff673ab7),
Color(0xff3f51b5),
Color(0xff2196f3),
Color(0xff03a9f4),
Color(0xff00bcd4),
Color(0xff009688),
Color(0xff4caf50),
Color(0xff8bc34a),
Color(0xffcddc39),
Color(0xffffeb3b),
Color(0xffffc107),
Color(0xffff9800),
Color(0xffff5722),
];
@override
State<ThemeProvider> createState() => ThemeProviderState();
}
@ -29,7 +49,7 @@ class ThemeProviderState extends State<ThemeProvider> {
late final _themeTypeNotifier = ValueNotifier<ThemeType>(_prefs.themeType);
late final _dynamicColorNotifier = ValueNotifier<bool>(_prefs.dynamicColor);
late final _primaryColorNotifier = ValueNotifier<Color>(const Color(0xFF2196f3));
late final _primaryColorNotifier = ValueNotifier<Color>(_prefs.primaryColor);
@override
void dispose() {
@ -80,6 +100,11 @@ class ThemeProviderState extends State<ThemeProvider> {
}
}
void setPrimaryColor(Color color) {
_primaryColorNotifier.value = color;
_prefs.primaryColor = color;
}
void enableDynamicColor(bool enable) {
_dynamicColorNotifier.value = enable;
_prefs.dynamicColor = enable;
@ -148,9 +173,33 @@ class _ThemeDataProvider extends StatelessWidget {
return ThemeData(
useMaterial3: true,
brightness: scheme.brightness,
primaryColor: primaryColor,
colorScheme: scheme,
appBarTheme: AppBarTheme(
elevation: 4,
color: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
),
cardTheme: CardTheme(
clipBehavior: Clip.antiAlias,
color: scheme.surface,
elevation: 4,
margin: EdgeInsets.zero,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(Dimens.borderRadiusL)),
surfaceTintColor: scheme.surfaceTint,
),
dialogBackgroundColor: scheme.surface,
dialogTheme: DialogTheme(backgroundColor: scheme.surface),
dialogTheme: DialogTheme(
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
elevation: 6,
),
listTileTheme: ListTileThemeData(
style: ListTileStyle.list,
iconColor: scheme.onSurface,
textColor: scheme.onSurface,
),
scaffoldBackgroundColor: scheme.surface,
);
}

View file

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
/// `valueM` represents the base value.
/// All other values differs by 8dp.
class Dimens {
@ -13,6 +15,7 @@ class Dimens {
static const double grid56 = 56;
static const double grid168 = 168;
static const double paddingS = 8;
static const double paddingM = 16;
static const double paddingL = 24;
@ -31,4 +34,8 @@ class Dimens {
static const double cameraSliderTrackRadius = cameraSliderTrackHeight / 2;
static const double cameraSliderHandleSize = 32;
static const double cameraSliderHandleIconSize = cameraSliderHandleSize * 2 / 3;
// Dialog
// Taken from `Dialog` documentation
static const EdgeInsets dialogMargin = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0);
}

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/shared/widget_circle_filled.dart';
import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dart';
class MeteringMeasureButton extends StatefulWidget {
final double size;

View file

@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/bottom_controls/components/shared/widget_circle_filled.dart';
class MeteringSecondaryButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const MeteringSecondaryButton({
required this.icon,
required this.onPressed,
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: FilledCircle(
color: Theme.of(context).colorScheme.surfaceVariant,
size: Dimens.grid48,
child: Center(
child: IconButton(
onPressed: onPressed,
color: Theme.of(context).colorScheme.onSurface,
icon: Icon(icon),
),
),
),
);
}
}

View file

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
import 'widget_bottom_controls.dart';
class MeteringBottomControlsProvider extends StatelessWidget {
final VoidCallback? onSwitchEvSourceType;
final VoidCallback onMeasure;
final VoidCallback onSettings;
const MeteringBottomControlsProvider({
required this.onSwitchEvSourceType,
required this.onMeasure,
required this.onSettings,
super.key,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return IconButtonTheme(
data: IconButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(scheme.surface),
elevation: const MaterialStatePropertyAll(4),
iconColor: MaterialStatePropertyAll(scheme.onSurface),
shadowColor: const MaterialStatePropertyAll(Colors.transparent),
surfaceTintColor: MaterialStatePropertyAll(scheme.surfaceTint),
fixedSize: const MaterialStatePropertyAll(Size(Dimens.grid48, Dimens.grid48)),
),
),
child: MeteringBottomControls(
onSwitchEvSourceType: onSwitchEvSourceType,
onMeasure: onMeasure,
onSettings: onSettings,
),
);
}
}

View file

@ -4,7 +4,6 @@ import 'package:lightmeter/res/dimens.dart';
import 'package:provider/provider.dart';
import 'components/measure_button/widget_button_measure.dart';
import 'components/secondary_button/widget_button_secondary.dart';
class MeteringBottomControls extends StatelessWidget {
final VoidCallback? onSwitchEvSourceType;
@ -36,11 +35,13 @@ class MeteringBottomControls extends StatelessWidget {
children: [
if (onSwitchEvSourceType != null)
Expanded(
child: MeteringSecondaryButton(
onPressed: onSwitchEvSourceType!,
icon: context.watch<EvSourceType>() != EvSourceType.camera
? Icons.camera_rear
: Icons.wb_incandescent,
child: Center(
child: IconButton(
onPressed: onSwitchEvSourceType,
icon: Icon(context.watch<EvSourceType>() != EvSourceType.camera
? Icons.camera_rear
: Icons.wb_incandescent),
),
),
)
else
@ -49,9 +50,11 @@ class MeteringBottomControls extends StatelessWidget {
onTap: onMeasure,
),
Expanded(
child: MeteringSecondaryButton(
onPressed: onSettings,
icon: Icons.settings,
child: Center(
child: IconButton(
onPressed: onSettings,
icon: const Icon(Icons.settings),
),
),
),
],

View file

@ -35,6 +35,8 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
late final Animation<double> _borderRadiusAnimation;
late final Animation<double> _closedOpacityAnimation;
late final Animation<double> _openedOpacityAnimation;
late final Animation<Color?> _foregroundColorAnimation;
late final Animation<double> _elevationAnimation;
bool _isDialogShown = false;
@ -91,8 +93,10 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
begin: _closedSize,
end: widget.openedSize ??
Size(
mediaQuery.size.width - mediaQuery.padding.horizontal - Dimens.paddingM * 2,
mediaQuery.size.height - mediaQuery.padding.vertical - Dimens.paddingM * 2,
mediaQuery.size.width -
mediaQuery.padding.horizontal -
Dimens.dialogMargin.horizontal,
mediaQuery.size.height - mediaQuery.padding.vertical - Dimens.dialogMargin.vertical,
),
);
_sizeAnimation = _sizeTween.animate(_defaultCurvedAnimation);
@ -112,6 +116,20 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_foregroundColorAnimation = ColorTween(
begin: Theme.of(context).colorScheme.primaryContainer,
end: Theme.of(context).colorScheme.surface,
).animate(_defaultCurvedAnimation);
_elevationAnimation = Tween<double>(
begin: 0,
end: Theme.of(context).dialogTheme.elevation!,
).animate(_defaultCurvedAnimation);
}
@override
void dispose() {
_animationController.dispose();
@ -125,13 +143,7 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
onTap: _openDialog,
child: Opacity(
opacity: _isDialogShown ? 0 : 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: widget.child ?? widget.closedChild,
),
),
child: widget.child ?? widget.closedChild,
),
);
}
@ -151,6 +163,8 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
sizeAnimation: _sizeAnimation,
offsetAnimation: _offsetAnimation,
borderRadiusAnimation: _borderRadiusAnimation,
foregroundColorAnimation: _foregroundColorAnimation,
elevationAnimation: _elevationAnimation,
onDismiss: close,
builder: widget.closedChild != null && widget.openedChild != null
? (_) => _AnimatedSwitcher(
@ -195,6 +209,8 @@ class _AnimatedOverlay extends StatelessWidget {
final Animation<Size?> sizeAnimation;
final Animation<Size?> offsetAnimation;
final Animation<double> borderRadiusAnimation;
final Animation<Color?> foregroundColorAnimation;
final Animation<double> elevationAnimation;
final VoidCallback onDismiss;
final Widget? child;
final Widget Function(BuildContext context)? builder;
@ -205,6 +221,8 @@ class _AnimatedOverlay extends StatelessWidget {
required this.sizeAnimation,
required this.offsetAnimation,
required this.borderRadiusAnimation,
required this.foregroundColorAnimation,
required this.elevationAnimation,
required this.onDismiss,
this.child,
this.builder,
@ -236,7 +254,9 @@ class _AnimatedOverlay extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadiusAnimation.value),
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
elevation: elevationAnimation.value,
surfaceTintColor: Theme.of(context).colorScheme.surfaceTint,
color: foregroundColorAnimation.value,
child: builder?.call(context) ?? child,
),
),

View file

@ -57,37 +57,34 @@ class _PhotographyValuePickerDialogState<T extends PhotographyValue>
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingM,
),
child: Column(
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.headlineSmall!,
),
const SizedBox(height: Dimens.grid16),
Text(
widget.subtitle,
style: Theme.of(context).textTheme.bodyMedium!,
),
],
),
Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingM,
),
Divider(
color: Theme.of(context).colorScheme.onPrimaryContainer,
height: 0,
child: Column(
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.headlineSmall!,
),
const SizedBox(height: Dimens.grid16),
Text(
widget.subtitle,
style: Theme.of(context).textTheme.bodyMedium!,
),
],
),
],
),
),
Divider(
color: Theme.of(context).colorScheme.onSurface,
height: 0,
),
],
),
Expanded(
child: ListView.builder(
@ -117,35 +114,32 @@ class _PhotographyValuePickerDialogState<T extends PhotographyValue>
),
),
),
ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Column(
children: [
Divider(
color: Theme.of(context).colorScheme.onPrimaryContainer,
height: 0,
Column(
children: [
Divider(
color: Theme.of(context).colorScheme.onSurface,
height: 0,
),
Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.max,
children: [
const Spacer(),
TextButton(
onPressed: widget.onCancel,
child: Text(S.of(context).cancel),
),
const SizedBox(width: Dimens.grid16),
TextButton(
onPressed: () => widget.onSelect(_selectedValue),
child: Text(S.of(context).select),
),
],
),
Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.max,
children: [
const Spacer(),
TextButton(
onPressed: widget.onCancel,
child: Text(S.of(context).cancel),
),
const SizedBox(width: Dimens.grid16),
TextButton(
onPressed: () => widget.onSelect(_selectedValue),
child: Text(S.of(context).select),
),
],
),
),
],
),
),
],
),
],
);

View file

@ -119,7 +119,7 @@ class _NdValueTile extends StatelessWidget {
onChanged: onChanged,
closedChild: ReadingValueContainer.singleValue(
value: ReadingValue(
label: S.of(context).iso,
label: S.of(context).nd,
value: value.value.toString(),
),
),

View file

@ -5,7 +5,7 @@ import 'package:lightmeter/data/models/photography_values/photography_value.dart
import 'package:lightmeter/environment.dart';
import 'package:lightmeter/providers/ev_source_type_provider.dart';
import 'components/bottom_controls/widget_bottom_controls.dart';
import 'components/bottom_controls/provider_bottom_controls.dart';
import 'components/camera_container/provider_container_camera.dart';
import 'components/light_sensor_container/provider_container_light_sensor.dart';
import 'bloc_metering.dart';
@ -57,7 +57,7 @@ class _MeteringScreenState extends State<MeteringScreen> {
),
),
),
MeteringBottomControls(
MeteringBottomControlsProvider(
onSwitchEvSourceType: context.read<Environment>().hasLightSensor
? EvSourceTypeProvider.of(context).toggleType
: null,

View file

@ -9,20 +9,6 @@ class DynamicColorListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (context.read<DynamicColorState>() == DynamicColorState.unavailable) {
return Opacity(
opacity: 0.5,
child: IgnorePointer(
child: SwitchListTile(
secondary: const Icon(Icons.colorize),
title: Text(S.of(context).dynamicColor),
value: false,
enableFeedback: false,
onChanged: (value) {},
),
),
);
}
return SwitchListTile(
secondary: const Icon(Icons.colorize),
title: Text(S.of(context).dynamicColor),

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/theme_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dart';
class PrimaryColorDialogPicker extends StatefulWidget {
const PrimaryColorDialogPicker({super.key});
@override
State<PrimaryColorDialogPicker> createState() => _PrimaryColorDialogPickerState();
}
class _PrimaryColorDialogPickerState extends State<PrimaryColorDialogPicker> {
late Color _selected = Theme.of(context).primaryColor;
late final ScrollController _scrollController = ScrollController(
initialScrollOffset:
ThemeProvider.primaryColorsList.indexOf(_selected) * (Dimens.grid48 + Dimens.grid8),
);
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingL,
Dimens.paddingM,
),
title: Text(S.of(context).choosePrimaryColor),
content: SizedBox(
height: Dimens.grid48,
width: double.maxFinite,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
separatorBuilder: (_, __) => const SizedBox(width: Dimens.grid8),
itemCount: ThemeProvider.primaryColorsList.length,
itemBuilder: (_, index) {
final color = ThemeProvider.primaryColorsList[index];
return _SelectableColorItem(
color: color,
selected: color.value == _selected.value,
onTap: () {
setState(() {
_selected = color;
});
},
);
},
),
),
actionsPadding: const EdgeInsets.fromLTRB(
Dimens.paddingL,
Dimens.paddingM,
Dimens.paddingL,
Dimens.paddingL,
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(S.of(context).cancel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(_selected);
},
child: Text(S.of(context).save),
),
],
);
}
}
class _SelectableColorItem extends StatelessWidget {
final Color color;
final bool selected;
final VoidCallback onTap;
_SelectableColorItem({
required this.color,
required this.selected,
required this.onTap,
}) : super(key: ValueKey(color));
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: FilledCircle(
size: Dimens.grid48,
color: color,
child: AnimatedSwitcher(
duration: Dimens.durationS,
child: selected
? Icon(
Icons.check,
color: ThemeData.estimateBrightnessForColor(color) == Brightness.light
? Colors.black
: Colors.white,
)
: null,
),
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/models/dynamic_colors_state.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/theme_provider.dart';
import 'components/primary_color_picker_dialog/widget_dialog_picker_primary_color.dart';
class PrimaryColorListTile extends StatelessWidget {
const PrimaryColorListTile({super.key});
@override
Widget build(BuildContext context) {
if (context.watch<DynamicColorState>() == DynamicColorState.enabled) {
return Opacity(
opacity: 0.5,
child: IgnorePointer(
child: ListTile(
leading: const Icon(Icons.palette),
title: Text(S.of(context).primaryColor),
),
),
);
}
return ListTile(
leading: const Icon(Icons.palette),
title: Text(S.of(context).primaryColor),
onTap: () {
showDialog<Color>(
context: context,
builder: (_) => const PrimaryColorDialogPicker(),
).then((value) {
if (value != null) {
ThemeProvider.of(context).setPrimaryColor(value);
}
});
},
);
}
}

View file

@ -20,10 +20,7 @@ class SettingsSection extends StatelessWidget {
Dimens.paddingM,
Dimens.paddingM,
),
child: Material(
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(Dimens.borderRadiusL),
color: Theme.of(context).colorScheme.primaryContainer,
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM),
child: Column(
@ -34,7 +31,10 @@ class SettingsSection extends StatelessWidget {
padding: const EdgeInsets.only(left: Dimens.paddingM),
child: Text(
title,
style: Theme.of(context).textTheme.labelLarge,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.onSurface),
),
),
...children,

View file

@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/dynamic_colors_state.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:provider/provider.dart';
import 'components/calibration/widget_list_tile_calibration.dart';
import 'components/haptics/provider_list_tile_haptics.dart';
import 'components/primary_color/widget_list_tile_primary_color.dart';
import 'components/report_issue/widget_list_tile_report_issue.dart';
import 'components/shared/settings_section/widget_settings_section.dart';
import 'components/source_code/widget_list_tile_source_code.dart';
@ -63,9 +66,11 @@ class SettingsScreen extends StatelessWidget {
),
SettingsSection(
title: S.of(context).theme,
children: const [
ThemeTypeListTile(),
DynamicColorListTile(),
children: [
const ThemeTypeListTile(),
const PrimaryColorListTile(),
if (context.read<DynamicColorState>() != DynamicColorState.unavailable)
const DynamicColorListTile(),
],
),
SettingsSection(