Set app version to 1.0.0+3; share sheet and markdown viewer polish; remove demo screenshot tooling
Build App Bundle / build (push) Successful in 5m22s

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 14:50:07 -04:00
co-authored by Claude Code
parent 8aa7015c3f
commit 9c9e57ec22
30 changed files with 341 additions and 1230 deletions
+1
View File
@@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
const SizedBox(width: NooSpace.md),
Expanded(child: androidTitleTrailing!),
] else
Expanded(
@@ -64,6 +64,8 @@ class NooPersonAccessRow extends StatelessWidget {
final String permission;
final VoidCallback? onPermissionTap;
final String ownerLabel;
/// Replaces the owner label / permission pill (e.g. a plain search result).
final Widget? trailing;
const NooPersonAccessRow({
super.key,
@@ -74,6 +76,7 @@ class NooPersonAccessRow extends StatelessWidget {
this.permission = 'Can edit',
this.onPermissionTap,
this.ownerLabel = 'Owner',
this.trailing,
});
@override
@@ -113,7 +116,9 @@ class NooPersonAccessRow extends StatelessWidget {
),
),
const SizedBox(width: NooSpace.sm),
if (owner)
if (trailing != null)
trailing!
else if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else
NooPermissionPill(label: permission, onTap: onPermissionTap),
+44 -33
View File
@@ -106,40 +106,51 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
if (!_needsPeek) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
),
child: _buildScrollable(null),
);
}
return DraggableScrollableSheet(
initialChildSize: _kNooSheetPeekFraction,
minChildSize: _kNooSheetMinFraction,
maxChildSize: _kNooSheetMaxFraction,
expand: false,
builder: (context, scrollController) {
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
// route on its own when dragged down to its floor - it just
// stops resizing there - so pop explicitly once it's been
// dragged (near) all the way down, matching a native
// peek sheet's swipe-to-dismiss.
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= notification.minExtent + 0.01) {
Navigator.of(context).maybePop();
}
return false;
},
child: _buildScrollable(scrollController),
// A modal bottom sheet doesn't avoid the keyboard itself, so a focused
// field would be covered. Inset by the keyboard and, while it's up, let
// the content use all the space above it.
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final keyboardUp = keyboard > 0;
return Padding(
padding: EdgeInsets.only(bottom: keyboard),
child: SafeArea(
bottom: !keyboardUp,
child: LayoutBuilder(
builder: (context, constraints) {
if (!_needsPeek) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: keyboardUp
? constraints.maxHeight
: constraints.maxHeight * _kNooSheetPeekFraction,
),
child: _buildScrollable(null),
);
},
);
},
}
return DraggableScrollableSheet(
initialChildSize: _kNooSheetPeekFraction,
minChildSize: _kNooSheetMinFraction,
maxChildSize: _kNooSheetMaxFraction,
expand: false,
builder: (context, scrollController) {
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
// route on its own when dragged down to its floor - it just
// stops resizing there - so pop explicitly once it's been
// dragged (near) all the way down, matching a native
// peek sheet's swipe-to-dismiss.
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= notification.minExtent + 0.01) {
Navigator.of(context).maybePop();
}
return false;
},
child: _buildScrollable(scrollController),
);
},
);
},
),
),
);
}
+82 -19
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -71,11 +72,13 @@ class ShareSheet extends StatefulWidget {
return showNooSheet(
context,
children: [
NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context),
Builder(
builder: (sheetContext) => NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.of(sheetContext).pop(),
),
),
ShareSheet(item: item),
],
@@ -99,9 +102,36 @@ class _ShareSheetState extends State<ShareSheet> {
bool _showInherited = false;
final _peopleController = TextEditingController();
final _linkController = TextEditingController();
final _peopleFocus = FocusNode();
final _peopleKey = GlobalKey();
/// Once the user types, scrolls the sheet so the people field and its
/// results (under their title) sit near the top, leaving a little room under the sheet's
/// rounded top edge.
void _revealPeopleSection() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _peopleKey.currentContext;
final box = ctx?.findRenderObject();
if (ctx == null || box is! RenderBox) return;
final position = Scrollable.maybeOf(ctx)?.position;
final viewport = RenderAbstractViewport.maybeOf(box);
if (position == null || viewport == null) return;
final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
position.animateTo(
target,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
@override
void dispose() {
_peopleFocus.dispose();
_peopleController.dispose();
_linkController.dispose();
super.dispose();
@@ -128,6 +158,7 @@ class _ShareSheetState extends State<ShareSheet> {
return;
}
setState(() => _isSearching = true);
_revealPeopleSection();
final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed);
if (!mounted) return;
@@ -135,6 +166,7 @@ class _ShareSheetState extends State<ShareSheet> {
_searchResults = results;
_isSearching = false;
});
_revealPeopleSection();
}
/// The single "Name, email or group" field: reuses whichever match is
@@ -364,6 +396,7 @@ class _ShareSheetState extends State<ShareSheet> {
absorbing: _isAddingPerson,
child: NooTextField(
controller: _peopleController,
focusNode: _peopleFocus,
leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group',
onChanged: _search,
@@ -381,17 +414,34 @@ class _ShareSheetState extends State<ShareSheet> {
),
),
for (final sharee in _searchResults)
NooSettingsRow(
icon: sharee.type == ShareeType.group
? LucideIcons.users
: LucideIcons.user,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
InkWell(
borderRadius: BorderRadius.circular(NooRadii.input),
onTap: () => _addSharee(sharee),
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(sharee.label),
color: NooPhotoTile.paletteColor(sharee.label.hashCode),
icon: sharee.type == ShareeType.group
? LucideIcons.users
: null,
),
name: sharee.label,
subtitle: sharee.subtitle,
trailing: const SizedBox.shrink(),
),
),
const SizedBox(height: NooSpace.xs),
if (_searchResults.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
child: Divider(height: 1, thickness: 1, color: colors.line),
)
else
const SizedBox(height: NooSpace.xs),
NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true),
avatar: NooAvatar(
initials: _initial(session.username),
current: true,
),
name: session.username,
owner: true,
),
@@ -402,12 +452,16 @@ class _ShareSheetState extends State<ShareSheet> {
color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode,
),
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
icon: share.shareType == ShareType.group
? LucideIcons.users
: null,
),
name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group
? 'Group'
: (share.shareType == ShareType.email ? 'Invited by email' : null),
: (share.shareType == ShareType.email
? 'Invited by email'
: null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share),
),
@@ -429,7 +483,9 @@ class _ShareSheetState extends State<ShareSheet> {
),
),
Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
_showInherited
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 16,
color: colors.fg3,
),
@@ -464,7 +520,10 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
Text(
'Internal link',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
Text(
'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3),
@@ -559,6 +618,7 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooShareSection(
key: _peopleKey,
title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink),
),
@@ -572,13 +632,16 @@ class _ShareSheetState extends State<ShareSheet> {
caption: hasLink
? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
child: hasLink
? _linkSection(publicLinkShares.first)
: const SizedBox.shrink(),
),
if (!widget.item.isFolder) ...[
const SizedBox(height: 22),
NooShareSection(
title: 'Send file directly',
caption: "Link settings above don't apply - this sends the file's bytes directly.",
caption:
"Link settings above don't apply - this sends the file's bytes directly.",
child: NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
+155 -11
View File
@@ -1,12 +1,25 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non-
/// swipeable) preview path (`_textPreviewExtensions`).
/// Space the viewer's floating top bar and bottom action bar cover, so the
/// text sits clear of them (and the status bar) instead of scrolling
/// underneath.
const double _kTopBarClearance = 72;
const double _kActionBarClearance = 112;
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
/// editable when online; a Save button appears once it has changed. Markdown
/// files open rendered, with an Edit/Preview toggle. Opened
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
/// local copy has no server to write back to.
class MediaTextPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
}
class _MediaTextPreviewState extends State<MediaTextPreview> {
String? _content;
final _controller = TextEditingController();
String? _saved;
String? _error;
bool _isSaving = false;
bool _editing = false;
bool get _isMarkdown {
final name = widget.item.name.toLowerCase();
return name.endsWith('.md') || name.endsWith('.markdown');
}
bool get _showRendered => _isMarkdown && !_editing;
bool get _readOnly => widget.localPathResolver != null;
bool get _dirty => _saved != null && _controller.text != _saved;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final List<int> bytes;
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
bytes = await widget.session.service!.fetchBytes(widget.item.path);
}
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
final text = utf8.decode(bytes, allowMalformed: true);
_controller.text = text;
setState(() => _saved = text);
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
final text = _controller.text;
setState(() => _isSaving = true);
var ok = false;
try {
ok = await widget.session.service!.putBytes(
widget.item.path,
utf8.encode(text),
);
} catch (_) {}
if (!mounted) return;
setState(() {
_isSaving = false;
if (ok) _saved = text;
});
messenger.showSnackBar(
SnackBar(
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
@@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
),
);
}
if (_content == null) {
if (_saved == null) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
final safe = MediaQuery.paddingOf(context);
final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5);
final padding = EdgeInsets.fromLTRB(
20,
safe.top + _kTopBarClearance,
20,
safe.bottom + _kActionBarClearance,
);
return Container(
color: colors.surface,
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: SelectableText(
_content!,
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5),
),
child: Stack(
children: [
Positioned.fill(
child: _showRendered
? Markdown(
data: _controller.text,
padding: padding,
selectable: true,
styleSheet: _markdownStyle(context, colors),
onTapLink: (_, href, _) {},
)
: SingleChildScrollView(
padding: padding,
child: TextField(
controller: _controller,
readOnly: _readOnly,
maxLines: null,
keyboardType: TextInputType.multiline,
style: style,
cursorColor: colors.accentText,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
),
if (_isMarkdown && !_readOnly)
Positioned(
right: 20,
top: safe.top + _kTopBarClearance - 8,
child: NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
onTap: () => setState(() => _editing = !_editing),
child: Text(_editing ? 'Preview' : 'Edit'),
),
),
if (_dirty)
Positioned(
right: 20,
bottom: safe.bottom + _kActionBarClearance,
child: NooButton(
size: NooButtonSize.card,
icon: LucideIcons.save,
disabled: _isSaving,
onTap: _isSaving ? null : _save,
child: Text(_isSaving ? 'Saving…' : 'Save'),
),
),
],
),
);
}
}
MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) {
final body = NooText.body.copyWith(color: colors.fg1, height: 1.55);
TextStyle heading(double size) =>
NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25);
return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: body,
h1: heading(26),
h2: heading(22),
h3: heading(19),
h4: heading(17),
listBullet: body,
a: body.copyWith(color: colors.accentText),
blockquote: body.copyWith(color: colors.fg2),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: colors.line, width: 3)),
),
code: NooText.mono.copyWith(
color: colors.fg1,
backgroundColor: colors.surface2,
),
codeblockDecoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.input),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.line)),
),
);
}