Viewer: round icon buttons with backgrounds; plain text follows Edit -> Save too
Edit/Preview and Save are now compact (36px) icon-only NooButtons - primary when alone, Edit/Preview secondary beside a primary Save, a spinner circle while saving. Plain text opens read-only like Markdown and gets the same toggle. NooButton gains an opt-in iconOnly mode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
5fa20ff3e9
commit
1634d34341
@@ -759,7 +759,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
|
||||
/// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save
|
||||
/// (once the content changed; a spinner while it saves). Same
|
||||
/// [NooTopBarButton] look as the back button next to it.
|
||||
/// round [NooButton] look (primary for the main action, secondary beside it).
|
||||
class _TextActions extends StatelessWidget {
|
||||
final TextPreviewController controller;
|
||||
|
||||
@@ -767,39 +767,92 @@ class _TextActions extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final toggle = controller.canToggleEditing;
|
||||
final save = controller.saving || controller.showSave;
|
||||
// One button is the primary action; with two, Save is and Edit/Preview
|
||||
// steps back to secondary.
|
||||
final both = toggle && save;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (controller.canToggleEditing)
|
||||
NooTopBarButton(
|
||||
if (toggle)
|
||||
_ActionButton(
|
||||
icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||
tooltip: controller.editing ? 'Preview' : 'Edit',
|
||||
variant: both
|
||||
? NooButtonVariant.secondary
|
||||
: NooButtonVariant.primary,
|
||||
onTap: controller.toggleEditing,
|
||||
),
|
||||
if (both) const SizedBox(width: 8),
|
||||
if (controller.saving)
|
||||
Semantics(
|
||||
label: 'Saving',
|
||||
child: SizedBox.square(
|
||||
dimension: 48,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (controller.showSave)
|
||||
NooTopBarButton(
|
||||
const _SavingIndicator()
|
||||
else if (save)
|
||||
_ActionButton(
|
||||
icon: LucideIcons.save,
|
||||
tooltip: 'Save',
|
||||
variant: NooButtonVariant.primary,
|
||||
onTap: controller.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A round icon-only [NooButton] with the tooltip and semantic label an
|
||||
/// icon-only button needs.
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final NooButtonVariant variant;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.variant,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
child: NooButton(
|
||||
icon: icon,
|
||||
iconOnly: true,
|
||||
variant: variant,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in for the Save button while it saves: the same primary circle,
|
||||
/// with a spinner, so it can't be tapped twice.
|
||||
class _SavingIndicator extends StatelessWidget {
|
||||
const _SavingIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
label: 'Saving',
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.accent, shape: BoxShape.circle),
|
||||
child: const SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
|
||||
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
|
||||
enum NooButtonVariant {
|
||||
primary,
|
||||
tonal,
|
||||
secondary,
|
||||
danger,
|
||||
outline,
|
||||
text,
|
||||
textDanger,
|
||||
}
|
||||
|
||||
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
||||
|
||||
@@ -37,6 +45,11 @@ class NooButton extends StatefulWidget {
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// A round, icon-only button (no label) - [icon] centred in a circle as
|
||||
/// wide as the size's height, instead of the pill's side padding. Callers
|
||||
/// are responsible for a tooltip/semantic label, since there's no text.
|
||||
final bool iconOnly;
|
||||
|
||||
const NooButton({
|
||||
super.key,
|
||||
this.variant = NooButtonVariant.primary,
|
||||
@@ -46,6 +59,7 @@ class NooButton extends StatefulWidget {
|
||||
this.fullWidth = false,
|
||||
this.disabled = false,
|
||||
this.onTap,
|
||||
this.iconOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -87,7 +101,8 @@ class _NooButtonState extends State<NooButton> {
|
||||
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 hasLeadingIcon =
|
||||
widget.icon != null && widget.child != null && !_isTextVariant;
|
||||
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -107,7 +122,9 @@ class _NooButtonState extends State<NooButton> {
|
||||
child: GestureDetector(
|
||||
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
||||
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
||||
onTapCancel: 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,
|
||||
@@ -115,8 +132,10 @@ class _NooButtonState extends State<NooButton> {
|
||||
curve: NooMotion.ease,
|
||||
child: Container(
|
||||
height: s.height,
|
||||
width: widget.fullWidth ? double.infinity : null,
|
||||
padding: _isTextVariant
|
||||
width: widget.iconOnly
|
||||
? s.height
|
||||
: (widget.fullWidth ? double.infinity : null),
|
||||
padding: _isTextVariant || widget.iconOnly
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(
|
||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
||||
@@ -128,7 +147,9 @@ class _NooButtonState extends State<NooButton> {
|
||||
// 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,
|
||||
alignment: widget.fullWidth || widget.iconOnly
|
||||
? Alignment.center
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||
|
||||
@@ -89,8 +89,9 @@ class TextPreviewController extends ChangeNotifier {
|
||||
|
||||
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||
/// editable when online; Save appears once it has changed. Markdown files open
|
||||
/// rendered, with an Edit/Preview toggle. Both controls live in the viewer's
|
||||
/// editable when online - after tapping Edit; every file opens read-only
|
||||
/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it
|
||||
/// has changed. Both controls live in the viewer's
|
||||
/// top bar, driven through [controller]. Opened from the Offline tab
|
||||
/// ([localPathResolver] set) it is read-only, since the local copy has no
|
||||
/// server to write back to.
|
||||
@@ -125,12 +126,15 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
String? _error;
|
||||
bool _isSaving = false;
|
||||
bool _editing = false;
|
||||
final _focus = FocusNode();
|
||||
|
||||
bool get _isMarkdown {
|
||||
final name = widget.item.name.toLowerCase();
|
||||
return name.endsWith('.md') || name.endsWith('.markdown');
|
||||
}
|
||||
|
||||
/// Markdown shows rendered until the user taps Edit. Plain text has no
|
||||
/// rendered form, so it just shows its (read-only) text until then.
|
||||
bool get _showRendered => _isMarkdown && !_editing;
|
||||
|
||||
bool get _readOnly => widget.localPathResolver != null;
|
||||
@@ -150,6 +154,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller?._unbind();
|
||||
_focus.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -159,7 +164,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
/// initState, where notifying the bar's listeners would be illegal.
|
||||
void _syncToolbar() {
|
||||
widget.controller?._update(
|
||||
canToggleEditing: _saved != null && _isMarkdown && !_readOnly,
|
||||
canToggleEditing: _saved != null && !_readOnly,
|
||||
editing: _editing,
|
||||
dirty: _dirty,
|
||||
saving: _isSaving,
|
||||
@@ -169,6 +174,12 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
void _toggleEditing() {
|
||||
setState(() => _editing = !_editing);
|
||||
_syncToolbar();
|
||||
// Tapping Edit should put the cursor in the text, not just unlock it.
|
||||
if (_editing) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _focus.requestFocus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
@@ -262,7 +273,10 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||
padding: padding,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
readOnly: _readOnly,
|
||||
focusNode: _focus,
|
||||
// Read-only until Edit is tapped (always, for the
|
||||
// Offline copy).
|
||||
readOnly: _readOnly || !_editing,
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: style,
|
||||
|
||||
Reference in New Issue
Block a user