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
+32 -5
View File
@@ -113,15 +113,42 @@ owns the app's share-intent listener (`ShareIntentService`, backed by
hand-rolled native handling in `MainActivity.kt` - see `server.md` for why hand-rolled native handling in `MainActivity.kt` - see `server.md` for why
this isn't the `receive_sharing_intent` plugin): both `getInitialShare()` this isn't the `receive_sharing_intent` plugin): both `getInitialShare()`
(cold start via another app's "Share to...") and `onNewShare` (already (cold start via another app's "Share to...") and `onNewShare` (already
running) push `ShareUploadView`. running) push `ShareUploadView`. It similarly owns the pick-intent listener
(`PickIntentService` - see `server.md` for the full "being picked by
another app" story) that feeds `ServerProvider.pickRequest`; while
`isPicking`, the visible tab list is overridden to just Files and Photos
regardless of the user's own hidden/reordered tab settings, since those
are the only two views that know how to handle a picking-mode tap and the
only two that make sense as external "choose a file" sources.
Five of the six tabs (all but Files) plus each tab's own controls share All six tabs, plus `ShareUploadView` (the share-to-upload destination
picker, pushed rather than a tab - see below), share
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
`CustomScrollView` with a pull-down "sync status" header (Google `CustomScrollView` with a pull-down "sync status" header (Google
Photos-style) and a classic Material refresh spinner shown during a Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. `SearchView`, `AccountView` (Settings), the pull-triggered sync. `ShareUploadView` mirrors the Files tab's own
file-details sheet, the share sheet, and `ShareUploadView` (the controls-row/breadcrumbs sticky header almost exactly, so arriving via
share-to-upload destination picker) are pushed on top via another app's "Share to..." sheet still lands on the same top chrome
instead of a plain `AppBar` - its `actions` are the exact same
`[MoreTabsButton(), ProfileAvatarButton()]` every non-Files tab uses (kept
uniform deliberately; backing out is the system back gesture/button, not a
bespoke close icon in the app bar). Its bottom action - "Upload to
{folder}" - and the uploading-file-name summary above it (single line,
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a
sheet peeking up from the bottom edge rather than a plain flat bar.
[`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
`Text` when the content already fits, so short text never marquees.
`SearchView`, `AccountView` (Settings), the file-details sheet, and the
share sheet are pushed on top via
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than `Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
tap and cycles between saved accounts on a vertical swipe. tap and cycles between saved accounts on a vertical swipe.
Files and Photos (the two tabs with multi-select) pass their selection
toolbar into `SyncedHeaderScaffold`'s `selectionBar` param rather than
rendering it as a second sliver app bar inside their own content: while
non-null, it fully takes over the pinned top bar in place of the
sync-status chip/`actions`/pull-to-reveal quota panel, so selecting reads
as replacing the whole top chrome rather than adding a strip beneath it.
+52
View File
@@ -26,6 +26,15 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
Chrome-autofill (Android's own system Autofill framework, e.g. a Chrome-autofill (Android's own system Autofill framework, e.g. a
password manager, may still work in the WebView; Chrome's own password manager, may still work in the WebView; Chrome's own
saved-password autofill specifically cannot, since that's Chrome-only). saved-password autofill specifically cannot, since that's Chrome-only).
`LoginWebViewView` also clears cookies (`WebViewCookieManager().
clearCookies()`) before every load, not just once - Android's WebView
`CookieManager` is a single store shared/persisted across every WebView
instance in the app process, not scoped per-controller, so without this
a second/"Add Account" login silently reuses whichever account's
Nextcloud session cookie is already there instead of prompting for
credentials (the same class of bug the Custom-Tab-reuse issue above
was, just recurring one layer down once everything moved to the owned
WebView).
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every 3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in 2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/ `server_provider.dart`) until it gets a 200 with `server`/`loginName`/
@@ -119,6 +128,49 @@ rather than needing a rewrite for multi-account support.
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to `AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
the Dart UI, by design, since the app may not even be running. the Dart UI, by design, since the app may not even be running.
## Being picked by other apps (photo/file picker)
Noo can also be launched *by* another app as a `GET_CONTENT` picker (e.g.
Google Drive/Instagram's "choose a file" flow), the reverse direction of
"Share to Noo" above - hand-rolled the same way, not a plugin.
- `MainActivity.kt` matches `ACTION_GET_CONTENT` (`OPENABLE`, any
mimeType - a single `*/*` filter, since Android matches it against
whatever the caller actually requested) alongside its existing
`ACTION_SEND`/`SEND_MULTIPLE` filters, and exposes the caller's requested
mimeType/multi-select flag/app label via the
`dev.ayushya.noo/pick_intent` method+event channel pair
([`PickIntentService`](../../lib/services/pick_intent_service.dart)/
[`PickRequest`](../../lib/models/pick_request.dart)) - same
cold-start-vs-already-running split as the share-intent channels.
- `ServerProvider.pickRequest`/`isPicking` drive picking mode app-wide once
`MainShellView` learns about a request at startup or via
`onNewPickRequest`. While picking, `MainShellView` restricts the visible
bottom-nav tabs to just Files and Photos (see `architecture.md`) -
`FilesView`/`PhotosView` route taps through
`itemMatchesPickFilter`/`confirmPick` instead of their normal
open/select behavior (folders still navigate; a mime-mismatched file is
rejected with a snackbar; matching files toggle-select or immediately
confirm depending on `PickRequest.allowMultiple`).
- `ServerProvider.confirmPick` downloads the selected item(s) to a
`picker/` scratch subfolder in the app's cache dir (`downloadToFile`,
same as any other download) - each item into its own `picker/<item.id>/`
subfolder, keeping the on-disk filename as plain `item.name` rather than
prefixing it with the id to dodge collisions between same-named items;
the caller reads that on-disk name back as the display name, so
prefixing it there was a real bug (Drive showing e.g. `163332_photo.jpg`
instead of `photo.jpg`) - then hands the local paths to
`PickIntentService.finishPick`, which calls back into
`MainActivity.kt.finishPick`: it wraps each file in a `content://` Uri
via this app's own `FileProvider` (`${applicationId}.picker.fileprovider`,
scoped to just that cache subfolder - see `android/app/src/main/res/xml/
file_paths.xml`) and returns it to the caller via `setResult`. Single
file uses `setDataAndType` (never `.data =`/`.type =` as two separate
calls - each one silently nulls out the other field on a plain
`Intent`); multiple files use `ClipData`. `cancelPick` mirrors this for
backing out (system back while picking, or a picked-item mismatch) with
`RESULT_CANCELED` instead.
## Multi-account storage & session persistence ## Multi-account storage & session persistence
[`AccountStore`](../../lib/services/account_store.dart) owns everything [`AccountStore`](../../lib/services/account_store.dart) owns everything
+8
View File
@@ -42,6 +42,14 @@ widgets. Key points:
`files_view.dart` and `share_upload_view.dart`'s destination picker). `files_view.dart` and `share_upload_view.dart`'s destination picker).
Extend this rather than re-deriving per-type icons/colors at a new call Extend this rather than re-deriving per-type icons/colors at a new call
site. site.
- [`DetailsHeader`](../../lib/widgets/details/details_sheet.dart) — the
icon-box/name/meta row every per-item bottom sheet opens on
(`DetailsSheet`, the media viewer's collapsed peek state, and
`ShareSheet`). Takes a `padding` override for callers whose own scroll
view already applies horizontal insets (`ShareSheet`'s `ListView`), so it
doesn't get doubled up. Reuse this instead of a bare title `Text` for any
new per-item sheet - a plain title reads as under-designed next to the
other sheets (a real instance: `ShareSheet` used to be just that).
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) — - [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
the blurred/translucent pill background shared by all floating chrome the blurred/translucent pill background shared by all floating chrome
(bottom nav bar, media-viewer top/bottom bars and video transport (bottom nav bar, media-viewer top/bottom bars and video transport
+1
View File
@@ -4,6 +4,7 @@ gradle-wrapper.jar
/gradlew /gradlew
/gradlew.bat /gradlew.bat
/local.properties /local.properties
/build/
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
+23
View File
@@ -49,11 +49,34 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="*/*"/> <data android:mimeType="*/*"/>
</intent-filter> </intent-filter>
<!-- Lets Noo be picked as a source ("Google Photos", "Files", ...)
from another app's own file/photo picker chooser - handled by
MainActivity.kt/PickIntentService, not a plugin. A single
"*/*" filter matches any mimeType the caller requests
(image/*, video/*, or a generic file pick). -->
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.OPENABLE"/>
<data android:mimeType="*/*"/>
</intent-filter>
</activity> </activity>
<service <service
android:name=".ShareUploadService" android:name=".ShareUploadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync"/> android:foregroundServiceType="dataSync"/>
<!-- Hands back the file(s) a caller picked via the GET_CONTENT filter
above - only the app's own cache/picker/ subfolder is exposed,
see file_paths.xml. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.picker.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
@@ -1,6 +1,7 @@
package dev.ayushya.noo package dev.ayushya.noo
import android.Manifest import android.Manifest
import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@@ -10,11 +11,13 @@ import android.os.Looper
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File
// FlutterFragmentActivity (not the default FlutterActivity) is required by // FlutterFragmentActivity (not the default FlutterActivity) is required by
// local_auth's Android implementation, which hosts its biometric/device // local_auth's Android implementation, which hosts its biometric/device
@@ -38,10 +41,17 @@ class MainActivity : FlutterFragmentActivity() {
private val shareIntentChannelName = "dev.ayushya.noo/share_intent" private val shareIntentChannelName = "dev.ayushya.noo/share_intent"
private val newShareChannelName = "dev.ayushya.noo/share_intent/new" private val newShareChannelName = "dev.ayushya.noo/share_intent/new"
private val uploadServiceChannelName = "dev.ayushya.noo/upload_service" private val uploadServiceChannelName = "dev.ayushya.noo/upload_service"
private val pickIntentChannelName = "dev.ayushya.noo/pick_intent"
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
private val notificationPermissionRequestCode = 4202 private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's
// fields are being constructed (crashes with an NPE if evaluated then).
private val pickerFileProviderAuthority by lazy { "$packageName.picker.fileprovider" }
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var newShareSink: EventChannel.EventSink? = null private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -73,6 +83,26 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, pickIntentChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"getPickRequest" -> result.success(extractPickRequest(intent))
"finishPick" -> finishPick(call, result)
"cancelPick" -> cancelPick(result)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, newPickChannelName)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
newPickSink = events
}
override fun onCancel(arguments: Any?) {
newPickSink = null
}
})
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -82,6 +112,10 @@ class MainActivity : FlutterFragmentActivity() {
if (shared.isNotEmpty()) { if (shared.isNotEmpty()) {
mainHandler.post { newShareSink?.success(shared) } mainHandler.post { newShareSink?.success(shared) }
} }
val pickRequest = extractPickRequest(intent)
if (pickRequest != null) {
mainHandler.post { newPickSink?.success(pickRequest) }
}
} }
/// Cheap Uri metadata only (display name / size / mime type) via a /// Cheap Uri metadata only (display name / size / mime type) via a
@@ -180,4 +214,73 @@ class MainActivity : FlutterFragmentActivity() {
ContextCompat.startForegroundService(this, serviceIntent) ContextCompat.startForegroundService(this, serviceIntent)
result.success(null) result.success(null)
} }
/// Non-null only when this Activity was launched (or re-delivered a new
/// Intent) as another app's GET_CONTENT picker - the mimeType filter and
/// multi-select flag the caller asked for, plus a best-effort display
/// name for the calling app to show in the "picking mode" banner.
private fun extractPickRequest(intent: Intent): Map<String, Any?>? {
if (intent.action != Intent.ACTION_GET_CONTENT) return null
val mimeType = intent.type ?: "*/*"
val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
val callerPackage = callingPackage
val callerLabel = callerPackage?.let {
try {
val appInfo = packageManager.getApplicationInfo(it, 0)
packageManager.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
it
}
}
return mapOf(
"mimeType" to mimeType,
"allowMultiple" to allowMultiple,
"callerLabel" to callerLabel,
)
}
/// Hands the already-downloaded local files (see PickIntentService/
/// ServerProvider.confirmPick - this Activity never touches the
/// Nextcloud server itself) back to the caller as content:// Uris
/// through this app's own FileProvider, then closes the picker.
private fun finishPick(call: MethodCall, result: MethodChannel.Result) {
val paths = call.argument<List<String>>("paths")
val mimeTypes = call.argument<List<String>>("mimeTypes") ?: emptyList()
if (paths.isNullOrEmpty()) {
result.error("no_files", "No files to return to the caller", null)
return
}
val uris = paths.map { path ->
FileProvider.getUriForFile(this, pickerFileProviderAuthority, File(path))
}
val resultIntent = Intent().apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// `Intent.setData()` and `.setType()` each silently null out the
// other field if called separately (Android framework behavior) -
// `setDataAndType` is the only way to have both survive on the same
// Intent, which every GET_CONTENT caller expects for `data`.
val firstMimeType = mimeTypes.getOrNull(0) ?: "*/*"
if (uris.size == 1) {
resultIntent.setDataAndType(uris[0], firstMimeType)
} else {
val clipMimeTypes = mimeTypes.ifEmpty { listOf("*/*") }.toTypedArray()
val clipData = ClipData("Noo picked files", clipMimeTypes, ClipData.Item(uris[0]))
for (i in 1 until uris.size) clipData.addItem(ClipData.Item(uris[i]))
resultIntent.clipData = clipData
// Some callers only read `data` even for a multi-item pick, so
// this still points at the first file as a fallback.
resultIntent.setDataAndType(uris[0], firstMimeType)
}
setResult(RESULT_OK, resultIntent)
result.success(null)
finish()
}
private fun cancelPick(result: MethodChannel.Result) {
setResult(RESULT_CANCELED)
result.success(null)
finish()
}
} }
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Exposes only the app's own "picker" cache subfolder as content:// Uris -
used to hand a file back to another app that launched Noo as a
GET_CONTENT picker (see PickIntentService/MainActivity.kt). -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="picker" path="picker/" />
</paths>
+107 -42
View File
@@ -3,7 +3,9 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'models/app_tab.dart'; import 'models/app_tab.dart';
import 'models/pick_request.dart';
import 'providers/server_provider.dart'; import 'providers/server_provider.dart';
import 'services/pick_intent_service.dart';
import 'services/share_intent_service.dart'; import 'services/share_intent_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'views/lock_screen_view.dart'; import 'views/lock_screen_view.dart';
@@ -113,6 +115,7 @@ class _MainShellViewState extends State<MainShellView> {
late AppTab _currentTab; late AppTab _currentTab;
late final Map<AppTab, ScrollController> _scrollControllers; late final Map<AppTab, ScrollController> _scrollControllers;
StreamSubscription<List<SharedFileRef>>? _shareSub; StreamSubscription<List<SharedFileRef>>? _shareSub;
StreamSubscription<PickRequest>? _pickSub;
@override @override
void initState() { void initState() {
@@ -133,6 +136,17 @@ class _MainShellViewState extends State<MainShellView> {
// file's actual bytes, so a large shared file can't block startup. // file's actual bytes, so a large shared file can't block startup.
ShareIntentService.getInitialShare().then(_handleSharedFiles); ShareIntentService.getInitialShare().then(_handleSharedFiles);
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles); _shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
// Same cold-start-vs-already-running split as the share intent above:
// another app may have launched Noo as its GET_CONTENT picker.
PickIntentService.getPickRequest().then((request) {
if (request != null && mounted) {
context.read<ServerProvider>().setPickRequest(request);
}
});
_pickSub = PickIntentService.onNewPickRequest.listen((request) {
if (mounted) context.read<ServerProvider>().setPickRequest(request);
});
} }
void _handleSharedFiles(List<SharedFileRef> files) { void _handleSharedFiles(List<SharedFileRef> files) {
@@ -151,6 +165,7 @@ class _MainShellViewState extends State<MainShellView> {
c.dispose(); c.dispose();
} }
_shareSub?.cancel(); _shareSub?.cancel();
_pickSub?.cancel();
super.dispose(); super.dispose();
} }
@@ -169,7 +184,16 @@ class _MainShellViewState extends State<MainShellView> {
); );
} }
final visible = provider.visibleTabs; final pickRequest = provider.pickRequest;
// While acting as another app's picker, only Files and Photos make
// sense as browsable sources - Trash/Shares/Activity/Recent aren't
// real "pick a file from here" destinations, and only these two tabs'
// views even know how to handle picking-mode taps. This overrides the
// user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing.
final visible = pickRequest != null
? [AppTab.files, AppTab.photos]
: provider.visibleTabs;
final selectedTab = visible.contains(_currentTab) final selectedTab = visible.contains(_currentTab)
? _currentTab ? _currentTab
: (visible.isNotEmpty ? visible.first : AppTab.files); : (visible.isNotEmpty ? visible.first : AppTab.files);
@@ -181,50 +205,91 @@ class _MainShellViewState extends State<MainShellView> {
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon)) .map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
.toList(); .toList();
return Scaffold( return PopScope(
body: Stack( // While picking, system back cancels the pick (and tells the caller
children: [ // nothing was chosen) instead of exiting the app from under it -
IndexedStack( // there's no Navigator route to pop at this, the root screen.
index: selectedIndex, canPop: pickRequest == null,
children: visible onPopInvokedWithResult: (didPop, result) {
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) if (!didPop && pickRequest != null) provider.cancelPick();
.toList(), },
), child: Scaffold(
// Each tab now renders its own sticky selection toolbar inline body: Stack(
// (right under its sort/filter row) instead of this shell children: [
// swapping in a shared floating bar - the bottom nav stays put IndexedStack(
// and usable regardless of selection state. index: selectedIndex,
FloatingBottomNavBar( children: visible
selectedIndex: selectedIndex, .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
items: navItems, .toList(),
opacity: provider.bottomBarOpacity, ),
blurSigma: provider.bottomBarBlur, if (provider.isDownloadingForPick) const _PickingProgressOverlay(),
onDestinationSelected: (index) { // Each tab now renders its own sticky selection toolbar inline
final tappedTab = visible[index]; // (right under its sort/filter row) instead of this shell
if (tappedTab == _currentTab) { // swapping in a shared floating bar - the bottom nav stays put
if (provider.tapTabToScrollTop) { // and usable regardless of selection state.
final controller = _scrollControllers[tappedTab]; FloatingBottomNavBar(
if (controller != null && controller.hasClients) { selectedIndex: selectedIndex,
controller.animateTo( items: navItems,
0, opacity: provider.bottomBarOpacity,
duration: const Duration(milliseconds: 300), blurSigma: provider.bottomBarBlur,
curve: Curves.easeOutCubic, onDestinationSelected: (index) {
); final tappedTab = visible[index];
if (tappedTab == _currentTab) {
if (provider.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
} }
return;
} }
return; setState(() {
} _currentTab = tappedTab;
setState(() { });
_currentTab = tappedTab; },
}); onSearchTap: () {
}, Navigator.of(
onSearchTap: () { context,
Navigator.of( ).push(MaterialPageRoute(builder: (_) => const SearchView()));
context, },
).push(MaterialPageRoute(builder: (_) => const SearchView())); ),
}, ],
),
),
);
}
}
/// Blocks interaction while the selected file(s) download to a local cache
/// folder before being handed back to the caller (see
/// `ServerProvider.confirmPick`) - can take a moment for a large file/video.
class _PickingProgressOverlay extends StatelessWidget {
const _PickingProgressOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparing file...'),
],
),
),
), ),
], ),
), ),
); );
} }
+34
View File
@@ -0,0 +1,34 @@
/// Another app asking Noo (via Android's GET_CONTENT chooser) to pick a
/// file/photo and hand it back - see `PickIntentService`/`MainActivity.kt`.
class PickRequest {
final String mimeType;
final bool allowMultiple;
final String? callerLabel;
const PickRequest({
required this.mimeType,
required this.allowMultiple,
this.callerLabel,
});
factory PickRequest.fromMap(Map<dynamic, dynamic> map) {
return PickRequest(
mimeType: map['mimeType'] as String? ?? '*/*',
allowMultiple: map['allowMultiple'] as bool? ?? false,
callerLabel: map['callerLabel'] as String?,
);
}
/// Whether an item's mime type satisfies this request's filter (which may
/// be a wildcard like `image/*` or the catch-all `*/*`).
bool matches(String? itemMimeType) {
if (mimeType == '*/*') return true;
if (itemMimeType == null) return false;
if (mimeType.endsWith('/*')) {
return itemMimeType.startsWith(
mimeType.substring(0, mimeType.length - 1),
);
}
return itemMimeType == mimeType;
}
}
-2
View File
@@ -6,13 +6,11 @@ import 'package:flutter/material.dart';
class SelectionAction { class SelectionAction {
final IconData icon; final IconData icon;
final String label; final String label;
final Color? color;
final VoidCallback onTap; final VoidCallback onTap;
const SelectionAction({ const SelectionAction({
required this.icon, required this.icon,
required this.label, required this.label,
required this.onTap, required this.onTap,
this.color,
}); });
} }
+108 -9
View File
@@ -1,18 +1,23 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/nextcloud_file_version.dart'; import '../models/nextcloud_file_version.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart'; import '../models/nextcloud_sharee.dart';
import '../models/pick_request.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../services/account_store.dart'; import '../services/account_store.dart';
import '../services/app_lock_service.dart'; import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart'; import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart'; import '../services/nextcloud_service.dart';
import '../services/pick_intent_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
@@ -140,6 +145,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
// (e.g. a search result landing on Files) - consumed and cleared by // (e.g. a search result landing on Files) - consumed and cleared by
// MainShellView the next time it builds, not a persisted preference. // MainShellView the next time it builds, not a persisted preference.
AppTab? _requestedTab; AppTab? _requestedTab;
// Non-null while the app is acting as another app's GET_CONTENT picker
// (see PickIntentService/MainActivity.kt) - set at startup/onNewPickRequest
// in MainShellView, cleared once the pick is confirmed or cancelled.
PickRequest? _pickRequest;
bool _isDownloadingForPick = false;
List<String> _pathStack = ['/']; List<String> _pathStack = ['/'];
bool _isGridView = false; bool _isGridView = false;
bool _showFavoritesOnlyFiles = false; bool _showFavoritesOnlyFiles = false;
@@ -296,6 +307,77 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
String get currentFolderPath => _currentFolderPath; String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab; AppTab? get requestedTab => _requestedTab;
PickRequest? get pickRequest => _pickRequest;
bool get isPicking => _pickRequest != null;
bool get isDownloadingForPick => _isDownloadingForPick;
/// Whether [item] can be handed back to the app that's currently picking
/// - always true for folders (still browsable), mime-filtered for files.
bool itemMatchesPickFilter(NextcloudItem item) {
if (item.isFolder) return true;
final request = _pickRequest;
return request == null || request.matches(item.mimeType);
}
/// Called once (from MainShellView) as soon as a pick request is known -
/// either the cold-start request or one that arrived via onNewPickRequest
/// while already running.
void setPickRequest(PickRequest request) {
_pickRequest = request;
notifyListeners();
}
/// Downloads each selected item to a scratch cache folder, then hands the
/// local paths back to the caller through PickIntentService, which closes
/// the picker Activity on success. Left in [_pickRequest] (i.e. picking
/// mode stays visually active) if the download fails partway, so the user
/// can see the error and retry rather than the screen finishing under
/// them with nothing returned to the caller.
Future<bool> confirmPick(List<NextcloudItem> items) async {
final service = _service;
if (_pickRequest == null || service == null || items.isEmpty) {
return false;
}
_isDownloadingForPick = true;
notifyListeners();
try {
final tempDir = await getTemporaryDirectory();
final pickDir = Directory(p.join(tempDir.path, 'picker'));
final localPaths = <String>[];
final mimeTypes = <String>[];
for (final item in items) {
// Each item gets its own subfolder (keyed by id, not smashed into
// the filename) so two different items can share a plain file
// name without colliding, while the file on disk - and therefore
// the display name the caller sees via the content:// Uri
// MainActivity.kt hands back - stays exactly `item.name`.
final itemDir = Directory(p.join(pickDir.path, item.id));
await itemDir.create(recursive: true);
final localPath = p.join(itemDir.path, item.name);
await service.downloadToFile(item.path, localPath);
localPaths.add(localPath);
mimeTypes.add(item.mimeType ?? 'application/octet-stream');
}
await PickIntentService.finishPick(localPaths, mimeTypes);
_pickRequest = null;
return true;
} catch (_) {
return false;
} finally {
_isDownloadingForPick = false;
notifyListeners();
}
}
/// Backs out of picking mode entirely, telling the caller nothing was
/// picked and closing the picker Activity.
Future<void> cancelPick() async {
if (_pickRequest == null) return;
_pickRequest = null;
notifyListeners();
await PickIntentService.cancelPick();
}
/// Asks the shell to switch its active bottom-nav tab to [tab] - e.g. so /// Asks the shell to switch its active bottom-nav tab to [tab] - e.g. so
/// tapping a search result lands the user on the Files tab even if they /// tapping a search result lands the user on the Files tab even if they
/// opened search from somewhere else. /// opened search from somewhere else.
@@ -690,9 +772,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
} catch (e) { } catch (e) {
if (gen != _sessionGeneration) return false; if (gen != _sessionGeneration) return false;
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
// The stored app password for this account no longer works - drop it // Only drop the stored password on an actual auth rejection (401) -
// so it doesn't keep silently failing on every future restore/switch. // NextcloudService.testConnection also throws for network-level
await _accountStore.deletePassword(account.id); // failures (DNS, timeout, unreachable host), and those are transient:
// deleting a still-valid password on a dropped connection would
// permanently log the account out with no way back in short of
// Login Flow v2 again, since _activeAccountId is never cleared and
// switchAccount() no-ops when asked to "switch" to the account
// that's already (nominally) active.
if (_errorMessage?.contains('401') == true) {
await _accountStore.deletePassword(account.id);
}
} }
if (gen != _sessionGeneration) return false; if (gen != _sessionGeneration) return false;
@@ -908,15 +998,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
unawaited(fetchRecent()); unawaited(fetchRecent());
} }
/// Switches to an already-saved account. No-op if it's already active or /// Switches to an already-saved account. No-op (returns true) if it's
/// unknown. Gated behind login lock when [lockAccountSwitching] is on. /// already active and logged in; no-op (returns false) if unknown - but
Future<void> switchAccount(String accountId) async { /// if [accountId] is nominally "active" while [isLoggedIn] is false (a
if (accountId == _activeAccountId) return; /// session-restore that failed, e.g. transient network trouble at cold
if (!_accounts.any((a) => a.id == accountId)) return; /// start), this still retries rather than no-op, since that's exactly the
/// case LoginView's "Continue as" tile exists to recover from. Gated
/// behind login lock when [lockAccountSwitching] is on. Returns whether
/// the account ended up logged in, so callers (LoginView's saved-account
/// tile) can surface a failure - e.g. a stored app password that no
/// longer works and needs the account removed/re-added.
Future<bool> switchAccount(String accountId) async {
if (accountId == _activeAccountId && _isLoggedIn) return true;
if (!_accounts.any((a) => a.id == accountId)) return false;
if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) { if (!await _passGate(_lockAccountSwitching, 'Unlock to switch accounts')) {
return; return false;
} }
await _activateAccount(accountId); await _activateAccount(accountId);
return _isLoggedIn;
} }
Future<SavedAccount?> _cycleAccount(int direction) async { Future<SavedAccount?> _cycleAccount(int direction) async {
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/services.dart';
import '../models/pick_request.dart';
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
/// stream for an intent arriving while the app is already running - the
/// same `singleTask` launch mode applies here).
class PickIntentService {
static const _methodChannel = MethodChannel('dev.ayushya.noo/pick_intent');
static const _newPickChannel = EventChannel(
'dev.ayushya.noo/pick_intent/new',
);
/// Non-null if the app was launched cold as another app's file/photo
/// picker.
static Future<PickRequest?> getPickRequest() async {
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
'getPickRequest',
);
return result == null ? null : PickRequest.fromMap(result);
}
/// Emits whenever the app is asked to act as a picker while already
/// running.
static Stream<PickRequest> get onNewPickRequest {
return _newPickChannel.receiveBroadcastStream().map(
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
);
}
/// Hands the already-downloaded local files back to the caller and closes
/// the picker. [mimeTypes] is parallel to [localPaths].
static Future<void> finishPick(
List<String> localPaths,
List<String> mimeTypes,
) {
return _methodChannel.invokeMethod('finishPick', {
'paths': localPaths,
'mimeTypes': mimeTypes,
});
}
/// Backs out of picking mode with no result, closing the picker.
static Future<void> cancelPick() {
return _methodChannel.invokeMethod('cancelPick');
}
}
+26 -55
View File
@@ -102,8 +102,6 @@ class AccountView extends StatelessWidget {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -188,62 +186,35 @@ class AccountView extends StatelessWidget {
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( IconButton(
child: OutlinedButton( icon: const Icon(Icons.refresh_rounded),
onPressed: () async { tooltip: 'Refresh cached data',
await provider.refreshData(); onPressed: () async {
if (context.mounted) { await provider.refreshData();
ScaffoldMessenger.of(context).showSnackBar( if (context.mounted) {
const SnackBar( ScaffoldMessenger.of(context).showSnackBar(
content: Text('Refreshed WebDAV data'), const SnackBar(
behavior: SnackBarBehavior.floating, 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),
),
),
), ),
const SizedBox(width: 8), IconButton(
Expanded( icon: const Icon(Icons.logout_rounded),
child: FilledButton( tooltip: 'Logout',
onPressed: () => _handleLogout(context, provider), color: Colors.orange.shade700,
style: FilledButton.styleFrom( onPressed: () => _handleLogout(context, provider),
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Logout',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
), ),
const SizedBox(width: 8), IconButton(
Expanded( icon: const Icon(Icons.delete_outline_rounded),
child: FilledButton( tooltip: 'Remove account',
onPressed: () => color: colorScheme.error,
_confirmRemoveActive(context, provider), onPressed: () =>
style: FilledButton.styleFrom( _confirmRemoveActive(context, provider),
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text(
'Remove',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13),
),
),
), ),
], ],
), ),
+2 -49
View File
@@ -3,7 +3,6 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:marquee/marquee.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -14,6 +13,7 @@ import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart'; import '../providers/server_provider.dart';
import '../widgets/details/details_sheet.dart'; import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart'; import '../widgets/frosted_glass_container.dart';
import '../widgets/marquee_title.dart';
import '../widgets/seek_bar_painter.dart'; import '../widgets/seek_bar_painter.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
@@ -339,7 +339,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: _MarqueeTitle( child: MarqueeTitle(
text: _currentItem.name, text: _currentItem.name,
style: Theme.of(context).textTheme.titleSmall style: Theme.of(context).textTheme.titleSmall
?.copyWith( ?.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 { class _ImagePreview extends StatefulWidget {
final String url; final String url;
final Map<String, String> headers; 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 = {}; final Set<String> _selectedIds = {};
int _lastPathDepth = 1; int _lastPathDepth = 1;
final ScrollController _controlsScrollController = ScrollController(); final ScrollController _controlsScrollController = ScrollController();
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -108,59 +111,135 @@ class _FilesViewState extends State<FilesView> {
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => _playControlsScrollHint(), (_) => _playScrollHint(_controlsScrollController),
); );
} }
/// A one-shot hint that the controls row scrolls: nudges it a little to /// A one-shot hint that a horizontally-scrollable row actually scrolls:
/// the right and back, once, right after it first appears - a motion cue /// nudges it a little to the right and back, once - a motion cue instead
/// instead of a persistent widget (a chevron badge, an edge fade) sitting /// of a persistent widget (a chevron badge, an edge fade) sitting on top
/// on top of the actual controls the whole time. No-ops if there's /// of the actual controls the whole time. Shared by the controls row
/// nothing to scroll (row already fits). /// (played once it first appears) and the selection actions row (played
Future<void> _playControlsScrollHint() async { /// 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 // The delay lets the row's first frame (and its actual layout/max
// scroll extent) settle before nudging it, and reads more like a // scroll extent) settle before nudging it, and reads more like a
// deliberate hint than something that happens to fire on load. // deliberate hint than something that happens to fire on load.
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !_controlsScrollController.hasClients) return; if (!mounted || !scrollController.hasClients) return;
final maxExtent = _controlsScrollController.position.maxScrollExtent; final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return; if (maxExtent <= 0) return;
await _controlsScrollController.animateTo( final double peak = maxExtent < 36 ? maxExtent : 36;
maxExtent < 36 ? maxExtent : 36, // Driven as a single controller (rather than two chained animateTo
duration: const Duration(milliseconds: 320), // calls) with mirrored ease-in-out halves, so the motion decelerates
curve: Curves.easeOut, // smoothly into the peak and back out instead of visibly changing
); // pace where the two legs meet.
if (!mounted || !_controlsScrollController.hasClients) return; final controller = AnimationController(
await _controlsScrollController.animateTo( vsync: this,
0, duration: const Duration(milliseconds: 900),
duration: const Duration(milliseconds: 320),
curve: Curves.easeIn,
); );
_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 @override
void dispose() { void dispose() {
for (final controller in _scrollHintControllers) {
controller.dispose();
}
_controlsScrollController.dispose(); _controlsScrollController.dispose();
_selectionActionsScrollController.dispose();
super.dispose(); super.dispose();
} }
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); 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 /// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items. /// currently-selected items.
List<SelectionAction> _buildSelectionActions( List<SelectionAction> _buildSelectionActions(
ServerProvider provider, ServerProvider provider,
List<NextcloudItem> selected, List<NextcloudItem> selected,
) { ) {
if (provider.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
),
];
}
return [ return [
SelectionAction( SelectionAction(
icon: selected.every((i) => i.isFavorite) icon: selected.every((i) => i.isFavorite)
@@ -169,7 +248,6 @@ class _FilesViewState extends State<FilesView> {
label: selected.every((i) => i.isFavorite) label: selected.every((i) => i.isFavorite)
? 'Remove from favorites' ? 'Remove from favorites'
: 'Favorite', : 'Favorite',
color: Colors.red.shade400,
onTap: () => _favoriteSelected(provider, selected), onTap: () => _favoriteSelected(provider, selected),
), ),
SelectionAction( SelectionAction(
@@ -187,7 +265,6 @@ class _FilesViewState extends State<FilesView> {
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
label: 'Delete', label: 'Delete',
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected), onTap: () => _confirmDeleteSelected(context, provider, selected),
), ),
if (selected.length == 1) if (selected.length == 1)
@@ -469,8 +546,9 @@ class _FilesViewState extends State<FilesView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over // Sticky while browsing; once selecting, the selection bar takes over
// the pinned slot and this is free to scroll away. // the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate( delegate: StickyHeaderDelegate(
@@ -478,56 +556,6 @@ class _FilesViewState extends State<FilesView> {
child: controlsRow, 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 // Files List / Grid
if (provider.isLoading) if (provider.isLoading)
const SliverFillRemaining( const SliverFillRemaining(
@@ -669,11 +697,81 @@ class _FilesViewState extends State<FilesView> {
const MoreTabsButton(), const MoreTabsButton(),
const ProfileAvatarButton(), const ProfileAvatarButton(),
], ],
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
: null,
contentSlivers: contentSlivers, 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( Future<void> _favoriteSelected(
ServerProvider provider, ServerProvider provider,
List<NextcloudItem> items, List<NextcloudItem> items,
@@ -795,7 +893,9 @@ class _FilesViewState extends State<FilesView> {
: colorScheme.surfaceContainerLow, : colorScheme.surfaceContainerLow,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (_isSelecting) { if (provider.isPicking) {
_handlePickTap(context, provider, item);
} else if (_isSelecting) {
_toggleSelection(item); _toggleSelection(item);
} else if (item.isFolder) { } else if (item.isFolder) {
provider.navigateToFolder(item.path); provider.navigateToFolder(item.path);
@@ -803,7 +903,9 @@ class _FilesViewState extends State<FilesView> {
_openFile(context, item, provider); _openFile(context, item, provider);
} }
}, },
onLongPress: () => _toggleSelection(item), onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
@@ -850,7 +952,7 @@ class _FilesViewState extends State<FilesView> {
), ),
); );
final content = _isSelecting final content = _isSelecting || provider.isPicking
? card ? card
: SwipeableItem( : SwipeableItem(
itemKey: ValueKey('file-${item.id}'), itemKey: ValueKey('file-${item.id}'),
@@ -901,7 +1003,9 @@ class _FilesViewState extends State<FilesView> {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (_isSelecting) { if (provider.isPicking) {
_handlePickTap(context, provider, item);
} else if (_isSelecting) {
_toggleSelection(item); _toggleSelection(item);
} else if (item.isFolder) { } else if (item.isFolder) {
provider.navigateToFolder(item.path); provider.navigateToFolder(item.path);
@@ -909,7 +1013,9 @@ class _FilesViewState extends State<FilesView> {
_openFile(context, item, provider); _openFile(context, item, provider);
} }
}, },
onLongPress: () => _toggleSelection(item), onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: SelectableThumbnail( child: SelectableThumbnail(
isSelected: isSelected, isSelected: isSelected,
checkmarkSize: 32, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -81,9 +135,21 @@ class _LoginViewState extends State<LoginView> {
final url = provider.pendingLoginUrl!; final url = provider.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return; if (!mounted) return;
await Navigator.of( // A plain MaterialPageRoute's animated slide/fade transition can
context, // leave this screen fully black after popping back off of it - a
).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url))); // 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; _webViewPushed = false;
}); });
} }
@@ -110,7 +176,8 @@ class _LoginViewState extends State<LoginView> {
provider.accounts.isNotEmpty) ...[ provider.accounts.isNotEmpty) ...[
_SavedAccountsSection( _SavedAccountsSection(
accounts: provider.accounts, accounts: provider.accounts,
onSelect: (account) => provider.switchAccount(account.id), onSelect: (account) => _continueAsAccount(account),
onRemove: (account) => _confirmRemoveAccount(account),
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
Row( Row(
@@ -329,8 +396,13 @@ class _ServerForm extends StatelessWidget {
class _SavedAccountsSection extends StatelessWidget { class _SavedAccountsSection extends StatelessWidget {
final List<SavedAccount> accounts; final List<SavedAccount> accounts;
final ValueChanged<SavedAccount> onSelect; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -358,6 +430,7 @@ class _SavedAccountsSection extends StatelessWidget {
_SavedAccountRow( _SavedAccountRow(
account: account, account: account,
onTap: () => onSelect(account), onTap: () => onSelect(account),
onRemove: () => onRemove(account),
), ),
if (account != accounts.last) if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16), const Divider(height: 1, indent: 16, endIndent: 16),
@@ -373,8 +446,13 @@ class _SavedAccountsSection extends StatelessWidget {
class _SavedAccountRow extends StatelessWidget { class _SavedAccountRow extends StatelessWidget {
final SavedAccount account; final SavedAccount account;
final VoidCallback onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -397,7 +475,12 @@ class _SavedAccountRow extends StatelessWidget {
), ),
title: Text(account.username), title: Text(account.username),
subtitle: Text(host), 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, onTap: onTap,
); );
} }
+11 -2
View File
@@ -42,8 +42,17 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (mounted) setState(() => _pageLoading = false); 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() { void _cancel() {
+174 -56
View File
@@ -29,29 +29,123 @@ class PhotosView extends StatefulWidget {
State<PhotosView> createState() => _PhotosViewState(); State<PhotosView> createState() => _PhotosViewState();
} }
class _PhotosViewState extends State<PhotosView> { class _PhotosViewState extends State<PhotosView>
with SingleTickerProviderStateMixin {
bool _requested = false; bool _requested = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) { void _toggleSelection(NextcloudItem item) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final enteringSelection = _selectedIds.isEmpty;
setState(() { setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
}); });
if (enteringSelection && _isSelecting) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _playScrollHint(_selectionActionsScrollController),
);
}
} }
void _clearSelection() { void _clearSelection() {
setState(_selectedIds.clear); 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 /// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items. /// currently-selected items.
List<SelectionAction> _buildSelectionActions( List<SelectionAction> _buildSelectionActions(
ServerProvider provider, ServerProvider provider,
List<NextcloudItem> selected, List<NextcloudItem> selected,
) { ) {
if (provider.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
),
];
}
return [ return [
SelectionAction( SelectionAction(
icon: selected.every((i) => i.isFavorite) icon: selected.every((i) => i.isFavorite)
@@ -60,7 +154,6 @@ class _PhotosViewState extends State<PhotosView> {
label: selected.every((i) => i.isFavorite) label: selected.every((i) => i.isFavorite)
? 'Remove from favorites' ? 'Remove from favorites'
: 'Favorite', : 'Favorite',
color: Colors.red.shade400,
onTap: () => _favoriteSelected(provider, selected), onTap: () => _favoriteSelected(provider, selected),
), ),
SelectionAction( SelectionAction(
@@ -78,7 +171,6 @@ class _PhotosViewState extends State<PhotosView> {
SelectionAction( SelectionAction(
icon: Icons.delete_outline_rounded, icon: Icons.delete_outline_rounded,
label: 'Delete', label: 'Delete',
color: Theme.of(context).colorScheme.error,
onTap: () => _confirmDeleteSelected(context, provider, selected), onTap: () => _confirmDeleteSelected(context, provider, selected),
), ),
if (selected.length == 1) if (selected.length == 1)
@@ -173,61 +265,13 @@ class _PhotosViewState extends State<PhotosView> {
); );
final List<Widget> contentSlivers = [ final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the toolbar below takes over // Sticky while browsing; once selecting, the selection bar takes over
// the pinned slot and this is free to scroll away. // the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
SliverPersistentHeader( SliverPersistentHeader(
pinned: !_isSelecting, pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow), 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)), const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isMediaLoading && photos.isEmpty) if (provider.isMediaLoading && photos.isEmpty)
@@ -335,11 +379,81 @@ class _PhotosViewState extends State<PhotosView> {
provider: provider, provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()], actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllMedia, onRefresh: provider.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
: null,
contentSlivers: contentSlivers, 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( Widget _buildPhotoTile(
BuildContext context, BuildContext context,
NextcloudItem photo, NextcloudItem photo,
@@ -353,13 +467,17 @@ class _PhotosViewState extends State<PhotosView> {
// floating rounded corner mid-drag (see the same fix in files_view). // floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector( final tile = GestureDetector(
onTap: () { onTap: () {
if (_isSelecting) { if (provider.isPicking) {
_handlePickTap(context, provider, photo);
} else if (_isSelecting) {
_toggleSelection(photo); _toggleSelection(photo);
} else { } else {
_openLightbox(context, photo, provider); _openLightbox(context, photo, provider);
} }
}, },
onLongPress: () => _toggleSelection(photo), onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox( child: ColoredBox(
color: colorScheme.surfaceContainerHigh, color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail( child: SelectableThumbnail(
+170 -61
View File
@@ -9,9 +9,13 @@ import '../services/share_intent_service.dart';
import '../services/upload_service.dart'; import '../services/upload_service.dart';
import '../widgets/breadcrumbs.dart'; import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.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/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.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 /// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder, /// "Share to..." sheet). Lets the user browse to a destination folder,
@@ -32,6 +36,8 @@ class ShareUploadView extends StatefulWidget {
} }
class _ShareUploadViewState extends State<ShareUploadView> { class _ShareUploadViewState extends State<ShareUploadView> {
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.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 { Future<void> _uploadHere(ServerProvider provider) async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
@@ -332,74 +344,171 @@ class _ShareUploadViewState extends State<ShareUploadView> {
: currentPath.split('/').where((s) => s.isNotEmpty).last; : currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.items; final items = provider.items;
return Scaffold( // Mirrors FilesView's own controls-row + breadcrumbs sticky header
appBar: AppBar( // exactly (padding, heights) so this reads as the same browser, just
title: Text( // reached from a share intent instead of the Files tab.
widget.files.length == 1 final controlsColumn = Padding(
? 'Upload ${widget.files.first.name}' padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
: 'Upload ${widget.files.length} files', child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
),
body: Column(
children: [ children: [
Padding( _buildControlsRow(provider),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), if (hasBreadcrumbs) ...[
child: _buildControlsRow(provider), const SizedBox(height: 10),
), SizedBox(
if (hasBreadcrumbs) height: 32,
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Breadcrumbs( child: Breadcrumbs(
pathStack: provider.pathStack, pathStack: provider.pathStack,
onTap: (index) => provider.navigateToPathIndex(index), 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), final contentSlivers = <Widget>[
child: FilledButton.icon( SliverPersistentHeader(
onPressed: () => _uploadHere(provider), pinned: true,
icon: const Icon(Icons.upload_rounded), delegate: StickyHeaderDelegate(
label: Text('Upload to $currentLabel'), 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'),
),
),
],
),
), ),
), ),
), ),
+16 -4
View File
@@ -73,13 +73,25 @@ class DetailsSheet extends StatelessWidget {
} }
} }
/// Icon/name/size/date row shared by [DetailsSheet] and the media viewer's /// Icon/name/size/date row shared by [DetailsSheet], the media viewer's
/// collapsed "peek" state (which shows just this, before the user drags the /// collapsed "peek" state (which shows just this, before the user drags the
/// sheet open far enough to reveal the tabs). /// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title
/// (so every per-item bottom sheet opens on the same header instead of
/// [ShareSheet] alone using a bare title `Text`).
class DetailsHeader extends StatelessWidget { class DetailsHeader extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
const DetailsHeader({super.key, required this.item}); /// Defaults to this widget's own standalone inset ([DetailsSheet]); a
/// caller whose surrounding scroll view already applies horizontal
/// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with
/// zero left/right to avoid doubling it up.
final EdgeInsetsGeometry padding;
const DetailsHeader({
super.key,
required this.item,
this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8),
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -87,7 +99,7 @@ class DetailsHeader extends StatelessWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), padding: padding,
child: Row( child: Row(
children: [ children: [
Container( Container(
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:marquee/marquee.dart';
/// A plain, single-line ellipsized [Text] for content that fits, or an
/// auto-scrolling [Marquee] for text too long for the available width -
/// measured once via [TextPainter] rather than always marqueeing, so short
/// text just sits still like normal.
class MarqueeTitle extends StatelessWidget {
final String text;
final TextStyle? style;
const MarqueeTitle({super.key, 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,
),
);
},
);
}
}
+7
View File
@@ -15,6 +15,13 @@ class MoreTabsButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>(); final provider = context.watch<ServerProvider>();
// While picking (another app's GET_CONTENT request), MainShellView
// already restricts the bottom nav to Files/Photos - this button would
// otherwise be the one remaining way to reach a tab that isn't a valid
// pick source (Trash, Shares, ...), so it hides entirely rather than
// just disabling.
if (provider.isPicking) return const SizedBox.shrink();
return PopupMenuButton<AppTab>( return PopupMenuButton<AppTab>(
icon: const Icon(Icons.apps_rounded), icon: const Icon(Icons.apps_rounded),
tooltip: 'More tabs', tooltip: 'More tabs',
+15 -12
View File
@@ -5,6 +5,7 @@ import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart'; import '../models/nextcloud_sharee.dart';
import '../providers/server_provider.dart'; import '../providers/server_provider.dart';
import 'details/details_sheet.dart' show DetailsHeader;
import 'gradual_bottom_sheet.dart'; import 'gradual_bottom_sheet.dart';
IconData _shareTypeIcon(ShareType type) { IconData _shareTypeIcon(ShareType type) {
@@ -217,12 +218,15 @@ class _ShareSheetState extends State<ShareSheet> {
} }
Widget _sectionHeader(String label) { Widget _sectionHeader(String label) {
final colorScheme = Theme.of(context).colorScheme; final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row( return Row(
children: [ children: [
Text( Text(
label, label,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(
@@ -301,17 +305,16 @@ class _ShareSheetState extends State<ShareSheet> {
return ListView( return ListView(
controller: widget.scrollController, controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
children: [ children: [
Text( // Same icon/name/meta header as DetailsSheet, so this and its
widget.item.name, // sibling per-item bottom sheet open on identical chrome - just a
maxLines: 1, // bare title `Text` here read as under-designed by comparison.
overflow: TextOverflow.ellipsis, DetailsHeader(
style: theme.textTheme.titleMedium?.copyWith( item: widget.item,
fontWeight: FontWeight.w700, padding: const EdgeInsets.only(top: 12, bottom: 8),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
_sectionHeader('Internal shares'), _sectionHeader('Internal shares'),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
@@ -380,7 +383,7 @@ class _ShareSheetState extends State<ShareSheet> {
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Type an email', hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_outlined), prefixIcon: const Icon(Icons.email_rounded),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true, filled: true,
fillColor: colorScheme.surfaceContainerLow, fillColor: colorScheme.surfaceContainerLow,
+31 -11
View File
@@ -41,6 +41,15 @@ class SyncedHeaderScaffold extends StatefulWidget {
/// `provider.fetchTrash` for a tab backed by different data. /// `provider.fetchTrash` for a tab backed by different data.
final Future<void> Function()? onRefresh; final Future<void> Function()? onRefresh;
/// When non-null (a tab is mid-selection), this takes over the pinned top
/// bar entirely - replacing the sync-status chip, [actions], and the
/// pull-to-reveal quota panel - instead of appearing as a second app bar
/// further down in [contentSlivers]. Selection is a modal-ish state (you
/// came here to act on specific items), so it reads better as the one
/// thing at the very top than as a strip sandwiched under the normal
/// chrome.
final Widget? selectionBar;
const SyncedHeaderScaffold({ const SyncedHeaderScaffold({
super.key, super.key,
required this.scrollController, required this.scrollController,
@@ -48,6 +57,7 @@ class SyncedHeaderScaffold extends StatefulWidget {
required this.actions, required this.actions,
required this.contentSlivers, required this.contentSlivers,
this.onRefresh, this.onRefresh,
this.selectionBar,
}); });
@override @override
@@ -175,22 +185,32 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
pinned: true, pinned: true,
stretch: true, stretch: widget.selectionBar == null,
expandedHeight: _headerLocked ? 190 : kToolbarHeight, expandedHeight: widget.selectionBar != null
? kToolbarHeight
: (_headerLocked ? 190 : kToolbarHeight),
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer, backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer, surfaceTintColor: colorScheme.surfaceContainer,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leadingWidth: _headerLocked ? 56 : 160, leadingWidth: widget.selectionBar != null
leading: leadingWidget, ? 0
actions: widget.actions, : (_headerLocked ? 56 : 160),
flexibleSpace: FlexibleSpaceBar( leading: widget.selectionBar != null ? null : leadingWidget,
background: _SyncedStretchPanel( titleSpacing: widget.selectionBar != null ? 0 : null,
provider: provider, title: widget.selectionBar,
forceVisible: _headerLocked, actions: widget.selectionBar != null
), ? const []
), : widget.actions,
flexibleSpace: widget.selectionBar != null
? null
: FlexibleSpaceBar(
background: _SyncedStretchPanel(
provider: provider,
forceVisible: _headerLocked,
),
),
), ),
DecoratedSliver( DecoratedSliver(
decoration: BoxDecoration( decoration: BoxDecoration(