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,
),
),
),
),
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// File-type buckets from `DESIGN_SYSTEM.md` 1.2 (`KIND` in `noo-kit.js`).
/// Drives the tile tint + icon in `NooFileTile`, `NooFileRow` and
/// `NooFileTableRow`. [other] isn't in the spec table - it's the neutral
/// fallback (archive colors, plain `file` icon) for anything the table
/// doesn't cover (audio, binaries, unknown extensions) so those don't
/// masquerade as an archive or a document.
enum NooFileKind {
folder,
pdf,
doc,
sheet,
video,
image,
archive,
other;
/// Lucide icon for this kind.
IconData get icon {
switch (this) {
case NooFileKind.folder:
return LucideIcons.folder;
case NooFileKind.pdf:
case NooFileKind.doc:
return LucideIcons.fileText;
case NooFileKind.sheet:
return LucideIcons.sheet;
case NooFileKind.video:
return LucideIcons.film;
case NooFileKind.image:
return LucideIcons.image;
case NooFileKind.archive:
return LucideIcons.fileArchive;
case NooFileKind.other:
return LucideIcons.file;
}
}
/// Tile background - the soft half of the pair.
Color background(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentSoft;
case NooFileKind.pdf:
return colors.dangerSoft;
case NooFileKind.doc:
case NooFileKind.image:
return colors.infoSoft;
case NooFileKind.sheet:
return colors.successSoft;
case NooFileKind.video:
return colors.warningSoft;
case NooFileKind.archive:
case NooFileKind.other:
return colors.surface3;
}
}
/// Icon color - always the strong half of [background]'s pair.
Color foreground(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentText;
case NooFileKind.pdf:
return colors.danger;
case NooFileKind.doc:
case NooFileKind.image:
return colors.info;
case NooFileKind.sheet:
return colors.success;
case NooFileKind.video:
return colors.warning;
case NooFileKind.archive:
case NooFileKind.other:
return colors.fg2;
}
}
/// Derives a kind from whatever the caller has: [isDirectory] wins, then
/// [mimeType] (e.g. WebDAV `getcontenttype`), then [name]'s extension.
static NooFileKind from({
String? name,
String? mimeType,
bool isDirectory = false,
}) {
final mime = mimeType?.toLowerCase().trim();
if (isDirectory || mime == 'httpd/unix-directory') {
return NooFileKind.folder;
}
if (mime != null && mime.isNotEmpty) {
final byMime = _fromMime(mime);
if (byMime != null) return byMime;
}
if (name != null) {
final dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length - 1) {
final byExt = _extensions[name.substring(dot + 1).toLowerCase()];
if (byExt != null) return byExt;
}
}
return NooFileKind.other;
}
static NooFileKind? _fromMime(String mime) {
if (mime == 'application/pdf') return NooFileKind.pdf;
if (mime.startsWith('image/')) return NooFileKind.image;
if (mime.startsWith('video/')) return NooFileKind.video;
if (mime.contains('spreadsheet') ||
mime.contains('ms-excel') ||
mime == 'text/csv') {
return NooFileKind.sheet;
}
if (mime.contains('zip') ||
mime.contains('compressed') ||
mime.contains('x-tar') ||
mime.contains('x-7z') ||
mime.contains('x-rar') ||
mime.contains('gzip')) {
return NooFileKind.archive;
}
if (mime.startsWith('text/') ||
mime.contains('wordprocessing') ||
mime.contains('msword') ||
mime.contains('opendocument.text') ||
mime.contains('presentation') ||
mime.contains('powerpoint') ||
mime == 'application/rtf') {
return NooFileKind.doc;
}
// Generic types (application/octet-stream etc.) fall through to the
// extension check.
return null;
}
static const _extensions = <String, NooFileKind>{
'pdf': NooFileKind.pdf,
// doc / text
'doc': NooFileKind.doc,
'docx': NooFileKind.doc,
'odt': NooFileKind.doc,
'rtf': NooFileKind.doc,
'txt': NooFileKind.doc,
'md': NooFileKind.doc,
'ppt': NooFileKind.doc,
'pptx': NooFileKind.doc,
'odp': NooFileKind.doc,
'pages': NooFileKind.doc,
// sheet
'xls': NooFileKind.sheet,
'xlsx': NooFileKind.sheet,
'ods': NooFileKind.sheet,
'csv': NooFileKind.sheet,
'numbers': NooFileKind.sheet,
// video
'mp4': NooFileKind.video,
'mov': NooFileKind.video,
'mkv': NooFileKind.video,
'webm': NooFileKind.video,
'avi': NooFileKind.video,
'm4v': NooFileKind.video,
'3gp': NooFileKind.video,
// image
'jpg': NooFileKind.image,
'jpeg': NooFileKind.image,
'png': NooFileKind.image,
'gif': NooFileKind.image,
'webp': NooFileKind.image,
'heic': NooFileKind.image,
'heif': NooFileKind.image,
'bmp': NooFileKind.image,
'svg': NooFileKind.image,
'tif': NooFileKind.image,
'tiff': NooFileKind.image,
'dng': NooFileKind.image,
// archive
'zip': NooFileKind.archive,
'tar': NooFileKind.archive,
'gz': NooFileKind.archive,
'tgz': NooFileKind.archive,
'bz2': NooFileKind.archive,
'xz': NooFileKind.archive,
'7z': NooFileKind.archive,
'rar': NooFileKind.archive,
};
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Mobile 64px file row (`DESIGN_SYSTEM.md` 2, "File row"): 40px tile, name
/// (16/500, one line) over a meta line (`[status][shared] size · modified`,
/// 13 fg-3), an optional favorite star / [trailing] widget, then the
/// overflow button. Paints its own `surface` background (accent-soft when
/// [selected]), so it can sit directly in a list or inside a
/// `NooSwipeAction`.
class NooFileRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Formatted size, e.g. "2.4 MB". Joined to [modified] with " · ".
final String? size;
/// Formatted modified time, e.g. "Yesterday".
final String? modified;
/// Replaces the "size · modified" text entirely - use it for the error
/// hint ("Couldn't sync · Tap to retry") or screen-specific meta (Trash's
/// "Deleted 3 days ago").
final String? meta;
/// Leading meta-line icons, drawn in order (spec order is sync status,
/// then shared). When this contains [NooSyncStatus.error] the meta text
/// turns `danger`.
final List<NooSyncStatus> statuses;
/// Shows the accent-text star before the overflow button.
final bool favorite;
/// Screen-specific trailing icon (e.g. `rotate-ccw` in Trash), placed
/// after the favorite star and before the overflow button.
final Widget? trailing;
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
final bool iosStyle;
final bool selected;
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileRow({
super.key,
required this.kind,
required this.name,
this.size,
this.modified,
this.meta,
this.statuses = const [],
this.favorite = false,
this.trailing,
this.iosStyle = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onLongPress,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaText =
meta ??
[
size,
modified,
].whereType<String>().where((s) => s.isNotEmpty).join(' · ');
final hasError = statuses.contains(NooSyncStatus.error);
return Material(
color: selected ? colors.accentSoft : colors.surface,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox(
height: NooSizes.rowMobile,
child: Padding(
padding: EdgeInsets.only(
left: NooSpace.md,
right: onMore != null ? NooSpace.xxs : NooSpace.md,
),
child: Row(
children: [
NooFileTile(kind: kind, thumbnail: thumbnail),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
if (statuses.isNotEmpty || metaText.isNotEmpty) ...[
const SizedBox(height: NooSpace.xxs),
Row(
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: NooSpace.xxs),
],
Expanded(
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: hasError ? colors.danger : colors.fg3,
),
),
),
],
),
],
],
),
),
if (favorite) ...[
const SizedBox(width: NooSpace.xs),
Icon(LucideIcons.star, size: 18, color: colors.accentText),
],
if (trailing != null) ...[
const SizedBox(width: NooSpace.xs),
IconTheme.merge(
data: IconThemeData(size: 18, color: colors.fg3),
child: trailing!,
),
],
if (onMore != null)
NooOverflowButton(iosStyle: iosStyle, onTap: onMore),
],
),
),
),
),
);
}
}
/// The 40px round overflow ("more") hit target used at the end of file
/// rows - `ellipsis` when [iosStyle], `ellipsis-vertical` otherwise, 20px
/// in fg-3.
class NooOverflowButton extends StatelessWidget {
final bool iosStyle;
final double size;
final VoidCallback? onTap;
const NooOverflowButton({
super.key,
this.iosStyle = false,
this.size = 40,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: size,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(
iosStyle ? LucideIcons.ellipsis : LucideIcons.ellipsisVertical,
size: size >= 40 ? 20 : 18,
color: colors.fg3,
),
),
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_row.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Fixed widths of the desktop file table's grid
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
/// The first column takes the remaining width.
const double _col2Width = 180;
const double _col3Width = 160;
const double _col4Width = 120;
/// Inset shared by header and rows so their columns line up; also the
/// padding inside a selected row's r12 fill.
const double _tableInset = NooSpace.sm;
/// Lays out the four table columns. Shared by [NooFileTableHeader] and
/// [NooFileTableRow] so the grid can't drift between them.
class _TableColumns extends StatelessWidget {
final Widget name;
final Widget col2;
final Widget col3;
final Widget col4;
const _TableColumns({
required this.name,
required this.col2,
required this.col3,
required this.col4,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
child: Row(
children: [
Expanded(child: name),
SizedBox(width: _col2Width, child: col2),
SizedBox(width: _col3Width, child: col3),
SizedBox(width: _col4Width, child: col4),
],
),
);
}
}
/// Desktop file table header: 36px, 12/600 fg-3 labels, and a sort arrow
/// on the active column. The fourth (status/overflow) column is unlabelled.
class NooFileTableHeader extends StatelessWidget {
/// Label for the flexible Name column.
final String nameLabel;
/// Labels for the 180px and 160px data columns, e.g. "Modified", "Size".
final String col2Label;
final String col3Label;
/// Active sort column - 0 = name, 1 = col2, 2 = col3 - or null for none.
final int? sortColumn;
final bool sortAscending;
/// Called with the tapped column's index; header cells aren't tappable
/// when null.
final ValueChanged<int>? onSort;
const NooFileTableHeader({
super.key,
this.nameLabel = 'Name',
required this.col2Label,
required this.col3Label,
this.sortColumn,
this.sortAscending = true,
this.onSort,
});
Widget _cell(BuildContext context, int index) {
final colors = context.nooColors;
final style = NooText.label.copyWith(
fontSize: 12,
height: 1,
color: colors.fg3,
);
final active = sortColumn == index;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
[nameLabel, col2Label, col3Label][index],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
if (active) ...[
const SizedBox(width: NooSpace.xxs),
Icon(
sortAscending ? LucideIcons.arrowUp : LucideIcons.arrowDown,
size: 12,
color: colors.fg3,
),
],
],
);
return Align(
alignment: Alignment.centerLeft,
child: onSort == null
? content
: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSort!(index),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: content,
),
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: _TableColumns(
name: _cell(context, 0),
col2: _cell(context, 1),
col3: _cell(context, 2),
col4: const SizedBox.shrink(),
),
);
}
}
/// Desktop file table row: 52px, 30px tile + name (14/500) in the flexible
/// column, two meta-style data columns, then status icons and the overflow
/// button right-aligned in the 120px column. Selected rows get an
/// accent-soft r12 fill; otherwise the row is transparent over the pane.
class NooFileTableRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Second and third column contents, e.g. modified time and size.
final String? col2;
final String? col3;
/// Status icons in the last column, drawn before the favorite star.
final List<NooSyncStatus> statuses;
final bool favorite;
final bool selected;
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileTableRow({
super.key,
required this.kind,
required this.name,
this.col2,
this.col3,
this.statuses = const [],
this.favorite = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onDoubleTap,
this.onSecondaryTap,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
Widget dataCell(String? text) => Text(
text ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
);
return Material(
color: selected ? colors.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(NooRadii.sidebarItem),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onDoubleTap: onDoubleTap,
onSecondaryTap: onSecondaryTap,
child: SizedBox(
height: NooSizes.rowDesktop,
child: _TableColumns(
name: Row(
children: [
NooFileTile(
kind: kind,
size: NooFileTileSize.desktop,
thumbnail: thumbnail,
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
col2: dataCell(col2),
col3: dataCell(col3),
col4: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: 6),
],
if (favorite) ...[
Icon(LucideIcons.star, size: 14, color: colors.accentText),
const SizedBox(width: 6),
],
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
],
),
),
),
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
/// The four tile sizes from `DESIGN_SYSTEM.md` 1.2 - pick by context; the
/// icon is always half the tile.
enum NooFileTileSize {
/// 40px / r12 - mobile file rows.
row(40, 12),
/// 30px / r9 - desktop table rows.
desktop(30, 9),
/// 44px / r12 - share sheet/dialog header.
header(44, 12),
/// 32px / r10 - activity items.
activity(32, 10);
final double extent;
final double radius;
const NooFileTileSize(this.extent, this.radius);
double get iconSize => extent / 2;
}
/// Rounded-square file-type tile: the kind's soft tint with its Lucide icon
/// in the strong tint (`DESIGN_SYSTEM.md` 1.2). Pass [thumbnail] (e.g. an
/// `Image`) to show real content instead - it's clipped to the same shape
/// and cover-fit over the kind's tint, which shows while it loads.
class NooFileTile extends StatelessWidget {
final NooFileKind kind;
final NooFileTileSize size;
final Widget? thumbnail;
const NooFileTile({
super.key,
required this.kind,
this.size = NooFileTileSize.row,
this.thumbnail,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(size.radius);
return SizedBox.square(
dimension: size.extent,
child: thumbnail != null
? ClipRRect(
borderRadius: radius,
child: ColoredBox(
color: kind.background(colors),
child: SizedBox.expand(
child: FittedBox(fit: BoxFit.cover, child: thumbnail),
),
),
)
: DecoratedBox(
decoration: BoxDecoration(
color: kind.background(colors),
borderRadius: radius,
),
child: Center(
child: Icon(
kind.icon,
size: size.iconSize,
color: kind.foreground(colors),
),
),
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Per-file status markers for a file row's meta line or a table row's
/// status column (`DESIGN_SYSTEM.md` 2, "Status icons").
enum NooSyncStatus {
synced,
syncing,
error,
shared;
IconData get icon {
switch (this) {
case NooSyncStatus.synced:
return LucideIcons.circleCheck;
case NooSyncStatus.syncing:
return LucideIcons.refreshCw;
case NooSyncStatus.error:
return LucideIcons.circleAlert;
case NooSyncStatus.shared:
return LucideIcons.users;
}
}
Color color(NooColors colors) {
switch (this) {
case NooSyncStatus.synced:
return colors.success;
case NooSyncStatus.syncing:
return colors.accentText;
case NooSyncStatus.error:
return colors.danger;
case NooSyncStatus.shared:
return colors.fg3;
}
}
}
/// A single 14px status icon. For [NooSyncStatus.error] the row's meta
/// text should say what to do ("Couldn't sync · Tap to retry") - this only
/// draws the icon.
class NooStatusIcon extends StatelessWidget {
final NooSyncStatus status;
final double size;
const NooStatusIcon(this.status, {super.key, this.size = 14});
@override
Widget build(BuildContext context) {
return Icon(
status.icon,
size: size,
color: status.color(context.nooColors),
);
}
}
+242
View File
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2,
/// "Swipe action"); which one sits on which side is a user setting
/// (Settings -> Swipe on a file).
enum NooSwipeActionKind { delete, favorite }
/// One side's action for [NooSwipeAction].
class NooSwipeActionSpec {
final NooSwipeActionKind kind;
final VoidCallback onTriggered;
/// Overrides the default label ("Delete" / "Favorite"), e.g.
/// "Unfavorite" for an already-starred file.
final String? label;
/// Overrides the default icon (`trash-2` / `star`).
final IconData? icon;
const NooSwipeActionSpec({
required this.kind,
required this.onTriggered,
this.label,
this.icon,
});
}
/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides
/// it aside to uncover a 96px action block: delete is white on
/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a
/// 12/600 label. Releasing past half the block snaps it open, otherwise it
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
/// Tapping the block fires the action and closes it; tapping the row while
/// open just closes it.
class NooSwipeAction extends StatefulWidget {
final Widget child;
/// Revealed on the leading edge (swipe toward the trailing edge).
final NooSwipeActionSpec? startAction;
/// Revealed on the trailing edge (swipe toward the leading edge).
final NooSwipeActionSpec? endAction;
const NooSwipeAction({
super.key,
required this.child,
this.startAction,
this.endAction,
});
/// Width of the uncovered action block.
static const double actionWidth = 96;
@override
State<NooSwipeAction> createState() => _NooSwipeActionState();
}
class _NooSwipeActionState extends State<NooSwipeAction>
with SingleTickerProviderStateMixin {
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
/// (in visual left/right terms after [_dir] is applied).
late final AnimationController _ctrl = AnimationController(
vsync: this,
lowerBound: -1,
upperBound: 1,
value: 0,
);
double get _dir => Directionality.of(context) == TextDirection.rtl ? -1 : 1;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _animateTo(double target) {
_ctrl.animateTo(target, duration: NooMotion.base, curve: NooMotion.ease);
}
void _close() => _animateTo(0);
void _onDragUpdate(DragUpdateDetails d) {
// Positive = revealing the start action.
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
final min = widget.endAction != null ? -1.0 : 0.0;
final max = widget.startAction != null ? 1.0 : 0.0;
_ctrl.value = (_ctrl.value + delta).clamp(min, max);
}
void _onDragEnd(DragEndDetails d) {
final v = _ctrl.value;
final velocity = d.primaryVelocity! * _dir;
double target;
if (velocity.abs() > 700) {
final opening = velocity.sign == v.sign || v == 0;
target = opening ? velocity.sign : 0;
} else {
target = v.abs() >= 0.5 ? v.sign : 0;
}
if (target > 0 && widget.startAction == null) target = 0;
if (target < 0 && widget.endAction == null) target = 0;
_animateTo(target);
}
void _trigger(NooSwipeActionSpec spec) {
_close();
spec.onTriggered();
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
if (widget.startAction == null && widget.endAction == null) {
return widget.child;
}
return GestureDetector(
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: ClipRect(
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
final v = _ctrl.value;
// Only the side being uncovered is painted, so the opposite
// block never peeks out from behind a translucent row.
final showing = v > 0
? widget.startAction
: (v < 0 ? widget.endAction : null);
return Stack(
children: [
if (showing != null)
Positioned.fill(
child: Align(
alignment: v > 0
? AlignmentDirectional.centerStart
: AlignmentDirectional.centerEnd,
child: _ActionBlock(
spec: showing,
colors: colors,
onTap: () => _trigger(showing),
),
),
),
Transform.translate(
offset: Offset(v * NooSwipeAction.actionWidth * _dir, 0),
child: child,
),
],
);
},
child: _OpenAwareChild(
controller: _ctrl,
onCloseTap: _close,
child: widget.child,
),
),
),
);
}
}
/// Swallows taps on the row while it's swiped open (closing it instead),
/// so an open row doesn't also navigate.
class _OpenAwareChild extends AnimatedWidget {
final Widget child;
final VoidCallback onCloseTap;
const _OpenAwareChild({
required AnimationController controller,
required this.onCloseTap,
required this.child,
}) : super(listenable: controller);
@override
Widget build(BuildContext context) {
final open = (listenable as AnimationController).value != 0;
return Stack(
children: [
child,
if (open)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCloseTap,
),
),
],
);
}
}
class _ActionBlock extends StatelessWidget {
final NooSwipeActionSpec spec;
final NooColors colors;
final VoidCallback onTap;
const _ActionBlock({
required this.spec,
required this.colors,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isDelete = spec.kind == NooSwipeActionKind.delete;
final bg = isDelete ? colors.dangerFill : colors.accent;
final icon =
spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star);
final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite');
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: NooSwipeAction.actionWidth,
color: bg,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: Colors.white),
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.label.copyWith(
fontSize: 12,
height: 1,
color: Colors.white,
),
),
],
),
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Inline info card with an optional text action, e.g. Trash's retention
/// notice + "Empty trash" (Noo Design System project,
/// `components/lists/Banner.jsx`, mobile variant).
class NooBanner extends StatelessWidget {
final IconData icon;
final Widget child;
final String? actionLabel;
final VoidCallback? onAction;
final bool actionIsDanger;
const NooBanner({
super.key,
this.icon = LucideIcons.info,
required this.child,
this.actionLabel,
this.onAction,
this.actionIsDanger = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: DefaultTextStyle(
style: NooText.body.copyWith(color: colors.fg2),
child: child,
),
),
if (actionLabel != null)
GestureDetector(
onTap: onAction,
child: Text(
actionLabel!,
style: NooText.buttonSm.copyWith(
fontSize: 14,
color: actionIsDanger ? colors.danger : colors.accentText,
),
),
),
],
),
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Radius-20 surface card of rows with 1px line dividers and an optional
/// label above it (Noo Design System project,
/// `components/lists/GroupedList.jsx`, mobile variant - desktop's bordered/
/// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...)
/// paints its own `surface` background; this only supplies the 1px `line`
/// gaps between them and the rounded clip. Stack groups 18-20px apart.
class NooGroupedList extends StatelessWidget {
final String? label;
/// Right-aligned secondary text next to [label].
final String? aside;
final List<Widget> children;
final Widget? footer;
const NooGroupedList({
super.key,
this.label,
this.aside,
required this.children,
this.footer,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label!, style: NooText.label.copyWith(color: colors.fg2)),
if (aside != null)
Text(
aside!,
style: NooText.body.copyWith(
fontSize: 13,
height: 1,
fontWeight: FontWeight.w500,
color: colors.fg3,
),
),
],
),
),
if (label != null) const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(height: 1),
children[i],
],
],
),
),
),
if (footer != null) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: DefaultTextStyle(
style: NooText.meta.copyWith(color: colors.fg3),
child: footer!,
),
),
],
],
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// 52/60px settings row: icon, label, subtitle, trailing control (Noo
/// Design System project, `components/lists/SettingsRow.jsx`, mobile
/// variant). Goes inside a [NooGroupedList] - it paints its own `surface`
/// background, the list only supplies the line gaps.
class NooSettingsRow extends StatelessWidget {
final IconData? icon;
final Widget label;
final Widget? subtitle;
/// Renders as `value` text + a trailing chevron - mutually exclusive with
/// [trailing] (a `NooToggle`, button, badge, etc.).
final String? value;
final Widget? trailing;
final bool danger;
/// Accent-text action row, e.g. "Add account".
final bool accent;
final VoidCallback? onTap;
const NooSettingsRow({
super.key,
this.icon,
required this.label,
this.subtitle,
this.value,
this.trailing,
this.danger = false,
this.accent = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1);
final minHeight = subtitle != null ? 60.0 : 52.0;
Widget? trail = trailing;
if (trail == null && value != null) {
trail = Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value!,
style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3),
),
const SizedBox(width: 4),
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
],
);
}
return Material(
color: colors.surface,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: subtitle != null ? 11 : 0,
),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 20, color: danger || accent ? color : colors.fg2),
const SizedBox(width: 14),
],
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
DefaultTextStyle(
style: NooText.bodyL.copyWith(
height: 1.1,
fontWeight: danger || accent
? (accent ? FontWeight.w600 : FontWeight.w500)
: FontWeight.w400,
color: color,
),
child: label,
),
if (subtitle != null) ...[
const SizedBox(height: 4),
DefaultTextStyle(
style: NooText.meta.copyWith(color: colors.fg3),
child: subtitle!,
),
],
],
),
),
?trail,
],
),
),
),
),
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
enum NooSummaryCardTone { normal, danger, accent }
/// Stat card with optional progress, meta and action (Offline summary,
/// desktop stats) - Noo Design System project,
/// `components/lists/SummaryCard.jsx`, mobile variant.
class NooSummaryCard extends StatelessWidget {
final String stat;
final String? caption;
/// 0-1; renders a [NooProgressBar].
final double? progress;
final String? meta;
final Widget? action;
final NooSummaryCardTone tone;
const NooSummaryCard({
super.key,
required this.stat,
this.caption,
this.progress,
this.meta,
this.action,
this.tone = NooSummaryCardTone.normal,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final bg = tone == NooSummaryCardTone.danger ? colors.dangerSoft : colors.surface;
final statColor = switch (tone) {
NooSummaryCardTone.danger => colors.danger,
NooSummaryCardTone.accent => colors.accentText,
NooSummaryCardTone.normal => colors.fg1,
};
final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.card)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(stat, style: NooText.stat.copyWith(color: statColor)),
if (caption != null) ...[
const SizedBox(height: 6),
Text(
caption!,
style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor),
),
],
],
),
),
?action,
],
),
if (progress != null) ...[
const SizedBox(height: 14),
NooProgressBar(value: progress!),
],
if (meta != null) ...[
const SizedBox(height: 14),
Text(meta!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Reorderable tab row with a grip and pin toggle - max 5 pinned (Noo
/// Design System project, `components/lists/TabOrderRow.jsx`, mobile
/// variant).
class NooTabOrderRow extends StatelessWidget {
final IconData icon;
final String label;
final bool pinned;
final VoidCallback? onTogglePin;
const NooTabOrderRow({
super.key,
required this.icon,
required this.label,
this.pinned = false,
this.onTogglePin,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: colors.surface,
child: Row(
children: [
Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3),
const SizedBox(width: 12),
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: NooText.bodyL.copyWith(height: 1, color: colors.fg1),
),
),
GestureDetector(
onTap: onTogglePin,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pinned ? colors.accentSoft : colors.surface2,
shape: BoxShape.circle,
),
child: Icon(
pinned ? LucideIcons.pin : LucideIcons.pinOff,
size: 16,
color: pinned ? colors.accentText : colors.fg3,
),
),
),
],
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_avatar.dart';
/// Activity feed entry (`DESIGN_SYSTEM.md` section 2, "Activity item"):
/// a 36px [NooAvatar], then the sentence **actor** verb **object** tail
/// with the time below in meta style, and an optional [trailing] slot on
/// the right for the 32px/r10 file tile (built by the caller, so this
/// doesn't depend on the file-kind mapping).
class NooActivityItem extends StatelessWidget {
final String actor;
/// Plain text between actor and object, e.g. `"shared"`.
final String verb;
/// Bold subject of the action, e.g. a file name.
final String? object;
/// Plain text after the object, e.g. `"with you"`.
final String? tail;
final String? time;
/// Avatar initials; defaults to the first letter of [actor].
final String? initials;
/// One of [NooColors.avatarPalette].
final Color? avatarColor;
/// Uses the accent-soft current-user avatar.
final bool currentUser;
/// Right-aligned slot, typically a 32px file tile.
final Widget? trailing;
final VoidCallback? onTap;
const NooActivityItem({
super.key,
required this.actor,
required this.verb,
this.object,
this.tail,
this.time,
this.initials,
this.avatarColor,
this.currentUser = false,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final base = NooText.body.copyWith(color: colors.fg1);
const bold = TextStyle(fontWeight: FontWeight.w600);
final fallbackInitials = actor.trim().isEmpty
? ''
: actor.trim().characters.first.toUpperCase();
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NooAvatar(
initials: initials ?? fallbackInitials,
size: 36,
color: avatarColor,
current: currentUser,
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: base,
children: [
TextSpan(text: actor, style: bold),
TextSpan(text: ' $verb'),
if (object != null) ...[
const TextSpan(text: ' '),
TextSpan(text: object, style: bold),
],
if (tail != null) TextSpan(text: ' $tail'),
],
),
),
if (time != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(time!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: NooSpace.sm),
trailing!,
],
],
),
),
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
/// icon, or a real [thumbnail]) above the name, an overflow button and a
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid
/// owns that, this card just fills its cell.
///
/// The placeholder colours/icon are plain parameters rather than a file
/// kind so this stays independent of the file-kind mapping; callers pass
/// e.g. `accentSoft`/`accentText`/`LucideIcons.folder` for a folder.
class NooGridCard extends StatelessWidget {
final String name;
final String? meta;
/// Soft fill behind [icon] when there's no [thumbnail] (e.g. `infoSoft`).
final Color? placeholderColor;
final IconData? icon;
/// Icon colour on the placeholder (e.g. `info`).
final Color? iconColor;
/// Real thumbnail (typically an `Image` with `BoxFit.cover`); replaces
/// the placeholder and is clipped to the card's top corners.
final Widget? thumbnail;
/// 104 (mobile) - 118 (desktop).
final double thumbnailHeight;
final bool selected;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Shows the overflow button when non-null.
final VoidCallback? onMore;
/// `ellipsis` on iOS/desktop, `ellipsis-vertical` on Android.
final bool verticalOverflowIcon;
const NooGridCard({
super.key,
required this.name,
this.meta,
this.placeholderColor,
this.icon,
this.iconColor,
this.thumbnail,
this.thumbnailHeight = 104,
this.selected = false,
this.onTap,
this.onLongPress,
this.onMore,
this.verticalOverflowIcon = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.gridCard);
final thumb = SizedBox(
height: thumbnailHeight,
width: double.infinity,
child:
thumbnail ??
ColoredBox(
color: placeholderColor ?? colors.surface2,
child: Center(
child: Icon(
icon ?? LucideIcons.file,
size: 32,
color: iconColor ?? colors.fg2,
),
),
),
);
return Material(
color: selected ? colors.accentSoft : colors.surface,
borderRadius: radius,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
thumb,
Padding(
padding: EdgeInsets.fromLTRB(
NooSpace.sm,
10,
onMore != null ? NooSpace.xxs : NooSpace.sm,
NooSpace.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1.2,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg1,
),
),
if (meta != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
fontSize: 12,
color: colors.fg3,
),
),
],
],
),
),
if (onMore != null)
_OverflowButton(
icon: verticalOverflowIcon
? LucideIcons.ellipsisVertical
: LucideIcons.ellipsis,
color: colors.fg3,
onTap: onMore!,
),
],
),
),
],
),
),
);
}
}
/// Compact 28px circular hit target so the overflow button doesn't push
/// the name/meta column taller than the text itself.
class _OverflowButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _OverflowButton({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(icon, size: 16, color: color),
),
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Month group heading for the Photos grid (`DESIGN_SYSTEM.md` section 2,
/// "Photo grid"): group heading (Schibsted 18/500, fg-1) on the left and
/// the item count in meta style (fg-3) on the right.
class NooPhotoGroupHeader extends StatelessWidget {
final String title;
/// e.g. `"128 photos"`; hidden when null.
final String? count;
/// Horizontal inset - 16 on mobile (the grid itself is edge to edge),
/// 24 on desktop.
final EdgeInsetsGeometry padding;
const NooPhotoGroupHeader({
super.key,
required this.title,
this.count,
this.padding = const EdgeInsets.fromLTRB(
NooSpace.md,
NooSpace.lg,
NooSpace.md,
NooSpace.sm,
),
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1),
),
),
if (count != null) ...[
const SizedBox(width: NooSpace.sm),
Text(count!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
);
}
}
/// Square-tile photo grid (`DESIGN_SYSTEM.md` section 2, "Photo grid"):
/// mobile is 3 columns with a 2px gap edge to edge ([NooPhotoGrid.mobile]
/// defaults), desktop 8 columns with 4px ([NooPhotoGrid.desktop]).
///
/// Builds lazily from [itemBuilder] (typically returning a `NooPhotoTile`).
/// Use the default constructor inside a `CustomScrollView` as a sliver
/// (one per month group, after a `SliverToBoxAdapter(NooPhotoGroupHeader)`),
/// or [NooPhotoGrid.box] for a non-scrolling box that sizes to its content
/// (e.g. inside a `Column`/`ListView` item).
class NooPhotoGrid extends StatelessWidget {
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final int columns;
final double gap;
final EdgeInsetsGeometry padding;
final bool _sliver;
/// Sliver form. Defaults are the mobile layout.
const NooPhotoGrid({
super.key,
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.padding = EdgeInsets.zero,
}) : _sliver = true;
/// Box form: a shrink-wrapped, non-scrolling grid.
const NooPhotoGrid.box({
super.key,
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.padding = EdgeInsets.zero,
}) : _sliver = false;
/// Mobile/desktop column + gap presets from the spec.
static const mobileColumns = 3;
static const mobileGap = 2.0;
static const desktopColumns = 8;
static const desktopGap = 4.0;
SliverGridDelegate get _delegate =>
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
);
@override
Widget build(BuildContext context) {
if (_sliver) {
return SliverPadding(
padding: padding,
sliver: SliverGrid(
gridDelegate: _delegate,
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: itemCount,
),
),
);
}
return GridView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
padding: padding,
gridDelegate: _delegate,
itemCount: itemCount,
itemBuilder: itemBuilder,
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Video badge background from `DESIGN_SYSTEM.md` section 2 ("Photo
/// grid"): `rgba(14,13,11,.7)` - not a [NooColors] token, it's a fixed
/// overlay that sits on photo content in both themes.
const _videoBadgeColor = Color(0xB30E0D0B);
/// Square photo tile for [NooPhotoGrid] (`DESIGN_SYSTEM.md` section 2,
/// "Photo grid"): an image [child] (usually `Image` with `BoxFit.cover`)
/// or, while loading/absent, a flat [placeholderColor] from
/// [NooColors.avatarPalette]. Videos get a bottom-right badge with a `play`
/// icon and [videoDuration].
class NooPhotoTile extends StatelessWidget {
final Widget? child;
/// Fill shown behind/instead of [child]; defaults to the first
/// [NooColors.avatarPalette] entry. Use [NooPhotoTile.paletteColor] to
/// pick a stable one per item.
final Color? placeholderColor;
/// Non-null marks the tile as a video and shows the badge, e.g. `0:42`.
/// Pass an empty string for a play-icon-only badge.
final String? videoDuration;
final bool selected;
/// Shows the selection check circle even when not [selected] (i.e. the
/// grid is in multi-select mode).
final bool selectionMode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const NooPhotoTile({
super.key,
this.child,
this.placeholderColor,
this.videoDuration,
this.selected = false,
this.selectionMode = false,
this.onTap,
this.onLongPress,
});
/// Stable secondary-palette colour for an item (e.g. from its id/path
/// hash), so placeholders don't reshuffle between rebuilds.
static Color paletteColor(int seed) =>
NooColors.avatarPalette[seed.abs() % NooColors.avatarPalette.length];
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return AspectRatio(
aspectRatio: 1,
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Stack(
fit: StackFit.expand,
children: [
// Selected tiles inset so an accent-soft frame shows around
// the photo, instead of covering it with a tint.
ColoredBox(color: selected ? colors.accentSoft : Colors.transparent),
AnimatedPadding(
duration: NooMotion.fast,
curve: NooMotion.ease,
padding: EdgeInsets.all(selected ? 8 : 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(selected ? 8 : 0),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: placeholderColor ?? NooColors.avatarPalette.first,
),
?child,
],
),
),
),
if (videoDuration != null)
Positioned(
right: 6,
bottom: 6,
child: _VideoBadge(duration: videoDuration!),
),
if (selected || selectionMode)
Positioned(
left: 6,
top: 6,
child: _SelectCheck(selected: selected),
),
],
),
),
);
}
}
class _VideoBadge extends StatelessWidget {
final String duration;
const _VideoBadge({required this.duration});
@override
Widget build(BuildContext context) {
return Container(
height: 20,
padding: EdgeInsets.symmetric(horizontal: duration.isEmpty ? 4 : 6),
decoration: BoxDecoration(
color: _videoBadgeColor,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.play, size: 12, color: Colors.white),
if (duration.isNotEmpty) ...[
const SizedBox(width: 3),
Text(
duration,
style: NooText.meta.copyWith(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
],
),
);
}
}
/// 22px selection circle: accent fill + white check when selected, a
/// white-outlined empty circle on the video-badge scrim otherwise.
class _SelectCheck extends StatelessWidget {
final bool selected;
const _SelectCheck({required this.selected});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? colors.accent : _videoBadgeColor,
border: selected
? null
: Border.all(color: Colors.white, width: 1.5),
),
child: selected
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
: null,
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// Desktop stat card (`DESIGN_SYSTEM.md` sections 1.3/1.4): radius 18,
/// Stat text (Schibsted 600, 28 on desktop), a label below and an optional
/// 6px [NooProgressBar]. The mobile/Offline equivalent with an action
/// button is `NooSummaryCard`.
class NooStatCard extends StatelessWidget {
final String stat;
final String label;
/// 0-1; renders a [NooProgressBar] under the label.
final double? progress;
/// Optional line under the progress bar, e.g. `"of 50 GB"`.
final String? meta;
final IconData? icon;
/// Defaults to `surface`; pass `surface2` when the card sits on a
/// `surface` pane.
final Color? color;
final VoidCallback? onTap;
const NooStatCard({
super.key,
required this.stat,
required this.label,
this.progress,
this.meta,
this.icon,
this.color,
this.onTap,
});
/// Desktop Stat size (spec 1.3: 32 mobile / 28 desktop); [NooText.stat]
/// only carries the mobile size.
static const _desktopStatSize = 28.0;
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: color ?? colors.surface,
borderRadius: BorderRadius.circular(NooRadii.gridCard),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: colors.fg2),
const SizedBox(height: NooSpace.sm),
],
Text(
stat,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.stat.copyWith(
fontSize: _desktopStatSize,
letterSpacing: -_desktopStatSize * 0.03,
color: colors.fg1,
),
),
const SizedBox(height: NooSpace.xs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg2),
),
if (progress != null) ...[
const SizedBox(height: NooSpace.smd),
NooProgressBar(value: progress!),
],
if (meta != null) ...[
const SizedBox(height: NooSpace.xs),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(fontSize: 12, color: colors.fg3),
),
],
],
),
),
),
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import 'noo_nav_style.dart';
export 'noo_nav_style.dart';
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
/// what order, is the caller's business; this only draws [destinations].
///
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
/// Idle: fg-3, 500. No ripple.
/// - [NooNavStyle.android]: surface fill, 80px. The active icon sits in a
/// 56x32 accent-soft pill (accent-text icon); label is 12px, fg-1/600
/// active and fg-2/500 idle (idle icon fg-2).
///
/// The home indicator / gesture bar area below the row comes from the
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
/// right on every device. Meant for `Scaffold.bottomNavigationBar`.
class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
const NooBottomBar({
super.key,
required this.style,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final ios = style == NooNavStyle.ios;
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinations.length; i++)
Expanded(
child: ios
? _IosItem(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
)
: _AndroidItem(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
),
),
],
);
return Container(
decoration: BoxDecoration(
color: colors.surface,
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
),
child: SafeArea(
top: false,
child: SizedBox(height: ios ? 50 : 80, child: row),
),
);
}
}
class _IosItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final VoidCallback onTap;
const _IosItem({
required this.destination,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final fg = selected ? colors.accentText : colors.fg3;
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(destination.icon, size: 24, color: fg),
const SizedBox(height: 4),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.navLabel.copyWith(
fontSize: 10,
height: 1,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: fg,
),
),
],
),
),
),
);
}
}
class _AndroidItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final VoidCallback onTap;
const _AndroidItem({
required this.destination,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
selected: selected,
// No ripple: the animated pill is the press/selection feedback.
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: NooMotion.fast,
curve: NooMotion.ease,
width: 56,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? colors.accentSoft
: colors.accentSoft.withValues(alpha: 0),
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Icon(
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
),
const SizedBox(height: 4),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (selected ? NooText.navLabelActive : NooText.navLabel)
.copyWith(
height: 1,
color: selected ? colors.fg1 : colors.fg2,
),
),
],
),
),
);
}
}
+365
View File
@@ -0,0 +1,365 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// The mobile drawer shell (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in
/// `Mobile Screen.dc.html`): 316px wide, `surface` fill, radius 28 on the
/// trailing edge, flat (no elevation). Slots top to bottom:
///
/// 1. [account] - usually a [NooDrawerAccount]
/// 2. [storage] - usually a [NooDrawerStorage]
/// 3. a divider, then - only when [moreItems] isn't empty - the [moreLabel]
/// heading and [moreItems] (the unpinned tabs, as [NooDrawerItem]s)
/// 4. a divider, then [settings] (a [NooDrawerItem])
/// 5. a spacer, then [footer] - the "Edit tabs" [NooDrawerLink]
///
/// Wraps Flutter's [Drawer], so pass it to `Scaffold.drawer` and the open/
/// close gesture and semantics come for free. The scrim is the Scaffold's
/// job, not the drawer's: set `Scaffold.drawerScrimColor` to
/// `context.nooColors.scrim`. The content scrolls if it doesn't fit (e.g.
/// landscape), keeping [footer] pinned to the bottom when it does.
class NooDrawer extends StatelessWidget {
final Widget? account;
final Widget? storage;
final String moreLabel;
final List<Widget> moreItems;
final Widget? settings;
final Widget? footer;
const NooDrawer({
super.key,
this.account,
this.storage,
this.moreLabel = 'More',
this.moreItems = const [],
this.settings,
this.footer,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final insets = MediaQuery.paddingOf(context);
Widget divider(EdgeInsets margin) =>
Container(height: 1, margin: margin, color: colors.line);
final children = <Widget>[
?account,
?storage,
if (account != null || storage != null)
divider(const EdgeInsets.fromLTRB(12, 0, 12, 8)),
if (moreItems.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
moreLabel,
style: NooText.label.copyWith(height: 1, color: colors.fg2),
),
),
...moreItems,
if (settings != null)
divider(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
],
?settings,
const Spacer(),
?footer,
];
return Drawer(
width: 316,
backgroundColor: colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadiusDirectional.horizontal(
end: Radius.circular(NooRadii.sheetTop),
),
),
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
// Reference: 64px from the screen top (= status bar + 10-24px),
// 12px sides, 28px bottom.
padding: EdgeInsets.fromLTRB(
NooSpace.sm,
insets.top + NooSpace.md,
NooSpace.sm,
math.max(28, insets.bottom + NooSpace.sm),
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight -
insets.top -
NooSpace.md -
math.max(28, insets.bottom + NooSpace.sm),
),
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: children,
),
),
),
),
),
);
}
}
/// The drawer's account block: 48px [avatar], the account [name] (18
/// Schibsted) over a [subtitle] (usually the server host, 13 fg-3), and a
/// 36px surface-2 circle with `chevron-down` to switch accounts.
class NooDrawerAccount extends StatelessWidget {
/// Usually `NooAvatar(size: 48, current: true, ...)`.
final Widget avatar;
final String name;
final String? subtitle;
/// Tapping the block itself (e.g. open account settings).
final VoidCallback? onTap;
/// The chevron button; hidden when null.
final VoidCallback? onSwitchAccount;
const NooDrawerAccount({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.onTap,
this.onSwitchAccount,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.input),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 12,
children: [
avatar,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.groupHeading.copyWith(
height: 1,
color: colors.fg1,
),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
height: 1,
color: colors.fg3,
),
),
],
),
),
if (onSwitchAccount != null)
Tooltip(
message: 'Switch account',
child: Material(
color: colors.surface2,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSwitchAccount,
child: SizedBox.square(
dimension: 36,
child: Icon(
LucideIcons.chevronDown,
size: 18,
color: colors.fg1,
),
),
),
),
),
],
),
),
),
),
);
}
}
/// The drawer's storage meter: a 6px [NooProgressBar] over a meta line
/// ("38.2 GB of 100 GB used").
class NooDrawerStorage extends StatelessWidget {
/// 0-1.
final double value;
final String label;
const NooDrawerStorage({super.key, required this.value, required this.label});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
NooProgressBar(value: value),
Text(
label,
style: NooText.meta.copyWith(height: 1, color: colors.fg3),
),
],
),
);
}
}
/// A 52px pill row in [NooDrawer]: 24px icon, 16/500 label, optional
/// trailing [count] (14/500 fg-3). [selected] gives it an accent-soft fill
/// with accent-text content (label at 600, matching the sidebar's active
/// item).
class NooDrawerItem extends StatelessWidget {
final IconData icon;
final String label;
final String? count;
final bool selected;
final VoidCallback? onTap;
const NooDrawerItem({
super.key,
required this.icon,
required this.label,
this.count,
this.selected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final fg = selected ? colors.accentText : colors.fg1;
return Semantics(
button: true,
selected: selected,
child: Material(
color: selected ? colors.accentSoft : Colors.transparent,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
spacing: 14,
children: [
Icon(icon, size: 24, color: fg),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
height: 1,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: fg,
),
),
),
if (count != null && count!.isNotEmpty)
Text(
count!,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg3,
),
),
],
),
),
),
),
),
);
}
}
/// The accent-text link at the bottom of [NooDrawer] ("Edit tabs"): 16px
/// icon, 14/600 label.
class NooDrawerLink extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
const NooDrawerLink({
super.key,
this.label = 'Edit tabs',
this.icon = LucideIcons.pencil,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
// Vertical padding only grows the hit target (to 44px); the
// 16px sides match the reference inset.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Icon(icon, size: 16, color: colors.accentText),
Text(
label,
style: NooText.buttonSm.copyWith(
height: 1,
color: colors.accentText,
),
),
],
),
),
),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/widgets.dart';
/// Which platform's navigation chrome a mobile nav component draws
/// (DESIGN_SYSTEM.md 3, "Mobile"). Only the chrome differs per platform -
/// content components look the same everywhere, so this enum is only taken
/// by the `nav/` widgets ([NooBottomBar], [NooTopBar]).
enum NooNavStyle {
ios,
android;
/// iOS (and macOS, when a mobile layout is shown there) get the iOS
/// chrome; everything else gets the Android chrome.
static NooNavStyle fromPlatform(TargetPlatform platform) =>
switch (platform) {
TargetPlatform.iOS || TargetPlatform.macOS => NooNavStyle.ios,
_ => NooNavStyle.android,
};
}
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these.
@immutable
class NooNavDestination {
final IconData icon;
final String label;
const NooNavDestination({required this.icon, required this.label});
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// The desktop sidebar (DESIGN_SYSTEM.md 3, "Desktop"; the 256px column in
/// `Desktop Screen.dc.html`): `bg` fill, 12/12/16 padding, 2px between
/// rows, and a 1px `line` on its trailing edge separating it from the
/// `surface` main pane. Slots top to bottom:
///
/// 1. [windowControls] - e.g. macOS traffic lights (not built here)
/// 2. [account] - usually a [NooSidebarAccount] (14px gap below it)
/// 3. [items] - the pinned tabs, a [NooSidebarDivider], then the rest, as
/// [NooSidebarItem]s
/// 4. a spacer, then [storage] (usually a [NooSidebarStorage]) and
/// [settings] (a [NooSidebarItem])
///
/// Scrolls when the window is too short, keeping the bottom slots pinned
/// down when it isn't.
class NooSidebar extends StatelessWidget {
final Widget? windowControls;
final Widget? account;
final List<Widget> items;
final Widget? storage;
final Widget? settings;
/// Draw the 1px trailing-edge `line`. Turn off if the main pane draws
/// its own leading border.
final bool showDivider;
const NooSidebar({
super.key,
this.windowControls,
this.account,
this.items = const [],
this.storage,
this.settings,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
return Container(
width: NooSizes.sidebarWidth,
decoration: BoxDecoration(
color: colors.bg,
border: showDivider
? BorderDirectional(end: BorderSide(color: colors.line))
: null,
),
child: Material(
type: MaterialType.transparency,
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: padding,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight - padding.vertical,
),
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 2,
children: [
?windowControls,
if (account != null)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: account,
),
...items,
const Spacer(),
?storage,
?settings,
],
),
),
),
),
),
),
);
}
}
/// A 38px sidebar row: 18px icon, 14/500 label, optional trailing [count]
/// (12/500). Idle: fg-1 label, fg-2 icon, fg-3 count, `surface` on hover.
/// Active ([selected]): accent-soft fill, everything accent-text, label at
/// 600. Radius 12.
class NooSidebarItem extends StatelessWidget {
final IconData icon;
final String label;
final String? count;
final bool selected;
final VoidCallback? onTap;
const NooSidebarItem({
super.key,
required this.icon,
required this.label,
this.count,
this.selected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.sidebarItem);
return Semantics(
button: true,
selected: selected,
child: Material(
color: selected ? colors.accentSoft : Colors.transparent,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
hoverColor: selected ? Colors.transparent : colors.surface,
splashFactory: NoSplash.splashFactory,
child: SizedBox(
height: NooSizes.sidebarItem,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 12,
children: [
Icon(
icon,
size: 18,
color: selected ? colors.accentText : colors.fg2,
),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: selected ? colors.accentText : colors.fg1,
),
),
),
if (count != null && count!.isNotEmpty)
Text(
count!,
style: NooText.body.copyWith(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg3,
),
),
],
),
),
),
),
),
);
}
}
/// The 1px `line` between the pinned and remaining tabs, inset 12px with
/// 8px above and below.
class NooSidebarDivider extends StatelessWidget {
const NooSidebarDivider({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: context.nooColors.line,
);
}
}
/// The account switcher card at the top of [NooSidebar]: a `surface` card
/// (radius 14, 8/10 padding) with a 32px [avatar], [name] (14/600) over
/// [subtitle] (12, fg-3), and a `chevron-right` in fg-3.
class NooSidebarAccount extends StatelessWidget {
/// Usually `NooAvatar(size: 32, current: true, ...)`.
final Widget avatar;
final String name;
final String? subtitle;
final VoidCallback? onTap;
const NooSidebarAccount({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.input);
return Material(
color: colors.surface,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
spacing: 10,
children: [
avatar,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
fontSize: 12,
height: 1,
color: colors.fg3,
),
),
],
),
),
Icon(LucideIcons.chevronRight, size: 16, color: colors.fg3),
],
),
),
),
);
}
}
/// The sidebar storage meter: "Storage" (12/500 fg-1) and a [detail]
/// ("38.2 of 100 GB", 12 fg-2) on one line, over a 6px [NooProgressBar].
class NooSidebarStorage extends StatelessWidget {
/// 0-1.
final double value;
final String detail;
final String label;
const NooSidebarStorage({
super.key,
required this.value,
required this.detail,
this.label = 'Storage',
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final meta = NooText.meta.copyWith(
fontSize: 12,
height: 1,
color: colors.fg2,
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Row(
children: [
Text(
label,
style: meta.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: meta,
),
),
],
),
NooProgressBar(value: value),
],
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// The desktop main-pane toolbar (DESIGN_SYSTEM.md 3, "Desktop"; the 64px
/// header in `Desktop Screen.dc.html`): 24px leading / 20px trailing
/// padding, 12px gaps, a 1px `line` along the bottom. Left to right:
///
/// - optional back/forward chevrons ([showHistoryNav] - macOS only; Windows
/// doesn't show them), 20px, fg-3
/// - the [title] (22 Schibsted) - or [titleWidget] instead, e.g. a
/// breadcrumb in Files; it inherits the title [DefaultTextStyle]
/// - flexible space
/// - the [search] slot, 260px wide (pass a `NooSearchField(onSurface: true)`)
/// - [actions] - `NooSegmentedControl`, `NooButton(size: toolbar)`, etc.
///
/// A [PreferredSizeWidget], so it can be the main pane's `Scaffold.appBar`.
/// No [SafeArea]: it never sits under a status bar.
class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final Widget? titleWidget;
final Widget? search;
final List<Widget> actions;
final bool showHistoryNav;
/// Back/forward handlers for [showHistoryNav]; a null one draws that
/// chevron disabled.
final VoidCallback? onBack;
final VoidCallback? onForward;
/// Defaults to `surface` - the toolbar belongs to the main pane.
final Color? backgroundColor;
const NooToolbar({
super.key,
this.title,
this.titleWidget,
this.search,
this.actions = const [],
this.showHistoryNav = false,
this.onBack,
this.onForward,
this.backgroundColor,
}) : assert(
title == null || titleWidget == null,
'Pass either title or titleWidget, not both.',
);
@override
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar);
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
return Material(
color: backgroundColor ?? colors.surface,
child: Container(
height: NooSizes.toolbar,
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.line)),
),
child: Row(
spacing: 12,
children: [
if (showHistoryNav)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
_HistoryButton(
icon: LucideIcons.chevronLeft,
tooltip: 'Back',
onTap: onBack,
),
_HistoryButton(
icon: LucideIcons.chevronRight,
tooltip: 'Forward',
onTap: onForward,
),
],
),
),
Expanded(
child: Align(
alignment: AlignmentDirectional.centerStart,
child: DefaultTextStyle(
style: titleStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: titleWidget ?? Text(title ?? ''),
),
),
),
if (search != null) SizedBox(width: 260, child: search),
...actions,
],
),
),
);
}
}
/// A 20px fg-3 chevron with a 28px hover/press target. Dimmed when
/// [onTap] is null (nothing to go back/forward to).
class _HistoryButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onTap;
const _HistoryButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Tooltip(
message: tooltip,
child: Opacity(
opacity: onTap == null ? 0.45 : 1,
child: InkResponse(
onTap: onTap,
radius: 16,
splashFactory: NoSplash.splashFactory,
hoverColor: colors.surface2,
child: SizedBox.square(
dimension: 28,
child: Icon(icon, size: 20, color: colors.fg3),
),
),
),
);
}
}
+296
View File
@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_nav_style.dart';
export 'noo_nav_style.dart';
// iOS large-title block: 2px above, a 34/0.9 line box (30.6 -> 31), then
// 12px below (14 when there's no search field under it, per the `iSet`
// Settings header in `Mobile Screen.dc.html`).
const double _iosRow = 44;
const double _iosTitleTop = 2;
const double _iosTitleBox = 31;
const double _iosSearch = 38;
const double _iosSearchBottom = 12;
const double _androidRow = 64;
/// Mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"; `iBar`/`aBar`/`iSet`/
/// `aSet` in `Mobile Screen.dc.html`).
///
/// - [NooNavStyle.ios]: a 44px row with [leading] on the left and
/// [actions] on the right, all in accent-text; then the 34px large
/// [title]; then the optional [search] slot (laid out at 38px - pass a
/// `NooSearchField(ios: true)`).
/// - [NooNavStyle.android]: one 64px row: [leading], the 22px [title], then
/// [actions] (fg-1). Android has no [search] slot - search is an action
/// icon there, so [search] is ignored.
///
/// **Why a [PreferredSizeWidget] and not a sliver:** every part of it has a
/// fixed height, so it drops straight into `Scaffold.appBar` (which adds
/// the status-bar inset on top of [preferredSize]; the bar applies that
/// inset itself via [SafeArea]). It's still a plain box widget, so a screen
/// that wants the iOS large title to scroll away with content can put it in
/// a `SliverToBoxAdapter` (or a `SliverPersistentHeader` with a fixed
/// extent) instead - no sliver-specific variant needed. A collapsing
/// large-title-to-inline-title animation isn't in the spec, so it isn't
/// built here.
///
/// Use [NooTopBarButton] for icon actions and [NooTopBarBack] for a pushed
/// screen's back button; both size themselves for the current [style].
class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
final NooNavStyle style;
final String title;
/// Replaces the default leading button - e.g. a [NooTopBarBack] on a
/// pushed screen. When null, a `menu` [NooTopBarButton] calling [onMenu]
/// is shown if [onMenu] is set; otherwise nothing.
final Widget? leading;
final VoidCallback? onMenu;
/// Trailing widgets, left to right: [NooTopBarButton]s, an avatar, etc.
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
/// Android) through [IconTheme].
final List<Widget> actions;
/// iOS only: the search field shown below the large title.
final Widget? search;
/// Defaults to `bg` - the top bar sits on the screen background.
final Color? backgroundColor;
const NooTopBar({
super.key,
required this.style,
required this.title,
this.leading,
this.onMenu,
this.actions = const [],
this.search,
this.backgroundColor,
});
bool get _ios => style == NooNavStyle.ios;
@override
Size get preferredSize {
if (!_ios) return const Size.fromHeight(_androidRow);
return Size.fromHeight(
_iosRow +
_iosTitleTop +
_iosTitleBox +
(search != null ? 12 + _iosSearch + _iosSearchBottom : 14),
);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final lead =
leading ??
(onMenu != null
? NooTopBarButton(
icon: LucideIcons.menu,
tooltip: 'Menu',
onTap: onMenu,
)
: null);
final Widget body = _ios
? _buildIos(colors, lead)
: _buildAndroid(colors, lead);
return _NooTopBarScope(
style: style,
child: IconTheme.merge(
data: IconThemeData(
size: 24,
color: _ios ? colors.accentText : colors.fg1,
),
child: Material(
color: backgroundColor ?? colors.bg,
child: SafeArea(bottom: false, child: body),
),
),
);
}
Widget _buildIos(NooColors colors, Widget? lead) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: _iosRow,
// 44px hit boxes around 24px icons add 10px each side, so 6px of
// row padding puts the icons 16px from the screen edge.
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(children: [?lead, const Spacer(), ...actions]),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
NooSpace.md,
_iosTitleTop,
NooSpace.md,
search != null ? 12 : 14,
),
child: SizedBox(
height: _iosTitleBox,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.largeTitle.copyWith(
leadingDistribution: TextLeadingDistribution.even,
color: colors.fg1,
),
),
),
),
),
if (search != null)
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
_iosSearchBottom,
),
child: SizedBox(height: _iosSearch, child: search),
),
],
);
}
Widget _buildAndroid(NooColors colors, Widget? lead) {
return SizedBox(
height: _androidRow,
child: Padding(
padding: EdgeInsetsDirectional.only(
start: lead != null ? 4 : 16,
end: 8,
),
child: Row(
spacing: 4,
children: [
?lead,
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
...actions,
],
),
),
);
}
}
/// Lets [NooTopBarButton]/[NooTopBarBack] size themselves for the bar
/// they're in without the caller repeating the style.
class _NooTopBarScope extends InheritedWidget {
final NooNavStyle style;
const _NooTopBarScope({required this.style, required super.child});
static NooNavStyle of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_NooTopBarScope>()?.style ??
NooNavStyle.android;
@override
bool updateShouldNotify(_NooTopBarScope oldWidget) =>
style != oldWidget.style;
}
/// A 24px icon action for [NooTopBar] - a 44px (iOS) or 48px (Android)
/// round hit box, colored by the bar's [IconTheme].
class NooTopBarButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final String? tooltip;
const NooTopBarButton({
super.key,
required this.icon,
this.onTap,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final box = _NooTopBarScope.of(context) == NooNavStyle.ios ? 44.0 : 48.0;
Widget button = SizedBox.square(
dimension: box,
child: InkResponse(
onTap: onTap,
radius: box / 2,
child: Center(child: Icon(icon)),
),
);
if (tooltip != null) button = Tooltip(message: tooltip, child: button);
return Semantics(button: true, label: tooltip, child: button);
}
}
/// Back button for a pushed screen's [NooTopBar.leading]. iOS: a
/// `chevron-left` plus the previous screen's [label] in 17px accent-text
/// ("‹ Files"). Android: an `arrow-left` icon button ([label] becomes its
/// tooltip).
class NooTopBarBack extends StatelessWidget {
final String label;
final VoidCallback? onTap;
const NooTopBarBack({super.key, this.label = 'Back', this.onTap});
@override
Widget build(BuildContext context) {
final onPressed = onTap ?? () => Navigator.maybePop(context);
if (_NooTopBarScope.of(context) == NooNavStyle.android) {
return NooTopBarButton(
icon: LucideIcons.arrowLeft,
tooltip: label,
onTap: onPressed,
);
}
final colors = context.nooColors;
return Semantics(
button: true,
child: InkResponse(
onTap: onPressed,
highlightShape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(NooRadii.pill),
child: SizedBox(
height: 44,
child: Padding(
// The row already has 6px padding; 4 more lands the chevron at
// the reference's 10px inset.
padding: const EdgeInsetsDirectional.only(start: 4, end: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 2,
children: [
Icon(LucideIcons.chevronLeft, color: colors.accentText),
Text(
label,
style: NooText.bodyL.copyWith(
fontSize: 17,
height: 1,
color: colors.accentText,
),
),
],
),
),
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'nav/noo_nav_style.dart';
/// The one place screens decide mobile vs desktop layout and iOS vs Android
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
class NooLayout {
const NooLayout._();
/// Window width at which the shell swaps the bottom bar + drawer for the
/// desktop sidebar + toolbar, and screens switch to desktop recipes
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
static const double desktopBreakpoint = 900;
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= desktopBreakpoint;
static NooNavStyle navStyle(BuildContext context) =>
NooNavStyle.fromPlatform(Theme.of(context).platform);
/// True where the spec calls for the iOS variant of a content detail
/// (`ellipsis` rather than `ellipsis-vertical` overflow, etc.).
static bool iosStyle(BuildContext context) =>
navStyle(context) == NooNavStyle.ios;
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Desktop counterpart of `showNooSheet`: a 540px, radius-24 card on the
/// scrim, carrying the design system's one allowed shadow
/// ([nooDialogShadow]). `showGeneralDialog` is used instead of
/// `showDialog` so the barrier takes the `scrim` token and the transition
/// follows [NooMotion] (fade + 0.98 scale, no bounce) rather than
/// Material's defaults.
Future<T?> showNooDialog<T>(
BuildContext context, {
Widget? leading,
required String title,
String? subtitle,
required List<Widget> children,
bool barrierDismissible = true,
}) {
final colors = context.nooColors;
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: colors.scrim,
transitionDuration: NooMotion.base,
pageBuilder: (dialogContext, _, _) => NooDialog(
leading: leading,
title: title,
subtitle: subtitle,
onClose: () => Navigator.of(dialogContext).pop(),
children: children,
),
transitionBuilder: (_, animation, _, child) {
final curved = CurvedAnimation(parent: animation, curve: NooMotion.ease);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(
begin: NooMotion.pressScale,
end: 1,
).animate(curved),
child: child,
),
);
},
);
}
/// The dialog card itself, centered in whatever space it's given. Exposed
/// separately from [showNooDialog] so it can be embedded/tested without a
/// route. Sections are spaced 22px apart to match `showNooSheet`, so the
/// same share-sheet parts lay out identically in both.
class NooDialog extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
final List<Widget> children;
final double width;
const NooDialog({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
required this.children,
this.width = 540,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
// Padding (not MediaQuery maths) keeps a 24px margin on every side, so
// the card shrinks to fit narrow windows and short content-heavy
// dialogs scroll rather than overflow.
return Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: width),
child: Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.dialog),
boxShadow: const [nooDialogShadow],
),
// Material (not just a DecoratedBox) so TextFields/InkWells inside
// the dialog have an ancestor to paint on.
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooDialogHeader(
leading: leading,
title: title,
subtitle: subtitle,
onClose: onClose,
),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
),
],
),
),
),
),
),
),
);
}
}
/// Desktop dialog header: leading slot (a 44px file tile for Share),
/// title, optional meta subtitle, and a 32px surface-2 close circle.
/// `NooOverlayHeader` is the mobile sheet variant (36px close).
class NooDialogHeader extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
const NooDialogHeader({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: NooSpace.sm)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.sectionTitle.copyWith(
fontSize: 19,
color: colors.fg1,
),
),
if (subtitle != null) ...[
const SizedBox(height: 5),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
],
),
),
if (onClose != null) ...[
const SizedBox(width: NooSpace.sm),
NooCloseButton(onTap: onClose!),
],
],
);
}
}
/// 32px surface-2 circle with an `x` - the dialog close control.
class NooCloseButton extends StatelessWidget {
final VoidCallback onTap;
final double size;
const NooCloseButton({super.key, required this.onTap, this.size = 32});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
label: MaterialLocalizations.of(context).closeButtonTooltip,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.surface2,
shape: BoxShape.circle,
),
child: Icon(LucideIcons.x, size: 16, color: colors.fg1),
),
),
);
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Sheet/dialog header: an optional leading tile, title, subtitle, close
/// circle (Noo Design System project,
/// `components/overlays/OverlayHeader.jsx`, mobile variant). [leading] is a
/// plain [Widget] rather than a file kind so this stays decoupled from the
/// file model - pass an `ItemThumbnail`/file-type tile for a file header.
class NooOverlayHeader extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
const NooOverlayHeader({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.sectionTitle.copyWith(fontSize: 19, color: colors.fg1),
),
if (subtitle != null) ...[
const SizedBox(height: 5),
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
),
if (onClose != null)
Semantics(
button: true,
label: 'Close',
child: GestureDetector(
onTap: onClose,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.x, size: 18, color: colors.fg1),
),
),
),
],
);
}
}
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
// Building blocks for the share sheet (mobile, via `showNooSheet`) and the
// share dialog (desktop, via `showNooDialog`) - DESIGN_SYSTEM.md section 4,
// "Share sheet / dialog". Both containers already space their children
// 22px apart, so each [NooShareSection] is one of those children.
/// A titled share-sheet section ("Share with people", "Share link", "Send
/// file directly"). [trailing] sits on the title row - the link section's
/// `NooToggle`. [caption] is the meta line under the content (e.g. "Link
/// settings don't apply").
class NooShareSection extends StatelessWidget {
final String title;
final Widget? trailing;
final Widget child;
final String? caption;
const NooShareSection({
super.key,
required this.title,
this.trailing,
required this.child,
this.caption,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
),
?trailing,
],
),
const SizedBox(height: NooSpace.sm),
child,
if (caption != null) ...[
const SizedBox(height: NooSpace.xs),
Text(caption!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
);
}
}
/// One person/group with access: avatar, name, subtitle, then either the
/// owner label or a tappable permission pill ("Can edit ▾"). Owner rows
/// aren't interactive, so they get plain fg-3 text instead of a pill - the
/// pill shape is reserved for things that open a menu.
class NooPersonAccessRow extends StatelessWidget {
/// Typically a 36px `NooAvatar`.
final Widget avatar;
final String name;
final String? subtitle;
final bool owner;
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
final String permission;
final VoidCallback? onPermissionTap;
final String ownerLabel;
const NooPersonAccessRow({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.owner = false,
this.permission = 'Can edit',
this.onPermissionTap,
this.ownerLabel = 'Owner',
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: Row(
children: [
avatar,
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
height: 1.2,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
],
),
),
const SizedBox(width: NooSpace.sm),
if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else
NooPermissionPill(label: permission, onTap: onPermissionTap),
],
),
);
}
}
/// Surface-2 pill with a trailing chevron that opens a permission menu.
/// Also used for the link section's permission option.
class NooPermissionPill extends StatelessWidget {
final String label;
final VoidCallback? onTap;
const NooPermissionPill({super.key, required this.label, this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: NooSizes.buttonCompact,
padding: const EdgeInsets.only(left: NooSpace.sm, right: 10),
decoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
const SizedBox(width: NooSpace.xxs),
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
],
),
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between
/// sections (Noo Design System project, `components/overlays/Sheet.jsx`).
/// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss
/// machinery, so this only standardizes the shape/padding - use it instead
/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a
/// tall sheet (e.g. share) can grow past half the screen; content scrolls
/// once it hits the top.
Future<T?> showNooSheet<T>(
BuildContext context, {
required List<Widget> children,
}) {
final colors = context.nooColors;
return showModalBottomSheet<T>(
context: context,
backgroundColor: colors.surface,
barrierColor: colors.scrim,
isScrollControlled: true,
useSafeArea: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)),
),
builder: (context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
),
for (final child in children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
);
},
);
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Radius-14 inset input (share "Name, email or group", link URL). Unlike
/// the pill `NooSearchField`, this is the inset-panel shape from the radius
/// scale. [trailing] hosts an inline 40px button (`NooButtonSize.field`),
/// e.g. "Copy link" - the field then tightens its right padding to 4 so the
/// button sits inset inside the rounded box. [mono] switches to the mono
/// style for URLs and local paths.
class NooTextField extends StatelessWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final String? placeholder;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final IconData? leadingIcon;
final Widget? trailing;
final bool mono;
final bool readOnly;
final bool autofocus;
final TextInputType? keyboardType;
/// True (default) on a `surface` background - uses surface-2 fill. Pass
/// false when placed on `bg`, which uses surface instead.
final bool onSurface;
const NooTextField({
super.key,
this.controller,
this.focusNode,
this.placeholder,
this.onChanged,
this.onSubmitted,
this.leadingIcon,
this.trailing,
this.mono = false,
this.readOnly = false,
this.autofocus = false,
this.keyboardType,
this.onSurface = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final base = mono
? NooText.mono.copyWith(fontSize: 13)
: NooText.body.copyWith(fontSize: 15, height: 1.2);
return Container(
constraints: const BoxConstraints(minHeight: 48),
padding: EdgeInsets.only(left: NooSpace.smd, right: trailing != null ? 4 : NooSpace.smd),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(NooRadii.input),
),
child: Row(
children: [
if (leadingIcon != null) ...[
Icon(leadingIcon, size: 18, color: colors.fg3),
const SizedBox(width: NooSpace.xs),
],
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onSubmitted: onSubmitted,
readOnly: readOnly,
autofocus: autofocus,
keyboardType: keyboardType,
style: base.copyWith(color: colors.fg1),
cursorColor: colors.accentText,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: NooSpace.smd),
hintText: placeholder,
hintStyle: base.copyWith(color: colors.fg3),
),
),
),
if (trailing != null) ...[const SizedBox(width: NooSpace.xs), trailing!],
],
),
);
}
}