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: