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