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:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
+61
View File
@@ -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,
),
),
);
}
}
+47
View File
@@ -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,
),
],
),
);
}
}
+143
View File
@@ -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,
),
),
),
);
}
}
+92
View File
@@ -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),
],
],
),
),
);
}
}
+47
View File
@@ -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),
);
}
}
+54
View File
@@ -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,
),
),
),
),
);
}
}