diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index af32f91..ddf4def 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -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 this isn't the `receive_sharing_intent` plugin): both `getInitialShare()` (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 `CustomScrollView` with a pull-down "sync status" header (Google Photos-style) and a classic Material refresh spinner shown during a -pull-triggered sync. `SearchView`, `AccountView` (Settings), the -file-details sheet, the share sheet, and `ShareUploadView` (the -share-to-upload destination picker) are pushed on top via +pull-triggered sync. `ShareUploadView` mirrors the Files tab's own +controls-row/breadcrumbs sticky header almost exactly, so arriving 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 being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on 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. diff --git a/.claude/context/server.md b/.claude/context/server.md index 3674214..b4909cc 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -26,6 +26,15 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart): Chrome-autofill (Android's own system Autofill framework, e.g. a password manager, may still work in the WebView; Chrome's own 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 2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in `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 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//` + 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 [`AccountStore`](../../lib/services/account_store.dart) owns everything diff --git a/.claude/context/styling.md b/.claude/context/styling.md index a71d9d3..f9e14c7 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -42,6 +42,14 @@ widgets. Key points: `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 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) — the blurred/translucent pill background shared by all floating chrome (bottom nav bar, media-viewer top/bottom bars and video transport diff --git a/android/.gitignore b/android/.gitignore index be3943c..93e964b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -4,6 +4,7 @@ gradle-wrapper.jar /gradlew /gradlew.bat /local.properties +/build/ GeneratedPluginRegistrant.java .cxx/ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eefa75a..5869d8f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,11 +49,34 @@ + + + + + + + + + + + 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) { @@ -82,6 +112,10 @@ class MainActivity : FlutterFragmentActivity() { if (shared.isNotEmpty()) { 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 @@ -180,4 +214,73 @@ class MainActivity : FlutterFragmentActivity() { ContextCompat.startForegroundService(this, serviceIntent) 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? { + 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>("paths") + val mimeTypes = call.argument>("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() + } } diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..2494e8f --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/lib/main.dart b/lib/main.dart index 695116f..d4b43bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,9 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'models/app_tab.dart'; +import 'models/pick_request.dart'; import 'providers/server_provider.dart'; +import 'services/pick_intent_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'views/lock_screen_view.dart'; @@ -113,6 +115,7 @@ class _MainShellViewState extends State { late AppTab _currentTab; late final Map _scrollControllers; StreamSubscription>? _shareSub; + StreamSubscription? _pickSub; @override void initState() { @@ -133,6 +136,17 @@ class _MainShellViewState extends State { // file's actual bytes, so a large shared file can't block startup. ShareIntentService.getInitialShare().then(_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().setPickRequest(request); + } + }); + _pickSub = PickIntentService.onNewPickRequest.listen((request) { + if (mounted) context.read().setPickRequest(request); + }); } void _handleSharedFiles(List files) { @@ -151,6 +165,7 @@ class _MainShellViewState extends State { c.dispose(); } _shareSub?.cancel(); + _pickSub?.cancel(); super.dispose(); } @@ -169,7 +184,16 @@ class _MainShellViewState extends State { ); } - 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) ? _currentTab : (visible.isNotEmpty ? visible.first : AppTab.files); @@ -181,50 +205,91 @@ class _MainShellViewState extends State { .map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon)) .toList(); - return Scaffold( - body: Stack( - children: [ - IndexedStack( - index: selectedIndex, - children: visible - .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) - .toList(), - ), - // Each tab now renders its own sticky selection toolbar inline - // (right under its sort/filter row) instead of this shell - // swapping in a shared floating bar - the bottom nav stays put - // and usable regardless of selection state. - FloatingBottomNavBar( - selectedIndex: selectedIndex, - items: navItems, - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, - 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 PopScope( + // While picking, system back cancels the pick (and tells the caller + // nothing was chosen) instead of exiting the app from under it - + // there's no Navigator route to pop at this, the root screen. + canPop: pickRequest == null, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && pickRequest != null) provider.cancelPick(); + }, + child: Scaffold( + body: Stack( + children: [ + IndexedStack( + index: selectedIndex, + children: visible + .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) + .toList(), + ), + if (provider.isDownloadingForPick) const _PickingProgressOverlay(), + // Each tab now renders its own sticky selection toolbar inline + // (right under its sort/filter row) instead of this shell + // swapping in a shared floating bar - the bottom nav stays put + // and usable regardless of selection state. + FloatingBottomNavBar( + selectedIndex: selectedIndex, + items: navItems, + opacity: provider.bottomBarOpacity, + blurSigma: provider.bottomBarBlur, + 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; - }); - }, - onSearchTap: () { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const SearchView())); - }, + setState(() { + _currentTab = tappedTab; + }); + }, + onSearchTap: () { + Navigator.of( + 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...'), + ], + ), + ), ), - ], + ), ), ); } diff --git a/lib/models/pick_request.dart b/lib/models/pick_request.dart new file mode 100644 index 0000000..7915631 --- /dev/null +++ b/lib/models/pick_request.dart @@ -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 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; + } +} diff --git a/lib/models/selection_action.dart b/lib/models/selection_action.dart index b07da88..4ca4411 100644 --- a/lib/models/selection_action.dart +++ b/lib/models/selection_action.dart @@ -6,13 +6,11 @@ import 'package:flutter/material.dart'; class SelectionAction { final IconData icon; final String label; - final Color? color; final VoidCallback onTap; const SelectionAction({ required this.icon, required this.label, required this.onTap, - this.color, }); } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 932b051..744af87 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -1,18 +1,23 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; 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 '../models/app_tab.dart'; import '../models/nextcloud_file_version.dart'; import '../models/nextcloud_item.dart'; import '../models/nextcloud_share.dart'; import '../models/nextcloud_sharee.dart'; +import '../models/pick_request.dart'; import '../models/saved_account.dart'; import '../services/account_store.dart'; import '../services/app_lock_service.dart'; import '../services/login_flow_service.dart'; import '../services/nextcloud_service.dart'; +import '../services/pick_intent_service.dart'; import '../theme/app_theme.dart'; 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 // MainShellView the next time it builds, not a persisted preference. 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 _pathStack = ['/']; bool _isGridView = false; bool _showFavoritesOnlyFiles = false; @@ -296,6 +307,77 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { String get currentFolderPath => _currentFolderPath; 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 confirmPick(List 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 = []; + final mimeTypes = []; + 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 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 /// tapping a search result lands the user on the Files tab even if they /// opened search from somewhere else. @@ -690,9 +772,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { if (gen != _sessionGeneration) return false; _errorMessage = e.toString().replaceAll('Exception: ', ''); - // The stored app password for this account no longer works - drop it - // so it doesn't keep silently failing on every future restore/switch. - await _accountStore.deletePassword(account.id); + // Only drop the stored password on an actual auth rejection (401) - + // NextcloudService.testConnection also throws for network-level + // 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; @@ -908,15 +998,24 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { unawaited(fetchRecent()); } - /// Switches to an already-saved account. No-op if it's already active or - /// unknown. Gated behind login lock when [lockAccountSwitching] is on. - Future switchAccount(String accountId) async { - if (accountId == _activeAccountId) return; - if (!_accounts.any((a) => a.id == accountId)) return; + /// Switches to an already-saved account. No-op (returns true) if it's + /// already active and logged in; no-op (returns false) if unknown - but + /// if [accountId] is nominally "active" while [isLoggedIn] is false (a + /// session-restore that failed, e.g. transient network trouble at cold + /// 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 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')) { - return; + return false; } await _activateAccount(accountId); + return _isLoggedIn; } Future _cycleAccount(int direction) async { diff --git a/lib/services/pick_intent_service.dart b/lib/services/pick_intent_service.dart new file mode 100644 index 0000000..7781b5d --- /dev/null +++ b/lib/services/pick_intent_service.dart @@ -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 getPickRequest() async { + final result = await _methodChannel.invokeMethod>( + 'getPickRequest', + ); + return result == null ? null : PickRequest.fromMap(result); + } + + /// Emits whenever the app is asked to act as a picker while already + /// running. + static Stream get onNewPickRequest { + return _newPickChannel.receiveBroadcastStream().map( + (event) => PickRequest.fromMap(event as Map), + ); + } + + /// Hands the already-downloaded local files back to the caller and closes + /// the picker. [mimeTypes] is parallel to [localPaths]. + static Future finishPick( + List localPaths, + List mimeTypes, + ) { + return _methodChannel.invokeMethod('finishPick', { + 'paths': localPaths, + 'mimeTypes': mimeTypes, + }); + } + + /// Backs out of picking mode with no result, closing the picker. + static Future cancelPick() { + return _methodChannel.invokeMethod('cancelPick'); + } +} diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 3ce1491..ecff13e 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -102,8 +102,6 @@ class AccountView extends StatelessWidget { ], ), const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -188,62 +186,35 @@ class AccountView extends StatelessWidget { const Divider(height: 1), const SizedBox(height: 16), Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - Expanded( - child: OutlinedButton( - onPressed: () async { - await provider.refreshData(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Refreshed WebDAV data'), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text( - 'Refresh Cache', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13), - ), - ), + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh cached data', + onPressed: () async { + await provider.refreshData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Refreshed WebDAV data'), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: () => _handleLogout(context, provider), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.errorContainer, - foregroundColor: colorScheme.onErrorContainer, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text( - 'Logout', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13), - ), - ), + IconButton( + icon: const Icon(Icons.logout_rounded), + tooltip: 'Logout', + color: Colors.orange.shade700, + onPressed: () => _handleLogout(context, provider), ), - const SizedBox(width: 8), - Expanded( - child: FilledButton( - onPressed: () => - _confirmRemoveActive(context, provider), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text( - 'Remove', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13), - ), - ), + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Remove account', + color: colorScheme.error, + onPressed: () => + _confirmRemoveActive(context, provider), ), ], ), diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 0ff75d8..13a36c3 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; -import 'package:marquee/marquee.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -14,6 +13,7 @@ import '../models/nextcloud_item.dart'; import '../providers/server_provider.dart'; import '../widgets/details/details_sheet.dart'; import '../widgets/frosted_glass_container.dart'; +import '../widgets/marquee_title.dart'; import '../widgets/seek_bar_painter.dart'; import '../widgets/share_sheet.dart'; @@ -339,7 +339,7 @@ class _FileViewerScreenState extends State { ), const SizedBox(width: 4), Expanded( - child: _MarqueeTitle( + child: MarqueeTitle( text: _currentItem.name, style: Theme.of(context).textTheme.titleSmall ?.copyWith( @@ -562,53 +562,6 @@ class _ActionIconButton extends StatelessWidget { } } -/// The media viewer's title: a plain, single-line ellipsized [Text] for -/// names that fit, or an auto-scrolling [Marquee] for names too long for -/// the available width - measured once via [TextPainter] rather than -/// always marqueeing, so a short filename just sits still like normal. -class _MarqueeTitle extends StatelessWidget { - final String text; - final TextStyle? style; - - const _MarqueeTitle({required this.text, required this.style}); - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - final painter = TextPainter( - text: TextSpan(text: text, style: style), - maxLines: 1, - textDirection: Directionality.of(context), - )..layout(maxWidth: double.infinity); - - if (painter.width <= constraints.maxWidth) { - return Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: style, - ); - } - - return SizedBox( - height: painter.height, - child: Marquee( - text: text, - style: style, - blankSpace: 48, - velocity: 30, - startPadding: 0, - pauseAfterRound: const Duration(seconds: 1), - fadingEdgeStartFraction: 0.1, - fadingEdgeEndFraction: 0.15, - ), - ); - }, - ); - } -} - class _ImagePreview extends StatefulWidget { final String url; final Map headers; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 2a43c6e..5825245 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -97,10 +97,13 @@ class _FolderEnterAnimationState extends State<_FolderEnterAnimation> } } -class _FilesViewState extends State { +class _FilesViewState extends State + with SingleTickerProviderStateMixin { final Set _selectedIds = {}; int _lastPathDepth = 1; final ScrollController _controlsScrollController = ScrollController(); + final ScrollController _selectionActionsScrollController = ScrollController(); + final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -108,59 +111,135 @@ class _FilesViewState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback( - (_) => _playControlsScrollHint(), + (_) => _playScrollHint(_controlsScrollController), ); } - /// A one-shot hint that the controls row scrolls: nudges it a little to - /// the right and back, once, right after it first appears - a motion cue - /// instead of a persistent widget (a chevron badge, an edge fade) sitting - /// on top of the actual controls the whole time. No-ops if there's - /// nothing to scroll (row already fits). - Future _playControlsScrollHint() async { + /// A one-shot hint that a horizontally-scrollable row actually scrolls: + /// nudges it a little to the right and back, once - a motion cue instead + /// of a persistent widget (a chevron badge, an edge fade) sitting on top + /// of the actual controls the whole time. Shared by the controls row + /// (played once it first appears) and the selection actions row (played + /// the first time a selection starts, see `_toggleSelection`). No-ops if + /// there's nothing to scroll (row already fits). + Future _playScrollHint(ScrollController scrollController) async { // The delay lets the row's first frame (and its actual layout/max // scroll extent) settle before nudging it, and reads more like a // deliberate hint than something that happens to fire on load. await Future.delayed(const Duration(milliseconds: 500)); - if (!mounted || !_controlsScrollController.hasClients) return; - final maxExtent = _controlsScrollController.position.maxScrollExtent; + if (!mounted || !scrollController.hasClients) return; + final maxExtent = scrollController.position.maxScrollExtent; if (maxExtent <= 0) return; - await _controlsScrollController.animateTo( - maxExtent < 36 ? maxExtent : 36, - duration: const Duration(milliseconds: 320), - curve: Curves.easeOut, - ); - if (!mounted || !_controlsScrollController.hasClients) return; - await _controlsScrollController.animateTo( - 0, - duration: const Duration(milliseconds: 320), - curve: Curves.easeIn, + final double peak = maxExtent < 36 ? maxExtent : 36; + // Driven as a single controller (rather than two chained animateTo + // calls) with mirrored ease-in-out halves, so the motion decelerates + // smoothly into the peak and back out instead of visibly changing + // pace where the two legs meet. + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), ); + _scrollHintControllers.add(controller); + final hint = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 0.0, + end: peak, + ).chain(CurveTween(curve: Curves.easeInOutSine)), + weight: 50, + ), + TweenSequenceItem( + tween: Tween( + begin: peak, + end: 0.0, + ).chain(CurveTween(curve: Curves.easeInOutSine)), + weight: 50, + ), + ]).animate(controller); + void onTick() { + if (scrollController.hasClients) { + scrollController.jumpTo(hint.value); + } + } + + hint.addListener(onTick); + await controller.forward(); + hint.removeListener(onTick); + _scrollHintControllers.remove(controller); + controller.dispose(); } @override void dispose() { + for (final controller in _scrollHintControllers) { + controller.dispose(); + } _controlsScrollController.dispose(); + _selectionActionsScrollController.dispose(); super.dispose(); } void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); + final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + if (enteringSelection && _isSelecting) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _playScrollHint(_selectionActionsScrollController), + ); + } } void _clearSelection() { setState(_selectedIds.clear); } + /// Routes a tap on an item while Noo is acting as another app's picker: + /// folders are still browsable, a matching file either toggles selection + /// (multi-select requests) or immediately finishes the pick, and a + /// non-matching file (wrong mime type for the caller) is rejected. + void _handlePickTap( + BuildContext context, + ServerProvider provider, + NextcloudItem item, + ) { + if (item.isFolder) { + provider.navigateToFolder(item.path); + return; + } + if (!provider.itemMatchesPickFilter(item)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("This app can't accept this file type"), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + if (provider.pickRequest!.allowMultiple) { + _toggleSelection(item); + } else { + provider.confirmPick([item]); + } + } + /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items. List _buildSelectionActions( ServerProvider provider, List selected, ) { + if (provider.isPicking) { + return [ + SelectionAction( + icon: Icons.check_rounded, + label: 'Use ${selected.length} item(s)', + onTap: () => provider.confirmPick(selected), + ), + ]; + } return [ SelectionAction( icon: selected.every((i) => i.isFavorite) @@ -169,7 +248,6 @@ class _FilesViewState extends State { label: selected.every((i) => i.isFavorite) ? 'Remove from favorites' : 'Favorite', - color: Colors.red.shade400, onTap: () => _favoriteSelected(provider, selected), ), SelectionAction( @@ -187,7 +265,6 @@ class _FilesViewState extends State { SelectionAction( icon: Icons.delete_outline_rounded, label: 'Delete', - color: Theme.of(context).colorScheme.error, onTap: () => _confirmDeleteSelected(context, provider, selected), ), if (selected.length == 1) @@ -469,8 +546,9 @@ class _FilesViewState extends State { ); final List contentSlivers = [ - // Sticky while browsing; once selecting, the toolbar below takes over - // the pinned slot and this is free to scroll away. + // Sticky while browsing; once selecting, the selection bar takes over + // the very top of the screen instead (see `selectionBar` below), so + // this is free to scroll away rather than staying pinned under it. SliverPersistentHeader( pinned: !_isSelecting, delegate: StickyHeaderDelegate( @@ -478,56 +556,6 @@ class _FilesViewState extends State { child: controlsRow, ), ), - if (_isSelecting) - SliverAppBar( - pinned: true, - // This isn't the scroll view's topmost sliver - the Synced - // header above it already reserves status-bar space, so leaving - // this at its default `primary: true` double-reserves it too, - // showing up as a big empty gap above the toolbar. - primary: false, - automaticallyImplyLeading: false, - toolbarHeight: 48, - // No leading/actions slots - everything lives in `title`, wrapped - // in the exact same 16px horizontal Padding as the controls row - // above it, so the two rows' content lines up edge-to-edge - // instead of relying on SliverAppBar's own leadingWidth/actions - // insets (which don't match the controls row's). - titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close_rounded, size: 20), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${selectedItems.length} selected', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - for (final action in _buildSelectionActions( - provider, - selectedItems, - )) - IconButton( - icon: Icon(action.icon, size: 20, color: action.color), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - // Files List / Grid if (provider.isLoading) const SliverFillRemaining( @@ -669,11 +697,81 @@ class _FilesViewState extends State { const MoreTabsButton(), const ProfileAvatarButton(), ], + selectionBar: _isSelecting + ? _buildSelectionBar(context, theme, provider, selectedItems) + : null, contentSlivers: contentSlivers, ), ); } + /// Replaces the top bar entirely while selecting (see + /// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N + /// selected" count, and the horizontally-scrollable bulk actions. + Widget _buildSelectionBar( + BuildContext context, + ThemeData theme, + ServerProvider provider, + List 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 _favoriteSelected( ServerProvider provider, List items, @@ -795,7 +893,9 @@ class _FilesViewState extends State { : colorScheme.surfaceContainerLow, child: InkWell( onTap: () { - if (_isSelecting) { + if (provider.isPicking) { + _handlePickTap(context, provider, item); + } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { provider.navigateToFolder(item.path); @@ -803,7 +903,9 @@ class _FilesViewState extends State { _openFile(context, item, provider); } }, - onLongPress: () => _toggleSelection(item), + onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + ? null + : () => _toggleSelection(item), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( @@ -850,7 +952,7 @@ class _FilesViewState extends State { ), ); - final content = _isSelecting + final content = _isSelecting || provider.isPicking ? card : SwipeableItem( itemKey: ValueKey('file-${item.id}'), @@ -901,7 +1003,9 @@ class _FilesViewState extends State { clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - if (_isSelecting) { + if (provider.isPicking) { + _handlePickTap(context, provider, item); + } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { provider.navigateToFolder(item.path); @@ -909,7 +1013,9 @@ class _FilesViewState extends State { _openFile(context, item, provider); } }, - onLongPress: () => _toggleSelection(item), + onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + ? null + : () => _toggleSelection(item), child: SelectableThumbnail( isSelected: isSelected, checkmarkSize: 32, diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index df69ddc..09ac32c 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -47,6 +47,60 @@ class _LoginViewState extends State { ); } + /// 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 _continueAsAccount(SavedAccount account) async { + final provider = context.read(); + 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 _confirmRemoveAccount(SavedAccount account) async { + final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl; + final confirmed = await showDialog( + 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().removeAccount(account.id); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -81,9 +135,21 @@ class _LoginViewState extends State { final url = provider.pendingLoginUrl!; WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => LoginWebViewView(url: url))); + // A plain MaterialPageRoute's animated slide/fade transition can + // leave this screen fully black after popping back off of it - a + // known Android WebView-as-PlatformView compositing issue with the + // Impeller renderer (the platform view's surface isn't always torn + // down in sync with an animated route transition). An instant, + // non-animated route sidesteps it entirely; the close (X) button + // already reads like a immediate action, not something that needs + // a slide, so no UX is lost. + await Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (_, _, _) => LoginWebViewView(url: url), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ); _webViewPushed = false; }); } @@ -110,7 +176,8 @@ class _LoginViewState extends State { provider.accounts.isNotEmpty) ...[ _SavedAccountsSection( accounts: provider.accounts, - onSelect: (account) => provider.switchAccount(account.id), + onSelect: (account) => _continueAsAccount(account), + onRemove: (account) => _confirmRemoveAccount(account), ), const SizedBox(height: 28), Row( @@ -329,8 +396,13 @@ class _ServerForm extends StatelessWidget { class _SavedAccountsSection extends StatelessWidget { final List accounts; final ValueChanged onSelect; + final ValueChanged onRemove; - const _SavedAccountsSection({required this.accounts, required this.onSelect}); + const _SavedAccountsSection({ + required this.accounts, + required this.onSelect, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -358,6 +430,7 @@ class _SavedAccountsSection extends StatelessWidget { _SavedAccountRow( account: account, onTap: () => onSelect(account), + onRemove: () => onRemove(account), ), if (account != accounts.last) const Divider(height: 1, indent: 16, endIndent: 16), @@ -373,8 +446,13 @@ class _SavedAccountsSection extends StatelessWidget { class _SavedAccountRow extends StatelessWidget { final SavedAccount account; final VoidCallback onTap; + final VoidCallback onRemove; - const _SavedAccountRow({required this.account, required this.onTap}); + const _SavedAccountRow({ + required this.account, + required this.onTap, + required this.onRemove, + }); @override Widget build(BuildContext context) { @@ -397,7 +475,12 @@ class _SavedAccountRow extends StatelessWidget { ), title: Text(account.username), subtitle: Text(host), - trailing: const Icon(Icons.chevron_right_rounded), + trailing: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Remove account', + visualDensity: VisualDensity.compact, + onPressed: onRemove, + ), onTap: onTap, ); } diff --git a/lib/views/login_webview_view.dart b/lib/views/login_webview_view.dart index ce544d6..77a173e 100644 --- a/lib/views/login_webview_view.dart +++ b/lib/views/login_webview_view.dart @@ -42,8 +42,17 @@ class _LoginWebViewViewState extends State { 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() { diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index cb7ffa6..cf3ba73 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -29,29 +29,123 @@ class PhotosView extends StatefulWidget { State createState() => _PhotosViewState(); } -class _PhotosViewState extends State { +class _PhotosViewState extends State + with SingleTickerProviderStateMixin { bool _requested = false; final Set _selectedIds = {}; + final ScrollController _selectionActionsScrollController = ScrollController(); + final List _scrollHintControllers = []; bool get _isSelecting => _selectedIds.isNotEmpty; void _toggleSelection(NextcloudItem item) { HapticFeedback.selectionClick(); + final enteringSelection = _selectedIds.isEmpty; setState(() { if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id); }); + if (enteringSelection && _isSelecting) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _playScrollHint(_selectionActionsScrollController), + ); + } } void _clearSelection() { setState(_selectedIds.clear); } + /// A one-shot hint that the selection actions row actually scrolls - + /// mirrors `FilesView`'s identical controls-row hint (see its doc + /// comment): nudges it right and back, once, the first time a selection + /// starts. No-ops if there's nothing to scroll (row already fits). + Future _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([ + TweenSequenceItem( + tween: Tween( + begin: 0.0, + end: peak, + ).chain(CurveTween(curve: Curves.easeInOutSine)), + weight: 50, + ), + TweenSequenceItem( + tween: Tween( + begin: peak, + end: 0.0, + ).chain(CurveTween(curve: Curves.easeInOutSine)), + weight: 50, + ), + ]).animate(controller); + void onTick() { + if (scrollController.hasClients) { + scrollController.jumpTo(hint.value); + } + } + + hint.addListener(onTick); + await controller.forward(); + hint.removeListener(onTick); + _scrollHintControllers.remove(controller); + controller.dispose(); + } + + @override + void dispose() { + for (final controller in _scrollHintControllers) { + controller.dispose(); + } + _selectionActionsScrollController.dispose(); + super.dispose(); + } + + /// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is + /// just the matching/toggle/immediate-confirm branch. + void _handlePickTap( + BuildContext context, + ServerProvider provider, + NextcloudItem item, + ) { + if (!provider.itemMatchesPickFilter(item)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("This app can't accept this file type"), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + if (provider.pickRequest!.allowMultiple) { + _toggleSelection(item); + } else { + provider.confirmPick([item]); + } + } + /// The bulk actions shown in the sticky selection toolbar for the /// currently-selected items. List _buildSelectionActions( ServerProvider provider, List selected, ) { + if (provider.isPicking) { + return [ + SelectionAction( + icon: Icons.check_rounded, + label: 'Use ${selected.length} item(s)', + onTap: () => provider.confirmPick(selected), + ), + ]; + } return [ SelectionAction( icon: selected.every((i) => i.isFavorite) @@ -60,7 +154,6 @@ class _PhotosViewState extends State { label: selected.every((i) => i.isFavorite) ? 'Remove from favorites' : 'Favorite', - color: Colors.red.shade400, onTap: () => _favoriteSelected(provider, selected), ), SelectionAction( @@ -78,7 +171,6 @@ class _PhotosViewState extends State { SelectionAction( icon: Icons.delete_outline_rounded, label: 'Delete', - color: Theme.of(context).colorScheme.error, onTap: () => _confirmDeleteSelected(context, provider, selected), ), if (selected.length == 1) @@ -173,61 +265,13 @@ class _PhotosViewState extends State { ); final List contentSlivers = [ - // Sticky while browsing; once selecting, the toolbar below takes over - // the pinned slot and this is free to scroll away. + // Sticky while browsing; once selecting, the selection bar takes over + // the very top of the screen instead (see `selectionBar` below), so + // this is free to scroll away rather than staying pinned under it. SliverPersistentHeader( pinned: !_isSelecting, delegate: StickyHeaderDelegate(height: 60, child: controlsRow), ), - if (_isSelecting) - SliverAppBar( - pinned: true, - // This isn't the scroll view's topmost sliver - the Synced - // header above it already reserves status-bar space, so leaving - // this at its default `primary: true` double-reserves it too, - // showing up as a big empty gap above the toolbar. - primary: false, - automaticallyImplyLeading: false, - toolbarHeight: 48, - // No leading/actions slots - everything lives in `title`, wrapped - // in the exact same 16px horizontal Padding as the controls row - // above it, so the two rows' content lines up edge-to-edge - // instead of relying on SliverAppBar's own leadingWidth/actions - // insets (which don't match the controls row's). - titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close_rounded, size: 20), - tooltip: 'Cancel selection', - onPressed: _clearSelection, - visualDensity: VisualDensity.compact, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${selectedItems.length} selected', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - for (final action in _buildSelectionActions( - provider, - selectedItems, - )) - IconButton( - icon: Icon(action.icon, size: 20, color: action.color), - tooltip: action.label, - onPressed: action.onTap, - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), const SliverToBoxAdapter(child: SizedBox(height: 8)), if (provider.isMediaLoading && photos.isEmpty) @@ -335,11 +379,81 @@ class _PhotosViewState extends State { provider: provider, actions: const [MoreTabsButton(), ProfileAvatarButton()], onRefresh: provider.fetchAllMedia, + selectionBar: _isSelecting + ? _buildSelectionBar(context, theme, provider, selectedItems) + : null, contentSlivers: contentSlivers, ), ); } + /// Replaces the top bar entirely while selecting (see + /// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N + /// selected" count, and the horizontally-scrollable bulk actions. + Widget _buildSelectionBar( + BuildContext context, + ThemeData theme, + ServerProvider provider, + List selectedItems, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + tooltip: 'Cancel selection', + onPressed: _clearSelection, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 8), + Text( + '${selectedItems.length} selected', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + Expanded( + child: SingleChildScrollView( + controller: _selectionActionsScrollController, + scrollDirection: Axis.horizontal, + // Left-aligned (not anchored to the trailing edge) so the + // first action's left edge sits at a fixed spot - lining up + // with the controls row's own first icon directly below it - + // regardless of how many actions there are. + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final action in _buildSelectionActions( + provider, + selectedItems, + )) + IconButton( + icon: Icon(action.icon, size: 20), + tooltip: action.label, + onPressed: action.onTap, + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ), + ], + ), + ); + } + Widget _buildPhotoTile( BuildContext context, NextcloudItem photo, @@ -353,13 +467,17 @@ class _PhotosViewState extends State { // floating rounded corner mid-drag (see the same fix in files_view). final tile = GestureDetector( onTap: () { - if (_isSelecting) { + if (provider.isPicking) { + _handlePickTap(context, provider, photo); + } else if (_isSelecting) { _toggleSelection(photo); } else { _openLightbox(context, photo, provider); } }, - onLongPress: () => _toggleSelection(photo), + onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple + ? null + : () => _toggleSelection(photo), child: ColoredBox( color: colorScheme.surfaceContainerHigh, child: SelectableThumbnail( diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 59bdff9..f35b0c6 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -9,9 +9,13 @@ import '../services/share_intent_service.dart'; import '../services/upload_service.dart'; import '../widgets/breadcrumbs.dart'; import '../widgets/item_icon.dart'; +import '../widgets/marquee_title.dart'; +import '../widgets/more_tabs_button.dart'; +import '../widgets/profile_avatar_button.dart'; import '../widgets/segmented_icon_toggle.dart'; import '../widgets/sort_menu_button.dart'; -import '../widgets/synced_header_scaffold.dart' show formatBytes; +import '../widgets/sticky_header_delegate.dart'; +import '../widgets/synced_header_scaffold.dart'; /// Shown when another app shares one or more files to Noo (Android's /// "Share to..." sheet). Lets the user browse to a destination folder, @@ -32,6 +36,8 @@ class ShareUploadView extends StatefulWidget { } class _ShareUploadViewState extends State { + final ScrollController _scrollController = ScrollController(); + @override void initState() { super.initState(); @@ -42,6 +48,12 @@ class _ShareUploadViewState extends State { }); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + Future _uploadHere(ServerProvider provider) async { final messenger = ScaffoldMessenger.of(context); final navigator = Navigator.of(context); @@ -332,74 +344,171 @@ class _ShareUploadViewState extends State { : currentPath.split('/').where((s) => s.isNotEmpty).last; final items = provider.items; - return Scaffold( - appBar: AppBar( - title: Text( - widget.files.length == 1 - ? 'Upload ${widget.files.first.name}' - : 'Upload ${widget.files.length} files', - ), - ), - body: Column( + // Mirrors FilesView's own controls-row + breadcrumbs sticky header + // exactly (padding, heights) so this reads as the same browser, just + // reached from a share intent instead of the Files tab. + final controlsColumn = Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: _buildControlsRow(provider), - ), - if (hasBreadcrumbs) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + _buildControlsRow(provider), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( + height: 32, child: Breadcrumbs( pathStack: provider.pathStack, onTap: (index) => provider.navigateToPathIndex(index), ), ), - Expanded( - child: provider.isLoading - ? const Center(child: CircularProgressIndicator()) - : items.isEmpty - ? Center( - child: Text( - 'Folder is empty', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ) - : provider.isGridView - ? GridView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1.1, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: items.length, - itemBuilder: (context, index) => - _buildGridCard(context, items[index], provider), - ) - : ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - itemCount: items.length, - itemBuilder: (context, index) => - _buildListTile(context, items[index], provider), - ), - ), + ], ], ), - bottomNavigationBar: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: FilledButton.icon( - onPressed: () => _uploadHere(provider), - icon: const Icon(Icons.upload_rounded), - label: Text('Upload to $currentLabel'), + ); + + final contentSlivers = [ + SliverPersistentHeader( + pinned: true, + delegate: StickyHeaderDelegate( + height: hasBreadcrumbs ? 114 : 72, + child: controlsColumn, + ), + ), + if (provider.isLoading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (items.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'Folder is empty', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + ) + else if (provider.isGridView) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 1.1, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return _buildGridCard(context, items[index], provider); + }, childCount: items.length), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return _buildListTile(context, items[index], provider); + }, childCount: items.length), + ), + ), + // So the last row isn't hidden behind the bottom "Upload to..." bar. + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ]; + + return Scaffold( + body: SyncedHeaderScaffold( + scrollController: _scrollController, + provider: provider, + // Same trailing actions as every other tab - no bespoke close + // button here, so the top chrome is identical regardless of how + // this screen was reached. Backing out is still the system + // back gesture/button, same as any other pushed screen. + actions: const [MoreTabsButton(), ProfileAvatarButton()], + contentSlivers: contentSlivers, + ), + // A rounded-top, elevated bar "peeking" up from the bottom edge - the + // uploading-file summary (marqueed if it doesn't fit on one line) + // sits directly above the destination button, both inside the one + // sheet, rather than the summary living up in the scrolling content + // far away from the action it describes. + bottomNavigationBar: Material( + color: colorScheme.surfaceContainerHigh, + elevation: 8, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Same pill/chip look and exact same duller background + // color as the filter toggles' shared background + // (SegmentedIconGroup's `surfaceContainerHigh`), so it reads + // as part of the same visual language rather than a new + // accent color. Sized to the text itself (like a real chip) + // up to the row's available width - MarqueeTitle needs a + // concrete (not just loose) width to know whether/how far + // to scroll, so this measures the text once up front rather + // than leaving the chip unconstrained. + LayoutBuilder( + builder: (context, constraints) { + final uploadingText = widget.files.length == 1 + ? 'Uploading ${widget.files.first.name}' + : 'Uploading ${widget.files.length} files'; + final chipTextStyle = theme.textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ); + const horizontalPadding = 28.0; + final painter = TextPainter( + text: TextSpan(text: uploadingText, style: chipTextStyle), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(maxWidth: double.infinity); + final chipWidth = (painter.width + horizontalPadding).clamp( + 0.0, + constraints.maxWidth, + ); + + return Container( + width: chipWidth, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: SizedBox( + height: 20, + child: MarqueeTitle( + text: uploadingText, + style: chipTextStyle, + ), + ), + ); + }, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _uploadHere(provider), + icon: const Icon(Icons.upload_rounded), + label: Text('Upload to $currentLabel'), + ), + ), + ], + ), ), ), ), diff --git a/lib/widgets/details/details_sheet.dart b/lib/widgets/details/details_sheet.dart index 9726499..6170887 100644 --- a/lib/widgets/details/details_sheet.dart +++ b/lib/widgets/details/details_sheet.dart @@ -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 -/// 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 { 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 Widget build(BuildContext context) { @@ -87,7 +99,7 @@ class DetailsHeader extends StatelessWidget { final colorScheme = theme.colorScheme; return Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + padding: padding, child: Row( children: [ Container( diff --git a/lib/widgets/marquee_title.dart b/lib/widgets/marquee_title.dart new file mode 100644 index 0000000..daa8971 --- /dev/null +++ b/lib/widgets/marquee_title.dart @@ -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, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/more_tabs_button.dart b/lib/widgets/more_tabs_button.dart index 3940090..57d9755 100644 --- a/lib/widgets/more_tabs_button.dart +++ b/lib/widgets/more_tabs_button.dart @@ -15,6 +15,13 @@ class MoreTabsButton extends StatelessWidget { Widget build(BuildContext context) { final provider = context.watch(); + // 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( icon: const Icon(Icons.apps_rounded), tooltip: 'More tabs', diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index a9267a7..4dcb67c 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -5,6 +5,7 @@ import '../models/nextcloud_item.dart'; import '../models/nextcloud_share.dart'; import '../models/nextcloud_sharee.dart'; import '../providers/server_provider.dart'; +import 'details/details_sheet.dart' show DetailsHeader; import 'gradual_bottom_sheet.dart'; IconData _shareTypeIcon(ShareType type) { @@ -217,12 +218,15 @@ class _ShareSheetState extends State { } Widget _sectionHeader(String label) { - final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Row( children: [ Text( label, - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(width: 6), Icon( @@ -301,17 +305,16 @@ class _ShareSheetState extends State { return ListView( controller: widget.scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), children: [ - Text( - widget.item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + // Same icon/name/meta header as DetailsSheet, so this and its + // sibling per-item bottom sheet open on identical chrome - just a + // bare title `Text` here read as under-designed by comparison. + DetailsHeader( + item: widget.item, + padding: const EdgeInsets.only(top: 12, bottom: 8), ), - const SizedBox(height: 16), + const SizedBox(height: 8), _sectionHeader('Internal shares'), const SizedBox(height: 8), TextField( @@ -380,7 +383,7 @@ class _ShareSheetState extends State { keyboardType: TextInputType.emailAddress, decoration: InputDecoration( hintText: 'Type an email', - prefixIcon: const Icon(Icons.email_outlined), + prefixIcon: const Icon(Icons.email_rounded), border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), filled: true, fillColor: colorScheme.surfaceContainerLow, diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index 0a28e88..28628a7 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -41,6 +41,15 @@ class SyncedHeaderScaffold extends StatefulWidget { /// `provider.fetchTrash` for a tab backed by different data. final Future 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({ super.key, required this.scrollController, @@ -48,6 +57,7 @@ class SyncedHeaderScaffold extends StatefulWidget { required this.actions, required this.contentSlivers, this.onRefresh, + this.selectionBar, }); @override @@ -175,22 +185,32 @@ class _SyncedHeaderScaffoldState extends State { slivers: [ SliverAppBar( pinned: true, - stretch: true, - expandedHeight: _headerLocked ? 190 : kToolbarHeight, + stretch: widget.selectionBar == null, + expandedHeight: widget.selectionBar != null + ? kToolbarHeight + : (_headerLocked ? 190 : kToolbarHeight), collapsedHeight: kToolbarHeight, backgroundColor: colorScheme.surfaceContainer, surfaceTintColor: colorScheme.surfaceContainer, scrolledUnderElevation: 0, automaticallyImplyLeading: false, - leadingWidth: _headerLocked ? 56 : 160, - leading: leadingWidget, - actions: widget.actions, - flexibleSpace: FlexibleSpaceBar( - background: _SyncedStretchPanel( - provider: provider, - forceVisible: _headerLocked, - ), - ), + leadingWidth: widget.selectionBar != null + ? 0 + : (_headerLocked ? 56 : 160), + leading: widget.selectionBar != null ? null : leadingWidget, + titleSpacing: widget.selectionBar != null ? 0 : null, + title: widget.selectionBar, + actions: widget.selectionBar != null + ? const [] + : widget.actions, + flexibleSpace: widget.selectionBar != null + ? null + : FlexibleSpaceBar( + background: _SyncedStretchPanel( + provider: provider, + forceVisible: _headerLocked, + ), + ), ), DecoratedSliver( decoration: BoxDecoration(