Add GET_CONTENT picker support, fix account/login bugs, restyle selection UI
Build APK / build (push) Successful in 7m40s

- Let Noo be picked as a source (Files + Photos) by other apps' native
  file/photo pickers via Android's GET_CONTENT intent, mirroring the
  existing "Share to Noo" plumbing: MainActivity.kt/PickIntentService/
  PickRequest, ServerProvider.pickRequest/confirmPick/cancelPick, and
  pick-mode tap handling in FilesView/PhotosView. While picking, the
  bottom nav and MoreTabsButton are restricted to Files/Photos only.
- Fix two account bugs: testConnection() failures no longer delete a
  still-valid stored app password (only an actual 401 does), and
  switchAccount() now retries a nominally-active-but-logged-out account
  instead of no-op'ing - both were silently locking users out of "Continue
  as" with no way back in except removing and re-adding the account
  (now exposed via login_view.dart's inline remove button).
- Fix the login WebView: clear cookies before every load so "Add Account"
  can't silently reuse an existing session, and push it as a
  non-animated route to avoid a known WebView-as-PlatformView black-screen-
  on-pop issue under Impeller.
- Redesign the Files/Photos selection toolbar to take over the top app bar
  entirely (SyncedHeaderScaffold.selectionBar) instead of sitting as a
  second bar under the controls row, with a horizontally-scrollable,
  uniformly-colored actions row and the same one-shot scroll-hint nudge
  the controls row uses.
- Redesign ShareUploadView to share the same SyncedHeaderScaffold chrome
  as every tab, with the uploading-file summary (marqueed via the new
  shared MarqueeTitle widget) and upload button grouped into one
  rounded/elevated bottom sheet.
- Restyle ShareSheet to open on the same DetailsHeader every other
  per-item sheet uses, and fix a couple of small design-language drifts
  (hardcoded text style, a non-rounded icon).
- Settings' account card: icon buttons with tooltips instead of text
  buttons for refresh/logout/remove, divider removed.
This commit is contained in:
2026-09-17 23:36:54 -04:00
parent eee2730fbe
commit 44a23c3632
24 changed files with 1298 additions and 394 deletions
+26 -55
View File
@@ -102,8 +102,6 @@ class AccountView extends StatelessWidget {
],
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -188,62 +186,35 @@ class AccountView extends StatelessWidget {
const Divider(height: 1),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: OutlinedButton(
onPressed: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Refresh Cache',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh cached data',
onPressed: () async {
await provider.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refreshed WebDAV data'),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () => _handleLogout(context, provider),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Logout',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.logout_rounded),
tooltip: 'Logout',
color: Colors.orange.shade700,
onPressed: () => _handleLogout(context, provider),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () =>
_confirmRemoveActive(context, provider),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Remove',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Remove account',
color: colorScheme.error,
onPressed: () =>
_confirmRemoveActive(context, provider),
),
],
),
+2 -49
View File
@@ -3,7 +3,6 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:marquee/marquee.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -14,6 +13,7 @@ import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
import '../widgets/seek_bar_painter.dart';
import '../widgets/share_sheet.dart';
@@ -339,7 +339,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
),
const SizedBox(width: 4),
Expanded(
child: _MarqueeTitle(
child: MarqueeTitle(
text: _currentItem.name,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
@@ -562,53 +562,6 @@ class _ActionIconButton extends StatelessWidget {
}
}
/// The media viewer's title: a plain, single-line ellipsized [Text] for
/// names that fit, or an auto-scrolling [Marquee] for names too long for
/// the available width - measured once via [TextPainter] rather than
/// always marqueeing, so a short filename just sits still like normal.
class _MarqueeTitle extends StatelessWidget {
final String text;
final TextStyle? style;
const _MarqueeTitle({required this.text, required this.style});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
if (painter.width <= constraints.maxWidth) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
return SizedBox(
height: painter.height,
child: Marquee(
text: text,
style: style,
blankSpace: 48,
velocity: 30,
startPadding: 0,
pauseAfterRound: const Duration(seconds: 1),
fadingEdgeStartFraction: 0.1,
fadingEdgeEndFraction: 0.15,
),
);
},
);
}
}
class _ImagePreview extends StatefulWidget {
final String url;
final Map<String, String> headers;
+185 -79
View File
@@ -97,10 +97,13 @@ class _FolderEnterAnimationState extends State<_FolderEnterAnimation>
}
}
class _FilesViewState extends State<FilesView> {
class _FilesViewState extends State<FilesView>
with SingleTickerProviderStateMixin {
final Set<String> _selectedIds = {};
int _lastPathDepth = 1;
final ScrollController _controlsScrollController = ScrollController();
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -108,59 +111,135 @@ class _FilesViewState extends State<FilesView> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playControlsScrollHint(),
(_) => _playScrollHint(_controlsScrollController),
);
}
/// A one-shot hint that the controls row scrolls: nudges it a little to
/// the right and back, once, right after it first appears - a motion cue
/// instead of a persistent widget (a chevron badge, an edge fade) sitting
/// on top of the actual controls the whole time. No-ops if there's
/// nothing to scroll (row already fits).
Future<void> _playControlsScrollHint() async {
/// A one-shot hint that a horizontally-scrollable row actually scrolls:
/// nudges it a little to the right and back, once - a motion cue instead
/// of a persistent widget (a chevron badge, an edge fade) sitting on top
/// of the actual controls the whole time. Shared by the controls row
/// (played once it first appears) and the selection actions row (played
/// the first time a selection starts, see `_toggleSelection`). No-ops if
/// there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
// The delay lets the row's first frame (and its actual layout/max
// scroll extent) settle before nudging it, and reads more like a
// deliberate hint than something that happens to fire on load.
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !_controlsScrollController.hasClients) return;
final maxExtent = _controlsScrollController.position.maxScrollExtent;
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
await _controlsScrollController.animateTo(
maxExtent < 36 ? maxExtent : 36,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOut,
);
if (!mounted || !_controlsScrollController.hasClients) return;
await _controlsScrollController.animateTo(
0,
duration: const Duration(milliseconds: 320),
curve: Curves.easeIn,
final double peak = maxExtent < 36 ? maxExtent : 36;
// Driven as a single controller (rather than two chained animateTo
// calls) with mirrored ease-in-out halves, so the motion decelerates
// smoothly into the peak and back out instead of visibly changing
// pace where the two legs meet.
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_controlsScrollController.dispose();
_selectionActionsScrollController.dispose();
super.dispose();
}
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
}
void _clearSelection() {
setState(_selectedIds.clear);
}
/// Routes a tap on an item while Noo is acting as another app's picker:
/// folders are still browsable, a matching file either toggles selection
/// (multi-select requests) or immediately finishes the pick, and a
/// non-matching file (wrong mime type for the caller) is rejected.
void _handlePickTap(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) {
if (item.isFolder) {
provider.navigateToFolder(item.path);
return;
}
if (!provider.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (provider.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
provider.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
if (provider.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
),
];
}
return [
SelectionAction(
icon: selected.every((i) => i.isFavorite)
@@ -169,7 +248,6 @@ class _FilesViewState extends State<FilesView> {
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
color: Colors.red.shade400,
onTap: () => _favoriteSelected(provider, selected),
),
SelectionAction(
@@ -187,7 +265,6 @@ class _FilesViewState extends State<FilesView> {
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
if (selected.length == 1)
@@ -469,8 +546,9 @@ class _FilesViewState extends State<FilesView> {
);
final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over
// the pinned slot and this is free to scroll away.
// Sticky while browsing; once selecting, the selection bar takes over
// the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
@@ -478,56 +556,6 @@ class _FilesViewState extends State<FilesView> {
child: controlsRow,
),
),
if (_isSelecting)
SliverAppBar(
pinned: true,
// This isn't the scroll view's topmost sliver - the Synced
// header above it already reserves status-bar space, so leaving
// this at its default `primary: true` double-reserves it too,
// showing up as a big empty gap above the toolbar.
primary: false,
automaticallyImplyLeading: false,
toolbarHeight: 48,
// No leading/actions slots - everything lives in `title`, wrapped
// in the exact same 16px horizontal Padding as the controls row
// above it, so the two rows' content lines up edge-to-edge
// instead of relying on SliverAppBar's own leadingWidth/actions
// insets (which don't match the controls row's).
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
// Files List / Grid
if (provider.isLoading)
const SliverFillRemaining(
@@ -669,11 +697,81 @@ class _FilesViewState extends State<FilesView> {
const MoreTabsButton(),
const ProfileAvatarButton(),
],
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
: null,
contentSlivers: contentSlivers,
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
/// selected" count, and the horizontally-scrollable bulk actions.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
Future<void> _favoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
@@ -795,7 +893,9 @@ class _FilesViewState extends State<FilesView> {
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
if (provider.isPicking) {
_handlePickTap(context, provider, item);
} else if (_isSelecting) {
_toggleSelection(item);
} else if (item.isFolder) {
provider.navigateToFolder(item.path);
@@ -803,7 +903,9 @@ class _FilesViewState extends State<FilesView> {
_openFile(context, item, provider);
}
},
onLongPress: () => _toggleSelection(item),
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
@@ -850,7 +952,7 @@ class _FilesViewState extends State<FilesView> {
),
);
final content = _isSelecting
final content = _isSelecting || provider.isPicking
? card
: SwipeableItem(
itemKey: ValueKey('file-${item.id}'),
@@ -901,7 +1003,9 @@ class _FilesViewState extends State<FilesView> {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
if (_isSelecting) {
if (provider.isPicking) {
_handlePickTap(context, provider, item);
} else if (_isSelecting) {
_toggleSelection(item);
} else if (item.isFolder) {
provider.navigateToFolder(item.path);
@@ -909,7 +1013,9 @@ class _FilesViewState extends State<FilesView> {
_openFile(context, item, provider);
}
},
onLongPress: () => _toggleSelection(item),
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
+90 -7
View File
@@ -47,6 +47,60 @@ class _LoginViewState extends State<LoginView> {
);
}
/// Resumes a saved account with one tap (see [ServerProvider.switchAccount]).
/// That can fail silently from the account's own perspective - most often
/// a stored app password that no longer works (revoked server-side, or
/// left over from before a since-fixed bug that deleted it too eagerly on
/// a plain network hiccup) - so this surfaces that as a SnackBar with a
/// silently failing every time - a delete button on the row itself (see
/// [_SavedAccountRow]) is the way out.
Future<void> _continueAsAccount(SavedAccount account) async {
final provider = context.read<ServerProvider>();
final success = await provider.switchAccount(account.id);
if (success || !mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text("Couldn't sign in as ${account.username}"),
behavior: SnackBarBehavior.floating,
),
);
}
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same
/// destructive-action gate, just reached from the login screen instead of
/// Settings.
Future<void> _confirmRemoveAccount(SavedAccount account) async {
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true || !mounted) return;
await context.read<ServerProvider>().removeAccount(account.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -81,9 +135,21 @@ class _LoginViewState extends State<LoginView> {
final url = provider.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url)));
// A plain MaterialPageRoute's animated slide/fade transition can
// leave this screen fully black after popping back off of it - a
// known Android WebView-as-PlatformView compositing issue with the
// Impeller renderer (the platform view's surface isn't always torn
// down in sync with an animated route transition). An instant,
// non-animated route sidesteps it entirely; the close (X) button
// already reads like a immediate action, not something that needs
// a slide, so no UX is lost.
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (_, _, _) => LoginWebViewView(url: url),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
);
_webViewPushed = false;
});
}
@@ -110,7 +176,8 @@ class _LoginViewState extends State<LoginView> {
provider.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: provider.accounts,
onSelect: (account) => provider.switchAccount(account.id),
onSelect: (account) => _continueAsAccount(account),
onRemove: (account) => _confirmRemoveAccount(account),
),
const SizedBox(height: 28),
Row(
@@ -329,8 +396,13 @@ class _ServerForm extends StatelessWidget {
class _SavedAccountsSection extends StatelessWidget {
final List<SavedAccount> accounts;
final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({required this.accounts, required this.onSelect});
const _SavedAccountsSection({
required this.accounts,
required this.onSelect,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -358,6 +430,7 @@ class _SavedAccountsSection extends StatelessWidget {
_SavedAccountRow(
account: account,
onTap: () => onSelect(account),
onRemove: () => onRemove(account),
),
if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16),
@@ -373,8 +446,13 @@ class _SavedAccountsSection extends StatelessWidget {
class _SavedAccountRow extends StatelessWidget {
final SavedAccount account;
final VoidCallback onTap;
final VoidCallback onRemove;
const _SavedAccountRow({required this.account, required this.onTap});
const _SavedAccountRow({
required this.account,
required this.onTap,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -397,7 +475,12 @@ class _SavedAccountRow extends StatelessWidget {
),
title: Text(account.username),
subtitle: Text(host),
trailing: const Icon(Icons.chevron_right_rounded),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Remove account',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
onTap: onTap,
);
}
+11 -2
View File
@@ -42,8 +42,17 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (mounted) setState(() => _pageLoading = false);
},
),
)
..loadRequest(widget.url);
);
// Android's WebView CookieManager is a single store shared/persisted
// across every WebView instance in the app, not scoped to this
// controller - without clearing it first, "Add Account" (or logging
// back in as a different user on the same server) would silently land
// on whichever account's Nextcloud session cookie is already there
// instead of prompting for credentials, defeating the point of
// starting a fresh Login Flow v2 at all.
WebViewCookieManager().clearCookies().then((_) {
if (mounted) _controller.loadRequest(widget.url);
});
}
void _cancel() {
+174 -56
View File
@@ -29,29 +29,123 @@ class PhotosView extends StatefulWidget {
State<PhotosView> createState() => _PhotosViewState();
}
class _PhotosViewState extends State<PhotosView> {
class _PhotosViewState extends State<PhotosView>
with SingleTickerProviderStateMixin {
bool _requested = false;
final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
}
void _clearSelection() {
setState(_selectedIds.clear);
}
/// A one-shot hint that the selection actions row actually scrolls -
/// mirrors `FilesView`'s identical controls-row hint (see its doc
/// comment): nudges it right and back, once, the first time a selection
/// starts. No-ops if there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scrollHintControllers.add(controller);
final hint = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(
begin: 0.0,
end: peak,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
TweenSequenceItem(
tween: Tween(
begin: peak,
end: 0.0,
).chain(CurveTween(curve: Curves.easeInOutSine)),
weight: 50,
),
]).animate(controller);
void onTick() {
if (scrollController.hasClients) {
scrollController.jumpTo(hint.value);
}
}
hint.addListener(onTick);
await controller.forward();
hint.removeListener(onTick);
_scrollHintControllers.remove(controller);
controller.dispose();
}
@override
void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_selectionActionsScrollController.dispose();
super.dispose();
}
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
/// just the matching/toggle/immediate-confirm branch.
void _handlePickTap(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) {
if (!provider.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (provider.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
provider.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
if (provider.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
),
];
}
return [
SelectionAction(
icon: selected.every((i) => i.isFavorite)
@@ -60,7 +154,6 @@ class _PhotosViewState extends State<PhotosView> {
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
color: Colors.red.shade400,
onTap: () => _favoriteSelected(provider, selected),
),
SelectionAction(
@@ -78,7 +171,6 @@ class _PhotosViewState extends State<PhotosView> {
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected),
),
if (selected.length == 1)
@@ -173,61 +265,13 @@ class _PhotosViewState extends State<PhotosView> {
);
final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over
// the pinned slot and this is free to scroll away.
// Sticky while browsing; once selecting, the selection bar takes over
// the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
),
if (_isSelecting)
SliverAppBar(
pinned: true,
// This isn't the scroll view's topmost sliver - the Synced
// header above it already reserves status-bar space, so leaving
// this at its default `primary: true` double-reserves it too,
// showing up as a big empty gap above the toolbar.
primary: false,
automaticallyImplyLeading: false,
toolbarHeight: 48,
// No leading/actions slots - everything lives in `title`, wrapped
// in the exact same 16px horizontal Padding as the controls row
// above it, so the two rows' content lines up edge-to-edge
// instead of relying on SliverAppBar's own leadingWidth/actions
// insets (which don't match the controls row's).
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20, color: action.color),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isMediaLoading && photos.isEmpty)
@@ -335,11 +379,81 @@ class _PhotosViewState extends State<PhotosView> {
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
: null,
contentSlivers: contentSlivers,
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
/// selected" count, and the horizontally-scrollable bulk actions.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
controller: _selectionActionsScrollController,
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
);
}
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
@@ -353,13 +467,17 @@ class _PhotosViewState extends State<PhotosView> {
// floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector(
onTap: () {
if (_isSelecting) {
if (provider.isPicking) {
_handlePickTap(context, provider, photo);
} else if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo, provider);
}
},
onLongPress: () => _toggleSelection(photo),
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox(
color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail(
+170 -61
View File
@@ -9,9 +9,13 @@ import '../services/share_intent_service.dart';
import '../services/upload_service.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/marquee_title.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes;
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
/// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder,
@@ -32,6 +36,8 @@ class ShareUploadView extends StatefulWidget {
}
class _ShareUploadViewState extends State<ShareUploadView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
@@ -42,6 +48,12 @@ class _ShareUploadViewState extends State<ShareUploadView> {
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _uploadHere(ServerProvider provider) async {
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
@@ -332,74 +344,171 @@ class _ShareUploadViewState extends State<ShareUploadView> {
: currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.items;
return Scaffold(
appBar: AppBar(
title: Text(
widget.files.length == 1
? 'Upload ${widget.files.first.name}'
: 'Upload ${widget.files.length} files',
),
),
body: Column(
// Mirrors FilesView's own controls-row + breadcrumbs sticky header
// exactly (padding, heights) so this reads as the same browser, just
// reached from a share intent instead of the Files tab.
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _buildControlsRow(provider),
),
if (hasBreadcrumbs)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
_buildControlsRow(provider),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: provider.pathStack,
onTap: (index) => provider.navigateToPathIndex(index),
),
),
Expanded(
child: provider.isLoading
? const Center(child: CircularProgressIndicator())
: items.isEmpty
? Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
)
: provider.isGridView
? GridView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildGridCard(context, items[index], provider),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
itemCount: items.length,
itemBuilder: (context, index) =>
_buildListTile(context, items[index], provider),
),
),
],
],
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: () => _uploadHere(provider),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
),
),
if (provider.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (provider.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index], provider);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, items[index], provider);
}, childCount: items.length),
),
),
// So the last row isn't hidden behind the bottom "Upload to..." bar.
const SliverToBoxAdapter(child: SizedBox(height: 24)),
];
return Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
provider: provider,
// Same trailing actions as every other tab - no bespoke close
// button here, so the top chrome is identical regardless of how
// this screen was reached. Backing out is still the system
// back gesture/button, same as any other pushed screen.
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the
// uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button, both inside the one
// sheet, rather than the summary living up in the scrolling content
// far away from the action it describes.
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Same pill/chip look and exact same duller background
// color as the filter toggles' shared background
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads
// as part of the same visual language rather than a new
// accent color. Sized to the text itself (like a real chip)
// up to the row's available width - MarqueeTitle needs a
// concrete (not just loose) width to know whether/how far
// to scroll, so this measures the text once up front rather
// than leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
text: TextSpan(text: uploadingText, style: chipTextStyle),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout(maxWidth: double.infinity);
final chipWidth = (painter.width + horizontalPadding).clamp(
0.0,
constraints.maxWidth,
);
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(
text: uploadingText,
style: chipTextStyle,
),
),
);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(provider),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
),
),
],
),
),
),
),