From 5299ac5c2cd3940a23a796e8e898250c7de534cf Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 16 Sep 2026 11:05:54 -0400 Subject: [PATCH] Add share-to-upload, per-folder sort, and media viewer polish - Accept Android's "Share to..." intent (ACTION_SEND/SEND_MULTIPLE) to upload a shared file, with a new destination-folder picker screen - Unlink Files/Photos sort settings and make Files remember sort per folder (like per-folder view settings), instead of one shared global sort field for both tabs - Media viewer: show the file name (marquee-scrolling if too long) in the top bar next to a resized back button, slide the top bar out of view instead of fading it, and unify icon color/contrast across the top bar, bottom action bar, and video transport controls - Add a scrollbar thumb to every scrollable list in the app (all tab views via SyncedHeaderScaffold, search results, settings, the new upload folder picker, share sheet, and the Details sheet's tabs) Co-Authored-By: Claude Sonnet 5 --- android/app/src/main/AndroidManifest.xml | 14 +- android/build.gradle.kts | 15 + lib/main.dart | 31 + lib/providers/server_provider.dart | 107 ++- lib/views/account_view.dart | 706 +++++++++--------- lib/views/file_viewer_screen.dart | 131 +++- lib/views/files_view.dart | 12 +- lib/views/photos_view.dart | 12 +- lib/views/search_view.dart | 46 +- lib/views/share_upload_view.dart | 182 +++++ lib/widgets/details/details_activity_tab.dart | 152 ++-- lib/widgets/details/details_info_tab.dart | 59 +- lib/widgets/details/details_versions_tab.dart | 74 +- lib/widgets/share_sheet.dart | 191 ++--- lib/widgets/synced_header_scaffold.dart | 81 +- pubspec.lock | 24 + pubspec.yaml | 2 + 17 files changed, 1137 insertions(+), 702 deletions(-) create mode 100644 lib/views/share_upload_view.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4a84133..160942d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ + + + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..d95ee72 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -19,6 +19,21 @@ subprojects { project.evaluationDependsOn(":app") } +// receive_sharing_intent declares Java 11 for its Java sources but leaves +// its Kotlin compilation to default to whatever JDK runs the build - with +// JDK 17 that mismatch (Java 11 vs Kotlin 17) fails with "Inconsistent JVM +// Target Compatibility". Pin just this plugin's Kotlin target down to match +// its own declared Java target, rather than touching every subproject. +project(":receive_sharing_intent") { + afterEvaluate { + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + } +} + tasks.register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/lib/main.dart b/lib/main.dart index eb2eecf..ba28e5f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,14 @@ +import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'models/app_tab.dart'; import 'providers/server_provider.dart'; import 'theme/app_theme.dart'; import 'views/login_view.dart'; import 'views/search_view.dart'; +import 'views/share_upload_view.dart'; import 'widgets/app_tab_view_builder.dart'; import 'widgets/floating_bottom_bar.dart'; @@ -92,6 +95,7 @@ class MainShellView extends StatefulWidget { class _MainShellViewState extends State { late AppTab _currentTab; late final Map _scrollControllers; + StreamSubscription>? _shareSub; @override void initState() { @@ -103,6 +107,32 @@ class _MainShellViewState extends State { // session restore (which also has to hit the network) does, so by the // time this shell mounts `defaultTab` already reflects the saved value. _currentTab = context.read().defaultTab; + + // Handles both a cold start via another app's "Share to..." sheet + // (getInitialMedia) and a share arriving while the app is already + // running (getMediaStream) - the plugin guarantees the stream doesn't + // re-emit whatever getInitialMedia already returned. + ReceiveSharingIntent.instance.getInitialMedia().then(_handleSharedFiles); + _shareSub = ReceiveSharingIntent.instance.getMediaStream().listen( + _handleSharedFiles, + ); + } + + void _handleSharedFiles(List files) { + final uploadable = files + .where( + (f) => + f.type != SharedMediaType.text && f.type != SharedMediaType.url, + ) + .toList(); + if (uploadable.isEmpty) return; + ReceiveSharingIntent.instance.reset(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ShareUploadView(files: uploadable)), + ); + }); } @override @@ -110,6 +140,7 @@ class _MainShellViewState extends State { for (final c in _scrollControllers.values) { c.dispose(); } + _shareSub?.cancel(); super.dispose(); } diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 1b4ad0a..40d2812 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; @@ -76,6 +77,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; static const _prefSortField = 'ui_sort_field'; static const _prefSortAscending = 'ui_sort_ascending'; + static const _prefFolderSort = 'ui_folder_sort'; static const _prefTabOrder = 'ui_tab_order'; static const _prefHiddenTabs = 'ui_hidden_tabs'; static const _prefDefaultTab = 'ui_default_tab'; @@ -130,8 +132,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { StorageScope _storageScope = StorageScope.cloud; bool _showHiddenFiles = false; bool _showHiddenPhotos = false; - FileSortField _sortField = FileSortField.name; - bool _sortAscending = true; + // Photos tab sort - a single global setting (Photos has no folder concept, + // it spans the whole account). + FileSortField _photosSortField = FileSortField.name; + bool _photosSortAscending = true; + + // Files tab sort - unlinked from Photos and remembered per folder path + // (like Windows Explorer's per-folder view settings), so switching + // folders can restore a different sort than the parent. Falls back to + // name/ascending for any folder with no saved entry. + final Map _folderSortField = {}; + final Map _folderSortAscending = {}; // Bottom nav tab configuration List _tabOrder = AppTab.values.toList(); @@ -248,8 +259,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { StorageScope get storageScope => _storageScope; bool get showHiddenFiles => _showHiddenFiles; bool get showHiddenPhotos => _showHiddenPhotos; - FileSortField get sortField => _sortField; - bool get sortAscending => _sortAscending; + FileSortField get photosSortField => _photosSortField; + bool get photosSortAscending => _photosSortAscending; + FileSortField get filesSortField => + _folderSortField[_currentFolderPath] ?? FileSortField.name; + bool get filesSortAscending => + _folderSortAscending[_currentFolderPath] ?? true; CachePolicy get cachePolicy => _cachePolicy; int get cacheIntervalMinutes => _cacheIntervalMinutes; @@ -270,8 +285,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { /// and trigger downloads/previews directly. NextcloudService? get service => _service; - int _compareItems(NextcloudItem a, NextcloudItem b) { - switch (_sortField) { + int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) { + switch (field) { case FileSortField.name: return a.name.toLowerCase().compareTo(b.name.toLowerCase()); case FileSortField.dateCreated: @@ -324,11 +339,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { showHidden: _showHiddenFiles, ); + final field = filesSortField; final folders = filtered.where((i) => i.isFolder).toList() - ..sort(_compareItems); + ..sort((a, b) => _compareItems(a, b, field)); final files = filtered.where((i) => !i.isFolder).toList() - ..sort(_compareItems); - return _sortAscending + ..sort((a, b) => _compareItems(a, b, field)); + return filesSortAscending ? [...folders, ...files] : [...folders.reversed, ...files.reversed]; } @@ -341,8 +357,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { media, showFavoritesOnly: _showFavoritesOnlyPhotos, showHidden: _showHiddenPhotos, - )..sort(_compareItems); - return _sortAscending ? filtered : filtered.reversed.toList(); + )..sort((a, b) => _compareItems(a, b, _photosSortField)); + return _photosSortAscending ? filtered : filtered.reversed.toList(); } bool get isMediaLoading => _isMediaLoading; @@ -428,12 +444,34 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos; final sortFieldName = prefs.getString(_prefSortField); if (sortFieldName != null) { - _sortField = FileSortField.values.firstWhere( + _photosSortField = FileSortField.values.firstWhere( (f) => f.name == sortFieldName, orElse: () => FileSortField.name, ); } - _sortAscending = prefs.getBool(_prefSortAscending) ?? _sortAscending; + _photosSortAscending = + prefs.getBool(_prefSortAscending) ?? _photosSortAscending; + + final folderSortJson = prefs.getString(_prefFolderSort); + if (folderSortJson != null) { + try { + final decoded = jsonDecode(folderSortJson) as Map; + for (final entry in decoded.entries) { + final value = entry.value as Map; + final fieldName = value['field'] as String?; + if (fieldName != null) { + _folderSortField[entry.key] = FileSortField.values.firstWhere( + (f) => f.name == fieldName, + orElse: () => FileSortField.name, + ); + } + final ascending = value['ascending'] as bool?; + if (ascending != null) _folderSortAscending[entry.key] = ascending; + } + } catch (e) { + debugPrint('[ServerProvider] Folder sort restore failed: $e'); + } + } final savedOrderNames = prefs.getStringList(_prefTabOrder); if (savedOrderNames != null) { @@ -879,17 +917,48 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - void setSortField(FileSortField field) { - if (_sortField == field) return; - _sortField = field; + void setPhotosSortField(FileSortField field) { + if (_photosSortField == field) return; + _photosSortField = field; notifyListeners(); _prefsFuture.then((p) => p.setString(_prefSortField, field.name)); } - void toggleSortOrder() { - _sortAscending = !_sortAscending; + void togglePhotosSortOrder() { + _photosSortAscending = !_photosSortAscending; notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefSortAscending, _sortAscending)); + _prefsFuture.then( + (p) => p.setBool(_prefSortAscending, _photosSortAscending), + ); + } + + void _persistFolderSort() { + final combined = {}; + for (final path in { + ..._folderSortField.keys, + ..._folderSortAscending.keys, + }) { + combined[path] = { + 'field': _folderSortField[path]?.name, + 'ascending': _folderSortAscending[path], + }; + } + _prefsFuture.then( + (p) => p.setString(_prefFolderSort, jsonEncode(combined)), + ); + } + + void setFilesSortField(FileSortField field) { + if (filesSortField == field) return; + _folderSortField[_currentFolderPath] = field; + notifyListeners(); + _persistFolderSort(); + } + + void toggleFilesSortOrder() { + _folderSortAscending[_currentFolderPath] = !filesSortAscending; + notifyListeners(); + _persistFolderSort(); } void setTabOrder(List order) { diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 0981e81..9fbb06b 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -35,398 +35,400 @@ class AccountView extends StatelessWidget { ], ), body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), - physics: const BouncingScrollPhysics(), - children: [ - Text( - 'Connected to ${provider.serverUrl}', - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 20), - - // User Profile & Storage Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(28), - border: Border.all( - color: colorScheme.primary.withValues(alpha: 0.2), + child: Scrollbar( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), + physics: const BouncingScrollPhysics(), + children: [ + Text( + 'Connected to ${provider.serverUrl}', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: colorScheme.primary, - child: Text( - (quota?.userName ?? provider.username) - .substring(0, 1) - .toUpperCase(), - style: theme.textTheme.headlineSmall?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - quota?.userName ?? provider.username, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - quota?.email ?? provider.username, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: provider.isLoggedIn - ? Colors.green.withValues(alpha: 0.2) - : Colors.red.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - provider.isLoggedIn ? 'ONLINE' : 'OFFLINE', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: provider.isLoggedIn - ? Colors.green.shade800 - : Colors.red.shade800, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - const Divider(height: 1), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Storage Quota', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - quota != null - ? (quota.totalBytes > 0 - ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}' - : '${_formatBytes(quota.usedBytes)} (Unlimited)') - : 'Live Server Storage', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: quota != null && quota.totalBytes > 0 - ? quota.usagePercentage - : 0.1, - minHeight: 10, - backgroundColor: colorScheme.surfaceContainerHighest, - valueColor: AlwaysStoppedAnimation( - colorScheme.primary, - ), - ), - ), - const SizedBox(height: 8), - Text( - quota != null - ? (quota.totalBytes > 0 - ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' - : 'Unlimited Storage Plan') - : 'WebDAV connection active', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], - ), - ), - const SizedBox(height: 24), + const SizedBox(height: 20), - // Server Credentials Section - Text( - 'Server Connection Info', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.dns_rounded), - title: const Text('Nextcloud Host'), - subtitle: Text(provider.serverUrl), + // User Profile & Storage Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.2), ), - const Divider(height: 1, indent: 16, endIndent: 16), - ListTile( - leading: const Icon(Icons.person_rounded), - title: const Text('Logged In User'), - subtitle: Text(provider.username), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ListTile( - leading: const Icon(Icons.refresh_rounded), - title: const Text('Refresh WebDAV Cache'), - onTap: () async { - await provider.refreshData(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Refreshed WebDAV directory data'), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - ], - ), - ), - const SizedBox(height: 24), - - // Material You Design Settings - Text( - 'Material You Aesthetics', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Card( - child: Padding( - padding: const EdgeInsets.all(16), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Accent Color', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - provider.useDynamicColor - ? 'Matching your wallpaper' - : 'Custom color', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _AccentSwatch( - isSelected: provider.useDynamicColor, - onTap: () => provider.setUseDynamicColor(true), - borderColor: colorScheme.onSurface, - background: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.wallpaper_rounded, - color: colorScheme.onSurfaceVariant, - size: 20, + CircleAvatar( + radius: 28, + backgroundColor: colorScheme.primary, + child: Text( + (quota?.userName ?? provider.username) + .substring(0, 1) + .toUpperCase(), + style: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quota?.userName ?? provider.username, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + quota?.email ?? provider.username, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: provider.isLoggedIn + ? Colors.green.withValues(alpha: 0.2) + : Colors.red.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + provider.isLoggedIn ? 'ONLINE' : 'OFFLINE', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: provider.isLoggedIn + ? Colors.green.shade800 + : Colors.red.shade800, + ), ), ), - ...AppTheme.seedColors.map((color) { - final isSelected = - !provider.useDynamicColor && - provider.seedColor == color; - return _AccentSwatch( - isSelected: isSelected, - onTap: () => provider.setSeedColor(color), - borderColor: colorScheme.onSurface, - background: color, - child: isSelected - ? const Icon( - Icons.check_rounded, - color: Colors.white, - size: 20, - ) - : null, - ); - }), ], ), const SizedBox(height: 20), const Divider(height: 1), const SizedBox(height: 16), - Text( - 'Theme Mode', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Storage Quota', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + quota != null + ? (quota.totalBytes > 0 + ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}' + : '${_formatBytes(quota.usedBytes)} (Unlimited)') + : 'Live Server Storage', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ], ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.system, - icon: Icon(Icons.brightness_auto_rounded), - label: Text('System'), - ), - ButtonSegment( - value: ThemeMode.light, - icon: Icon(Icons.light_mode_rounded), - label: Text('Light'), - ), - ButtonSegment( - value: ThemeMode.dark, - icon: Icon(Icons.dark_mode_rounded), - label: Text('Dark'), - ), - ], - selected: {provider.themeMode}, - onSelectionChanged: (set) => - provider.setThemeMode(set.first), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: quota != null && quota.totalBytes > 0 + ? quota.usagePercentage + : 0.1, + minHeight: 10, + backgroundColor: colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), ), ), const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('AMOLED Black'), - subtitle: const Text( - 'Use pure black backgrounds in dark mode', + Text( + quota != null + ? (quota.totalBytes > 0 + ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used' + : 'Unlimited Storage Plan') + : 'WebDAV connection active', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, ), - value: provider.amoledDark, - onChanged: provider.setAmoledDark, ), ], ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // UI Settings - Text( - 'UI', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - const _BottomBarAppearanceCard(), - const SizedBox(height: 12), - Card( - child: SwitchListTile( - title: const Text('Tap Tab to Scroll to Top'), - subtitle: const Text( - 'Tapping the current bottom bar tab scrolls its list back to the top', + // Server Credentials Section + Text( + 'Server Connection Info', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), - value: provider.tapTabToScrollTop, - onChanged: provider.setTapTabToScrollTop, ), - ), - const SizedBox(height: 24), + const SizedBox(height: 10), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.dns_rounded), + title: const Text('Nextcloud Host'), + subtitle: Text(provider.serverUrl), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.person_rounded), + title: const Text('Logged In User'), + subtitle: Text(provider.username), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.refresh_rounded), + title: const Text('Refresh WebDAV Cache'), + onTap: () async { + await provider.refreshData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Refreshed WebDAV directory data'), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + ), + ], + ), + ), + const SizedBox(height: 24), - // Bottom Nav Tabs - Text( - 'Bottom Bar Tabs', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + // Material You Design Settings + Text( + 'Material You Aesthetics', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Drag to reorder, tap the star to set the default, toggle to show or hide', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 10), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Accent Color', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + provider.useDynamicColor + ? 'Matching your wallpaper' + : 'Custom color', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _AccentSwatch( + isSelected: provider.useDynamicColor, + onTap: () => provider.setUseDynamicColor(true), + borderColor: colorScheme.onSurface, + background: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.wallpaper_rounded, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ), + ...AppTheme.seedColors.map((color) { + final isSelected = + !provider.useDynamicColor && + provider.seedColor == color; + return _AccentSwatch( + isSelected: isSelected, + onTap: () => provider.setSeedColor(color), + borderColor: colorScheme.onSurface, + background: color, + child: isSelected + ? const Icon( + Icons.check_rounded, + color: Colors.white, + size: 20, + ) + : null, + ); + }), + ], + ), + const SizedBox(height: 20), + const Divider(height: 1), + const SizedBox(height: 16), + Text( + 'Theme Mode', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto_rounded), + label: Text('System'), + ), + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode_rounded), + label: Text('Light'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode_rounded), + label: Text('Dark'), + ), + ], + selected: {provider.themeMode}, + onSelectionChanged: (set) => + provider.setThemeMode(set.first), + ), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('AMOLED Black'), + subtitle: const Text( + 'Use pure black backgrounds in dark mode', + ), + value: provider.amoledDark, + onChanged: provider.setAmoledDark, + ), + ], + ), + ), ), - ), - const SizedBox(height: 10), - const _TabSettingsCard(), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Swipe Actions - Text( - 'Files Swipe Actions', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + // UI Settings + Text( + 'UI', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Choose what swiping a file left or right does in list view', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 10), + const _BottomBarAppearanceCard(), + const SizedBox(height: 12), + Card( + child: SwitchListTile( + title: const Text('Tap Tab to Scroll to Top'), + subtitle: const Text( + 'Tapping the current bottom bar tab scrolls its list back to the top', + ), + value: provider.tapTabToScrollTop, + onChanged: provider.setTapTabToScrollTop, + ), ), - ), - const SizedBox(height: 10), - const _SwipeActionsCard(), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Media Player - Text( - 'Media Player', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + // Bottom Nav Tabs + Text( + 'Bottom Bar Tabs', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Choose the seek bar style used when playing videos', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 4), + Text( + 'Drag to reorder, tap the star to set the default, toggle to show or hide', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(height: 10), - const _MediaPlayerCard(), - const SizedBox(height: 24), + const SizedBox(height: 10), + const _TabSettingsCard(), + const SizedBox(height: 24), - // Files Cache - Text( - 'Files Cache', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + // Swipe Actions + Text( + 'Files Swipe Actions', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 4), - Text( - 'Reuse a folder\'s listing instead of refetching it on every visit', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + const SizedBox(height: 4), + Text( + 'Choose what swiping a file left or right does in list view', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(height: 10), - const _CacheSettingsCard(), - ], + const SizedBox(height: 10), + const _SwipeActionsCard(), + const SizedBox(height: 24), + + // Media Player + Text( + 'Media Player', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Choose the seek bar style used when playing videos', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + const _MediaPlayerCard(), + const SizedBox(height: 24), + + // Files Cache + Text( + 'Files Cache', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Reuse a folder\'s listing instead of refetching it on every visit', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + const _CacheSettingsCard(), + ], + ), ), ), ); diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index f92a10e..0ff75d8 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -3,6 +3,7 @@ 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'; @@ -312,24 +313,43 @@ class _FileViewerScreenState extends State { ), ), ), - IgnorePointer( - ignoring: !_controlsVisible, - child: AnimatedOpacity( - opacity: _controlsVisible ? 1 : 0, - duration: const Duration(milliseconds: 200), + AnimatedSlide( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4), + child: IgnorePointer( + ignoring: !_controlsVisible, child: SafeArea( child: Padding( - padding: const EdgeInsets.only(left: 20, top: 12), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: FrostedGlassContainer( opacity: provider.bottomBarOpacity, blurSigma: provider.bottomBarBlur, - child: SizedBox( - width: 48, - height: 48, - child: IconButton( - icon: const Icon(Icons.arrow_back_rounded), - color: colorScheme.onSurfaceVariant, - onPressed: () => Navigator.pop(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 6, + ), + child: Row( + children: [ + _ActionIconButton( + icon: Icons.arrow_back_rounded, + tooltip: 'Back', + onTap: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: _MarqueeTitle( + text: _currentItem.name, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 10), + ], ), ), ), @@ -525,8 +545,8 @@ class _ActionIconButton extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final fg = onTap == null - ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) - : (color ?? colorScheme.onSurfaceVariant); + ? colorScheme.onSurface.withValues(alpha: 0.4) + : (color ?? colorScheme.onSurface); return Tooltip( message: tooltip, @@ -542,6 +562,53 @@ 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; @@ -770,7 +837,7 @@ class _VideoPreviewState extends State<_VideoPreview> { animation: controller, builder: (context, _) { final colorScheme = Theme.of(context).colorScheme; - final fg = colorScheme.onSurfaceVariant; + final fg = colorScheme.onSurface; // Same frosted-glass treatment (and user opacity/blur // settings) as the back button and media action bar, so the // transport controls match the rest of the app's chrome. @@ -788,14 +855,14 @@ class _VideoPreviewState extends State<_VideoPreview> { children: [ Row( children: [ - IconButton( - icon: Icon( - controller.value.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - color: fg, - ), - onPressed: () => controller.value.isPlaying + _ActionIconButton( + icon: controller.value.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + tooltip: controller.value.isPlaying + ? 'Pause' + : 'Play', + onTap: () => controller.value.isPlaying ? controller.pause() : controller.play(), ), @@ -806,14 +873,14 @@ class _VideoPreviewState extends State<_VideoPreview> { style: TextStyle(color: fg, fontSize: 13), ), ), - IconButton( - icon: Icon( - controller.value.volume == 0 - ? Icons.volume_off_rounded - : Icons.volume_up_rounded, - color: fg, - ), - onPressed: () => controller.setVolume( + _ActionIconButton( + icon: controller.value.volume == 0 + ? Icons.volume_off_rounded + : Icons.volume_up_rounded, + tooltip: controller.value.volume == 0 + ? 'Unmute' + : 'Mute', + onTap: () => controller.setVolume( controller.value.volume == 0 ? 1 : 0, ), ), diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 0c13c24..7ac02cc 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -350,19 +350,21 @@ class _FilesViewState extends State { children: [ IconButton( icon: Icon( - provider.sortAscending + provider.filesSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.sortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleSortOrder, + tooltip: provider.filesSortAscending + ? 'Ascending' + : 'Descending', + onPressed: provider.toggleFilesSortOrder, ), Expanded( child: SortMenuButton( - field: provider.sortField, - onChanged: provider.setSortField, + field: provider.filesSortField, + onChanged: provider.setFilesSortField, ), ), ToggleIconButton( diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index f3d9aae..c0afa02 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -116,19 +116,21 @@ class _PhotosViewState extends State { children: [ IconButton( icon: Icon( - provider.sortAscending + provider.photosSortAscending ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, size: 20, ), visualDensity: VisualDensity.compact, - tooltip: provider.sortAscending ? 'Ascending' : 'Descending', - onPressed: provider.toggleSortOrder, + tooltip: provider.photosSortAscending + ? 'Ascending' + : 'Descending', + onPressed: provider.togglePhotosSortOrder, ), Expanded( child: SortMenuButton( - field: provider.sortField, - onChanged: provider.setSortField, + field: provider.photosSortField, + onChanged: provider.setPhotosSortField, ), ), ToggleIconButton( diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart index 662c9bc..da006bc 100644 --- a/lib/views/search_view.dart +++ b/lib/views/search_view.dart @@ -18,6 +18,7 @@ class SearchView extends StatefulWidget { class _SearchViewState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); + final _resultsScrollController = ScrollController(); Timer? _debounce; List _results = []; @@ -30,6 +31,7 @@ class _SearchViewState extends State { _debounce?.cancel(); _controller.dispose(); _focusNode.dispose(); + _resultsScrollController.dispose(); super.dispose(); } @@ -185,24 +187,32 @@ class _SearchViewState extends State { ), ); } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: _results.length, - itemBuilder: (context, index) { - final item = _results[index]; - return ListTile( - leading: Icon(_iconFor(item.type), color: colorScheme.primary), - title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - item.isFolder - ? item.path - : DateFormat.yMMMd().format(item.lastModified), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => _openResult(item), - ); - }, + return Scrollbar( + controller: _resultsScrollController, + child: ListView.builder( + controller: _resultsScrollController, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: _results.length, + itemBuilder: (context, index) { + final item = _results[index]; + return ListTile( + leading: Icon(_iconFor(item.type), color: colorScheme.primary), + title: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + item.isFolder + ? item.path + : DateFormat.yMMMd().format(item.lastModified), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => _openResult(item), + ); + }, + ), ); } } diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart new file mode 100644 index 0000000..737a6e0 --- /dev/null +++ b/lib/views/share_upload_view.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import '../models/app_tab.dart'; +import '../providers/server_provider.dart'; +import '../widgets/breadcrumbs.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, then +/// uploads every shared file into it via the same WebDAV upload path as the +/// Files tab's own "Upload File" action. +class ShareUploadView extends StatefulWidget { + final List files; + + const ShareUploadView({super.key, required this.files}); + + @override + State createState() => _ShareUploadViewState(); +} + +class _ShareUploadViewState extends State { + bool _uploading = false; + int _currentFileIndex = 0; + double? _currentFileProgress; + + @override + void initState() { + super.initState(); + // Shared files have no relationship to wherever the user was last + // browsing, so start the destination picker fresh at the root. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().navigateToAbsoluteFolder('/'); + }); + } + + Future _uploadHere(ServerProvider provider) async { + setState(() => _uploading = true); + + var anyFailed = false; + for (var i = 0; i < widget.files.length; i++) { + final file = widget.files[i]; + setState(() { + _currentFileIndex = i; + _currentFileProgress = 0; + }); + final name = p.basename(file.path); + final success = await provider.uploadFileFromPath( + name, + file.path, + onProgress: (sent, total) { + if (total > 0 && mounted) { + setState(() => _currentFileProgress = sent / total); + } + }, + ); + if (!success) anyFailed = true; + } + + if (!mounted) return; + + provider.requestTab(AppTab.files); + Navigator.of(context).popUntil((route) => route.isFirst); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + anyFailed + ? 'Some files failed to upload' + : widget.files.length == 1 + ? 'Uploaded ${p.basename(widget.files.first.path)}' + : 'Uploaded ${widget.files.length} files', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final provider = context.watch(); + final hasBreadcrumbs = provider.pathStack.length > 1; + final currentPath = provider.pathStack.last; + final currentLabel = currentPath == '/' + ? 'Home' + : currentPath.split('/').where((s) => s.isNotEmpty).last; + final folders = provider.items.where((i) => i.isFolder).toList(); + + return Scaffold( + appBar: AppBar( + title: Text( + widget.files.length == 1 + ? 'Upload ${p.basename(widget.files.first.path)}' + : 'Upload ${widget.files.length} files', + ), + ), + body: _uploading + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + value: _currentFileProgress, + color: colorScheme.primary, + ), + const SizedBox(height: 20), + Text( + widget.files.length == 1 + ? 'Uploading ${p.basename(widget.files[_currentFileIndex].path)}…' + : 'Uploading ${_currentFileIndex + 1} of ${widget.files.length}…', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ) + : Column( + children: [ + if (hasBreadcrumbs) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Breadcrumbs( + pathStack: provider.pathStack, + onTap: (index) => provider.navigateToPathIndex(index), + ), + ), + Expanded( + child: provider.isLoading + ? const Center(child: CircularProgressIndicator()) + : folders.isEmpty + ? Center( + child: Text( + 'No subfolders here', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : Scrollbar( + child: ListView.builder( + itemCount: folders.length, + itemBuilder: (context, index) { + final folder = folders[index]; + return ListTile( + leading: Icon( + Icons.folder_rounded, + color: colorScheme.primary, + ), + title: Text(folder.name), + trailing: const Icon( + Icons.chevron_right_rounded, + ), + onTap: () => + provider.navigateToFolder(folder.path), + ); + }, + ), + ), + ), + ], + ), + bottomNavigationBar: _uploading + ? null + : 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'), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/details/details_activity_tab.dart b/lib/widgets/details/details_activity_tab.dart index 1238752..02bdcf3 100644 --- a/lib/widgets/details/details_activity_tab.dart +++ b/lib/widgets/details/details_activity_tab.dart @@ -53,90 +53,92 @@ class _DetailsActivityTabState extends State { ); } - return ListView.builder( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: _activities.length, - itemBuilder: (context, index) { - final act = _activities[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.2), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: Icon( - act.icon, - color: colorScheme.onPrimaryContainer, - size: 20, - ), + return Scrollbar( + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + itemCount: _activities.length, + itemBuilder: (context, index) { + final act = _activities[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.2), ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - act.title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 14, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + act.icon, + color: colorScheme.onPrimaryContainer, + size: 20, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + act.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 14, + ), ), ), - ), - const SizedBox(width: 8), - Text( - DateFormat.jm().format(act.timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, + const SizedBox(width: 8), + Text( + DateFormat.jm().format(act.timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), + ], + ), + const SizedBox(height: 4), + Text( + act.subject, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 13, ), - ], - ), - const SizedBox(height: 4), - Text( - act.subject, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 13, ), - ), - const SizedBox(height: 6), - Text( - 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w500, - fontSize: 11, + const SizedBox(height: 6), + Text( + 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w500, + fontSize: 11, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/lib/widgets/details/details_info_tab.dart b/lib/widgets/details/details_info_tab.dart index 821441c..64a5562 100644 --- a/lib/widgets/details/details_info_tab.dart +++ b/lib/widgets/details/details_info_tab.dart @@ -26,39 +26,42 @@ class DetailsInfoTab extends StatelessWidget { ('Favorite', item.isFavorite ? 'Yes' : 'No'), ]; - return ListView.separated( + return Scrollbar( controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: rows.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final (label, value) = rows[index]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 96, - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: ListView.separated( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + itemCount: rows.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final (label, value) = rows[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, + Expanded( + child: Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ); } } diff --git a/lib/widgets/details/details_versions_tab.dart b/lib/widgets/details/details_versions_tab.dart index c276d82..a38aaf3 100644 --- a/lib/widgets/details/details_versions_tab.dart +++ b/lib/widgets/details/details_versions_tab.dart @@ -118,43 +118,45 @@ class _DetailsVersionsTabState extends State { ..._versions, ]; - return ListView.separated( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - itemCount: items.length, - separatorBuilder: (context, index) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final version = items[index]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - backgroundColor: colorScheme.primaryContainer, - child: Icon( - Icons.history_rounded, - color: colorScheme.onPrimaryContainer, - size: 20, + return Scrollbar( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + itemCount: items.length, + separatorBuilder: (context, index) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final version = items[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.history_rounded, + color: colorScheme.onPrimaryContainer, + size: 20, + ), ), - ), - title: Text( - version.isCurrent - ? 'Current version' - : DateFormat.yMMMd().add_jm().format(version.timestamp), - ), - subtitle: Text( - '${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}', - ), - trailing: version.isCurrent - ? null - : PopupMenuButton( - onSelected: (value) => value == 'restore' - ? _restore(version) - : _download(version), - itemBuilder: (context) => const [ - PopupMenuItem(value: 'restore', child: Text('Restore')), - PopupMenuItem(value: 'download', child: Text('Download')), - ], - ), - ); - }, + title: Text( + version.isCurrent + ? 'Current version' + : DateFormat.yMMMd().add_jm().format(version.timestamp), + ), + subtitle: Text( + '${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}', + ), + trailing: version.isCurrent + ? null + : PopupMenuButton( + onSelected: (value) => value == 'restore' + ? _restore(version) + : _download(version), + itemBuilder: (context) => const [ + PopupMenuItem(value: 'restore', child: Text('Restore')), + PopupMenuItem(value: 'download', child: Text('Download')), + ], + ), + ); + }, + ), ); } } diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart index a9267a7..c2df25c 100644 --- a/lib/widgets/share_sheet.dart +++ b/lib/widgets/share_sheet.dart @@ -299,108 +299,115 @@ class _ShareSheetState extends State { .toList(); final internalLink = internalLinkFor(provider.serverUrl, widget.item.id); - return ListView( + return Scrollbar( controller: widget.scrollController, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), - children: [ - Text( - widget.item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 16), - _sectionHeader('Internal shares'), - const SizedBox(height: 8), - TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Type names or teams', - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: _isSearching - ? const Padding( - padding: EdgeInsets.all(14), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ), - onChanged: _search, - ), - for (final sharee in _searchResults) - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - sharee.type == ShareeType.group - ? Icons.groups_rounded - : Icons.person_rounded, + child: ListView( + controller: widget.scrollController, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + children: [ + Text( + widget.item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, ), - title: Text(sharee.label), - subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, - onTap: () => _addSharee(sharee), ), - for (final share in userGroupShares) _shareRow(share), - if (_inherited.isNotEmpty) - ExpansionTile( - tilePadding: EdgeInsets.zero, - leading: const Icon(Icons.more_horiz_rounded), - title: Text('Others with access (${_inherited.length})'), - children: [ - for (final share in _inherited) - _shareRow(share, removable: false), - ], + const SizedBox(height: 16), + _sectionHeader('Internal shares'), + const SizedBox(height: 8), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Type names or teams', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _isSearching + ? const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLow, + ), + onChanged: _search, ), - if (internalLink != null) + for (final sharee in _searchResults) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + sharee.type == ShareeType.group + ? Icons.groups_rounded + : Icons.person_rounded, + ), + title: Text(sharee.label), + subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null, + onTap: () => _addSharee(sharee), + ), + for (final share in userGroupShares) _shareRow(share), + if (_inherited.isNotEmpty) + ExpansionTile( + tilePadding: EdgeInsets.zero, + leading: const Icon(Icons.more_horiz_rounded), + title: Text('Others with access (${_inherited.length})'), + children: [ + for (final share in _inherited) + _shareRow(share, removable: false), + ], + ), + if (internalLink != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.link_rounded), + title: const Text('Internal link'), + subtitle: const Text('For people who already have access'), + trailing: IconButton( + icon: const Icon(Icons.copy_rounded), + tooltip: 'Copy link', + onPressed: () => _copy(internalLink, 'internal link'), + ), + ), + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 12), + _sectionHeader('External shares'), + const SizedBox(height: 8), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Type an email', + prefixIcon: const Icon(Icons.email_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLow, + ), + onSubmitted: (_) => _addEmailShare(), + ), + for (final share in emailShares) _shareRow(share), + const SizedBox(height: 8), + for (final share in publicLinkShares) _shareRow(share, copyUrl: true), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.link_rounded), - title: const Text('Internal link'), - subtitle: const Text('For people who already have access'), + title: const Text('Create public link'), trailing: IconButton( - icon: const Icon(Icons.copy_rounded), - tooltip: 'Copy link', - onPressed: () => _copy(internalLink, 'internal link'), + icon: const Icon(Icons.add_rounded), + tooltip: 'Create public link', + onPressed: _createPublicLink, ), ), - const SizedBox(height: 20), - const Divider(), - const SizedBox(height: 12), - _sectionHeader('External shares'), - const SizedBox(height: 8), - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - hintText: 'Type an email', - prefixIcon: const Icon(Icons.email_outlined), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ), - onSubmitted: (_) => _addEmailShare(), - ), - for (final share in emailShares) _shareRow(share), - const SizedBox(height: 8), - for (final share in publicLinkShares) _shareRow(share, copyUrl: true), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.link_rounded), - title: const Text('Create public link'), - trailing: IconButton( - icon: const Icon(Icons.add_rounded), - tooltip: 'Create public link', - onPressed: _createPublicLink, - ), - ), - ], + ], + ), ); } } diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index d7bea9d..c09a78a 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -161,47 +161,50 @@ class _SyncedHeaderScaffoldState extends State { children: [ NotificationListener( onNotification: _handleScrollNotification, - child: CustomScrollView( + child: Scrollbar( controller: widget.scrollController, - // Plain BouncingScrollPhysics only bounces/overscrolls reliably - // once content already fills the viewport — with too little - // content (e.g. a single item) the pull gesture can fail to - // register at all. AlwaysScrollableScrollPhysics keeps the pull - // (and therefore the sync header) working regardless of content - // length. - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), + child: CustomScrollView( + controller: widget.scrollController, + // Plain BouncingScrollPhysics only bounces/overscrolls + // reliably once content already fills the viewport — with + // too little content (e.g. a single item) the pull gesture + // can fail to register at all. AlwaysScrollableScrollPhysics + // keeps the pull (and therefore the sync header) working + // regardless of content length. + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + SliverAppBar( + pinned: true, + stretch: true, + expandedHeight: _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, + ), + ), + ), + DecoratedSliver( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + sliver: SliverMainAxisGroup(slivers: widget.contentSlivers), + ), + ], ), - slivers: [ - SliverAppBar( - pinned: true, - stretch: true, - expandedHeight: _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, - ), - ), - ), - DecoratedSliver( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(28), - ), - ), - sliver: SliverMainAxisGroup(slivers: widget.contentSlivers), - ), - ], ), ), // The classic Material pull-to-refresh "bubble" — a floating diff --git a/pubspec.lock b/pubspec.lock index 5a3d86c..f112ea6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + fading_edge_scrollview: + dependency: transitive + description: + name: fading_edge_scrollview + sha256: "1f84fe3ea8e251d00d5735e27502a6a250e4aa3d3b330d3fdcb475af741464ef" + url: "https://pub.dev" + source: hosted + version: "4.1.1" fake_async: dependency: transitive description: @@ -456,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + marquee: + dependency: "direct main" + description: + name: marquee + sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862 + url: "https://pub.dev" + source: hosted + version: "2.3.0" matcher: dependency: transitive description: @@ -704,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + receive_sharing_intent: + dependency: "direct main" + description: + name: receive_sharing_intent + sha256: ec76056e4d258ad708e76d85591d933678625318e411564dcb9059048ca3a593 + url: "https://pub.dev" + source: hosted + version: "1.8.1" record_use: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 00ee150..054d8f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,8 @@ dependencies: share_plus: ^13.3.0 material_symbols_icons: ^4.2960.0 shared_preferences: ^2.5.5 + receive_sharing_intent: ^1.8.1 + marquee: ^2.3.0 dev_dependencies: flutter_test: