Add GET_CONTENT picker support, fix account/login bugs, restyle selection UI
Build APK / build (push) Successful in 7m40s
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:
+174
-56
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user