mirror of
https://github.com/openfoodfacts/smooth-app.git
synced 2025-08-06 18:25:11 +08:00
618 lines
19 KiB
Dart
618 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:smooth_app/generic_lib/bottom_sheets/smooth_bottom_sheet.dart';
|
|
import 'package:smooth_app/generic_lib/design_constants.dart';
|
|
import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart';
|
|
import 'package:smooth_app/helpers/paint_helper.dart';
|
|
import 'package:smooth_app/l10n/app_localizations.dart';
|
|
import 'package:smooth_app/pages/preferences/user_preferences_item.dart';
|
|
import 'package:smooth_app/themes/smooth_theme_colors.dart';
|
|
import 'package:smooth_app/themes/theme_provider.dart';
|
|
|
|
/// A dashed line
|
|
class UserPreferencesListItemDivider extends StatelessWidget {
|
|
const UserPreferencesListItemDivider({this.margin, super.key});
|
|
|
|
final EdgeInsetsGeometry? margin;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: margin ?? const EdgeInsets.symmetric(horizontal: LARGE_SPACE),
|
|
child: CustomPaint(
|
|
size: const Size(double.infinity, 1.0),
|
|
painter: DashedLinePainter(color: Theme.of(context).dividerColor),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserPreferencesSwitchWidget extends StatelessWidget {
|
|
const UserPreferencesSwitchWidget({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SwitchListTile.adaptive(
|
|
title: Padding(
|
|
padding: const EdgeInsetsDirectional.only(
|
|
top: SMALL_SPACE,
|
|
bottom: SMALL_SPACE,
|
|
),
|
|
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
|
|
),
|
|
subtitle: subtitle == null
|
|
? null
|
|
: Padding(
|
|
padding: const EdgeInsetsDirectional.only(bottom: SMALL_SPACE),
|
|
child: Text(subtitle!, style: const TextStyle(height: 1.5)),
|
|
),
|
|
activeColor: Theme.of(context).primaryColor,
|
|
value: value,
|
|
onChanged: onChanged,
|
|
isThreeLine: subtitle != null,
|
|
);
|
|
}
|
|
|
|
class UserPreferencesItemSwitch implements UserPreferencesItem {
|
|
const UserPreferencesItemSwitch({
|
|
required this.title,
|
|
this.subtitle,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
List<String> get labels => <String>[title, if (subtitle != null) subtitle!];
|
|
|
|
@override
|
|
WidgetBuilder get builder =>
|
|
(final BuildContext context) => UserPreferencesSwitchWidget(
|
|
title: title,
|
|
subtitle: subtitle,
|
|
value: value,
|
|
onChanged: onChanged,
|
|
);
|
|
}
|
|
|
|
class UserPreferencesItemTile implements UserPreferencesItem {
|
|
const UserPreferencesItemTile({
|
|
required this.title,
|
|
this.subtitle,
|
|
this.onTap,
|
|
this.leading,
|
|
this.trailing,
|
|
this.visibleWhen,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final VoidCallback? onTap;
|
|
final Widget? leading;
|
|
final Widget? trailing;
|
|
final bool Function(BuildContext context)? visibleWhen;
|
|
|
|
@override
|
|
List<String> get labels => <String>[title, if (subtitle != null) subtitle!];
|
|
|
|
@override
|
|
WidgetBuilder get builder => (final BuildContext context) {
|
|
if (visibleWhen?.call(context) == false) {
|
|
return EMPTY_WIDGET;
|
|
}
|
|
|
|
return ListTile(
|
|
title: Text(title),
|
|
subtitle: subtitle == null ? null : Text(subtitle!),
|
|
onTap: onTap,
|
|
leading: leading,
|
|
trailing: trailing,
|
|
);
|
|
};
|
|
}
|
|
|
|
/// Same as [UserPreferencesItemTile] but with [WidgetBuilder].
|
|
class UserPreferencesItemTileBuilder implements UserPreferencesItem {
|
|
const UserPreferencesItemTileBuilder({
|
|
required this.title,
|
|
required this.subtitleBuilder,
|
|
this.onTap,
|
|
this.leadingBuilder,
|
|
this.trailingBuilder,
|
|
});
|
|
|
|
final String title;
|
|
final WidgetBuilder subtitleBuilder;
|
|
final VoidCallback? onTap;
|
|
final WidgetBuilder? leadingBuilder;
|
|
final WidgetBuilder? trailingBuilder;
|
|
|
|
@override
|
|
List<String> get labels => <String>[title];
|
|
|
|
@override
|
|
WidgetBuilder get builder =>
|
|
(final BuildContext context) => ListTile(
|
|
title: Text(title),
|
|
subtitle: subtitleBuilder.call(context),
|
|
onTap: onTap,
|
|
leading: leadingBuilder?.call(context),
|
|
trailing: trailingBuilder?.call(context),
|
|
);
|
|
}
|
|
|
|
class UserPreferencesItemSection implements UserPreferencesItem {
|
|
const UserPreferencesItemSection({required this.label, this.icon})
|
|
: assert(label.length > 0);
|
|
|
|
final String label;
|
|
final Widget? icon;
|
|
|
|
@override
|
|
WidgetBuilder get builder => (BuildContext context) {
|
|
final SmoothColorsThemeExtension colors = Theme.of(
|
|
context,
|
|
).extension<SmoothColorsThemeExtension>()!;
|
|
|
|
return Container(
|
|
color: colors.primaryDark,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: LARGE_SPACE,
|
|
vertical: SMALL_SPACE,
|
|
),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: colors.primaryLight,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
if (icon != null)
|
|
IconTheme(
|
|
data: IconThemeData(color: colors.primaryLight),
|
|
child: icon!,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
};
|
|
|
|
@override
|
|
Iterable<String> get labels => <String>[label];
|
|
}
|
|
|
|
/// A preference allowing to choose between a list of items.
|
|
/// Before clicking on an item, the selected value is displayed via its [title]
|
|
/// and [subtitle]
|
|
///
|
|
/// [labels] contains all the visible labels for the user in the dialog
|
|
/// For each item, a [descriptions] can be provided (displayed below [labels])
|
|
/// [values] are of type [T] and are returned according to the selected value
|
|
/// A [currentValue] can be provided to auto-select a value
|
|
class UserPreferencesMultipleChoicesItem<T> extends StatelessWidget {
|
|
const UserPreferencesMultipleChoicesItem({
|
|
required this.title,
|
|
required this.labels,
|
|
required this.values,
|
|
required this.currentValue,
|
|
required this.onChanged,
|
|
this.leading,
|
|
this.leadingBuilder,
|
|
this.descriptions,
|
|
this.dialogHeight,
|
|
super.key,
|
|
}) : assert(labels.length > 0),
|
|
assert(values.length == labels.length),
|
|
assert(descriptions == null || descriptions.length == labels.length),
|
|
assert(dialogHeight == null || dialogHeight > 0.0);
|
|
|
|
final String title;
|
|
final IconData? leading;
|
|
final Iterable<WidgetBuilder>? leadingBuilder;
|
|
final Iterable<String> labels;
|
|
final Iterable<String>? descriptions;
|
|
final Iterable<T> values;
|
|
final T? currentValue;
|
|
final ValueChanged<T>? onChanged;
|
|
final double? dialogHeight;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final int currentValueIndex = _findCurrentValueIndex();
|
|
|
|
return ListTile(
|
|
title: Padding(
|
|
padding: const EdgeInsetsDirectional.only(
|
|
top: SMALL_SPACE,
|
|
bottom: SMALL_SPACE,
|
|
),
|
|
child: Text(title, style: theme.textTheme.headlineMedium),
|
|
),
|
|
subtitle: Padding(
|
|
padding: const EdgeInsetsDirectional.only(
|
|
start: SMALL_SPACE,
|
|
top: SMALL_SPACE,
|
|
bottom: LARGE_SPACE,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: <Widget>[
|
|
if (leadingBuilder != null)
|
|
Builder(builder: leadingBuilder!.elementAt(currentValueIndex))
|
|
else if (leading != null)
|
|
Icon(leading),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsetsDirectional.only(
|
|
start: leadingBuilder != null || leading != null
|
|
? LARGE_SPACE
|
|
: 0.0,
|
|
end: LARGE_SPACE,
|
|
),
|
|
child: Text(
|
|
labels.elementAt(currentValueIndex),
|
|
style: theme.textTheme.bodyMedium,
|
|
),
|
|
),
|
|
),
|
|
const Icon(Icons.edit),
|
|
],
|
|
),
|
|
),
|
|
onTap: () async {
|
|
final double itemHeight =
|
|
(descriptions != null ? 15.0 : 0.0) +
|
|
(5.0 * 2) +
|
|
1.0 +
|
|
(56.0 + Theme.of(context).visualDensity.baseSizeAdjustment.dy);
|
|
|
|
final MediaQueryData queryData = MediaQueryData.fromView(
|
|
WidgetsBinding.instance.platformDispatcher.implicitView!,
|
|
);
|
|
|
|
// If there is not enough space, we use the scrolling sheet
|
|
final T? res;
|
|
final SmoothModalSheetHeader header = SmoothModalSheetHeader(
|
|
title: title,
|
|
prefix: const SmoothModalSheetHeaderPrefixIndicator(),
|
|
);
|
|
|
|
if ((itemHeight * labels.length + header.computeHeight(context)) >
|
|
(queryData.size.height * 0.9) - queryData.viewPadding.top) {
|
|
res = await showSmoothDraggableModalSheet<T>(
|
|
context: context,
|
|
header: header,
|
|
bodyBuilder: (BuildContext context) {
|
|
return SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
childCount: labels.length,
|
|
(BuildContext context, int position) {
|
|
final bool selected =
|
|
currentValue == values.elementAt(position);
|
|
|
|
return _ChoiceItem<T>(
|
|
selected: selected,
|
|
label: labels.elementAt(position),
|
|
value: values.elementAt(position),
|
|
description: descriptions?.elementAt(position),
|
|
leading: leadingBuilder != null
|
|
? Builder(
|
|
builder: leadingBuilder!.elementAt(position),
|
|
)
|
|
: null,
|
|
hasDivider: position < labels.length - 1,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
final SmoothModalSheet smoothModalSheet = SmoothModalSheet(
|
|
title: title,
|
|
prefixIndicator: true,
|
|
bodyPadding: EdgeInsets.zero,
|
|
body: SizedBox(
|
|
height: itemHeight * labels.length,
|
|
child: ListView.separated(
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: labels.length,
|
|
itemBuilder: (BuildContext context, int position) {
|
|
final bool selected =
|
|
currentValue == values.elementAt(position);
|
|
|
|
return _ChoiceItem<T>(
|
|
selected: selected,
|
|
label: labels.elementAt(position),
|
|
value: values.elementAt(position),
|
|
description: descriptions?.elementAt(position),
|
|
leading: leadingBuilder != null
|
|
? Builder(builder: leadingBuilder!.elementAt(position))
|
|
: null,
|
|
hasDivider: false,
|
|
);
|
|
},
|
|
separatorBuilder: (_, _) => const Divider(height: 1.0),
|
|
),
|
|
),
|
|
);
|
|
|
|
res = await showSmoothModalSheet<T>(
|
|
context: context,
|
|
minHeight:
|
|
smoothModalSheet.computeHeaderHeight(context) +
|
|
itemHeight * labels.length,
|
|
builder: (BuildContext context) {
|
|
return smoothModalSheet;
|
|
},
|
|
);
|
|
}
|
|
|
|
if (res != null) {
|
|
onChanged?.call(res);
|
|
}
|
|
},
|
|
isThreeLine: true,
|
|
);
|
|
}
|
|
|
|
int _findCurrentValueIndex() {
|
|
for (int i = 0; i < values.length; i++) {
|
|
if (values.elementAt(i) == currentValue) {
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
class _ChoiceItem<T> extends StatelessWidget {
|
|
const _ChoiceItem({
|
|
required this.value,
|
|
required this.label,
|
|
required this.selected,
|
|
this.description,
|
|
this.leading,
|
|
this.hasDivider = true,
|
|
});
|
|
|
|
final T value;
|
|
final String label;
|
|
final String? description;
|
|
final Widget? leading;
|
|
final bool selected;
|
|
final bool hasDivider;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final SmoothColorsThemeExtension extension = theme
|
|
.extension<SmoothColorsThemeExtension>()!;
|
|
final bool lightTheme = context.lightTheme();
|
|
|
|
final Color backgroundColor = selected
|
|
? (lightTheme ? extension.primaryMedium : extension.primaryTone)
|
|
: context.lightTheme()
|
|
? Colors.transparent
|
|
: extension.primaryUltraBlack;
|
|
|
|
return Semantics(
|
|
value: label,
|
|
selected: selected,
|
|
button: true,
|
|
excludeSemantics: true,
|
|
child: Ink(
|
|
color: backgroundColor,
|
|
child: Column(
|
|
children: <Widget>[
|
|
ListTile(
|
|
leading: leading,
|
|
titleAlignment: ListTileTitleAlignment.center,
|
|
title: Text(
|
|
label,
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
color: !lightTheme ? Colors.white : null,
|
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
subtitle: description != null ? Text(description!) : null,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: LARGE_SPACE,
|
|
vertical: 5.0,
|
|
),
|
|
onTap: () => Navigator.of(context).pop(value),
|
|
),
|
|
if (hasDivider) const Divider(height: 1.0),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserPreferenceListTile extends StatelessWidget {
|
|
const UserPreferenceListTile({
|
|
required this.title,
|
|
required this.leading,
|
|
required this.onTap,
|
|
required this.showDivider,
|
|
this.subTitle,
|
|
super.key,
|
|
});
|
|
|
|
final String title;
|
|
final String? subTitle;
|
|
final Widget leading;
|
|
final Future<void> Function(BuildContext) onTap;
|
|
final bool showDivider;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final TextTheme textTheme = Theme.of(context).textTheme;
|
|
|
|
return Column(
|
|
children: <Widget>[
|
|
ListTile(
|
|
leading: Padding(
|
|
padding: const EdgeInsets.all(VERY_SMALL_SPACE),
|
|
child: leading,
|
|
),
|
|
title: Text(title, style: textTheme.headlineMedium),
|
|
subtitle: subTitle != null
|
|
? Text(subTitle!, style: textTheme.bodyMedium)
|
|
: null,
|
|
onTap: () => onTap(context),
|
|
contentPadding: const EdgeInsetsDirectional.symmetric(
|
|
horizontal: LARGE_SPACE,
|
|
vertical: SMALL_SPACE,
|
|
),
|
|
),
|
|
if (showDivider) const UserPreferencesListItemDivider(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class UserPreferencesEditableItemTile extends UserPreferencesItemTile {
|
|
const UserPreferencesEditableItemTile({
|
|
required super.title,
|
|
required String dialogAction,
|
|
required this.onNewValue,
|
|
this.subtitleWithEmptyValue,
|
|
this.validator,
|
|
this.hint,
|
|
this.value,
|
|
}) : assert(dialogAction.length > 0),
|
|
super(subtitle: dialogAction);
|
|
|
|
final String? value;
|
|
final String? hint;
|
|
final String? subtitleWithEmptyValue;
|
|
final bool Function(String)? validator;
|
|
final Function(String) onNewValue;
|
|
|
|
@override
|
|
WidgetBuilder get builder => (BuildContext context) {
|
|
return ListTile(
|
|
title: Text(title),
|
|
subtitle: Text(
|
|
value?.isNotEmpty == true ? value! : (subtitleWithEmptyValue ?? '-'),
|
|
),
|
|
onTap: () async => _showInputTextDialog(context),
|
|
);
|
|
};
|
|
|
|
Future<void> _showInputTextDialog(BuildContext context) async {
|
|
final TextEditingController controller = TextEditingController(
|
|
text: value ?? '',
|
|
);
|
|
|
|
final dynamic res = await showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext context) {
|
|
final AppLocalizations appLocalizations = AppLocalizations.of(context);
|
|
|
|
return ChangeNotifierProvider<TextEditingController>.value(
|
|
value: controller,
|
|
child: Consumer<TextEditingController>(
|
|
builder:
|
|
(BuildContext context, TextEditingController controller, _) {
|
|
return SmoothAlertDialog(
|
|
title: title,
|
|
close: true,
|
|
body: _UserPreferencesEditableDialogContent(
|
|
title: subtitle!,
|
|
hint: hint,
|
|
),
|
|
positiveAction: SmoothActionButton(
|
|
text: appLocalizations.okay,
|
|
onPressed: validator?.call(controller.text) != false
|
|
? () => Navigator.of(context).pop(controller.text)
|
|
: null,
|
|
),
|
|
negativeAction: SmoothActionButton(
|
|
text: appLocalizations.cancel,
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (res is String && res != value) {
|
|
onNewValue.call(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _UserPreferencesEditableDialogContent extends StatefulWidget {
|
|
const _UserPreferencesEditableDialogContent({required this.title, this.hint});
|
|
|
|
final String title;
|
|
final String? hint;
|
|
|
|
@override
|
|
State<_UserPreferencesEditableDialogContent> createState() =>
|
|
_InputTextDialogBodyState();
|
|
}
|
|
|
|
class _InputTextDialogBodyState
|
|
extends State<_UserPreferencesEditableDialogContent> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(widget.title),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: Provider.of<TextEditingController>(context),
|
|
autocorrect: false,
|
|
autofocus: true,
|
|
textInputAction: TextInputAction.send,
|
|
decoration: InputDecoration(
|
|
hintText: widget.hint,
|
|
suffix: Semantics(
|
|
button: true,
|
|
label: MaterialLocalizations.of(context).deleteButtonTooltip,
|
|
excludeSemantics: true,
|
|
child: InkWell(
|
|
onTap: () => context.read<TextEditingController>().clear(),
|
|
customBorder: const CircleBorder(),
|
|
child: const Padding(
|
|
padding: EdgeInsetsDirectional.all(SMALL_SPACE),
|
|
child: Icon(Icons.clear),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
onSubmitted: (String value) => Navigator.of(context).pop(value),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|