Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// Initials circle on a secondary-palette colour; the current user uses
|
||||
/// accent-soft (Noo Design System project, `components/core/Avatar.jsx`).
|
||||
/// Sizes: 30-32 in bars, 36 in lists, 48 in the drawer, 52-56 on the
|
||||
/// account card.
|
||||
class NooAvatar extends StatelessWidget {
|
||||
final String? initials;
|
||||
final double size;
|
||||
/// One of [NooColors.avatarPalette]; ignored when [current] or [icon] is set.
|
||||
final Color? color;
|
||||
final bool current;
|
||||
/// Show an icon instead of initials (e.g. a group share).
|
||||
final IconData? icon;
|
||||
|
||||
const NooAvatar({
|
||||
super.key,
|
||||
this.initials,
|
||||
this.size = 36,
|
||||
this.color,
|
||||
this.current = false,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
if (current) {
|
||||
bg = colors.accentSoft;
|
||||
fg = colors.accentText;
|
||||
} else if (icon != null) {
|
||||
bg = colors.surface2;
|
||||
fg = colors.fg2;
|
||||
} else {
|
||||
bg = color ?? NooColors.avatarPalette.first;
|
||||
fg = NooColors.avatarTextColor;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
|
||||
child: icon != null
|
||||
? Icon(icon, size: (size * 0.5).round().toDouble(), color: fg)
|
||||
: Text(
|
||||
initials ?? '',
|
||||
style: GoogleFonts.instrumentSans(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: (size * 0.36).round().toDouble(),
|
||||
height: 1,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
enum NooBadgeTone { success, danger, accent, warning, info }
|
||||
|
||||
/// Small status pill, e.g. "Connected" in settings (Noo Design System
|
||||
/// project, `components/core/Badge.jsx`).
|
||||
class NooBadge extends StatelessWidget {
|
||||
final NooBadgeTone tone;
|
||||
final Widget child;
|
||||
|
||||
const NooBadge({super.key, this.tone = NooBadgeTone.success, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final (bg, fg) = switch (tone) {
|
||||
NooBadgeTone.success => (colors.successSoft, colors.success),
|
||||
NooBadgeTone.danger => (colors.dangerSoft, colors.danger),
|
||||
NooBadgeTone.accent => (colors.accentSoft, colors.accentText),
|
||||
NooBadgeTone.warning => (colors.warningSoft, colors.warning),
|
||||
NooBadgeTone.info => (colors.infoSoft, colors.info),
|
||||
};
|
||||
return Container(
|
||||
height: 24,
|
||||
// Centering the text vertically within the fixed 24px height needs
|
||||
// *some* alignment mechanism, but Container's own `alignment:` (with
|
||||
// a height but no width) would expand to fill all available
|
||||
// *bounded* width instead of shrink-wrapping (see NooFab's doc
|
||||
// comment) - a Row with mainAxisSize.min sidesteps that entirely:
|
||||
// it shrink-wraps horizontally regardless of incoming constraints,
|
||||
// while its default crossAxisAlignment.center still centers the
|
||||
// text within the Container's tight 24px height.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.pill)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DefaultTextStyle(
|
||||
style: NooText.label.copyWith(fontSize: 12, height: 1, color: fg),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
|
||||
|
||||
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
||||
|
||||
class _SizeSpec {
|
||||
final double height;
|
||||
final double px;
|
||||
final double font;
|
||||
final double icon;
|
||||
final double gap;
|
||||
const _SizeSpec(this.height, this.px, this.font, this.icon, this.gap);
|
||||
}
|
||||
|
||||
const _sizes = {
|
||||
NooButtonSize.cta: _SizeSpec(52, 20, 16, 20, 10),
|
||||
NooButtonSize.card: _SizeSpec(44, 18, 15, 16, 8),
|
||||
NooButtonSize.field: _SizeSpec(40, 16, 14, 16, 6),
|
||||
NooButtonSize.toolbar: _SizeSpec(36, 16, 14, 16, 8),
|
||||
NooButtonSize.compact: _SizeSpec(32, 14, 13, 14, 6),
|
||||
NooButtonSize.xs: _SizeSpec(28, 12, 12, 14, 6),
|
||||
};
|
||||
|
||||
/// The pill button for every action in the design system
|
||||
/// (Noo Design System project, `components/core/Button.jsx`). Pick
|
||||
/// [NooButtonVariant] by emphasis and [NooButtonSize] by context - never
|
||||
/// build a bespoke `ElevatedButton`/`FilledButton` for app chrome; extend
|
||||
/// this instead.
|
||||
class NooButton extends StatefulWidget {
|
||||
final NooButtonVariant variant;
|
||||
final NooButtonSize size;
|
||||
final IconData? icon;
|
||||
final Widget? child;
|
||||
final bool fullWidth;
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const NooButton({
|
||||
super.key,
|
||||
this.variant = NooButtonVariant.primary,
|
||||
this.size = NooButtonSize.toolbar,
|
||||
this.icon,
|
||||
this.child,
|
||||
this.fullWidth = false,
|
||||
this.disabled = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NooButton> createState() => _NooButtonState();
|
||||
}
|
||||
|
||||
class _NooButtonState extends State<NooButton> {
|
||||
bool _down = false;
|
||||
|
||||
(Color, Color, BoxBorder?) _colors(NooColors colors) {
|
||||
switch (widget.variant) {
|
||||
case NooButtonVariant.primary:
|
||||
return (colors.accent, Colors.white, null);
|
||||
case NooButtonVariant.tonal:
|
||||
return (colors.accentSoft, colors.accentText, null);
|
||||
case NooButtonVariant.secondary:
|
||||
return (colors.surface2, colors.fg1, null);
|
||||
case NooButtonVariant.danger:
|
||||
return (colors.dangerSoft, colors.danger, null);
|
||||
case NooButtonVariant.outline:
|
||||
return (
|
||||
Colors.transparent,
|
||||
colors.fg1,
|
||||
Border.all(color: colors.surface3, width: 1.5),
|
||||
);
|
||||
case NooButtonVariant.text:
|
||||
return (Colors.transparent, colors.accentText, null);
|
||||
case NooButtonVariant.textDanger:
|
||||
return (Colors.transparent, colors.danger, null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isTextVariant =>
|
||||
widget.variant == NooButtonVariant.text ||
|
||||
widget.variant == NooButtonVariant.textDanger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final s = _sizes[widget.size]!;
|
||||
final (bg, fg, border) = _colors(colors);
|
||||
final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant;
|
||||
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
||||
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
|
||||
if (widget.child != null)
|
||||
DefaultTextStyle(
|
||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||
child: widget.child!,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Opacity(
|
||||
opacity: widget.disabled ? 0.45 : 1,
|
||||
child: GestureDetector(
|
||||
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
||||
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
||||
onTapCancel: widget.disabled ? null : () => setState(() => _down = false),
|
||||
onTap: widget.disabled ? null : widget.onTap,
|
||||
child: AnimatedScale(
|
||||
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
child: Container(
|
||||
height: s.height,
|
||||
width: widget.fullWidth ? double.infinity : null,
|
||||
padding: _isTextVariant
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(
|
||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
||||
right: s.px,
|
||||
),
|
||||
// Only align when fullWidth actually expands this Container -
|
||||
// otherwise (no width, alignment set) Container wraps the child
|
||||
// in an Align that fills all available *bounded* space instead
|
||||
// of shrink-wrapping to it, which is what stretched this button
|
||||
// edge-to-edge instead of sizing to its content (see NooFab's
|
||||
// doc comment for the same bug).
|
||||
alignment: widget.fullWidth ? Alignment.center : null,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
border: border,
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
enum NooChipTrailing { none, menu, remove }
|
||||
|
||||
/// A pill filter/sort chip; selected = accent-soft (Noo Design System
|
||||
/// project, `components/core/Chip.jsx`).
|
||||
class NooChip extends StatelessWidget {
|
||||
final Widget child;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
final NooChipTrailing trailing;
|
||||
/// filled = mobile (surface fill, 34px); outline = desktop (1px line, 32px)
|
||||
final bool outline;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const NooChip({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.icon,
|
||||
this.selected = false,
|
||||
this.trailing = NooChipTrailing.none,
|
||||
this.outline = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final height = outline ? 32.0 : 34.0;
|
||||
final hasSideContent = icon != null || trailing != NooChipTrailing.none;
|
||||
|
||||
Color bg;
|
||||
Color fg;
|
||||
FontWeight weight;
|
||||
Border? border;
|
||||
if (selected) {
|
||||
bg = colors.accentSoft;
|
||||
fg = colors.accentText;
|
||||
weight = FontWeight.w600;
|
||||
} else if (outline) {
|
||||
bg = Colors.transparent;
|
||||
fg = colors.fg1;
|
||||
weight = FontWeight.w500;
|
||||
border = Border.all(color: colors.line, width: 1);
|
||||
} else {
|
||||
bg = colors.surface;
|
||||
fg = colors.fg1;
|
||||
weight = FontWeight.w500;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: EdgeInsets.symmetric(horizontal: hasSideContent ? 12 : 14),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: border,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: outline ? 14 : 16, color: fg),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
DefaultTextStyle(
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: outline ? 13 : 14,
|
||||
height: 1,
|
||||
fontWeight: weight,
|
||||
color: fg,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
if (trailing == NooChipTrailing.menu) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(LucideIcons.chevronDown, size: 14, color: fg),
|
||||
],
|
||||
if (trailing == NooChipTrailing.remove) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(LucideIcons.x, size: 14, color: fg),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
|
||||
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
|
||||
/// fill - iOS uses a `plus` in the nav bar instead (not yet built).
|
||||
class NooFab extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const NooFab({super.key, this.label = 'Upload', this.icon = LucideIcons.plus, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.accent,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 20, 0),
|
||||
// No `alignment:` here - Container without an explicit width
|
||||
// wraps its child in an Align when alignment is set, and Align
|
||||
// EXPANDS to fill all available bounded space (not just the
|
||||
// child's size) unless the incoming constraints are unbounded.
|
||||
// The Scaffold floating-action-button slot gives bounded
|
||||
// constraints, so this shipped as a full-width bar instead of a
|
||||
// compact pill. Row+mainAxisSize.min already sizes correctly on
|
||||
// its own; the fix is just not adding alignment back here.
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: Colors.white),
|
||||
const SizedBox(width: 10),
|
||||
Text(label, style: NooText.button.copyWith(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// 6px pill meter for storage, cache and offline limits (Noo Design System
|
||||
/// project, `components/core/ProgressBar.jsx`).
|
||||
class NooProgressBar extends StatelessWidget {
|
||||
/// 0-1
|
||||
final double value;
|
||||
|
||||
const NooProgressBar({super.key, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
child: SizedBox(
|
||||
height: 6,
|
||||
child: LinearProgressIndicator(
|
||||
value: value.clamp(0.0, 1.0),
|
||||
backgroundColor: colors.surface3,
|
||||
valueColor: AlwaysStoppedAnimation(colors.accent),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// Pill search input with a leading icon (Noo Design System project,
|
||||
/// `components/core/SearchField.jsx`).
|
||||
class NooSearchField extends StatelessWidget {
|
||||
final String placeholder;
|
||||
final TextEditingController? controller;
|
||||
final FocusNode? focusNode;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
/// true = iOS inline field (radius 12, 38px, 16px text, below the large
|
||||
/// title); false = the generic 36px pill used everywhere else (also the
|
||||
/// Android "expands from a top-bar icon" search field once expanded).
|
||||
final bool ios;
|
||||
/// True when placed directly on a `surface` background (uses surface-2
|
||||
/// fill instead of surface, so it doesn't disappear against it).
|
||||
final bool onSurface;
|
||||
final double? width;
|
||||
/// A trailing widget (e.g. a clear button), shown after the input.
|
||||
final Widget? trailing;
|
||||
|
||||
const NooSearchField({
|
||||
super.key,
|
||||
this.placeholder = 'Search',
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.ios = false,
|
||||
this.onSurface = false,
|
||||
this.width,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Container(
|
||||
width: width,
|
||||
height: ios ? 38 : 36,
|
||||
padding: EdgeInsets.symmetric(horizontal: ios ? 10 : 14),
|
||||
decoration: BoxDecoration(
|
||||
color: onSurface ? colors.surface2 : colors.surface,
|
||||
borderRadius: BorderRadius.circular(ios ? 12 : NooRadii.pill),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.search, size: ios ? 18 : 16, color: colors.fg3),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
autofocus: autofocus,
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: ios ? 16 : 14,
|
||||
height: 1,
|
||||
color: colors.fg1,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: placeholder,
|
||||
hintStyle: NooText.body.copyWith(
|
||||
fontSize: ios ? 16 : 14,
|
||||
height: 1,
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
class NooSegmentOption<T> {
|
||||
final T value;
|
||||
final String? label;
|
||||
final IconData? icon;
|
||||
|
||||
const NooSegmentOption({required this.value, this.label, this.icon});
|
||||
}
|
||||
|
||||
enum NooSegmentedSize { md, sm, xs }
|
||||
|
||||
const _heights = {NooSegmentedSize.md: 34.0, NooSegmentedSize.sm: 30.0, NooSegmentedSize.xs: 28.0};
|
||||
|
||||
/// Pill track with an accent-soft active segment - List/Grid, Shares scope,
|
||||
/// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`).
|
||||
/// Replaces the older `SegmentedIconGroup`/`ToggleIconButton` pair; migrate
|
||||
/// call sites to this as they're touched rather than adding new ones there.
|
||||
class NooSegmentedControl<T> extends StatelessWidget {
|
||||
final List<NooSegmentOption<T>> options;
|
||||
final T value;
|
||||
final ValueChanged<T>? onChanged;
|
||||
/// Use surface on bg, surface-2 on surface.
|
||||
final bool onSurface;
|
||||
final NooSegmentedSize size;
|
||||
final bool fill;
|
||||
final bool iconOnly;
|
||||
|
||||
const NooSegmentedControl({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.value,
|
||||
this.onChanged,
|
||||
this.onSurface = false,
|
||||
this.size = NooSegmentedSize.md,
|
||||
this.fill = false,
|
||||
this.iconOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0);
|
||||
final segments = options.map((o) {
|
||||
final on = o.value == value;
|
||||
final child = Container(
|
||||
height: h,
|
||||
width: iconOnly ? 36 : null,
|
||||
padding: iconOnly || fill ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? colors.accentSoft : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (o.icon != null)
|
||||
Icon(
|
||||
o.icon,
|
||||
size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14,
|
||||
color: on ? colors.accentText : colors.fg2,
|
||||
),
|
||||
if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6),
|
||||
if (!iconOnly && o.label != null)
|
||||
Text(
|
||||
o.label!,
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: size == NooSegmentedSize.md ? 14 : 13,
|
||||
height: 1,
|
||||
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
|
||||
color: on ? colors.accentText : colors.fg2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final tappable = GestureDetector(
|
||||
onTap: onChanged == null ? null : () => onChanged!(o.value),
|
||||
child: child,
|
||||
);
|
||||
return fill ? Expanded(child: tappable) : tappable;
|
||||
}).toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: onSurface ? colors.surface2 : colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
),
|
||||
child: Row(mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, children: segments),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
/// On/off switch; accent track on, surface-3 track off, white knob -
|
||||
/// identical on every platform, never a native iOS/Android switch (Noo
|
||||
/// Design System project, `components/core/Toggle.jsx`).
|
||||
class NooToggle extends StatelessWidget {
|
||||
final bool checked;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final bool desktop;
|
||||
|
||||
const NooToggle({
|
||||
super.key,
|
||||
required this.checked,
|
||||
this.onChanged,
|
||||
this.desktop = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final w = desktop ? 44.0 : 48.0;
|
||||
final h = desktop ? 26.0 : 28.0;
|
||||
final knob = h - 6;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onChanged == null ? null : () => onChanged!(!checked),
|
||||
child: AnimatedContainer(
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
width: w,
|
||||
height: h,
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? colors.accent : colors.surface3,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: NooMotion.fast,
|
||||
curve: NooMotion.ease,
|
||||
alignment: checked ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: knob,
|
||||
height: knob,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user