Files
moonwell-launcher/lib/app/design_system/mw_primitives.dart
T

449 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:moonwell_launcher/app/theme/moonwell_design_system.dart';
enum MwButtonVariant { primary, secondary, ghost, destructive }
class MwButton extends StatelessWidget {
const MwButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.variant = MwButtonVariant.primary,
this.loading = false,
this.expand = false,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final MwButtonVariant variant;
final bool loading;
final bool expand;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final child = loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
],
);
final callback = loading ? null : onPressed;
final button = switch (variant) {
MwButtonVariant.primary => ElevatedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.secondary => OutlinedButton(
onPressed: callback,
child: child,
),
MwButtonVariant.ghost => TextButton(onPressed: callback, child: child),
MwButtonVariant.destructive => ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
backgroundColor: scheme.error,
foregroundColor: scheme.onError,
disabledBackgroundColor: scheme.error.withAlpha(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMedium),
),
),
child: child,
),
};
return Semantics(
button: true,
enabled: callback != null,
child: SizedBox(width: expand ? double.infinity : null, child: button),
);
}
}
class MwIconButton extends StatelessWidget {
const MwIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.selected = false,
this.destructive = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool selected;
final bool destructive;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: Semantics(
label: tooltip,
button: true,
enabled: onPressed != null,
selected: selected,
child: IconButton(
onPressed: onPressed,
isSelected: selected,
icon: Icon(icon),
color: destructive ? scheme.error : scheme.onSurfaceVariant,
style: IconButton.styleFrom(minimumSize: const Size(44, 44)),
),
),
);
}
}
class MwTextField extends StatelessWidget {
const MwTextField({
super.key,
required this.label,
this.controller,
this.hint,
this.errorText,
this.prefixIcon,
this.enabled = true,
this.obscureText = false,
this.textInputAction,
this.onSubmitted,
});
final String label;
final TextEditingController? controller;
final String? hint;
final String? errorText;
final IconData? prefixIcon;
final bool enabled;
final bool obscureText;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
obscureText: obscureText,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
prefixIcon: prefixIcon == null ? null : Icon(prefixIcon),
),
);
}
}
class MwCheckbox extends StatelessWidget {
const MwCheckbox({
super.key,
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool?>? onChanged;
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: value,
onChanged: onChanged,
title: Text(label),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
);
}
}
class MwTabs extends StatelessWidget {
const MwTabs({
super.key,
required this.labels,
required this.selectedIndex,
required this.onSelected,
this.disabledIndices = const {},
});
final List<String> labels;
final int selectedIndex;
final ValueChanged<int> onSelected;
final Set<int> disabledIndices;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
children: [
for (var index = 0; index < labels.length; index++)
ChoiceChip(
label: Text(labels[index]),
selected: selectedIndex == index,
onSelected: disabledIndices.contains(index)
? null
: (_) => onSelected(index),
),
],
);
}
}
enum MwBadgeTone { neutral, success, warning, error, comingSoon }
class MwBadge extends StatelessWidget {
const MwBadge({
super.key,
required this.label,
this.tone = MwBadgeTone.neutral,
});
final String label;
final MwBadgeTone tone;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwBadgeTone.neutral => scheme.onSurfaceVariant,
MwBadgeTone.success => tokens.success,
MwBadgeTone.warning => tokens.warning,
MwBadgeTone.error => scheme.error,
MwBadgeTone.comingSoon => tokens.accentMuted,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(24),
border: Border.all(color: color.withAlpha(130)),
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
);
}
}
enum MwPanelLevel { low, regular, high }
class MwPanel extends StatelessWidget {
const MwPanel({
super.key,
required this.child,
this.level = MwPanelLevel.regular,
this.padding = const EdgeInsets.all(24),
});
final Widget child;
final MwPanelLevel level;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final color = switch (level) {
MwPanelLevel.low => tokens.surfaceLow,
MwPanelLevel.regular => tokens.surface,
MwPanelLevel.high => tokens.surfaceHigh,
};
return DecoratedBox(
decoration: BoxDecoration(
color: color.withAlpha(238),
border: Border.fromBorderSide(tokens.border),
borderRadius: BorderRadius.circular(tokens.radiusLarge),
boxShadow: tokens.panelShadow,
),
child: Padding(padding: padding, child: child),
);
}
}
enum MwProgressState { active, paused, completed, failure }
class MwProgressBar extends StatelessWidget {
const MwProgressBar({
super.key,
this.value,
this.label,
this.state = MwProgressState.active,
});
final double? value;
final String? label;
final MwProgressState state;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (state) {
MwProgressState.active => scheme.primary,
MwProgressState.paused => tokens.warning,
MwProgressState.completed => tokens.success,
MwProgressState.failure => scheme.error,
};
return Semantics(
label: label ?? 'Прогресс',
value: value == null ? null : '${(value! * 100).round()}%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (label != null) ...[
Text(label!, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
],
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: LinearProgressIndicator(
value: value,
color: color,
backgroundColor: color.withAlpha(35),
minHeight: 7,
),
),
],
),
);
}
}
enum MwStatusTone { operational, success, warning, error }
class MwStatusText extends StatelessWidget {
const MwStatusText({
super.key,
required this.text,
this.tone = MwStatusTone.operational,
this.monospace = false,
});
final String text;
final MwStatusTone tone;
final bool monospace;
@override
Widget build(BuildContext context) {
final tokens = MoonWellDesignTokens.of(context);
final scheme = Theme.of(context).colorScheme;
final color = switch (tone) {
MwStatusTone.operational => scheme.onSurfaceVariant,
MwStatusTone.success => tokens.success,
MwStatusTone.warning => tokens.warning,
MwStatusTone.error => scheme.error,
};
return Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: color,
fontFamily: monospace ? tokens.monospaceFontFamily : null,
),
);
}
}
enum MwStateKind { loading, empty, error }
class MwStateView extends StatelessWidget {
const MwStateView({
super.key,
required this.kind,
required this.title,
this.description,
this.onRetry,
});
final MwStateKind kind;
final String title;
final String? description;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final icon = switch (kind) {
MwStateKind.loading => null,
MwStateKind.empty => Icons.inbox_outlined,
MwStateKind.error => Icons.cloud_off_outlined,
};
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon == null)
const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(icon, size: 34),
const SizedBox(height: 12),
Text(title, textAlign: TextAlign.center),
if (description != null) ...[
const SizedBox(height: 6),
Text(
description!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 16),
MwButton(
label: 'Повторить',
onPressed: onRetry,
variant: MwButtonVariant.secondary,
),
],
],
),
),
);
}
}
class MwDisabledFeature extends StatelessWidget {
const MwDisabledFeature({
super.key,
required this.message,
required this.child,
});
final String message;
final Widget child;
@override
Widget build(BuildContext context) {
return Tooltip(message: message, child: child);
}
}