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 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
@@ -28,6 +28,18 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Lets other apps' share sheets ("Share to...") send a file here
|
||||||
|
to be uploaded, via receive_sharing_intent. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="*/*"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<data android:mimeType="*/*"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ subprojects {
|
|||||||
project.evaluationDependsOn(":app")
|
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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||||
import 'models/app_tab.dart';
|
import 'models/app_tab.dart';
|
||||||
import 'providers/server_provider.dart';
|
import 'providers/server_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'views/login_view.dart';
|
import 'views/login_view.dart';
|
||||||
import 'views/search_view.dart';
|
import 'views/search_view.dart';
|
||||||
|
import 'views/share_upload_view.dart';
|
||||||
import 'widgets/app_tab_view_builder.dart';
|
import 'widgets/app_tab_view_builder.dart';
|
||||||
import 'widgets/floating_bottom_bar.dart';
|
import 'widgets/floating_bottom_bar.dart';
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ class MainShellView extends StatefulWidget {
|
|||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
late AppTab _currentTab;
|
late AppTab _currentTab;
|
||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
|
StreamSubscription<List<SharedMediaFile>>? _shareSub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -103,6 +107,32 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
// session restore (which also has to hit the network) does, so by the
|
// session restore (which also has to hit the network) does, so by the
|
||||||
// time this shell mounts `defaultTab` already reflects the saved value.
|
// time this shell mounts `defaultTab` already reflects the saved value.
|
||||||
_currentTab = context.read<ServerProvider>().defaultTab;
|
_currentTab = context.read<ServerProvider>().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<SharedMediaFile> 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
|
@override
|
||||||
@@ -110,6 +140,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
for (final c in _scrollControllers.values) {
|
for (final c in _scrollControllers.values) {
|
||||||
c.dispose();
|
c.dispose();
|
||||||
}
|
}
|
||||||
|
_shareSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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 _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
static const _prefSortAscending = 'ui_sort_ascending';
|
static const _prefSortAscending = 'ui_sort_ascending';
|
||||||
|
static const _prefFolderSort = 'ui_folder_sort';
|
||||||
static const _prefTabOrder = 'ui_tab_order';
|
static const _prefTabOrder = 'ui_tab_order';
|
||||||
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
@@ -130,8 +132,17 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
StorageScope _storageScope = StorageScope.cloud;
|
StorageScope _storageScope = StorageScope.cloud;
|
||||||
bool _showHiddenFiles = false;
|
bool _showHiddenFiles = false;
|
||||||
bool _showHiddenPhotos = false;
|
bool _showHiddenPhotos = false;
|
||||||
FileSortField _sortField = FileSortField.name;
|
// Photos tab sort - a single global setting (Photos has no folder concept,
|
||||||
bool _sortAscending = true;
|
// 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<String, FileSortField> _folderSortField = {};
|
||||||
|
final Map<String, bool> _folderSortAscending = {};
|
||||||
|
|
||||||
// Bottom nav tab configuration
|
// Bottom nav tab configuration
|
||||||
List<AppTab> _tabOrder = AppTab.values.toList();
|
List<AppTab> _tabOrder = AppTab.values.toList();
|
||||||
@@ -248,8 +259,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
StorageScope get storageScope => _storageScope;
|
StorageScope get storageScope => _storageScope;
|
||||||
bool get showHiddenFiles => _showHiddenFiles;
|
bool get showHiddenFiles => _showHiddenFiles;
|
||||||
bool get showHiddenPhotos => _showHiddenPhotos;
|
bool get showHiddenPhotos => _showHiddenPhotos;
|
||||||
FileSortField get sortField => _sortField;
|
FileSortField get photosSortField => _photosSortField;
|
||||||
bool get sortAscending => _sortAscending;
|
bool get photosSortAscending => _photosSortAscending;
|
||||||
|
FileSortField get filesSortField =>
|
||||||
|
_folderSortField[_currentFolderPath] ?? FileSortField.name;
|
||||||
|
bool get filesSortAscending =>
|
||||||
|
_folderSortAscending[_currentFolderPath] ?? true;
|
||||||
CachePolicy get cachePolicy => _cachePolicy;
|
CachePolicy get cachePolicy => _cachePolicy;
|
||||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||||
|
|
||||||
@@ -270,8 +285,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// and trigger downloads/previews directly.
|
/// and trigger downloads/previews directly.
|
||||||
NextcloudService? get service => _service;
|
NextcloudService? get service => _service;
|
||||||
|
|
||||||
int _compareItems(NextcloudItem a, NextcloudItem b) {
|
int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) {
|
||||||
switch (_sortField) {
|
switch (field) {
|
||||||
case FileSortField.name:
|
case FileSortField.name:
|
||||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
case FileSortField.dateCreated:
|
case FileSortField.dateCreated:
|
||||||
@@ -324,11 +339,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
showHidden: _showHiddenFiles,
|
showHidden: _showHiddenFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final field = filesSortField;
|
||||||
final folders = filtered.where((i) => i.isFolder).toList()
|
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()
|
final files = filtered.where((i) => !i.isFolder).toList()
|
||||||
..sort(_compareItems);
|
..sort((a, b) => _compareItems(a, b, field));
|
||||||
return _sortAscending
|
return filesSortAscending
|
||||||
? [...folders, ...files]
|
? [...folders, ...files]
|
||||||
: [...folders.reversed, ...files.reversed];
|
: [...folders.reversed, ...files.reversed];
|
||||||
}
|
}
|
||||||
@@ -341,8 +357,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
media,
|
media,
|
||||||
showFavoritesOnly: _showFavoritesOnlyPhotos,
|
showFavoritesOnly: _showFavoritesOnlyPhotos,
|
||||||
showHidden: _showHiddenPhotos,
|
showHidden: _showHiddenPhotos,
|
||||||
)..sort(_compareItems);
|
)..sort((a, b) => _compareItems(a, b, _photosSortField));
|
||||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
return _photosSortAscending ? filtered : filtered.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isMediaLoading => _isMediaLoading;
|
bool get isMediaLoading => _isMediaLoading;
|
||||||
@@ -428,12 +444,34 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos;
|
prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos;
|
||||||
final sortFieldName = prefs.getString(_prefSortField);
|
final sortFieldName = prefs.getString(_prefSortField);
|
||||||
if (sortFieldName != null) {
|
if (sortFieldName != null) {
|
||||||
_sortField = FileSortField.values.firstWhere(
|
_photosSortField = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == sortFieldName,
|
(f) => f.name == sortFieldName,
|
||||||
orElse: () => FileSortField.name,
|
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<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value as Map<String, dynamic>;
|
||||||
|
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);
|
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||||
if (savedOrderNames != null) {
|
if (savedOrderNames != null) {
|
||||||
@@ -879,17 +917,48 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSortField(FileSortField field) {
|
void setPhotosSortField(FileSortField field) {
|
||||||
if (_sortField == field) return;
|
if (_photosSortField == field) return;
|
||||||
_sortField = field;
|
_photosSortField = field;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefSortField, field.name));
|
_prefsFuture.then((p) => p.setString(_prefSortField, field.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleSortOrder() {
|
void togglePhotosSortOrder() {
|
||||||
_sortAscending = !_sortAscending;
|
_photosSortAscending = !_photosSortAscending;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setBool(_prefSortAscending, _sortAscending));
|
_prefsFuture.then(
|
||||||
|
(p) => p.setBool(_prefSortAscending, _photosSortAscending),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistFolderSort() {
|
||||||
|
final combined = <String, dynamic>{};
|
||||||
|
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<AppTab> order) {
|
void setTabOrder(List<AppTab> order) {
|
||||||
|
|||||||
+354
-352
@@ -35,398 +35,400 @@ class AccountView extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ListView(
|
child: Scrollbar(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
child: ListView(
|
||||||
physics: const BouncingScrollPhysics(),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||||
children: [
|
physics: const BouncingScrollPhysics(),
|
||||||
Text(
|
children: [
|
||||||
'Connected to ${provider.serverUrl}',
|
Text(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
'Connected to ${provider.serverUrl}',
|
||||||
color: colorScheme.onSurfaceVariant,
|
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: Column(
|
const SizedBox(height: 20),
|
||||||
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<Color>(
|
|
||||||
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),
|
|
||||||
|
|
||||||
// Server Credentials Section
|
// User Profile & Storage Card
|
||||||
Text(
|
Container(
|
||||||
'Server Connection Info',
|
padding: const EdgeInsets.all(20),
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
decoration: BoxDecoration(
|
||||||
fontWeight: FontWeight.w700,
|
color: colorScheme.primaryContainer.withValues(alpha: 0.4),
|
||||||
),
|
borderRadius: BorderRadius.circular(28),
|
||||||
),
|
border: Border.all(
|
||||||
const SizedBox(height: 10),
|
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
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),
|
|
||||||
|
|
||||||
// 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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_AccentSwatch(
|
CircleAvatar(
|
||||||
isSelected: provider.useDynamicColor,
|
radius: 28,
|
||||||
onTap: () => provider.setUseDynamicColor(true),
|
backgroundColor: colorScheme.primary,
|
||||||
borderColor: colorScheme.onSurface,
|
child: Text(
|
||||||
background: colorScheme.surfaceContainerHighest,
|
(quota?.userName ?? provider.username)
|
||||||
child: Icon(
|
.substring(0, 1)
|
||||||
Icons.wallpaper_rounded,
|
.toUpperCase(),
|
||||||
color: colorScheme.onSurfaceVariant,
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
size: 20,
|
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 SizedBox(height: 20),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Row(
|
||||||
'Theme Mode',
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
children: [
|
||||||
fontWeight: FontWeight.w600,
|
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),
|
const SizedBox(height: 10),
|
||||||
SizedBox(
|
ClipRRect(
|
||||||
width: double.infinity,
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: SegmentedButton<ThemeMode>(
|
child: LinearProgressIndicator(
|
||||||
segments: const [
|
value: quota != null && quota.totalBytes > 0
|
||||||
ButtonSegment(
|
? quota.usagePercentage
|
||||||
value: ThemeMode.system,
|
: 0.1,
|
||||||
icon: Icon(Icons.brightness_auto_rounded),
|
minHeight: 10,
|
||||||
label: Text('System'),
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
),
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
ButtonSegment(
|
colorScheme.primary,
|
||||||
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),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
Text(
|
||||||
contentPadding: EdgeInsets.zero,
|
quota != null
|
||||||
title: const Text('AMOLED Black'),
|
? (quota.totalBytes > 0
|
||||||
subtitle: const Text(
|
? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
|
||||||
'Use pure black backgrounds in dark mode',
|
: '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
|
// Server Credentials Section
|
||||||
Text(
|
Text(
|
||||||
'UI',
|
'Server Connection Info',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
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',
|
|
||||||
),
|
),
|
||||||
value: provider.tapTabToScrollTop,
|
|
||||||
onChanged: provider.setTapTabToScrollTop,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 24),
|
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
|
// Material You Design Settings
|
||||||
Text(
|
Text(
|
||||||
'Bottom Bar Tabs',
|
'Material You Aesthetics',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 4),
|
Card(
|
||||||
Text(
|
child: Padding(
|
||||||
'Drag to reorder, tap the star to set the default, toggle to show or hide',
|
padding: const EdgeInsets.all(16),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
child: Column(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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<ThemeMode>(
|
||||||
|
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: 24),
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _TabSettingsCard(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Swipe Actions
|
// UI Settings
|
||||||
Text(
|
Text(
|
||||||
'Files Swipe Actions',
|
'UI',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 4),
|
const _BottomBarAppearanceCard(),
|
||||||
Text(
|
const SizedBox(height: 12),
|
||||||
'Choose what swiping a file left or right does in list view',
|
Card(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
child: SwitchListTile(
|
||||||
color: colorScheme.onSurfaceVariant,
|
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: 24),
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _SwipeActionsCard(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Media Player
|
// Bottom Nav Tabs
|
||||||
Text(
|
Text(
|
||||||
'Media Player',
|
'Bottom Bar Tabs',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 4),
|
Text(
|
||||||
Text(
|
'Drag to reorder, tap the star to set the default, toggle to show or hide',
|
||||||
'Choose the seek bar style used when playing videos',
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: colorScheme.onSurfaceVariant,
|
||||||
color: colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
const _TabSettingsCard(),
|
||||||
const _MediaPlayerCard(),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Files Cache
|
// Swipe Actions
|
||||||
Text(
|
Text(
|
||||||
'Files Cache',
|
'Files Swipe Actions',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 4),
|
Text(
|
||||||
Text(
|
'Choose what swiping a file left or right does in list view',
|
||||||
'Reuse a folder\'s listing instead of refetching it on every visit',
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: colorScheme.onSurfaceVariant,
|
||||||
color: colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
const _SwipeActionsCard(),
|
||||||
const _CacheSettingsCard(),
|
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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:file_saver/file_saver.dart';
|
import 'package:file_saver/file_saver.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:marquee/marquee.dart';
|
||||||
import 'package:open_file/open_file.dart';
|
import 'package:open_file/open_file.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -312,24 +313,43 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
AnimatedSlide(
|
||||||
ignoring: !_controlsVisible,
|
duration: const Duration(milliseconds: 220),
|
||||||
child: AnimatedOpacity(
|
curve: Curves.easeInOutCubic,
|
||||||
opacity: _controlsVisible ? 1 : 0,
|
offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4),
|
||||||
duration: const Duration(milliseconds: 200),
|
child: IgnorePointer(
|
||||||
|
ignoring: !_controlsVisible,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(left: 20, top: 12),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||||
child: FrostedGlassContainer(
|
child: FrostedGlassContainer(
|
||||||
opacity: provider.bottomBarOpacity,
|
opacity: provider.bottomBarOpacity,
|
||||||
blurSigma: provider.bottomBarBlur,
|
blurSigma: provider.bottomBarBlur,
|
||||||
child: SizedBox(
|
child: Padding(
|
||||||
width: 48,
|
padding: const EdgeInsets.symmetric(
|
||||||
height: 48,
|
horizontal: 6,
|
||||||
child: IconButton(
|
vertical: 6,
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
),
|
||||||
color: colorScheme.onSurfaceVariant,
|
child: Row(
|
||||||
onPressed: () => Navigator.pop(context),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final fg = onTap == null
|
final fg = onTap == null
|
||||||
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
? colorScheme.onSurface.withValues(alpha: 0.4)
|
||||||
: (color ?? colorScheme.onSurfaceVariant);
|
: (color ?? colorScheme.onSurface);
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 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 {
|
class _ImagePreview extends StatefulWidget {
|
||||||
final String url;
|
final String url;
|
||||||
final Map<String, String> headers;
|
final Map<String, String> headers;
|
||||||
@@ -770,7 +837,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
|
|||||||
animation: controller,
|
animation: controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final fg = colorScheme.onSurfaceVariant;
|
final fg = colorScheme.onSurface;
|
||||||
// Same frosted-glass treatment (and user opacity/blur
|
// Same frosted-glass treatment (and user opacity/blur
|
||||||
// settings) as the back button and media action bar, so the
|
// settings) as the back button and media action bar, so the
|
||||||
// transport controls match the rest of the app's chrome.
|
// transport controls match the rest of the app's chrome.
|
||||||
@@ -788,14 +855,14 @@ class _VideoPreviewState extends State<_VideoPreview> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
_ActionIconButton(
|
||||||
icon: Icon(
|
icon: controller.value.isPlaying
|
||||||
controller.value.isPlaying
|
? Icons.pause_rounded
|
||||||
? Icons.pause_rounded
|
: Icons.play_arrow_rounded,
|
||||||
: Icons.play_arrow_rounded,
|
tooltip: controller.value.isPlaying
|
||||||
color: fg,
|
? 'Pause'
|
||||||
),
|
: 'Play',
|
||||||
onPressed: () => controller.value.isPlaying
|
onTap: () => controller.value.isPlaying
|
||||||
? controller.pause()
|
? controller.pause()
|
||||||
: controller.play(),
|
: controller.play(),
|
||||||
),
|
),
|
||||||
@@ -806,14 +873,14 @@ class _VideoPreviewState extends State<_VideoPreview> {
|
|||||||
style: TextStyle(color: fg, fontSize: 13),
|
style: TextStyle(color: fg, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
_ActionIconButton(
|
||||||
icon: Icon(
|
icon: controller.value.volume == 0
|
||||||
controller.value.volume == 0
|
? Icons.volume_off_rounded
|
||||||
? Icons.volume_off_rounded
|
: Icons.volume_up_rounded,
|
||||||
: Icons.volume_up_rounded,
|
tooltip: controller.value.volume == 0
|
||||||
color: fg,
|
? 'Unmute'
|
||||||
),
|
: 'Mute',
|
||||||
onPressed: () => controller.setVolume(
|
onTap: () => controller.setVolume(
|
||||||
controller.value.volume == 0 ? 1 : 0,
|
controller.value.volume == 0 ? 1 : 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -350,19 +350,21 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
provider.sortAscending
|
provider.filesSortAscending
|
||||||
? Icons.arrow_upward_rounded
|
? Icons.arrow_upward_rounded
|
||||||
: Icons.arrow_downward_rounded,
|
: Icons.arrow_downward_rounded,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
tooltip: provider.sortAscending ? 'Ascending' : 'Descending',
|
tooltip: provider.filesSortAscending
|
||||||
onPressed: provider.toggleSortOrder,
|
? 'Ascending'
|
||||||
|
: 'Descending',
|
||||||
|
onPressed: provider.toggleFilesSortOrder,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SortMenuButton(
|
child: SortMenuButton(
|
||||||
field: provider.sortField,
|
field: provider.filesSortField,
|
||||||
onChanged: provider.setSortField,
|
onChanged: provider.setFilesSortField,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ToggleIconButton(
|
ToggleIconButton(
|
||||||
|
|||||||
@@ -116,19 +116,21 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
provider.sortAscending
|
provider.photosSortAscending
|
||||||
? Icons.arrow_upward_rounded
|
? Icons.arrow_upward_rounded
|
||||||
: Icons.arrow_downward_rounded,
|
: Icons.arrow_downward_rounded,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
tooltip: provider.sortAscending ? 'Ascending' : 'Descending',
|
tooltip: provider.photosSortAscending
|
||||||
onPressed: provider.toggleSortOrder,
|
? 'Ascending'
|
||||||
|
: 'Descending',
|
||||||
|
onPressed: provider.togglePhotosSortOrder,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SortMenuButton(
|
child: SortMenuButton(
|
||||||
field: provider.sortField,
|
field: provider.photosSortField,
|
||||||
onChanged: provider.setSortField,
|
onChanged: provider.setPhotosSortField,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ToggleIconButton(
|
ToggleIconButton(
|
||||||
|
|||||||
+28
-18
@@ -18,6 +18,7 @@ class SearchView extends StatefulWidget {
|
|||||||
class _SearchViewState extends State<SearchView> {
|
class _SearchViewState extends State<SearchView> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
final _resultsScrollController = ScrollController();
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
|
|
||||||
List<NextcloudItem> _results = [];
|
List<NextcloudItem> _results = [];
|
||||||
@@ -30,6 +31,7 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
_resultsScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,24 +187,32 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return Scrollbar(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
controller: _resultsScrollController,
|
||||||
itemCount: _results.length,
|
child: ListView.builder(
|
||||||
itemBuilder: (context, index) {
|
controller: _resultsScrollController,
|
||||||
final item = _results[index];
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
return ListTile(
|
itemCount: _results.length,
|
||||||
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
|
itemBuilder: (context, index) {
|
||||||
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
final item = _results[index];
|
||||||
subtitle: Text(
|
return ListTile(
|
||||||
item.isFolder
|
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
|
||||||
? item.path
|
title: Text(
|
||||||
: DateFormat.yMMMd().format(item.lastModified),
|
item.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
onTap: () => _openResult(item),
|
subtitle: Text(
|
||||||
);
|
item.isFolder
|
||||||
},
|
? item.path
|
||||||
|
: DateFormat.yMMMd().format(item.lastModified),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () => _openResult(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SharedMediaFile> files;
|
||||||
|
|
||||||
|
const ShareUploadView({super.key, required this.files});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareUploadView> createState() => _ShareUploadViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareUploadViewState extends State<ShareUploadView> {
|
||||||
|
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<ServerProvider>().navigateToAbsoluteFolder('/');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<ServerProvider>();
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,90 +53,92 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return Scrollbar(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
child: ListView.builder(
|
||||||
itemCount: _activities.length,
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
itemBuilder: (context, index) {
|
itemCount: _activities.length,
|
||||||
final act = _activities[index];
|
itemBuilder: (context, index) {
|
||||||
return Padding(
|
final act = _activities[index];
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
return Padding(
|
||||||
child: Container(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(16),
|
||||||
color: colorScheme.surfaceContainerLow,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
color: colorScheme.surfaceContainerLow,
|
||||||
border: Border.all(
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
),
|
||||||
Expanded(
|
child: Row(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Container(
|
||||||
Row(
|
padding: const EdgeInsets.all(10),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
decoration: BoxDecoration(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: colorScheme.primaryContainer,
|
||||||
children: [
|
shape: BoxShape.circle,
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
child: Icon(
|
||||||
act.title,
|
act.icon,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
color: colorScheme.onPrimaryContainer,
|
||||||
fontWeight: FontWeight.w600,
|
size: 20,
|
||||||
fontSize: 14,
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Text(
|
DateFormat.jm().format(act.timestamp),
|
||||||
DateFormat.jm().format(act.timestamp),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: colorScheme.onSurfaceVariant,
|
||||||
color: colorScheme.onSurfaceVariant,
|
fontSize: 11,
|
||||||
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),
|
||||||
const SizedBox(height: 6),
|
Text(
|
||||||
Text(
|
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
|
||||||
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: colorScheme.primary,
|
||||||
color: colorScheme.primary,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
fontSize: 11,
|
||||||
fontSize: 11,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,39 +26,42 @@ class DetailsInfoTab extends StatelessWidget {
|
|||||||
('Favorite', item.isFavorite ? 'Yes' : 'No'),
|
('Favorite', item.isFavorite ? 'Yes' : 'No'),
|
||||||
];
|
];
|
||||||
|
|
||||||
return ListView.separated(
|
return Scrollbar(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
child: ListView.separated(
|
||||||
itemCount: rows.length,
|
controller: scrollController,
|
||||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
itemBuilder: (context, index) {
|
itemCount: rows.length,
|
||||||
final (label, value) = rows[index];
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
return Padding(
|
itemBuilder: (context, index) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
final (label, value) = rows[index];
|
||||||
child: Row(
|
return Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
children: [
|
child: Row(
|
||||||
SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
width: 96,
|
children: [
|
||||||
child: Text(
|
SizedBox(
|
||||||
label,
|
width: 96,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
child: Text(
|
||||||
color: colorScheme.onSurfaceVariant,
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
value,
|
||||||
value,
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,43 +118,45 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
|||||||
..._versions,
|
..._versions,
|
||||||
];
|
];
|
||||||
|
|
||||||
return ListView.separated(
|
return Scrollbar(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
child: ListView.separated(
|
||||||
itemCount: items.length,
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||||
final version = items[index];
|
itemBuilder: (context, index) {
|
||||||
return ListTile(
|
final version = items[index];
|
||||||
contentPadding: EdgeInsets.zero,
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
contentPadding: EdgeInsets.zero,
|
||||||
backgroundColor: colorScheme.primaryContainer,
|
leading: CircleAvatar(
|
||||||
child: Icon(
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
Icons.history_rounded,
|
child: Icon(
|
||||||
color: colorScheme.onPrimaryContainer,
|
Icons.history_rounded,
|
||||||
size: 20,
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
title: Text(
|
||||||
title: Text(
|
version.isCurrent
|
||||||
version.isCurrent
|
? 'Current version'
|
||||||
? 'Current version'
|
: DateFormat.yMMMd().add_jm().format(version.timestamp),
|
||||||
: DateFormat.yMMMd().add_jm().format(version.timestamp),
|
),
|
||||||
),
|
subtitle: Text(
|
||||||
subtitle: Text(
|
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
|
||||||
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
|
),
|
||||||
),
|
trailing: version.isCurrent
|
||||||
trailing: version.isCurrent
|
? null
|
||||||
? null
|
: PopupMenuButton<String>(
|
||||||
: PopupMenuButton<String>(
|
onSelected: (value) => value == 'restore'
|
||||||
onSelected: (value) => value == 'restore'
|
? _restore(version)
|
||||||
? _restore(version)
|
: _download(version),
|
||||||
: _download(version),
|
itemBuilder: (context) => const [
|
||||||
itemBuilder: (context) => const [
|
PopupMenuItem(value: 'restore', child: Text('Restore')),
|
||||||
PopupMenuItem(value: 'restore', child: Text('Restore')),
|
PopupMenuItem(value: 'download', child: Text('Download')),
|
||||||
PopupMenuItem(value: 'download', child: Text('Download')),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,108 +299,115 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
.toList();
|
.toList();
|
||||||
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
|
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
|
||||||
|
|
||||||
return ListView(
|
return Scrollbar(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
child: ListView(
|
||||||
children: [
|
controller: widget.scrollController,
|
||||||
Text(
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
widget.item.name,
|
children: [
|
||||||
maxLines: 1,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
widget.item.name,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
maxLines: 1,
|
||||||
fontWeight: FontWeight.w700,
|
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,
|
|
||||||
),
|
),
|
||||||
title: Text(sharee.label),
|
|
||||||
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
|
|
||||||
onTap: () => _addSharee(sharee),
|
|
||||||
),
|
),
|
||||||
for (final share in userGroupShares) _shareRow(share),
|
const SizedBox(height: 16),
|
||||||
if (_inherited.isNotEmpty)
|
_sectionHeader('Internal shares'),
|
||||||
ExpansionTile(
|
const SizedBox(height: 8),
|
||||||
tilePadding: EdgeInsets.zero,
|
TextField(
|
||||||
leading: const Icon(Icons.more_horiz_rounded),
|
controller: _searchController,
|
||||||
title: Text('Others with access (${_inherited.length})'),
|
decoration: InputDecoration(
|
||||||
children: [
|
hintText: 'Type names or teams',
|
||||||
for (final share in _inherited)
|
prefixIcon: const Icon(Icons.search_rounded),
|
||||||
_shareRow(share, removable: false),
|
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(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.link_rounded),
|
leading: const Icon(Icons.link_rounded),
|
||||||
title: const Text('Internal link'),
|
title: const Text('Create public link'),
|
||||||
subtitle: const Text('For people who already have access'),
|
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.copy_rounded),
|
icon: const Icon(Icons.add_rounded),
|
||||||
tooltip: 'Copy link',
|
tooltip: 'Create public link',
|
||||||
onPressed: () => _copy(internalLink, 'internal 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,47 +161,50 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
|||||||
children: [
|
children: [
|
||||||
NotificationListener<ScrollNotification>(
|
NotificationListener<ScrollNotification>(
|
||||||
onNotification: _handleScrollNotification,
|
onNotification: _handleScrollNotification,
|
||||||
child: CustomScrollView(
|
child: Scrollbar(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
// Plain BouncingScrollPhysics only bounces/overscrolls reliably
|
child: CustomScrollView(
|
||||||
// once content already fills the viewport — with too little
|
controller: widget.scrollController,
|
||||||
// content (e.g. a single item) the pull gesture can fail to
|
// Plain BouncingScrollPhysics only bounces/overscrolls
|
||||||
// register at all. AlwaysScrollableScrollPhysics keeps the pull
|
// reliably once content already fills the viewport — with
|
||||||
// (and therefore the sync header) working regardless of content
|
// too little content (e.g. a single item) the pull gesture
|
||||||
// length.
|
// can fail to register at all. AlwaysScrollableScrollPhysics
|
||||||
physics: const AlwaysScrollableScrollPhysics(
|
// keeps the pull (and therefore the sync header) working
|
||||||
parent: BouncingScrollPhysics(),
|
// 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
|
// The classic Material pull-to-refresh "bubble" — a floating
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -456,6 +464,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
marquee:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: marquee
|
||||||
|
sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -704,6 +720,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
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:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ dependencies:
|
|||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
material_symbols_icons: ^4.2960.0
|
material_symbols_icons: ^4.2960.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
|
marquee: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user