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,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user