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