Add Trash/Shares/Recent tabs, file sharing, Details sheet, and rebrand to Noo
Major additions since the initial rebuild: - New tabs: Trash, Shares, Recent, configurable tab visibility/order/default - Nextcloud sharing: search users/teams, internal link, email shares, public links, "others with access" - in its own Share bottom sheet - Details bottom sheet (Info/Versions/Activity) with a persistent peek panel in the media viewer, reachable from selection, swipe, and the media viewer - Files/Photos: tap-and-hold multi-select with a sticky selection toolbar, swipe actions (favorite/delete/share), sticky sort/filter controls, list/grid views, breadcrumb chips, whole-account Photos view - Media viewer: pinch/double-tap zoom on images, predictive-back support, fixed PDF zoom-out floor - Settings: Material You theming incl. AMOLED black, configurable bottom bar tabs, swipe actions, media seek bar style, and folder-listing cache policy (never/periodic/manual) to cut down on refetching - Login flow moved to an in-app Chrome Custom Tab with retry-tolerant polling - Replaced ad hoc widgets with native Material 3 equivalents (toggle IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles) - Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo, new adaptive launcher icon Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@@ -1,4 +1,4 @@
|
|||||||
# nextcloud_client
|
# Noo
|
||||||
|
|
||||||
A new Flutter project.
|
A new Flutter project.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "family.nasa.nextcloud_client"
|
namespace = "dev.ayushya.noo"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "family.nasa.nextcloud_client"
|
applicationId = "dev.ayushya.noo"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package family.nasa.nextcloud_client
|
package dev.ayushya.noo
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -1,9 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground>
|
||||||
<inset
|
<inset
|
||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 23 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#3D5AD1</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -415,7 +415,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -583,7 +583,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Nextcloud Client</string>
|
<string>Noo</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>nextcloud_client</string>
|
<string>noo</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
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 '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/activity_view.dart';
|
|
||||||
import 'views/files_view.dart';
|
|
||||||
import 'views/login_view.dart';
|
import 'views/login_view.dart';
|
||||||
import 'views/photos_view.dart';
|
|
||||||
import 'views/search_view.dart';
|
import 'views/search_view.dart';
|
||||||
|
import 'widgets/app_tab_view_builder.dart';
|
||||||
import 'widgets/floating_bottom_bar.dart';
|
import 'widgets/floating_bottom_bar.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -41,6 +40,7 @@ class NextcloudApp extends StatelessWidget {
|
|||||||
provider.seedColor,
|
provider.seedColor,
|
||||||
dynamicScheme: darkDynamic,
|
dynamicScheme: darkDynamic,
|
||||||
useDynamicColor: provider.useDynamicColor,
|
useDynamicColor: provider.useDynamicColor,
|
||||||
|
amoled: provider.amoledDark,
|
||||||
),
|
),
|
||||||
themeMode: provider.themeMode,
|
themeMode: provider.themeMode,
|
||||||
home: provider.isRestoringSession
|
home: provider.isRestoringSession
|
||||||
@@ -83,19 +83,24 @@ class MainShellView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
int _currentIndex = 0;
|
late AppTab _currentTab;
|
||||||
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
late final List<ScrollController> _scrollControllers;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollControllers = List.generate(3, (index) => ScrollController());
|
_scrollControllers = {
|
||||||
|
for (final tab in AppTab.values) tab: ScrollController(),
|
||||||
|
};
|
||||||
|
// Preferences load asynchronously, but in practice finish well before
|
||||||
|
// 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<ServerProvider>().defaultTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
for (var c in _scrollControllers) {
|
for (final c in _scrollControllers.values) {
|
||||||
c.dispose();
|
c.dispose();
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -104,31 +109,39 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
final navItems = const [
|
final visible = provider.visibleTabs;
|
||||||
FloatingNavItem(label: 'Files', icon: Icons.folder_rounded),
|
final selectedTab = visible.contains(_currentTab)
|
||||||
FloatingNavItem(label: 'Photos', icon: Icons.photo_library_rounded),
|
? _currentTab
|
||||||
FloatingNavItem(label: 'Activity', icon: Icons.history_rounded),
|
: (visible.isNotEmpty ? visible.first : AppTab.files);
|
||||||
];
|
final selectedIndex = visible
|
||||||
|
.indexOf(selectedTab)
|
||||||
|
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
|
||||||
|
|
||||||
|
final navItems = visible
|
||||||
|
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
|
||||||
|
.toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
IndexedStack(
|
IndexedStack(
|
||||||
index: _currentIndex,
|
index: selectedIndex,
|
||||||
children: [
|
children: visible
|
||||||
FilesView(scrollController: _scrollControllers[0]),
|
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
||||||
PhotosView(scrollController: _scrollControllers[1]),
|
.toList(),
|
||||||
ActivityView(scrollController: _scrollControllers[2]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
// Each tab now renders its own sticky selection toolbar inline
|
||||||
|
// (right under its sort/filter row) instead of this shell
|
||||||
|
// swapping in a shared floating bar - the bottom nav stays put
|
||||||
|
// and usable regardless of selection state.
|
||||||
FloatingBottomNavBar(
|
FloatingBottomNavBar(
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: selectedIndex,
|
||||||
items: navItems,
|
items: navItems,
|
||||||
opacity: provider.bottomBarOpacity,
|
opacity: provider.bottomBarOpacity,
|
||||||
blurSigma: provider.bottomBarBlur,
|
blurSigma: provider.bottomBarBlur,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex = index;
|
_currentTab = visible[index];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSearchTap: () {
|
onSearchTap: () {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
/// Every destination the bottom nav bar can show. Order here is only the
|
||||||
|
/// fallback default — actual order/visibility/default-tab are user
|
||||||
|
/// configurable and persisted in [ServerProvider].
|
||||||
|
enum AppTab { files, photos, activity, trash, shares, recent }
|
||||||
|
|
||||||
|
/// At most this many tabs may be visible in the bottom nav bar at once —
|
||||||
|
/// the rest are reachable through the "more" dropdown instead.
|
||||||
|
const int maxVisibleTabs = 5;
|
||||||
|
|
||||||
|
extension AppTabInfo on AppTab {
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case AppTab.files:
|
||||||
|
return 'Files';
|
||||||
|
case AppTab.photos:
|
||||||
|
return 'Photos';
|
||||||
|
case AppTab.activity:
|
||||||
|
return 'Activity';
|
||||||
|
case AppTab.trash:
|
||||||
|
return 'Trash';
|
||||||
|
case AppTab.shares:
|
||||||
|
return 'Shares';
|
||||||
|
case AppTab.recent:
|
||||||
|
return 'Recent';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get icon {
|
||||||
|
switch (this) {
|
||||||
|
case AppTab.files:
|
||||||
|
return Icons.folder_rounded;
|
||||||
|
case AppTab.photos:
|
||||||
|
return Icons.photo_library_rounded;
|
||||||
|
case AppTab.activity:
|
||||||
|
return Icons.electric_bolt_rounded;
|
||||||
|
case AppTab.trash:
|
||||||
|
return Icons.delete_outline_rounded;
|
||||||
|
case AppTab.shares:
|
||||||
|
return Icons.groups_rounded;
|
||||||
|
case AppTab.recent:
|
||||||
|
return Symbols.search_activity_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// One entry in a file's version history (Nextcloud's DAV versions API).
|
||||||
|
class NextcloudFileVersion {
|
||||||
|
/// The timestamp segment used in the DAV versions/restore URLs, e.g.
|
||||||
|
/// `1699999999`.
|
||||||
|
final String versionLabel;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final int size;
|
||||||
|
final bool isCurrent;
|
||||||
|
|
||||||
|
const NextcloudFileVersion({
|
||||||
|
required this.versionLabel,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.size,
|
||||||
|
this.isCurrent = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ class NextcloudItem {
|
|||||||
final int? imageHeight;
|
final int? imageHeight;
|
||||||
final String? mountType;
|
final String? mountType;
|
||||||
|
|
||||||
|
/// Trash-bin items only: the folder path the item was deleted from, and
|
||||||
|
/// when it was deleted. Null for items fetched from a regular directory.
|
||||||
|
final String? originalLocation;
|
||||||
|
final DateTime? deletedAt;
|
||||||
|
|
||||||
const NextcloudItem({
|
const NextcloudItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -33,6 +38,8 @@ class NextcloudItem {
|
|||||||
this.imageWidth,
|
this.imageWidth,
|
||||||
this.imageHeight,
|
this.imageHeight,
|
||||||
this.mountType,
|
this.mountType,
|
||||||
|
this.originalLocation,
|
||||||
|
this.deletedAt,
|
||||||
}) : dateCreated = dateCreated ?? lastModified;
|
}) : dateCreated = dateCreated ?? lastModified;
|
||||||
|
|
||||||
bool get isFolder => type == NextcloudItemType.folder;
|
bool get isFolder => type == NextcloudItemType.folder;
|
||||||
@@ -55,6 +62,8 @@ class NextcloudItem {
|
|||||||
int? imageWidth,
|
int? imageWidth,
|
||||||
int? imageHeight,
|
int? imageHeight,
|
||||||
String? mountType,
|
String? mountType,
|
||||||
|
String? originalLocation,
|
||||||
|
DateTime? deletedAt,
|
||||||
}) {
|
}) {
|
||||||
return NextcloudItem(
|
return NextcloudItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -71,6 +80,8 @@ class NextcloudItem {
|
|||||||
imageWidth: imageWidth ?? this.imageWidth,
|
imageWidth: imageWidth ?? this.imageWidth,
|
||||||
imageHeight: imageHeight ?? this.imageHeight,
|
imageHeight: imageHeight ?? this.imageHeight,
|
||||||
mountType: mountType ?? this.mountType,
|
mountType: mountType ?? this.mountType,
|
||||||
|
originalLocation: originalLocation ?? this.originalLocation,
|
||||||
|
deletedAt: deletedAt ?? this.deletedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'nextcloud_item.dart';
|
||||||
|
|
||||||
|
enum ShareType { user, group, publicLink, email, federated, other }
|
||||||
|
|
||||||
|
/// A Nextcloud OCS share — either something the current user has shared
|
||||||
|
/// with someone else, or something someone else has shared with them
|
||||||
|
/// (distinguished by [sharedWithMe]).
|
||||||
|
class NextcloudShare {
|
||||||
|
final String id;
|
||||||
|
final String path;
|
||||||
|
final String name;
|
||||||
|
final NextcloudItemType itemType;
|
||||||
|
final ShareType shareType;
|
||||||
|
final String ownerDisplayName;
|
||||||
|
final String? sharedWithDisplayName;
|
||||||
|
final DateTime sharedAt;
|
||||||
|
final bool sharedWithMe;
|
||||||
|
|
||||||
|
/// Only set for public-link shares.
|
||||||
|
final String? url;
|
||||||
|
final int permissions;
|
||||||
|
final String? token;
|
||||||
|
final DateTime? expireDate;
|
||||||
|
|
||||||
|
const NextcloudShare({
|
||||||
|
required this.id,
|
||||||
|
required this.path,
|
||||||
|
required this.name,
|
||||||
|
required this.itemType,
|
||||||
|
required this.shareType,
|
||||||
|
required this.ownerDisplayName,
|
||||||
|
this.sharedWithDisplayName,
|
||||||
|
required this.sharedAt,
|
||||||
|
required this.sharedWithMe,
|
||||||
|
this.url,
|
||||||
|
this.permissions = 1,
|
||||||
|
this.token,
|
||||||
|
this.expireDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/// One result from a sharee search (`/ocs/.../sharees`) — a user or team
|
||||||
|
/// (group) that a file/folder can be shared with.
|
||||||
|
enum ShareeType { user, group, remote }
|
||||||
|
|
||||||
|
class NextcloudSharee {
|
||||||
|
final String shareWith;
|
||||||
|
final String label;
|
||||||
|
final ShareeType type;
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
const NextcloudSharee({
|
||||||
|
required this.shareWith,
|
||||||
|
required this.label,
|
||||||
|
required this.type,
|
||||||
|
this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The numeric OCS `shareType` used when creating a share with this
|
||||||
|
/// sharee (0 = user, 1 = group, 6 = federated/remote).
|
||||||
|
int get shareTypeValue {
|
||||||
|
switch (type) {
|
||||||
|
case ShareeType.user:
|
||||||
|
return 0;
|
||||||
|
case ShareeType.group:
|
||||||
|
return 1;
|
||||||
|
case ShareeType.remote:
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// One bulk action available for the current multi-selection (e.g. favorite,
|
||||||
|
/// download, delete), shown as an icon button in the sticky selection
|
||||||
|
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
||||||
|
class SelectionAction {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const SelectionAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ class LoginFlowResult {
|
|||||||
/// back a scoped app password.
|
/// back a scoped app password.
|
||||||
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||||
class LoginFlowService {
|
class LoginFlowService {
|
||||||
static const _userAgent = 'Nextcloud-Flutter-Client/1.0';
|
static const _userAgent = 'Noo/1.0';
|
||||||
|
|
||||||
static String normalizeServerUrl(String input) {
|
static String normalizeServerUrl(String input) {
|
||||||
var url = input.trim();
|
var url = input.trim();
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:xml/xml.dart' as xml;
|
import 'package:xml/xml.dart' as xml;
|
||||||
|
import '../models/nextcloud_file_version.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../models/nextcloud_share.dart';
|
||||||
|
import '../models/nextcloud_sharee.dart';
|
||||||
|
|
||||||
extension XmlElementHelper on xml.XmlNode {
|
extension XmlElementHelper on xml.XmlNode {
|
||||||
Iterable<xml.XmlElement> findLocalChildren(String localName) {
|
Iterable<xml.XmlElement> findLocalChildren(String localName) {
|
||||||
@@ -80,7 +83,7 @@ class NextcloudService {
|
|||||||
'Authorization': 'Basic $credentials',
|
'Authorization': 'Basic $credentials',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Accept': 'application/json, application/xml',
|
'Accept': 'application/json, application/xml',
|
||||||
'User-Agent': 'Nextcloud-Flutter-Client/1.0',
|
'User-Agent': 'Noo/1.0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +526,332 @@ class NextcloudService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds every image/video anywhere in the account (not just the current
|
||||||
|
/// folder) via the same WebDAV SEARCH-REPORT mechanism Nextcloud's own
|
||||||
|
/// Files app uses for its "Photos" filter, so the Photos tab can show a
|
||||||
|
/// true all-folders library instead of just the currently browsed folder.
|
||||||
|
Future<List<NextcloudItem>> fetchAllMedia() async {
|
||||||
|
final url = '$_cleanServerUrl/remote.php/dav/';
|
||||||
|
debugPrint('[Nextcloud DAV] SEARCH for all media from $url');
|
||||||
|
|
||||||
|
final body =
|
||||||
|
'''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:basicsearch>
|
||||||
|
<d:select>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:creationdate/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:favorite/>
|
||||||
|
<oc:fileid/>
|
||||||
|
<nc:mount-type/>
|
||||||
|
</d:prop>
|
||||||
|
</d:select>
|
||||||
|
<d:from>
|
||||||
|
<d:scope>
|
||||||
|
<d:href>/files/$username</d:href>
|
||||||
|
<d:depth>infinity</d:depth>
|
||||||
|
</d:scope>
|
||||||
|
</d:from>
|
||||||
|
<d:where>
|
||||||
|
<d:or>
|
||||||
|
<d:like>
|
||||||
|
<d:prop><d:getcontenttype/></d:prop>
|
||||||
|
<d:literal>image/%</d:literal>
|
||||||
|
</d:like>
|
||||||
|
<d:like>
|
||||||
|
<d:prop><d:getcontenttype/></d:prop>
|
||||||
|
<d:literal>video/%</d:literal>
|
||||||
|
</d:like>
|
||||||
|
</d:or>
|
||||||
|
</d:where>
|
||||||
|
<d:orderby>
|
||||||
|
<d:order>
|
||||||
|
<d:prop><d:getlastmodified/></d:prop>
|
||||||
|
<d:descending/>
|
||||||
|
</d:order>
|
||||||
|
</d:orderby>
|
||||||
|
<d:limit>
|
||||||
|
<d:nresults>2000</d:nresults>
|
||||||
|
</d:limit>
|
||||||
|
</d:basicsearch>
|
||||||
|
</d:searchrequest>''';
|
||||||
|
|
||||||
|
final request = http.Request('SEARCH', Uri.parse(url))
|
||||||
|
..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
|
||||||
|
..body = body;
|
||||||
|
|
||||||
|
final streamed = await http.Client().send(request);
|
||||||
|
final responseBody = await streamed.stream.bytesToString();
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] All-media SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
|
||||||
|
throw Exception('Failed to load media. HTTP ${streamed.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final document = xml.XmlDocument.parse(responseBody);
|
||||||
|
final responses = document.findLocalDescendants('response');
|
||||||
|
|
||||||
|
final results = <NextcloudItem>[];
|
||||||
|
for (var res in responses) {
|
||||||
|
final hrefNode =
|
||||||
|
res.findLocalChildren('href').firstOrNull ??
|
||||||
|
res.findLocalDescendants('href').firstOrNull;
|
||||||
|
final href = hrefNode?.innerText ?? '';
|
||||||
|
if (href.isEmpty) continue;
|
||||||
|
|
||||||
|
final props = res.findLocalDescendants('prop');
|
||||||
|
if (props.isEmpty) continue;
|
||||||
|
|
||||||
|
String? sizeStr;
|
||||||
|
String? lastModStr;
|
||||||
|
String? createdStr;
|
||||||
|
String? mimeType;
|
||||||
|
bool isFav = false;
|
||||||
|
String? fileId;
|
||||||
|
String? mountType;
|
||||||
|
|
||||||
|
for (var prop in props) {
|
||||||
|
sizeStr ??= prop
|
||||||
|
.findLocalChildren('getcontentlength')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
lastModStr ??= prop
|
||||||
|
.findLocalChildren('getlastmodified')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
createdStr ??= prop
|
||||||
|
.findLocalChildren('creationdate')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
mimeType ??= prop
|
||||||
|
.findLocalChildren('getcontenttype')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
if (!isFav) {
|
||||||
|
isFav =
|
||||||
|
prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
|
||||||
|
}
|
||||||
|
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
|
||||||
|
mountType ??= prop
|
||||||
|
.findLocalChildren('mount-type')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodedHref = Uri.decodeFull(href);
|
||||||
|
if (decodedHref.endsWith('/') && decodedHref.length > 1) {
|
||||||
|
decodedHref = decodedHref.substring(0, decodedHref.length - 1);
|
||||||
|
}
|
||||||
|
final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
|
||||||
|
if (name.isEmpty) continue;
|
||||||
|
|
||||||
|
// Strip the DAV root prefix so `path` matches what fetchDirectory produces.
|
||||||
|
final marker = '/files/$username';
|
||||||
|
final markerIndex = decodedHref.indexOf(marker);
|
||||||
|
final itemPath = markerIndex >= 0
|
||||||
|
? decodedHref.substring(markerIndex + marker.length)
|
||||||
|
: decodedHref;
|
||||||
|
if (itemPath.isEmpty) continue;
|
||||||
|
|
||||||
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
|
final created = _parseDavDate(createdStr);
|
||||||
|
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
||||||
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
|
results.add(
|
||||||
|
NextcloudItem(
|
||||||
|
id: validId,
|
||||||
|
name: name,
|
||||||
|
path: itemPath,
|
||||||
|
type: itemType,
|
||||||
|
size: size,
|
||||||
|
lastModified: lastMod,
|
||||||
|
dateCreated: created,
|
||||||
|
isFavorite: isFav,
|
||||||
|
mimeType: mimeType,
|
||||||
|
previewUrl:
|
||||||
|
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
|
||||||
|
mountType: mountType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] All-media SEARCH found ${results.length} items',
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds recently modified files across the whole account (folders
|
||||||
|
/// excluded), newest first — same SEARCH-REPORT mechanism as
|
||||||
|
/// [fetchAllMedia], just without the image/video mimetype filter.
|
||||||
|
Future<List<NextcloudItem>> fetchRecentFiles() async {
|
||||||
|
final url = '$_cleanServerUrl/remote.php/dav/';
|
||||||
|
debugPrint('[Nextcloud DAV] SEARCH for recent files from $url');
|
||||||
|
|
||||||
|
final body =
|
||||||
|
'''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:basicsearch>
|
||||||
|
<d:select>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:creationdate/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:favorite/>
|
||||||
|
<oc:fileid/>
|
||||||
|
</d:prop>
|
||||||
|
</d:select>
|
||||||
|
<d:from>
|
||||||
|
<d:scope>
|
||||||
|
<d:href>/files/$username</d:href>
|
||||||
|
<d:depth>infinity</d:depth>
|
||||||
|
</d:scope>
|
||||||
|
</d:from>
|
||||||
|
<d:where>
|
||||||
|
<d:like>
|
||||||
|
<d:prop><d:displayname/></d:prop>
|
||||||
|
<d:literal>%</d:literal>
|
||||||
|
</d:like>
|
||||||
|
</d:where>
|
||||||
|
<d:orderby>
|
||||||
|
<d:order>
|
||||||
|
<d:prop><d:getlastmodified/></d:prop>
|
||||||
|
<d:descending/>
|
||||||
|
</d:order>
|
||||||
|
</d:orderby>
|
||||||
|
<d:limit>
|
||||||
|
<d:nresults>200</d:nresults>
|
||||||
|
</d:limit>
|
||||||
|
</d:basicsearch>
|
||||||
|
</d:searchrequest>''';
|
||||||
|
|
||||||
|
final request = http.Request('SEARCH', Uri.parse(url))
|
||||||
|
..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
|
||||||
|
..body = body;
|
||||||
|
|
||||||
|
final streamed = await http.Client().send(request);
|
||||||
|
final responseBody = await streamed.stream.bytesToString();
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] Recent SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to load recent files. HTTP ${streamed.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final document = xml.XmlDocument.parse(responseBody);
|
||||||
|
final responses = document.findLocalDescendants('response');
|
||||||
|
|
||||||
|
final results = <NextcloudItem>[];
|
||||||
|
for (var res in responses) {
|
||||||
|
final hrefNode =
|
||||||
|
res.findLocalChildren('href').firstOrNull ??
|
||||||
|
res.findLocalDescendants('href').firstOrNull;
|
||||||
|
final href = hrefNode?.innerText ?? '';
|
||||||
|
if (href.isEmpty) continue;
|
||||||
|
|
||||||
|
final props = res.findLocalDescendants('prop');
|
||||||
|
if (props.isEmpty) continue;
|
||||||
|
|
||||||
|
bool isCollection = false;
|
||||||
|
String? sizeStr;
|
||||||
|
String? lastModStr;
|
||||||
|
String? createdStr;
|
||||||
|
String? mimeType;
|
||||||
|
bool isFav = false;
|
||||||
|
String? fileId;
|
||||||
|
|
||||||
|
for (var prop in props) {
|
||||||
|
if (!isCollection) {
|
||||||
|
final resTypeNode = prop
|
||||||
|
.findLocalChildren('resourcetype')
|
||||||
|
.firstOrNull;
|
||||||
|
if (resTypeNode != null &&
|
||||||
|
resTypeNode.findLocalChildren('collection').isNotEmpty) {
|
||||||
|
isCollection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeStr ??= prop
|
||||||
|
.findLocalChildren('getcontentlength')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
lastModStr ??= prop
|
||||||
|
.findLocalChildren('getlastmodified')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
createdStr ??= prop
|
||||||
|
.findLocalChildren('creationdate')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
mimeType ??= prop
|
||||||
|
.findLocalChildren('getcontenttype')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
if (!isFav) {
|
||||||
|
isFav =
|
||||||
|
prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
|
||||||
|
}
|
||||||
|
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollection) continue; // folders aren't "recent files"
|
||||||
|
|
||||||
|
var decodedHref = Uri.decodeFull(href);
|
||||||
|
if (decodedHref.endsWith('/') && decodedHref.length > 1) {
|
||||||
|
decodedHref = decodedHref.substring(0, decodedHref.length - 1);
|
||||||
|
}
|
||||||
|
final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
|
||||||
|
if (name.isEmpty) continue;
|
||||||
|
|
||||||
|
// Strip the DAV root prefix so `path` matches what fetchDirectory produces.
|
||||||
|
final marker = '/files/$username';
|
||||||
|
final markerIndex = decodedHref.indexOf(marker);
|
||||||
|
final itemPath = markerIndex >= 0
|
||||||
|
? decodedHref.substring(markerIndex + marker.length)
|
||||||
|
: decodedHref;
|
||||||
|
if (itemPath.isEmpty) continue;
|
||||||
|
|
||||||
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
|
final created = _parseDavDate(createdStr);
|
||||||
|
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
||||||
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
|
results.add(
|
||||||
|
NextcloudItem(
|
||||||
|
id: validId,
|
||||||
|
name: name,
|
||||||
|
path: itemPath,
|
||||||
|
type: itemType,
|
||||||
|
size: size,
|
||||||
|
lastModified: lastMod,
|
||||||
|
dateCreated: created,
|
||||||
|
isFavorite: isFav,
|
||||||
|
mimeType: mimeType,
|
||||||
|
previewUrl:
|
||||||
|
'$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[Nextcloud DAV] Recent SEARCH found ${results.length} items');
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
Future<NextcloudUserQuota> fetchUserQuota() async {
|
Future<NextcloudUserQuota> fetchUserQuota() async {
|
||||||
final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json';
|
final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json';
|
||||||
debugPrint('[Nextcloud OCS] Fetching user quota from $url');
|
debugPrint('[Nextcloud OCS] Fetching user quota from $url');
|
||||||
@@ -665,6 +994,606 @@ class NextcloudService {
|
|||||||
return response.statusCode == 201;
|
return response.statusCode == 201;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists the contents of the Nextcloud trash bin (flat — deleted folders
|
||||||
|
/// appear as a single entry, not expanded).
|
||||||
|
Future<List<NextcloudItem>> fetchTrash() async {
|
||||||
|
final url = '$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/';
|
||||||
|
debugPrint('[Nextcloud DAV] Requesting PROPFIND for trash: $url');
|
||||||
|
|
||||||
|
final request = http.Request('PROPFIND', Uri.parse(url))
|
||||||
|
..headers.addAll({
|
||||||
|
..._headers,
|
||||||
|
'Depth': '1',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid/>
|
||||||
|
<oc:size/>
|
||||||
|
<nc:trashbin-original-location/>
|
||||||
|
<nc:trashbin-deletion-time/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>''';
|
||||||
|
|
||||||
|
request.body = body;
|
||||||
|
|
||||||
|
final streamedResponse = await http.Client().send(request);
|
||||||
|
final responseBody = await streamedResponse.stream.bytesToString();
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] Trash PROPFIND status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamedResponse.statusCode != 207 &&
|
||||||
|
streamedResponse.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to load trash. HTTP ${streamedResponse.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final document = xml.XmlDocument.parse(responseBody);
|
||||||
|
final responses = document.findLocalDescendants('response');
|
||||||
|
final targetPath = _davPath(
|
||||||
|
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/',
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = <NextcloudItem>[];
|
||||||
|
for (var res in responses) {
|
||||||
|
final hrefNode =
|
||||||
|
res.findLocalChildren('href').firstOrNull ??
|
||||||
|
res.findLocalDescendants('href').firstOrNull;
|
||||||
|
final href = hrefNode?.innerText ?? '';
|
||||||
|
final hrefPath = _davPath(Uri.decodeFull(href));
|
||||||
|
if (hrefPath == targetPath) continue; // skip the trash root itself
|
||||||
|
|
||||||
|
final props = res.findLocalDescendants('prop');
|
||||||
|
if (props.isEmpty) continue;
|
||||||
|
|
||||||
|
bool isCollection = false;
|
||||||
|
String? sizeStr;
|
||||||
|
String? mimeType;
|
||||||
|
String? fileId;
|
||||||
|
String? originalLocation;
|
||||||
|
String? deletionTimeStr;
|
||||||
|
|
||||||
|
for (var prop in props) {
|
||||||
|
if (!isCollection) {
|
||||||
|
final resTypeNode = prop
|
||||||
|
.findLocalChildren('resourcetype')
|
||||||
|
.firstOrNull;
|
||||||
|
if (resTypeNode != null &&
|
||||||
|
resTypeNode.findLocalChildren('collection').isNotEmpty) {
|
||||||
|
isCollection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeStr ??=
|
||||||
|
prop.findLocalChildren('size').firstOrNull?.innerText ??
|
||||||
|
prop.findLocalChildren('getcontentlength').firstOrNull?.innerText;
|
||||||
|
mimeType ??= prop
|
||||||
|
.findLocalChildren('getcontenttype')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
|
||||||
|
originalLocation ??= prop
|
||||||
|
.findLocalChildren('trashbin-original-location')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
deletionTimeStr ??= prop
|
||||||
|
.findLocalChildren('trashbin-deletion-time')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trash entry's own on-disk name, e.g. "photo.jpg.d1735689600" —
|
||||||
|
// this (not the display name) is what restore/delete-forever need.
|
||||||
|
final trashName = Uri.decodeFull(
|
||||||
|
href.split('/').where((s) => s.isNotEmpty).last,
|
||||||
|
);
|
||||||
|
if (trashName.isEmpty) continue;
|
||||||
|
|
||||||
|
final displayName =
|
||||||
|
(originalLocation != null && originalLocation.isNotEmpty)
|
||||||
|
? originalLocation.split('/').where((s) => s.isNotEmpty).last
|
||||||
|
: trashName;
|
||||||
|
|
||||||
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
|
final deletionTime = deletionTimeStr != null
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(int.tryParse(deletionTimeStr) ?? 0) * 1000,
|
||||||
|
)
|
||||||
|
: DateTime.now();
|
||||||
|
final itemType = NextcloudItem.deduceType(
|
||||||
|
displayName,
|
||||||
|
isCollection,
|
||||||
|
mimeType,
|
||||||
|
);
|
||||||
|
final validId = (fileId != null && fileId.isNotEmpty)
|
||||||
|
? fileId
|
||||||
|
: trashName;
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
NextcloudItem(
|
||||||
|
id: validId,
|
||||||
|
name: displayName,
|
||||||
|
path: trashName,
|
||||||
|
type: itemType,
|
||||||
|
size: size,
|
||||||
|
lastModified: deletionTime,
|
||||||
|
originalLocation: originalLocation,
|
||||||
|
deletedAt: deletionTime,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[Nextcloud DAV] Loaded ${items.length} trash items');
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores a trashed item (identified by its trash-relative name, i.e.
|
||||||
|
/// [NextcloudItem.path] for a trash item) back to its original location.
|
||||||
|
Future<bool> restoreTrashItem(String trashName) async {
|
||||||
|
final sourceUrl =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/$trashName';
|
||||||
|
final destUrl =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/trashbin/$username/restore/$trashName';
|
||||||
|
debugPrint('[Nextcloud DAV] Restoring trash item $sourceUrl -> $destUrl');
|
||||||
|
|
||||||
|
final request = http.Request('MOVE', Uri.parse(sourceUrl))
|
||||||
|
..headers.addAll({..._headers, 'Destination': destUrl});
|
||||||
|
final response = await http.Client().send(request);
|
||||||
|
debugPrint('[Nextcloud DAV] Restore status: ${response.statusCode}');
|
||||||
|
return response.statusCode == 201 || response.statusCode == 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently deletes a trashed item (identified by its trash-relative
|
||||||
|
/// name, i.e. [NextcloudItem.path] for a trash item). Cannot be undone.
|
||||||
|
Future<bool> deleteTrashItemForever(String trashName) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/trashbin/$username/trash/$trashName';
|
||||||
|
debugPrint('[Nextcloud DAV] Permanently deleting trash item $url');
|
||||||
|
|
||||||
|
final response = await http.delete(Uri.parse(url), headers: _headers);
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] Permanent delete status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
return response.statusCode == 204 || response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareType _mapShareType(int value) {
|
||||||
|
switch (value) {
|
||||||
|
case 0:
|
||||||
|
return ShareType.user;
|
||||||
|
case 1:
|
||||||
|
return ShareType.group;
|
||||||
|
case 3:
|
||||||
|
return ShareType.publicLink;
|
||||||
|
case 4:
|
||||||
|
return ShareType.email;
|
||||||
|
case 6:
|
||||||
|
return ShareType.federated;
|
||||||
|
default:
|
||||||
|
return ShareType.other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists shares: things the current user has shared with others by
|
||||||
|
/// default, or things others have shared with the current user when
|
||||||
|
/// [sharedWithMe] is true.
|
||||||
|
Future<List<NextcloudShare>> fetchShares({bool sharedWithMe = false}) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares'
|
||||||
|
'?format=json${sharedWithMe ? '&shared_with_me=true' : ''}';
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud OCS] Fetching shares (sharedWithMe=$sharedWithMe) from $url',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud OCS] Shares response status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to load shares. HTTP ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final rawList = data['ocs']?['data'];
|
||||||
|
if (rawList is! List) return [];
|
||||||
|
|
||||||
|
return rawList.map<NextcloudShare>((raw) {
|
||||||
|
final path = (raw['path'] ?? '/').toString();
|
||||||
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
final name = segments.isNotEmpty ? segments.last : path;
|
||||||
|
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
|
||||||
|
final mimeType = raw['mimetype'] as String?;
|
||||||
|
|
||||||
|
return NextcloudShare(
|
||||||
|
id: (raw['id'] ?? '').toString(),
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
|
||||||
|
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
|
||||||
|
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
|
||||||
|
.toString(),
|
||||||
|
sharedWithDisplayName: raw['share_with_displayname'] as String?,
|
||||||
|
sharedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
|
||||||
|
),
|
||||||
|
sharedWithMe: sharedWithMe,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a public link share for [path] and returns its share URL.
|
||||||
|
Future<String> createPublicShareLink(String path) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json';
|
||||||
|
debugPrint('[Nextcloud OCS] Creating public share link for $path');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: _headers,
|
||||||
|
body: {'path': path, 'shareType': '3'},
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud OCS] Create share response status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create share link. HTTP ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final shareUrl = data['ocs']?['data']?['url'] as String?;
|
||||||
|
if (shareUrl == null) {
|
||||||
|
throw Exception('Server response did not include a share URL.');
|
||||||
|
}
|
||||||
|
return shareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a share (un-shares an item you shared, or removes one shared
|
||||||
|
/// with you).
|
||||||
|
Future<bool> deleteShare(String shareId) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
|
||||||
|
debugPrint('[Nextcloud OCS] Deleting share $shareId');
|
||||||
|
|
||||||
|
final response = await http.delete(Uri.parse(url), headers: _headers);
|
||||||
|
debugPrint('[Nextcloud OCS] Delete share status: ${response.statusCode}');
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
||||||
|
final path = (raw['path'] ?? '/').toString();
|
||||||
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
final name = segments.isNotEmpty ? segments.last : path;
|
||||||
|
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
|
||||||
|
final mimeType = raw['mimetype'] as String?;
|
||||||
|
final expiration = raw['expiration'] as String?;
|
||||||
|
|
||||||
|
return NextcloudShare(
|
||||||
|
id: (raw['id'] ?? '').toString(),
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
|
||||||
|
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
|
||||||
|
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
|
||||||
|
.toString(),
|
||||||
|
sharedWithDisplayName: raw['share_with_displayname'] as String?,
|
||||||
|
sharedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
|
||||||
|
),
|
||||||
|
sharedWithMe: sharedWithMe,
|
||||||
|
url: raw['url'] as String?,
|
||||||
|
permissions: (raw['permissions'] as num?)?.toInt() ?? 1,
|
||||||
|
token: raw['token'] as String?,
|
||||||
|
expireDate: expiration != null ? DateTime.tryParse(expiration) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current user's own direct shares on a single file/folder (for the
|
||||||
|
/// Details sheet's Sharing tab) — unlike [fetchShares], scoped to one path.
|
||||||
|
Future<List<NextcloudShare>> fetchSharesForPath(String path) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares'
|
||||||
|
'?format=json&path=${Uri.encodeQueryComponent(path)}&reshares=true';
|
||||||
|
debugPrint('[Nextcloud OCS] Fetching shares for path $path');
|
||||||
|
|
||||||
|
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to load shares. HTTP ${response.statusCode}');
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final rawList = data['ocs']?['data'];
|
||||||
|
if (rawList is! List) return [];
|
||||||
|
return rawList
|
||||||
|
.map<NextcloudShare>((raw) => _shareFromJson(raw, sharedWithMe: false))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shares inherited from a parent folder that the current user doesn't
|
||||||
|
/// own directly ("Others with access" in the web UI). Returns an empty
|
||||||
|
/// list rather than throwing on servers too old to support this endpoint.
|
||||||
|
Future<List<NextcloudShare>> fetchInheritedShares(String path) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/inherited'
|
||||||
|
'?format=json&path=${Uri.encodeQueryComponent(path)}';
|
||||||
|
debugPrint('[Nextcloud OCS] Fetching inherited shares for path $path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||||
|
if (response.statusCode != 200) return [];
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final rawList = data['ocs']?['data'];
|
||||||
|
if (rawList is! List) return [];
|
||||||
|
return rawList
|
||||||
|
.map<NextcloudShare>(
|
||||||
|
(raw) => _shareFromJson(raw, sharedWithMe: false),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Nextcloud OCS] Inherited shares unavailable: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches users/teams (groups) a file/folder can be shared with.
|
||||||
|
Future<List<NextcloudSharee>> searchSharees(String query) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/sharees'
|
||||||
|
'?format=json&itemType=file&search=${Uri.encodeQueryComponent(query)}';
|
||||||
|
debugPrint('[Nextcloud OCS] Searching sharees for "$query"');
|
||||||
|
|
||||||
|
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Sharee search failed. HTTP ${response.statusCode}');
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body)['ocs']?['data'];
|
||||||
|
if (data is! Map) return [];
|
||||||
|
|
||||||
|
NextcloudSharee? fromEntry(dynamic raw, ShareeType type) {
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
final value = raw['value'];
|
||||||
|
final shareWith = value is Map ? value['shareWith'] as String? : null;
|
||||||
|
if (shareWith == null) return null;
|
||||||
|
return NextcloudSharee(
|
||||||
|
shareWith: shareWith,
|
||||||
|
label: (raw['label'] ?? shareWith).toString(),
|
||||||
|
type: type,
|
||||||
|
subtitle: value is Map
|
||||||
|
? value['shareWithAdditionalInfo'] as String?
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final results = <NextcloudSharee>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
void addAll(String bucket, ShareeType type) {
|
||||||
|
final list = data[bucket];
|
||||||
|
final exactList = (data['exact'] is Map) ? data['exact'][bucket] : null;
|
||||||
|
for (final raw in [
|
||||||
|
...(exactList is List ? exactList : []),
|
||||||
|
...(list is List ? list : []),
|
||||||
|
]) {
|
||||||
|
final sharee = fromEntry(raw, type);
|
||||||
|
if (sharee == null) continue;
|
||||||
|
final key = '${sharee.type}:${sharee.shareWith}';
|
||||||
|
if (seen.add(key)) results.add(sharee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addAll('users', ShareeType.user);
|
||||||
|
addAll('groups', ShareeType.group);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDavDate(DateTime date) {
|
||||||
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
final d = date.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// General-purpose share creation, covering public links (shareType 3),
|
||||||
|
/// user/team shares (0/1), and email shares (4). [createPublicShareLink]
|
||||||
|
/// is left as the simpler dedicated entry point used by the swipe action.
|
||||||
|
Future<NextcloudShare> createShare({
|
||||||
|
required String path,
|
||||||
|
required int shareType,
|
||||||
|
String? shareWith,
|
||||||
|
String? password,
|
||||||
|
DateTime? expireDate,
|
||||||
|
}) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json';
|
||||||
|
debugPrint('[Nextcloud OCS] Creating share (type $shareType) for $path');
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
'path': path,
|
||||||
|
'shareType': '$shareType',
|
||||||
|
'shareWith': ?shareWith,
|
||||||
|
if (password != null && password.isNotEmpty) 'password': password,
|
||||||
|
if (expireDate != null) 'expireDate': _formatDavDate(expireDate),
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: _headers,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to create share. HTTP ${response.statusCode}');
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body)['ocs']?['data'];
|
||||||
|
if (data is! Map) {
|
||||||
|
throw Exception('Server response did not include the new share.');
|
||||||
|
}
|
||||||
|
return _shareFromJson(data, sharedWithMe: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-file activity feed (Details sheet's Activity tab), unlike
|
||||||
|
/// [fetchActivities] which is the whole-account feed.
|
||||||
|
Future<List<NextcloudActivity>> fetchFileActivity(String fileId) async {
|
||||||
|
List<NextcloudActivity> parse(String body) {
|
||||||
|
final data = jsonDecode(body);
|
||||||
|
final rawData = data['ocs']?['data'];
|
||||||
|
List<dynamic> list = [];
|
||||||
|
if (rawData is List) {
|
||||||
|
list = rawData;
|
||||||
|
} else if (rawData is Map && rawData['activity'] is List) {
|
||||||
|
list = rawData['activity'];
|
||||||
|
}
|
||||||
|
return list.map((a) {
|
||||||
|
return NextcloudActivity(
|
||||||
|
id: (a['activity_id'] ?? '').toString(),
|
||||||
|
title: a['subject'] ?? 'Activity',
|
||||||
|
subject: a['message'] ?? (a['subject'] ?? ''),
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(a['timestamp'] as int? ?? 0) * 1000,
|
||||||
|
),
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
author: a['user'] ?? username,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The activity app doesn't have one single documented way to scope the
|
||||||
|
// feed to a specific file across server versions - try the same base
|
||||||
|
// endpoint `fetchActivities` uses (proven to work) with object filter
|
||||||
|
// params first, then fall back to the `filter/files` route some
|
||||||
|
// versions expose, rather than betting on just one.
|
||||||
|
final candidates = [
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity'
|
||||||
|
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity/filter/files'
|
||||||
|
'?format=json&object_type=files&object_id=${Uri.encodeQueryComponent(fileId)}',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final url in candidates) {
|
||||||
|
try {
|
||||||
|
debugPrint('[Nextcloud OCS] Fetching activity for file $fileId: $url');
|
||||||
|
final response = await http.get(Uri.parse(url), headers: _headers);
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud OCS] File activity response status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) continue;
|
||||||
|
final items = parse(response.body);
|
||||||
|
if (items.isNotEmpty) return items;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Nextcloud OCS] File activity attempt failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists a file's version history via the DAV versions endpoint.
|
||||||
|
Future<List<NextcloudFileVersion>> fetchFileVersions(String fileId) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId';
|
||||||
|
debugPrint('[Nextcloud DAV] Requesting PROPFIND for versions of $fileId');
|
||||||
|
|
||||||
|
const body = '''<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:propfind xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>''';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final request = http.Request('PROPFIND', Uri.parse(url))
|
||||||
|
..headers.addAll({
|
||||||
|
..._headers,
|
||||||
|
'Depth': '1',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
})
|
||||||
|
..body = body;
|
||||||
|
final streamed = await http.Client().send(request);
|
||||||
|
final responseBody = await streamed.stream.bytesToString();
|
||||||
|
if (streamed.statusCode != 207 && streamed.statusCode != 200) {
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] Versions PROPFIND failed: ${streamed.statusCode}',
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final document = xml.XmlDocument.parse(responseBody);
|
||||||
|
final responses = document.findLocalDescendants('response');
|
||||||
|
final versions = <NextcloudFileVersion>[];
|
||||||
|
for (final res in responses) {
|
||||||
|
final href = res.findLocalChildren('href').firstOrNull?.innerText ?? '';
|
||||||
|
final hrefPath = _davPath(Uri.decodeFull(href));
|
||||||
|
// Skip the versions collection entry itself.
|
||||||
|
if (hrefPath.endsWith('/versions/$fileId')) continue;
|
||||||
|
final versionLabel = hrefPath.split('/').lastOrNull;
|
||||||
|
if (versionLabel == null || versionLabel.isEmpty) continue;
|
||||||
|
|
||||||
|
final props = res.findLocalDescendants('prop');
|
||||||
|
String? lastModStr;
|
||||||
|
String? sizeStr;
|
||||||
|
for (final prop in props) {
|
||||||
|
lastModStr ??= prop
|
||||||
|
.findLocalChildren('getlastmodified')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
sizeStr ??= prop
|
||||||
|
.findLocalChildren('getcontentlength')
|
||||||
|
.firstOrNull
|
||||||
|
?.innerText;
|
||||||
|
}
|
||||||
|
final timestamp = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
|
versions.add(
|
||||||
|
NextcloudFileVersion(
|
||||||
|
versionLabel: versionLabel,
|
||||||
|
timestamp: timestamp,
|
||||||
|
size: int.tryParse(sizeStr ?? '') ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
versions.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||||
|
return versions;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Nextcloud DAV] Fetching versions failed: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores [versionLabel] as the current version of [fileId].
|
||||||
|
Future<bool> restoreFileVersion(String fileId, String versionLabel) async {
|
||||||
|
final source =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId/$versionLabel';
|
||||||
|
final destination =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/versions/$username/restore/$versionLabel';
|
||||||
|
debugPrint('[Nextcloud DAV] Restoring version $versionLabel of $fileId');
|
||||||
|
|
||||||
|
final request = http.Request('MOVE', Uri.parse(source))
|
||||||
|
..headers.addAll({..._headers, 'Destination': destination});
|
||||||
|
final response = await http.Client().send(request);
|
||||||
|
debugPrint(
|
||||||
|
'[Nextcloud DAV] Restore version status: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
return response.statusCode == 201 || response.statusCode == 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a specific historical version's bytes to [savePath].
|
||||||
|
Future<void> downloadVersionToFile(
|
||||||
|
String fileId,
|
||||||
|
String versionLabel,
|
||||||
|
String savePath,
|
||||||
|
) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/remote.php/dav/versions/$username/versions/$fileId/$versionLabel';
|
||||||
|
final dio = Dio();
|
||||||
|
await dio.download(url, savePath, options: Options(headers: _headers));
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> toggleFavorite(
|
Future<bool> toggleFavorite(
|
||||||
String itemPath,
|
String itemPath,
|
||||||
bool currentFavoriteState,
|
bool currentFavoriteState,
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
class AppTheme {
|
class AppTheme {
|
||||||
static const Color defaultNextcloudBlue = Color(0xFF0082C9);
|
static const Color defaultNextcloudBlue = Color(0xFF0082C9);
|
||||||
|
|
||||||
|
// Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't
|
||||||
|
// implement Android's predictive-back gesture at all. Opting into
|
||||||
|
// PredictiveBackPageTransitionsBuilder here (Android only) is required —
|
||||||
|
// on top of the manifest's enableOnBackInvokedCallback flag — for the
|
||||||
|
// system back gesture to animate/scale the current screen away instead of
|
||||||
|
// popping instantly.
|
||||||
|
static const _pageTransitionsTheme = PageTransitionsTheme(
|
||||||
|
builders: {TargetPlatform.android: PredictiveBackPageTransitionsBuilder()},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flutter's Slider defaults to the older "2023" Material 3 look even with
|
||||||
|
// useMaterial3 on; year2023: false opts into the current redesign (gapped
|
||||||
|
// track, taller handle-shaped thumb) app-wide instead of per-slider. The
|
||||||
|
// flag itself is deprecated in favor of `false` becoming the new default
|
||||||
|
// in a future Flutter release - safe to keep using until then.
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
static const _sliderTheme = SliderThemeData(year2023: false);
|
||||||
|
|
||||||
static const List<Color> seedColors = [
|
static const List<Color> seedColors = [
|
||||||
Color(0xFF0082C9), // Nextcloud Blue
|
Color(0xFF0082C9), // Nextcloud Blue
|
||||||
Color(0xFF009688), // Ocean Teal
|
Color(0xFF009688), // Ocean Teal
|
||||||
@@ -29,6 +47,8 @@ class AppTheme {
|
|||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
|
sliderTheme: _sliderTheme,
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
|
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
headlineMedium: GoogleFonts.inter(
|
headlineMedium: GoogleFonts.inter(
|
||||||
@@ -64,19 +84,37 @@ class AppTheme {
|
|||||||
Color seedColor, {
|
Color seedColor, {
|
||||||
ColorScheme? dynamicScheme,
|
ColorScheme? dynamicScheme,
|
||||||
bool useDynamicColor = true,
|
bool useDynamicColor = true,
|
||||||
|
bool amoled = false,
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = (useDynamicColor && dynamicScheme != null)
|
var colorScheme = (useDynamicColor && dynamicScheme != null)
|
||||||
? dynamicScheme
|
? dynamicScheme
|
||||||
: ColorScheme.fromSeed(
|
: ColorScheme.fromSeed(
|
||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (amoled) {
|
||||||
|
// True OLED black across every surface tone Material 3 hands out -
|
||||||
|
// not just the scaffold - so cards, app bars and sheets all go black
|
||||||
|
// too instead of the usual dark-grey elevation tints.
|
||||||
|
colorScheme = colorScheme.copyWith(
|
||||||
|
surface: Colors.black,
|
||||||
|
surfaceContainerLowest: Colors.black,
|
||||||
|
surfaceContainerLow: Colors.black,
|
||||||
|
surfaceContainer: Colors.black,
|
||||||
|
surfaceContainerHigh: Colors.black,
|
||||||
|
surfaceContainerHighest: Colors.black,
|
||||||
|
surfaceDim: Colors.black,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
|
sliderTheme: _sliderTheme,
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
headlineMedium: GoogleFonts.inter(
|
headlineMedium: GoogleFonts.inter(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.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 '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
@@ -22,7 +23,7 @@ class AccountView extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Account & Storage'),
|
title: const Text('Settings'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => _showLogoutConfirmation(context, provider),
|
onPressed: () => _showLogoutConfirmation(context, provider),
|
||||||
@@ -316,6 +317,16 @@ class AccountView extends StatelessWidget {
|
|||||||
provider.setThemeMode(set.first),
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -331,6 +342,78 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const _BottomBarAppearanceCard(),
|
const _BottomBarAppearanceCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Bottom Nav Tabs
|
||||||
|
Text(
|
||||||
|
'Bottom Bar Tabs',
|
||||||
|
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),
|
||||||
|
const _TabSettingsCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Swipe Actions
|
||||||
|
Text(
|
||||||
|
'Files Swipe Actions',
|
||||||
|
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 _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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -575,3 +658,325 @@ class _AccentSwatch extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SwipeActionsCard extends StatelessWidget {
|
||||||
|
const _SwipeActionsCard();
|
||||||
|
|
||||||
|
String _label(SwipeAction action) {
|
||||||
|
switch (action) {
|
||||||
|
case SwipeAction.none:
|
||||||
|
return 'None';
|
||||||
|
case SwipeAction.favorite:
|
||||||
|
return 'Favorite';
|
||||||
|
case SwipeAction.delete:
|
||||||
|
return 'Delete';
|
||||||
|
case SwipeAction.share:
|
||||||
|
return 'Share';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildRow(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
label: 'Swipe right',
|
||||||
|
value: provider.swipeRightAction,
|
||||||
|
onChanged: provider.setSwipeRightAction,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildRow(
|
||||||
|
context,
|
||||||
|
theme,
|
||||||
|
label: 'Swipe left',
|
||||||
|
value: provider.swipeLeftAction,
|
||||||
|
onChanged: provider.setSwipeLeftAction,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(
|
||||||
|
BuildContext context,
|
||||||
|
ThemeData theme, {
|
||||||
|
required String label,
|
||||||
|
required SwipeAction value,
|
||||||
|
required ValueChanged<SwipeAction> onChanged,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<SwipeAction>(
|
||||||
|
value: value,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
items: SwipeAction.values
|
||||||
|
.map((a) => DropdownMenuItem(value: a, child: Text(_label(a))))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (a) {
|
||||||
|
if (a != null) onChanged(a);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaPlayerCard extends StatelessWidget {
|
||||||
|
const _MediaPlayerCard();
|
||||||
|
|
||||||
|
String _label(MediaProgressBarStyle style) {
|
||||||
|
switch (style) {
|
||||||
|
case MediaProgressBarStyle.wavy:
|
||||||
|
return 'Wavy';
|
||||||
|
case MediaProgressBarStyle.classic:
|
||||||
|
return 'Classic';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Seek bar style',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<MediaProgressBarStyle>(
|
||||||
|
value: provider.mediaProgressBarStyle,
|
||||||
|
underline: const SizedBox.shrink(),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
items: MediaProgressBarStyle.values
|
||||||
|
.map(
|
||||||
|
(s) => DropdownMenuItem(value: s, child: Text(_label(s))),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (s) {
|
||||||
|
if (s != null) provider.setMediaProgressBarStyle(s);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheSettingsCard extends StatelessWidget {
|
||||||
|
const _CacheSettingsCard();
|
||||||
|
|
||||||
|
String _label(CachePolicy policy) {
|
||||||
|
switch (policy) {
|
||||||
|
case CachePolicy.never:
|
||||||
|
return 'Never cache';
|
||||||
|
case CachePolicy.interval:
|
||||||
|
return 'Refresh periodically';
|
||||||
|
case CachePolicy.manual:
|
||||||
|
return 'Refresh manually only';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _description(CachePolicy policy) {
|
||||||
|
switch (policy) {
|
||||||
|
case CachePolicy.never:
|
||||||
|
return 'Every visit to a folder fetches it fresh (current behavior)';
|
||||||
|
case CachePolicy.interval:
|
||||||
|
return 'Reuse a folder\'s listing until it\'s a few minutes old';
|
||||||
|
case CachePolicy.manual:
|
||||||
|
return 'Reuse a folder\'s listing until you pull to refresh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
RadioGroup<CachePolicy>(
|
||||||
|
groupValue: provider.cachePolicy,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) provider.setCachePolicy(value);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final policy in CachePolicy.values)
|
||||||
|
RadioListTile<CachePolicy>(
|
||||||
|
value: policy,
|
||||||
|
title: Text(_label(policy)),
|
||||||
|
subtitle: Text(_description(policy)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.cachePolicy == CachePolicy.interval)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Refresh interval',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${provider.cacheIntervalMinutes} min',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: provider.cacheIntervalMinutes.toDouble(),
|
||||||
|
min: 1,
|
||||||
|
max: 60,
|
||||||
|
divisions: 59,
|
||||||
|
label: '${provider.cacheIntervalMinutes} min',
|
||||||
|
onChanged: (value) =>
|
||||||
|
provider.setCacheIntervalMinutes(value.round()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabSettingsCard extends StatelessWidget {
|
||||||
|
const _TabSettingsCard();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: ReorderableListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
|
final order = List<AppTab>.from(provider.tabOrder);
|
||||||
|
final tab = order.removeAt(oldIndex);
|
||||||
|
order.insert(newIndex, tab);
|
||||||
|
provider.setTabOrder(order);
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
for (final tab in provider.tabOrder)
|
||||||
|
_TabConfigRow(
|
||||||
|
key: ValueKey(tab),
|
||||||
|
tab: tab,
|
||||||
|
isVisible: !provider.hiddenTabs.contains(tab),
|
||||||
|
isDefault: provider.defaultTab == tab,
|
||||||
|
onVisibilityChanged: (value) {
|
||||||
|
final error = provider.setTabHidden(tab, !value);
|
||||||
|
if (error != null && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(error),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSetDefault: () => provider.setDefaultTab(tab),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabConfigRow extends StatelessWidget {
|
||||||
|
final AppTab tab;
|
||||||
|
final bool isVisible;
|
||||||
|
final bool isDefault;
|
||||||
|
final ValueChanged<bool> onVisibilityChanged;
|
||||||
|
final VoidCallback onSetDefault;
|
||||||
|
|
||||||
|
const _TabConfigRow({
|
||||||
|
required super.key,
|
||||||
|
required this.tab,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.isDefault,
|
||||||
|
required this.onVisibilityChanged,
|
||||||
|
required this.onSetDefault,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
tab.icon,
|
||||||
|
color: isVisible
|
||||||
|
? colorScheme.onSurfaceVariant
|
||||||
|
: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
tab.label,
|
||||||
|
style: TextStyle(color: isVisible ? null : colorScheme.outline),
|
||||||
|
),
|
||||||
|
subtitle: isDefault ? const Text('Default tab') : null,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isDefault ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||||
|
color: isDefault
|
||||||
|
? Colors.amber.shade700
|
||||||
|
: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
tooltip: 'Set as default tab',
|
||||||
|
onPressed: isVisible && !isDefault ? onSetDefault : null,
|
||||||
|
),
|
||||||
|
Switch(value: isVisible, onChanged: onVisibilityChanged),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/more_tabs_button.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/profile_avatar_button.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
|
|
||||||
class ActivityView extends StatelessWidget {
|
class ActivityView extends StatelessWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
@@ -16,23 +18,12 @@ class ActivityView extends StatelessWidget {
|
|||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
final activities = provider.activities;
|
final activities = provider.activities;
|
||||||
|
|
||||||
return CustomScrollView(
|
final List<Widget> contentSlivers = [
|
||||||
controller: scrollController,
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
slivers: [
|
|
||||||
SliverAppBar(
|
|
||||||
pinned: true,
|
|
||||||
expandedHeight: kToolbarHeight,
|
|
||||||
collapsedHeight: kToolbarHeight,
|
|
||||||
backgroundColor: colorScheme.surface,
|
|
||||||
surfaceTintColor: colorScheme.surface,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
actions: const [ProfileAvatarButton()],
|
|
||||||
),
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
|
||||||
|
|
||||||
if (activities.isEmpty)
|
if (activities.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No recent activity',
|
'No recent activity',
|
||||||
@@ -57,9 +48,7 @@ class ActivityView extends StatelessWidget {
|
|||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: colorScheme.outlineVariant.withValues(
|
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
|
||||||
alpha: 0.2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -83,10 +72,11 @@ class ActivityView extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
act.title,
|
act.title,
|
||||||
style: theme.textTheme.titleMedium
|
style: theme.textTheme.titleMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
@@ -94,6 +84,8 @@ class ActivityView extends StatelessWidget {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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(
|
||||||
@@ -132,7 +124,14 @@ class ActivityView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||||
],
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SyncedHeaderScaffold(
|
||||||
|
scrollController: scrollController,
|
||||||
|
provider: provider,
|
||||||
|
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
||||||
|
contentSlivers: contentSlivers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:pdfx/pdfx.dart';
|
import 'package:pdfx/pdfx.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/details/details_sheet.dart';
|
||||||
|
import '../widgets/details/peek_details_sheet.dart';
|
||||||
import '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
|
import '../widgets/share_sheet.dart';
|
||||||
|
|
||||||
const _textPreviewExtensions = {
|
const _textPreviewExtensions = {
|
||||||
'.txt',
|
'.txt',
|
||||||
@@ -55,15 +57,15 @@ class FileViewerScreen extends StatefulWidget {
|
|||||||
|
|
||||||
/// Opens the viewer with no page transition — media should appear
|
/// Opens the viewer with no page transition — media should appear
|
||||||
/// instantly, not fade/zoom in the way MaterialPageRoute normally would.
|
/// instantly, not fade/zoom in the way MaterialPageRoute normally would.
|
||||||
|
/// Going *back* still uses the theme's normal (predictive-back-capable)
|
||||||
|
/// transition, via [_InstantOpenPageRoute] below.
|
||||||
static Route<void> route({
|
static Route<void> route({
|
||||||
required NextcloudItem item,
|
required NextcloudItem item,
|
||||||
List<NextcloudItem>? siblings,
|
List<NextcloudItem>? siblings,
|
||||||
}) {
|
}) {
|
||||||
return PageRouteBuilder<void>(
|
return _InstantOpenPageRoute<void>(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
pageBuilder: (context) =>
|
||||||
FileViewerScreen(item: item, siblings: siblings),
|
FileViewerScreen(item: item, siblings: siblings),
|
||||||
transitionDuration: Duration.zero,
|
|
||||||
reverseTransitionDuration: Duration.zero,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,30 @@ class FileViewerScreen extends StatefulWidget {
|
|||||||
State<FileViewerScreen> createState() => _FileViewerScreenState();
|
State<FileViewerScreen> createState() => _FileViewerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A [PageRoute] that opens instantly (zero-duration forward transition)
|
||||||
|
/// but still delegates to the app's [PageTransitionsTheme] for the reverse
|
||||||
|
/// transition — unlike a plain [PageRouteBuilder] with no transitionsBuilder,
|
||||||
|
/// which never consults the theme and so can't participate in Android's
|
||||||
|
/// predictive-back gesture at all.
|
||||||
|
class _InstantOpenPageRoute<T> extends PageRoute<T>
|
||||||
|
with MaterialRouteTransitionMixin<T> {
|
||||||
|
_InstantOpenPageRoute({required this.pageBuilder, super.settings});
|
||||||
|
|
||||||
|
final WidgetBuilder pageBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildContent(BuildContext context) => pageBuilder(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get maintainState => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get transitionDuration => Duration.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get reverseTransitionDuration => const Duration(milliseconds: 300);
|
||||||
|
}
|
||||||
|
|
||||||
class _FileViewerScreenState extends State<FileViewerScreen> {
|
class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||||
double? _downloadProgress;
|
double? _downloadProgress;
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
@@ -190,23 +216,6 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _shareCurrentItem(ServerProvider provider) async {
|
|
||||||
final item = _currentItem;
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
try {
|
|
||||||
final path = await _downloadToTemp(provider, item);
|
|
||||||
await SharePlus.instance.share(ShareParams(files: [XFile(path)]));
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Could not share ${item.name}: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isBusy = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleFavorite(ServerProvider provider) async {
|
Future<void> _toggleFavorite(ServerProvider provider) async {
|
||||||
final item = _currentItem;
|
final item = _currentItem;
|
||||||
await provider.toggleItemFavorite(item);
|
await provider.toggleItemFavorite(item);
|
||||||
@@ -328,7 +337,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Align(
|
// The action bar floats just above the details peek below it,
|
||||||
|
// rather than overlapping it.
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.sizeOf(context).height * 0.16,
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: AnimatedSlide(
|
child: AnimatedSlide(
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
@@ -341,15 +356,29 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
isBusy: _isBusy,
|
isBusy: _isBusy,
|
||||||
opacity: provider.bottomBarOpacity,
|
opacity: provider.bottomBarOpacity,
|
||||||
blurSigma: provider.bottomBarBlur,
|
blurSigma: provider.bottomBarBlur,
|
||||||
onShare: () => _shareCurrentItem(provider),
|
onShare: () => ShareSheet.show(context, _currentItem),
|
||||||
onFavorite: () => _toggleFavorite(provider),
|
onFavorite: () => _toggleFavorite(provider),
|
||||||
onDelete: () => _deleteCurrentItem(provider),
|
onDelete: () => _deleteCurrentItem(provider),
|
||||||
onOpenExternally: () => _openExternally(provider),
|
onOpenExternally: () => _openExternally(provider),
|
||||||
onDownload: () => _downloadToDevice(provider),
|
onDownload: () => _downloadToDevice(provider),
|
||||||
|
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
// Always-present peek of the current item's details, draggable
|
||||||
|
// up into the full Info/Versions/Activity sheet.
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_controlsVisible,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _controlsVisible ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: PeekDetailsSheet(item: _currentItem),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -404,6 +433,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
item: widget.item,
|
item: widget.item,
|
||||||
isBusy: _isBusy,
|
isBusy: _isBusy,
|
||||||
onOpenExternally: () => _openExternally(provider),
|
onOpenExternally: () => _openExternally(provider),
|
||||||
|
onOpenDetails: () => DetailsSheet.show(context, widget.item),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +451,7 @@ class _MediaActionBar extends StatelessWidget {
|
|||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback onOpenExternally;
|
final VoidCallback onOpenExternally;
|
||||||
final VoidCallback onDownload;
|
final VoidCallback onDownload;
|
||||||
|
final VoidCallback onDetails;
|
||||||
|
|
||||||
const _MediaActionBar({
|
const _MediaActionBar({
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
@@ -432,6 +463,7 @@ class _MediaActionBar extends StatelessWidget {
|
|||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
required this.onOpenExternally,
|
required this.onOpenExternally,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
|
required this.onDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -458,10 +490,10 @@ class _MediaActionBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_ActionIconButton(
|
_ActionIconButton(
|
||||||
icon: isFavorite
|
icon: isFavorite
|
||||||
? Icons.star_rounded
|
? Icons.favorite_rounded
|
||||||
: Icons.star_outline_rounded,
|
: Icons.favorite_border_rounded,
|
||||||
tooltip: 'Favorite',
|
tooltip: 'Favorite',
|
||||||
color: isFavorite ? Colors.amber : null,
|
color: isFavorite ? Colors.red : null,
|
||||||
onTap: onFavorite,
|
onTap: onFavorite,
|
||||||
),
|
),
|
||||||
_ActionIconButton(
|
_ActionIconButton(
|
||||||
@@ -480,6 +512,11 @@ class _MediaActionBar extends StatelessWidget {
|
|||||||
color: colorScheme.error,
|
color: colorScheme.error,
|
||||||
onTap: isBusy ? null : onDelete,
|
onTap: isBusy ? null : onDelete,
|
||||||
),
|
),
|
||||||
|
_ActionIconButton(
|
||||||
|
icon: Icons.info_outline_rounded,
|
||||||
|
tooltip: 'Details',
|
||||||
|
onTap: onDetails,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -524,23 +561,89 @@ class _ActionIconButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImagePreview extends StatelessWidget {
|
class _ImagePreview extends StatefulWidget {
|
||||||
final String url;
|
final String url;
|
||||||
final Map<String, String> headers;
|
final Map<String, String> headers;
|
||||||
|
|
||||||
const _ImagePreview({super.key, required this.url, required this.headers});
|
const _ImagePreview({super.key, required this.url, required this.headers});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ImagePreview> createState() => _ImagePreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImagePreviewState extends State<_ImagePreview>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
static const _doubleTapScale = 3.0;
|
||||||
|
|
||||||
|
final TransformationController _transformController =
|
||||||
|
TransformationController();
|
||||||
|
late final AnimationController _animController;
|
||||||
|
Animation<Matrix4>? _animation;
|
||||||
|
Offset _doubleTapPosition = Offset.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animController =
|
||||||
|
AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
)..addListener(() {
|
||||||
|
if (_animation != null) {
|
||||||
|
_transformController.value = _animation!.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animController.dispose();
|
||||||
|
_transformController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDoubleTapDown(TapDownDetails details) {
|
||||||
|
_doubleTapPosition = details.localPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDoubleTap() {
|
||||||
|
final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01;
|
||||||
|
final Matrix4 endMatrix;
|
||||||
|
if (isZoomedIn) {
|
||||||
|
endMatrix = Matrix4.identity();
|
||||||
|
} else {
|
||||||
|
final p = _doubleTapPosition;
|
||||||
|
endMatrix = Matrix4.identity()
|
||||||
|
..translateByDouble(
|
||||||
|
-p.dx * (_doubleTapScale - 1),
|
||||||
|
-p.dy * (_doubleTapScale - 1),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1);
|
||||||
|
}
|
||||||
|
_animation = Matrix4Tween(
|
||||||
|
begin: _transformController.value,
|
||||||
|
end: endMatrix,
|
||||||
|
).animate(CurveTween(curve: Curves.easeOut).animate(_animController));
|
||||||
|
_animController.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return SizedBox.expand(
|
return SizedBox.expand(
|
||||||
|
child: GestureDetector(
|
||||||
|
onDoubleTapDown: _onDoubleTapDown,
|
||||||
|
onDoubleTap: _onDoubleTap,
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transformController,
|
||||||
minScale: 0.8,
|
minScale: 0.8,
|
||||||
maxScale: 5.0,
|
maxScale: 5.0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
url,
|
widget.url,
|
||||||
headers: headers,
|
headers: widget.headers,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
loadingBuilder: (context, child, progress) {
|
loadingBuilder: (context, child, progress) {
|
||||||
if (progress == null) return child;
|
if (progress == null) return child;
|
||||||
@@ -558,6 +661,7 @@ class _ImagePreview extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -647,6 +751,9 @@ class _VideoPreviewState extends State<_VideoPreview> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final progressBarStyle = context
|
||||||
|
.watch<ServerProvider>()
|
||||||
|
.mediaProgressBarStyle;
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -737,6 +844,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
playedColor: fg,
|
playedColor: fg,
|
||||||
trackColor: fg.withValues(alpha: 0.3),
|
trackColor: fg.withValues(alpha: 0.3),
|
||||||
|
style: progressBarStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -760,11 +868,13 @@ class _WavySeekBar extends StatefulWidget {
|
|||||||
final VideoPlayerController controller;
|
final VideoPlayerController controller;
|
||||||
final Color playedColor;
|
final Color playedColor;
|
||||||
final Color trackColor;
|
final Color trackColor;
|
||||||
|
final MediaProgressBarStyle style;
|
||||||
|
|
||||||
const _WavySeekBar({
|
const _WavySeekBar({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.playedColor,
|
required this.playedColor,
|
||||||
required this.trackColor,
|
required this.trackColor,
|
||||||
|
required this.style,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -793,7 +903,8 @@ class _WavySeekBarState extends State<_WavySeekBar>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final value = widget.controller.value;
|
final value = widget.controller.value;
|
||||||
final isPlaying = value.isPlaying && _dragRatio == null;
|
final isWavy = widget.style == MediaProgressBarStyle.wavy;
|
||||||
|
final isPlaying = isWavy && value.isPlaying && _dragRatio == null;
|
||||||
if (isPlaying && !_waveController.isAnimating) {
|
if (isPlaying && !_waveController.isAnimating) {
|
||||||
_waveController.repeat();
|
_waveController.repeat();
|
||||||
} else if (!isPlaying && _waveController.isAnimating) {
|
} else if (!isPlaying && _waveController.isAnimating) {
|
||||||
@@ -976,7 +1087,14 @@ class _PdfPreviewState extends State<_PdfPreview> {
|
|||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PdfViewPinch(controller: _controller!);
|
// pdfx's own default minScale (1.0 = the page's true/100% size) is
|
||||||
|
// often *larger* than the fit-to-width size a wide page first renders
|
||||||
|
// at, since that initial render is just normal box layout, not the
|
||||||
|
// InteractiveViewer transform pinching engages on first touch - so the
|
||||||
|
// moment you touch the page it snaps up to 1.0 and, with the default
|
||||||
|
// floor, can never pinch back down past it. A low floor here lets you
|
||||||
|
// zoom back out past that to the fit-width view you started at.
|
||||||
|
return PdfViewPinch(controller: _controller!, minScale: 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,11 +1167,13 @@ class _UnsupportedPreview extends StatelessWidget {
|
|||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
final bool isBusy;
|
final bool isBusy;
|
||||||
final VoidCallback onOpenExternally;
|
final VoidCallback onOpenExternally;
|
||||||
|
final VoidCallback onOpenDetails;
|
||||||
|
|
||||||
const _UnsupportedPreview({
|
const _UnsupportedPreview({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.isBusy,
|
required this.isBusy,
|
||||||
required this.onOpenExternally,
|
required this.onOpenExternally,
|
||||||
|
required this.onOpenDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1094,6 +1214,12 @@ class _UnsupportedPreview extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.open_in_new_rounded),
|
icon: const Icon(Icons.open_in_new_rounded),
|
||||||
label: const Text('Open with...'),
|
label: const Text('Open with...'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onOpenDetails,
|
||||||
|
icon: const Icon(Icons.info_outline_rounded),
|
||||||
|
label: const Text('Details'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,39 +1,256 @@
|
|||||||
|
import 'package:file_saver/file_saver.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../models/selection_action.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/details/details_sheet.dart';
|
||||||
|
import '../widgets/more_tabs_button.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/profile_avatar_button.dart';
|
||||||
|
import '../widgets/segmented_icon_toggle.dart';
|
||||||
|
import '../widgets/selectable_thumbnail.dart';
|
||||||
|
import '../widgets/share_sheet.dart';
|
||||||
|
import '../widgets/sort_menu_button.dart';
|
||||||
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
|
|
||||||
class PhotosView extends StatelessWidget {
|
class PhotosView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const PhotosView({super.key, required this.scrollController});
|
const PhotosView({super.key, required this.scrollController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhotosView> createState() => _PhotosViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotosViewState extends State<PhotosView> {
|
||||||
|
bool _requested = false;
|
||||||
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
void _toggleSelection(NextcloudItem item) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
setState(() {
|
||||||
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
setState(_selectedIds.clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bulk actions shown in the sticky selection toolbar for the
|
||||||
|
/// currently-selected items.
|
||||||
|
List<SelectionAction> _buildSelectionActions(
|
||||||
|
ServerProvider provider,
|
||||||
|
List<NextcloudItem> selected,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
SelectionAction(
|
||||||
|
icon: selected.every((i) => i.isFavorite)
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
label: selected.every((i) => i.isFavorite)
|
||||||
|
? 'Remove from favorites'
|
||||||
|
: 'Favorite',
|
||||||
|
color: Colors.red.shade400,
|
||||||
|
onTap: () => _favoriteSelected(provider, selected),
|
||||||
|
),
|
||||||
|
SelectionAction(
|
||||||
|
icon: Icons.share_rounded,
|
||||||
|
label: 'Share',
|
||||||
|
onTap: () => selected.length == 1
|
||||||
|
? ShareSheet.show(context, selected.single)
|
||||||
|
: _shareSelected(context, provider, selected),
|
||||||
|
),
|
||||||
|
SelectionAction(
|
||||||
|
icon: Icons.download_rounded,
|
||||||
|
label: 'Download',
|
||||||
|
onTap: () => _downloadSelected(context, provider, selected),
|
||||||
|
),
|
||||||
|
SelectionAction(
|
||||||
|
icon: Icons.delete_outline_rounded,
|
||||||
|
label: 'Delete',
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
onTap: () => _confirmDeleteSelected(context, provider, selected),
|
||||||
|
),
|
||||||
|
if (selected.length == 1)
|
||||||
|
SelectionAction(
|
||||||
|
icon: Icons.info_outline_rounded,
|
||||||
|
label: 'Details',
|
||||||
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
final photos = provider.photoItems;
|
|
||||||
|
|
||||||
return CustomScrollView(
|
if (!_requested) {
|
||||||
controller: scrollController,
|
_requested = true;
|
||||||
physics: const BouncingScrollPhysics(),
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
slivers: [
|
(_) => provider.fetchAllMedia(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final photos = provider.photoItems;
|
||||||
|
final selectedItems = photos
|
||||||
|
.where((i) => _selectedIds.contains(i.id))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final controlsRow = Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
provider.sortAscending
|
||||||
|
? Icons.arrow_upward_rounded
|
||||||
|
: Icons.arrow_downward_rounded,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: provider.sortAscending ? 'Ascending' : 'Descending',
|
||||||
|
onPressed: provider.toggleSortOrder,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SortMenuButton(
|
||||||
|
field: provider.sortField,
|
||||||
|
onChanged: provider.setSortField,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ToggleIconButton(
|
||||||
|
icon: provider.showFavoritesOnlyPhotos
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
isSelected: provider.showFavoritesOnlyPhotos,
|
||||||
|
onTap: provider.toggleFavoritesFilterPhotos,
|
||||||
|
tooltip: 'Favorites only',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
ToggleIconButton(
|
||||||
|
icon: provider.showHiddenPhotos
|
||||||
|
? Icons.visibility_rounded
|
||||||
|
: Icons.visibility_off_rounded,
|
||||||
|
isSelected: provider.showHiddenPhotos,
|
||||||
|
onTap: provider.toggleShowHiddenPhotos,
|
||||||
|
tooltip: 'Show hidden files',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SegmentedIconGroup(
|
||||||
|
children: [
|
||||||
|
ToggleIconButton(
|
||||||
|
icon: Symbols.circles_rounded,
|
||||||
|
isSelected: provider.storageScope == StorageScope.cloud,
|
||||||
|
onTap: () => provider.setStorageScope(StorageScope.cloud),
|
||||||
|
tooltip: 'Cloud storage',
|
||||||
|
),
|
||||||
|
ToggleIconButton(
|
||||||
|
icon: Symbols.hard_drive_rounded,
|
||||||
|
isSelected: provider.storageScope == StorageScope.external,
|
||||||
|
onTap: () => provider.setStorageScope(StorageScope.external),
|
||||||
|
tooltip: 'External storage',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<Widget> contentSlivers = [
|
||||||
|
// Sticky while browsing; once selecting, the toolbar below takes over
|
||||||
|
// the pinned slot and this is free to scroll away.
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: !_isSelecting,
|
||||||
|
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
|
||||||
|
),
|
||||||
|
if (_isSelecting)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
pinned: true,
|
pinned: true,
|
||||||
expandedHeight: kToolbarHeight,
|
automaticallyImplyLeading: false,
|
||||||
collapsedHeight: kToolbarHeight,
|
leading: IconButton(
|
||||||
backgroundColor: colorScheme.surface,
|
icon: const Icon(Icons.close_rounded),
|
||||||
surfaceTintColor: colorScheme.surface,
|
tooltip: 'Cancel selection',
|
||||||
scrolledUnderElevation: 0,
|
onPressed: _clearSelection,
|
||||||
actions: const [ProfileAvatarButton()],
|
),
|
||||||
|
title: Text('${selectedItems.length} selected'),
|
||||||
|
actions: [
|
||||||
|
for (final action in _buildSelectionActions(
|
||||||
|
provider,
|
||||||
|
selectedItems,
|
||||||
|
))
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(action.icon, color: action.color),
|
||||||
|
tooltip: action.label,
|
||||||
|
onPressed: action.onTap,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
|
|
||||||
if (photos.isEmpty)
|
if (provider.isMediaLoading && photos.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (provider.mediaErrorMessage != null)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Could not load photos',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
provider.mediaErrorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: provider.fetchAllMedia,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (photos.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -71,8 +288,27 @@ class PhotosView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Fixed clearance so the last row isn't hidden behind the floating
|
||||||
|
// nav bar, regardless of grid length.
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||||
],
|
// For a short grid this stretches the white card's background down to
|
||||||
|
// the screen edge (matching the empty state above); for a long grid
|
||||||
|
// that already fills the viewport it contributes nothing extra.
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSelecting,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
|
},
|
||||||
|
child: SyncedHeaderScaffold(
|
||||||
|
scrollController: widget.scrollController,
|
||||||
|
provider: provider,
|
||||||
|
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
||||||
|
onRefresh: provider.fetchAllMedia,
|
||||||
|
contentSlivers: contentSlivers,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,17 +317,26 @@ class PhotosView extends StatelessWidget {
|
|||||||
NextcloudItem photo,
|
NextcloudItem photo,
|
||||||
ServerProvider provider,
|
ServerProvider provider,
|
||||||
) {
|
) {
|
||||||
final theme = Theme.of(context);
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final colorScheme = theme.colorScheme;
|
final isSelected = _selectedIds.contains(photo.id);
|
||||||
|
|
||||||
return GestureDetector(
|
// No border radius on the tile itself — the outer ClipRRect below is
|
||||||
onTap: () => _openLightbox(context, photo, provider),
|
// the only place that rounds it, so a swipe doesn't reveal a stray
|
||||||
child: Container(
|
// floating rounded corner mid-drag (see the same fix in files_view).
|
||||||
decoration: BoxDecoration(
|
final tile = GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_isSelecting) {
|
||||||
|
_toggleSelection(photo);
|
||||||
|
} else {
|
||||||
|
_openLightbox(context, photo, provider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggleSelection(photo),
|
||||||
|
child: ColoredBox(
|
||||||
color: colorScheme.surfaceContainerHigh,
|
color: colorScheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(16),
|
child: SelectableThumbnail(
|
||||||
),
|
isSelected: isSelected,
|
||||||
clipBehavior: Clip.antiAlias,
|
checkmarkSize: 32,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -140,12 +385,19 @@ class PhotosView extends StatelessWidget {
|
|||||||
const Positioned(
|
const Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: Icon(Icons.star_rounded, color: Colors.amber, size: 18),
|
child: Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
|
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
|
||||||
@@ -174,4 +426,152 @@ class PhotosView extends StatelessWidget {
|
|||||||
FileViewerScreen.route(item: photo, siblings: provider.photoItems),
|
FileViewerScreen.route(item: photo, siblings: provider.photoItems),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _favoriteSelected(
|
||||||
|
ServerProvider provider,
|
||||||
|
List<NextcloudItem> items,
|
||||||
|
) async {
|
||||||
|
final allFavorited = items.every((i) => i.isFavorite);
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.isFavorite == allFavorited) {
|
||||||
|
await provider.toggleItemFavorite(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shareSelected(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
List<NextcloudItem> items,
|
||||||
|
) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Creating share link(s)…'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final lines = <String>[];
|
||||||
|
for (final item in items) {
|
||||||
|
final link = await provider.createShareLink(item);
|
||||||
|
if (link != null) {
|
||||||
|
lines.add(items.length > 1 ? '${item.name}: $link' : link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
_clearSelection();
|
||||||
|
if (lines.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not create share link(s)'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await SharePlus.instance.share(ShareParams(text: lines.join('\n')));
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not open the share sheet'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadSelected(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
List<NextcloudItem> items,
|
||||||
|
) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Downloading ${items.length} item(s)...'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
var succeeded = 0;
|
||||||
|
for (final item in items) {
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final tempPath = p.join(tempDir.path, item.name);
|
||||||
|
await provider.service!.downloadToFile(item.path, tempPath);
|
||||||
|
final ext = p.extension(item.name).replaceFirst('.', '');
|
||||||
|
final baseName = p.basenameWithoutExtension(item.name);
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: baseName,
|
||||||
|
filePath: tempPath,
|
||||||
|
ext: ext,
|
||||||
|
);
|
||||||
|
succeeded++;
|
||||||
|
} catch (_) {
|
||||||
|
// Reported in the summary snackbar below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Downloaded $succeeded of ${items.length} item(s)'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteSelected(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
List<NextcloudItem> items,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Delete Items'),
|
||||||
|
content: Text(
|
||||||
|
'Delete ${items.length} item(s) from the server? This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
var succeeded = 0;
|
||||||
|
for (final item in items) {
|
||||||
|
final success = await provider.deleteItem(item.path);
|
||||||
|
if (success) succeeded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Deleted $succeeded of ${items.length} item(s)'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/more_tabs_button.dart';
|
||||||
|
import '../widgets/profile_avatar_button.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
|
import 'file_viewer_screen.dart';
|
||||||
|
|
||||||
|
IconData _recentItemIcon(NextcloudItemType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NextcloudItemType.folder:
|
||||||
|
return Icons.folder_rounded;
|
||||||
|
case NextcloudItemType.image:
|
||||||
|
return Icons.image_rounded;
|
||||||
|
case NextcloudItemType.video:
|
||||||
|
return Icons.movie_rounded;
|
||||||
|
case NextcloudItemType.audio:
|
||||||
|
return Icons.audiotrack_rounded;
|
||||||
|
case NextcloudItemType.document:
|
||||||
|
return Icons.description_rounded;
|
||||||
|
case NextcloudItemType.archive:
|
||||||
|
return Icons.folder_zip_rounded;
|
||||||
|
case NextcloudItemType.file:
|
||||||
|
return Icons.insert_drive_file_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
switch (type) {
|
||||||
|
case NextcloudItemType.folder:
|
||||||
|
return colorScheme.primary;
|
||||||
|
case NextcloudItemType.image:
|
||||||
|
return Colors.amber.shade700;
|
||||||
|
case NextcloudItemType.video:
|
||||||
|
return Colors.deepOrange.shade600;
|
||||||
|
case NextcloudItemType.audio:
|
||||||
|
return Colors.purple.shade600;
|
||||||
|
case NextcloudItemType.document:
|
||||||
|
return Colors.blue.shade700;
|
||||||
|
case NextcloudItemType.archive:
|
||||||
|
return Colors.teal.shade700;
|
||||||
|
case NextcloudItemType.file:
|
||||||
|
return colorScheme.outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recently modified files across the whole account (not folders), newest
|
||||||
|
/// first — see [ServerProvider.fetchRecent].
|
||||||
|
class RecentView extends StatefulWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
const RecentView({super.key, required this.scrollController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RecentView> createState() => _RecentViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecentViewState extends State<RecentView> {
|
||||||
|
bool _requested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => provider.fetchRecent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = provider.recentItems;
|
||||||
|
|
||||||
|
final List<Widget> contentSlivers = [
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
|
||||||
|
if (provider.isRecentLoading && items.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (provider.recentErrorMessage != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Could not load recent files',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
provider.recentErrorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: provider.fetchRecent,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (items.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.history_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No recent files',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _buildTile(context, item, provider);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SyncedHeaderScaffold(
|
||||||
|
scrollController: widget.scrollController,
|
||||||
|
provider: provider,
|
||||||
|
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
||||||
|
onRefresh: provider.fetchRecent,
|
||||||
|
contentSlivers: contentSlivers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTile(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
ServerProvider provider,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final iconColor = _recentItemIconColor(context, item.type);
|
||||||
|
final folderPath = item.path.substring(
|
||||||
|
0,
|
||||||
|
item.path.length - item.name.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () => _openFile(context, item, provider),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_recentItemIcon(item.type),
|
||||||
|
color: iconColor,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
folderPath.isEmpty ? '/' : folderPath,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
DateFormat.yMMMd().add_jm().format(item.lastModified),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFile(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
ServerProvider provider,
|
||||||
|
) {
|
||||||
|
final siblings = provider.recentItems.where((i) => i.isMedia).toList();
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FileViewerScreen.route(item: item, siblings: siblings),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,6 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
|
||||||
(_) => _focusNode.requestFocus(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
@@ -104,19 +96,22 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: SafeArea(
|
||||||
titleSpacing: 0,
|
child: Column(
|
||||||
title: TextField(
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||||
|
child: SearchBar(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
onChanged: _onChanged,
|
autoFocus: true,
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Search your files...',
|
hintText: 'Search your files...',
|
||||||
border: InputBorder.none,
|
onChanged: _onChanged,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
style: theme.textTheme.titleMedium,
|
trailing: [
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
if (_controller.text.isNotEmpty)
|
if (_controller.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
@@ -127,7 +122,11 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _buildBody(context, colorScheme, theme),
|
),
|
||||||
|
Expanded(child: _buildBody(context, colorScheme, theme)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../models/nextcloud_share.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/more_tabs_button.dart';
|
||||||
|
import '../widgets/profile_avatar_button.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
|
|
||||||
|
IconData _shareItemIcon(NextcloudItemType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NextcloudItemType.folder:
|
||||||
|
return Icons.folder_rounded;
|
||||||
|
case NextcloudItemType.image:
|
||||||
|
return Icons.image_rounded;
|
||||||
|
case NextcloudItemType.video:
|
||||||
|
return Icons.movie_rounded;
|
||||||
|
case NextcloudItemType.audio:
|
||||||
|
return Icons.audiotrack_rounded;
|
||||||
|
case NextcloudItemType.document:
|
||||||
|
return Icons.description_rounded;
|
||||||
|
case NextcloudItemType.archive:
|
||||||
|
return Icons.folder_zip_rounded;
|
||||||
|
case NextcloudItemType.file:
|
||||||
|
return Icons.insert_drive_file_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _shareTypeIcon(ShareType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ShareType.user:
|
||||||
|
return Icons.person_rounded;
|
||||||
|
case ShareType.group:
|
||||||
|
return Icons.groups_rounded;
|
||||||
|
case ShareType.publicLink:
|
||||||
|
return Icons.link_rounded;
|
||||||
|
case ShareType.email:
|
||||||
|
return Icons.email_rounded;
|
||||||
|
case ShareType.federated:
|
||||||
|
return Icons.public_rounded;
|
||||||
|
case ShareType.other:
|
||||||
|
return Icons.share_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SharesView extends StatefulWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
const SharesView({super.key, required this.scrollController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SharesView> createState() => _SharesViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SharesViewState extends State<SharesView> {
|
||||||
|
bool _requested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => provider.fetchShares(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final shares = provider.shares;
|
||||||
|
|
||||||
|
final List<Widget> contentSlivers = [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: false, label: Text('Shared by me')),
|
||||||
|
ButtonSegment(value: true, label: Text('Shared with me')),
|
||||||
|
],
|
||||||
|
selected: {provider.sharesWithMe},
|
||||||
|
onSelectionChanged: (set) => provider.setSharesWithMe(set.first),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
|
|
||||||
|
if (provider.isSharesLoading && shares.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (provider.sharesErrorMessage != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Could not load shares',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
provider.sharesErrorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: provider.fetchShares,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (shares.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.groups_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
provider.sharesWithMe
|
||||||
|
? 'Nothing has been shared with you'
|
||||||
|
: 'You haven\'t shared anything yet',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final share = shares[index];
|
||||||
|
return _buildShareTile(context, share, provider);
|
||||||
|
}, childCount: shares.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SyncedHeaderScaffold(
|
||||||
|
scrollController: widget.scrollController,
|
||||||
|
provider: provider,
|
||||||
|
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
||||||
|
onRefresh: provider.fetchShares,
|
||||||
|
contentSlivers: contentSlivers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildShareTile(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudShare share,
|
||||||
|
ServerProvider provider,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final iconColor = colorScheme.primary;
|
||||||
|
|
||||||
|
final withWhom = share.sharedWithMe
|
||||||
|
? 'From ${share.ownerDisplayName}'
|
||||||
|
: (share.sharedWithDisplayName != null
|
||||||
|
? 'With ${share.sharedWithDisplayName}'
|
||||||
|
: 'Shared link');
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_shareItemIcon(share.itemType),
|
||||||
|
color: iconColor,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
share.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
_shareTypeIcon(share.shareType),
|
||||||
|
size: 13,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
|
||||||
|
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
|
||||||
|
onPressed: () => _confirmUnshare(context, provider, share),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmUnshare(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
NextcloudShare share,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
|
||||||
|
content: Text(
|
||||||
|
share.sharedWithMe
|
||||||
|
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
|
||||||
|
: 'Stop sharing "${share.name}"?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final success = await provider.deleteShare(share);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
success
|
||||||
|
? 'Removed share for ${share.name}'
|
||||||
|
: 'Failed to remove share for ${share.name}',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
import '../widgets/more_tabs_button.dart';
|
||||||
|
import '../widgets/profile_avatar_button.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart';
|
||||||
|
|
||||||
|
IconData _trashItemIcon(NextcloudItemType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NextcloudItemType.folder:
|
||||||
|
return Icons.folder_rounded;
|
||||||
|
case NextcloudItemType.image:
|
||||||
|
return Icons.image_rounded;
|
||||||
|
case NextcloudItemType.video:
|
||||||
|
return Icons.movie_rounded;
|
||||||
|
case NextcloudItemType.audio:
|
||||||
|
return Icons.audiotrack_rounded;
|
||||||
|
case NextcloudItemType.document:
|
||||||
|
return Icons.description_rounded;
|
||||||
|
case NextcloudItemType.archive:
|
||||||
|
return Icons.folder_zip_rounded;
|
||||||
|
case NextcloudItemType.file:
|
||||||
|
return Icons.insert_drive_file_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrashView extends StatefulWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
const TrashView({super.key, required this.scrollController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TrashView> createState() => _TrashViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrashViewState extends State<TrashView> {
|
||||||
|
bool _requested = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => provider.fetchTrash(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trash = provider.trashItems;
|
||||||
|
|
||||||
|
final List<Widget> contentSlivers = [
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
|
||||||
|
if (provider.isTrashLoading && trash.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (provider.trashErrorMessage != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Could not load trash',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
provider.trashErrorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: provider.fetchTrash,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (trash.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.delete_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Trash is empty',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = trash[index];
|
||||||
|
return _buildTrashTile(context, item, provider);
|
||||||
|
}, childCount: trash.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||||
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SyncedHeaderScaffold(
|
||||||
|
scrollController: widget.scrollController,
|
||||||
|
provider: provider,
|
||||||
|
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
||||||
|
onRefresh: provider.fetchTrash,
|
||||||
|
contentSlivers: contentSlivers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrashTile(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
ServerProvider provider,
|
||||||
|
) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final iconColor = colorScheme.outline;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_trashItemIcon(item.type),
|
||||||
|
color: iconColor,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.deletedAt != null
|
||||||
|
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
|
||||||
|
: 'Deleted',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restore_rounded),
|
||||||
|
tooltip: 'Restore',
|
||||||
|
color: colorScheme.primary,
|
||||||
|
onPressed: () => _restore(context, provider, item),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_forever_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
tooltip: 'Delete forever',
|
||||||
|
onPressed: () => _confirmDeleteForever(context, provider, item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restore(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
NextcloudItem item,
|
||||||
|
) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final success = await provider.restoreTrashItem(item);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
success ? 'Restored ${item.name}' : 'Failed to restore ${item.name}',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteForever(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
NextcloudItem item,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Delete Forever'),
|
||||||
|
content: Text(
|
||||||
|
'Permanently delete "${item.name}"? This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Delete Forever'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final success = await provider.deleteTrashItemForever(item);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
success ? 'Deleted ${item.name}' : 'Failed to delete ${item.name}',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/app_tab.dart';
|
||||||
|
import '../views/activity_view.dart';
|
||||||
|
import '../views/files_view.dart';
|
||||||
|
import '../views/photos_view.dart';
|
||||||
|
import '../views/recent_view.dart';
|
||||||
|
import '../views/shares_view.dart';
|
||||||
|
import '../views/trash_view.dart';
|
||||||
|
|
||||||
|
/// Builds the view widget for a given [AppTab]. Shared by the main shell
|
||||||
|
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
|
||||||
|
/// that's currently hidden from the bottom nav bar).
|
||||||
|
Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
||||||
|
switch (tab) {
|
||||||
|
case AppTab.files:
|
||||||
|
return FilesView(scrollController: controller);
|
||||||
|
case AppTab.photos:
|
||||||
|
return PhotosView(scrollController: controller);
|
||||||
|
case AppTab.activity:
|
||||||
|
return ActivityView(scrollController: controller);
|
||||||
|
case AppTab.trash:
|
||||||
|
return TrashView(scrollController: controller);
|
||||||
|
case AppTab.shares:
|
||||||
|
return SharesView(scrollController: controller);
|
||||||
|
case AppTab.recent:
|
||||||
|
return RecentView(scrollController: controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has
|
||||||
|
/// navigated below the root, in both the Files and Photos tabs.
|
||||||
|
class Breadcrumbs extends StatelessWidget {
|
||||||
|
final List<String> pathStack;
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
|
||||||
|
const Breadcrumbs({super.key, required this.pathStack, required this.onTap});
|
||||||
|
|
||||||
|
String _labelFor(int index) {
|
||||||
|
if (index == 0) return 'Home';
|
||||||
|
final segments = pathStack[index]
|
||||||
|
.split('/')
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
return segments.isEmpty ? 'Home' : segments.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final lastIndex = pathStack.length - 1;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < pathStack.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (i == lastIndex)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Text(
|
||||||
|
_labelFor(i),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
// A chip's whole pill is the tap target (not just the text),
|
||||||
|
// so the previous plain-text crumbs were fiddly to hit.
|
||||||
|
ActionChip(
|
||||||
|
label: Text(_labelFor(i)),
|
||||||
|
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
onPressed: () => onTap(i),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
side: BorderSide.none,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import '../../providers/server_provider.dart';
|
||||||
|
|
||||||
|
/// Per-file activity feed — same list-item look as the account-wide
|
||||||
|
/// Activity tab (activity_view.dart), scoped to a single item.
|
||||||
|
class DetailsActivityTab extends StatefulWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
|
||||||
|
const DetailsActivityTab({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DetailsActivityTab> createState() => _DetailsActivityTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailsActivityTabState extends State<DetailsActivityTab> {
|
||||||
|
bool _requested = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
List<NextcloudActivity> _activities = [];
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final result = await provider.fetchFileActivity(widget.item);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_activities = result;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_activities.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No recent activity',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import '../synced_header_scaffold.dart' show formatBytes;
|
||||||
|
|
||||||
|
/// Plain metadata tab of the Details sheet — name, type, size, location,
|
||||||
|
/// and dates. No network calls, so no loading state needed.
|
||||||
|
class DetailsInfoTab extends StatelessWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
|
const DetailsInfoTab({super.key, required this.item, this.scrollController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
final rows = <(String, String)>[
|
||||||
|
('Name', item.name),
|
||||||
|
('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')),
|
||||||
|
if (!item.isFolder) ('Size', formatBytes(item.size)),
|
||||||
|
('Location', item.path),
|
||||||
|
('Modified', DateFormat.yMMMd().add_jm().format(item.lastModified)),
|
||||||
|
('Created', DateFormat.yMMMd().add_jm().format(item.dateCreated)),
|
||||||
|
('Favorite', item.isFavorite ? 'Yes' : 'No'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import '../gradual_bottom_sheet.dart';
|
||||||
|
import '../synced_header_scaffold.dart' show formatBytes;
|
||||||
|
import 'details_activity_tab.dart';
|
||||||
|
import 'details_info_tab.dart';
|
||||||
|
import 'details_versions_tab.dart';
|
||||||
|
|
||||||
|
IconData _detailsItemIcon(NextcloudItemType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NextcloudItemType.folder:
|
||||||
|
return Icons.folder_rounded;
|
||||||
|
case NextcloudItemType.image:
|
||||||
|
return Icons.image_rounded;
|
||||||
|
case NextcloudItemType.video:
|
||||||
|
return Icons.movie_rounded;
|
||||||
|
case NextcloudItemType.audio:
|
||||||
|
return Icons.audiotrack_rounded;
|
||||||
|
case NextcloudItemType.document:
|
||||||
|
return Icons.description_rounded;
|
||||||
|
case NextcloudItemType.archive:
|
||||||
|
return Icons.folder_zip_rounded;
|
||||||
|
case NextcloudItemType.file:
|
||||||
|
return Icons.insert_drive_file_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reusable "Details" bottom sheet for a single file/folder — Info,
|
||||||
|
/// Versions, and Activity tabs. Sharing lives in its own sheet
|
||||||
|
/// ([ShareSheet]), triggered separately from wherever a "Share" action
|
||||||
|
/// appears. Triggered from long-press selection (exactly one item), the
|
||||||
|
/// media viewer's action bar, and the "open externally" flow for
|
||||||
|
/// unsupported file types.
|
||||||
|
class DetailsSheet extends StatelessWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
|
||||||
|
const DetailsSheet({super.key, required this.item});
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||||
|
return showGradualBottomSheet(
|
||||||
|
context,
|
||||||
|
builder: (context, scrollController) => DetailsSheet(item: item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
DetailsHeader(item: item),
|
||||||
|
const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'),
|
||||||
|
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'),
|
||||||
|
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
DetailsInfoTab(item: item),
|
||||||
|
DetailsVersionsTab(item: item),
|
||||||
|
DetailsActivityTab(item: item),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon/name/size/date row shared by [DetailsSheet] and the media viewer's
|
||||||
|
/// collapsed "peek" state (which shows just this, before the user drags the
|
||||||
|
/// sheet open far enough to reveal the tabs).
|
||||||
|
class DetailsHeader extends StatelessWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
|
||||||
|
const DetailsHeader({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_detailsItemIcon(item.type),
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.isFolder
|
||||||
|
? DateFormat.yMMMd().format(item.lastModified)
|
||||||
|
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:file_saver/file_saver.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/nextcloud_file_version.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import '../../providers/server_provider.dart';
|
||||||
|
import '../synced_header_scaffold.dart' show formatBytes;
|
||||||
|
|
||||||
|
/// File version history — a synthetic "Current version" row (from the
|
||||||
|
/// item's own metadata) followed by whatever the DAV versions endpoint
|
||||||
|
/// returns, each with Restore/Download actions.
|
||||||
|
class DetailsVersionsTab extends StatefulWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
|
||||||
|
const DetailsVersionsTab({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DetailsVersionsTab> createState() => _DetailsVersionsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||||
|
bool _requested = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
List<NextcloudFileVersion> _versions = [];
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final result = await provider.fetchFileVersions(widget.item);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_versions = result;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restore(NextcloudFileVersion version) async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final success = await provider.restoreFileVersion(
|
||||||
|
widget.item,
|
||||||
|
version.versionLabel,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
success ? 'Restored version' : 'Could not restore version',
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (success) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _download(NextcloudFileVersion version) async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final tempPath = p.join(
|
||||||
|
tempDir.path,
|
||||||
|
'${version.versionLabel}_${widget.item.name}',
|
||||||
|
);
|
||||||
|
final success = await provider.downloadVersion(
|
||||||
|
widget.item,
|
||||||
|
version.versionLabel,
|
||||||
|
tempPath,
|
||||||
|
);
|
||||||
|
if (!success) throw Exception('download failed');
|
||||||
|
final ext = p.extension(widget.item.name).replaceFirst('.', '');
|
||||||
|
final baseName = p.basenameWithoutExtension(widget.item.name);
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: baseName,
|
||||||
|
filePath: tempPath,
|
||||||
|
ext: ext,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Version downloaded'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not download version'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
NextcloudFileVersion(
|
||||||
|
versionLabel: 'current',
|
||||||
|
timestamp: widget.item.lastModified,
|
||||||
|
size: widget.item.size,
|
||||||
|
isCurrent: true,
|
||||||
|
),
|
||||||
|
..._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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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<String>(
|
||||||
|
onSelected: (value) => value == 'restore'
|
||||||
|
? _restore(version)
|
||||||
|
: _download(version),
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(value: 'restore', child: Text('Restore')),
|
||||||
|
PopupMenuItem(value: 'download', child: Text('Download')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import 'details_sheet.dart';
|
||||||
|
|
||||||
|
/// A persistent (non-modal) draggable panel that rests as a small "peek"
|
||||||
|
/// of a file's details - just the header - so it's always partly visible
|
||||||
|
/// without blocking the content above it, and expands into the full
|
||||||
|
/// [DetailsSheet] (with its Info/Versions/Activity tabs) once dragged past
|
||||||
|
/// [_expandThreshold]. Meant to be placed directly in a `Stack`, not shown
|
||||||
|
/// via `showModalBottomSheet`/`showBottomSheet` - there's no scrim and
|
||||||
|
/// nothing behind it is blocked from receiving gestures.
|
||||||
|
class PeekDetailsSheet extends StatefulWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
|
||||||
|
const PeekDetailsSheet({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PeekDetailsSheet> createState() => _PeekDetailsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeekDetailsSheetState extends State<PeekDetailsSheet> {
|
||||||
|
static const _peekSize = 0.16;
|
||||||
|
static const _maxSize = 0.9;
|
||||||
|
static const _expandThreshold = 0.3;
|
||||||
|
|
||||||
|
double _size = _peekSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final expanded = _size > _expandThreshold;
|
||||||
|
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: _peekSize,
|
||||||
|
minChildSize: _peekSize,
|
||||||
|
maxChildSize: _maxSize,
|
||||||
|
snap: true,
|
||||||
|
snapSizes: const [_peekSize, _maxSize],
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return NotificationListener<DraggableScrollableNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
if ((notification.extent - _size).abs() > 0.002) {
|
||||||
|
setState(() => _size = notification.extent);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: expanded
|
||||||
|
? DetailsSheet(item: widget.item)
|
||||||
|
// Peek state: just the header, but still wrapped in
|
||||||
|
// a scrollable using the sheet's own controller - a
|
||||||
|
// DraggableScrollableSheet only picks up drag-to-
|
||||||
|
// resize gestures from a Scrollable using that
|
||||||
|
// controller, even over otherwise-static content.
|
||||||
|
: SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: DetailsHeader(item: widget.item),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
typedef GradualSheetBuilder =
|
||||||
|
Widget Function(BuildContext context, ScrollController scrollController);
|
||||||
|
|
||||||
|
/// Shows a bottom sheet that can be dragged open/closed gradually (and
|
||||||
|
/// snaps to a few comfortable stops on release) instead of the default
|
||||||
|
/// modal bottom sheet's binary fully-open/fully-dismissed behavior.
|
||||||
|
/// [builder] gets a [ScrollController] it can hand to its own scrollable
|
||||||
|
/// content so dragging that content also resizes the sheet; content that
|
||||||
|
/// manages its own scrolling (e.g. a TabBarView of independent lists) can
|
||||||
|
/// just ignore it.
|
||||||
|
Future<T?> showGradualBottomSheet<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required GradualSheetBuilder builder,
|
||||||
|
double initialSize = 0.6,
|
||||||
|
double minSize = 0.3,
|
||||||
|
double maxSize = 0.95,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return showModalBottomSheet<T>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: initialSize,
|
||||||
|
minChildSize: minSize,
|
||||||
|
maxChildSize: maxSize,
|
||||||
|
snap: true,
|
||||||
|
snapSizes: [minSize, initialSize, maxSize],
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: builder(context, scrollController)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/app_tab.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
import 'app_tab_view_builder.dart';
|
||||||
|
|
||||||
|
/// Opens a tab that's currently hidden from the bottom nav bar as its own
|
||||||
|
/// standalone screen. Shown in every tab's toolbar (not just Files) so
|
||||||
|
/// hidden destinations stay reachable no matter which tab is active.
|
||||||
|
/// Always visible; dims/disables itself when nothing is hidden.
|
||||||
|
class MoreTabsButton extends StatelessWidget {
|
||||||
|
const MoreTabsButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
return PopupMenuButton<AppTab>(
|
||||||
|
icon: const Icon(Icons.apps_rounded),
|
||||||
|
tooltip: 'More tabs',
|
||||||
|
enabled: provider.hiddenTabs.isNotEmpty,
|
||||||
|
onSelected: (tab) => _openHiddenTab(context, tab),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final tab in provider.tabOrder.where(
|
||||||
|
(t) => provider.hiddenTabs.contains(t),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
value: tab,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(tab.icon, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(tab.label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openHiddenTab(BuildContext context, AppTab tab) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
Scaffold(body: buildAppTabView(tab, ScrollController())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
tooltip: 'Account',
|
tooltip: 'Settings',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A single icon button that fills with a solid circle when active —
|
/// A single icon button that fills with a solid circle when active —
|
||||||
/// the building block for Drive-style toggle controls.
|
/// the building block for Drive-style toggle controls. Thin wrapper around
|
||||||
|
/// the native Material 3 toggle `IconButton` (its `isSelected` constructor
|
||||||
|
/// param, not a hand-rolled Material+InkWell) so every call site here gets
|
||||||
|
/// the built-in selected/unselected treatment for free.
|
||||||
class ToggleIconButton extends StatelessWidget {
|
class ToggleIconButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
@@ -20,26 +23,26 @@ class ToggleIconButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
final button = Material(
|
return IconButton(
|
||||||
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
|
isSelected: isSelected,
|
||||||
shape: const CircleBorder(),
|
icon: Icon(icon, size: 20),
|
||||||
child: InkWell(
|
tooltip: tooltip,
|
||||||
onTap: onTap,
|
onPressed: onTap,
|
||||||
customBorder: const CircleBorder(),
|
visualDensity: VisualDensity.compact,
|
||||||
child: Padding(
|
style: ButtonStyle(
|
||||||
padding: const EdgeInsets.all(8),
|
shape: const WidgetStatePropertyAll(CircleBorder()),
|
||||||
child: Icon(
|
foregroundColor: WidgetStateProperty.resolveWith(
|
||||||
icon,
|
(states) => states.contains(WidgetState.selected)
|
||||||
size: 20,
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
: colorScheme.onSurfaceVariant,
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return tooltip != null ? Tooltip(message: tooltip!, child: button) : button;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Wraps a thumbnail so it can show the multi-select checkmark state:
|
||||||
|
/// dimmed with a checkmark overlaid once selected, matching the Google
|
||||||
|
/// Photos-style long-press-to-select pattern. Shared by the Files and
|
||||||
|
/// Photos tabs.
|
||||||
|
class SelectableThumbnail extends StatelessWidget {
|
||||||
|
final bool isSelected;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// Fixed width/height for a small square thumbnail (Files list/grid
|
||||||
|
/// tiles). Leave null to fill whatever space the parent already gives it
|
||||||
|
/// instead (e.g. a Photos grid cell, whose size comes from the grid
|
||||||
|
/// layout rather than a fixed constant).
|
||||||
|
final double? size;
|
||||||
|
final double checkmarkSize;
|
||||||
|
|
||||||
|
const SelectableThumbnail({
|
||||||
|
super.key,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.child,
|
||||||
|
this.size,
|
||||||
|
this.checkmarkSize = 28,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final stack = Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: isSelected ? 0.35 : 1,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: checkmarkSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (size == null) return stack;
|
||||||
|
return SizedBox(width: size, height: size, child: stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../models/nextcloud_share.dart';
|
||||||
|
import '../models/nextcloud_sharee.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
import 'gradual_bottom_sheet.dart';
|
||||||
|
|
||||||
|
IconData _shareTypeIcon(ShareType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ShareType.user:
|
||||||
|
return Icons.person_rounded;
|
||||||
|
case ShareType.group:
|
||||||
|
return Icons.groups_rounded;
|
||||||
|
case ShareType.publicLink:
|
||||||
|
return Icons.link_rounded;
|
||||||
|
case ShareType.email:
|
||||||
|
return Icons.email_rounded;
|
||||||
|
case ShareType.federated:
|
||||||
|
return Icons.public_rounded;
|
||||||
|
case ShareType.other:
|
||||||
|
return Icons.share_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the "internal link" URL for a file, or null if [fileId] isn't a
|
||||||
|
/// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item).
|
||||||
|
String? internalLinkFor(String serverUrl, String fileId) {
|
||||||
|
if (int.tryParse(fileId) == null) return null;
|
||||||
|
var url = serverUrl.trim();
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
url = 'https://$url';
|
||||||
|
}
|
||||||
|
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||||
|
return '$url/index.php/f/$fileId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standalone "Share" bottom sheet for a single file/folder — replicates
|
||||||
|
/// the web client's Sharing panel: search users/teams, "Others with
|
||||||
|
/// access" (inherited shares), an internal link to copy, an email-share
|
||||||
|
/// field, and a public link. Every "Share" action in the app (swipe,
|
||||||
|
/// multi-select, media viewer) opens this same sheet.
|
||||||
|
class ShareSheet extends StatefulWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
|
const ShareSheet({super.key, required this.item, this.scrollController});
|
||||||
|
|
||||||
|
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||||
|
return showGradualBottomSheet(
|
||||||
|
context,
|
||||||
|
builder: (context, scrollController) =>
|
||||||
|
ShareSheet(item: item, scrollController: scrollController),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareSheet> createState() => _ShareSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareSheetState extends State<ShareSheet> {
|
||||||
|
bool _requested = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
List<NextcloudShare> _shares = [];
|
||||||
|
List<NextcloudShare> _inherited = [];
|
||||||
|
List<NextcloudSharee> _searchResults = [];
|
||||||
|
bool _isSearching = false;
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final results = await Future.wait([
|
||||||
|
provider.fetchItemShares(widget.item),
|
||||||
|
provider.fetchInheritedShares(widget.item),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_shares = results[0];
|
||||||
|
_inherited = results[1];
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _search(String query) async {
|
||||||
|
final trimmed = query.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
setState(() => _searchResults = []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _isSearching = true);
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final results = await provider.searchSharees(trimmed);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addSharee(NextcloudSharee sharee) async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final share = await provider.createShare(
|
||||||
|
path: widget.item.path,
|
||||||
|
shareType: sharee.shareTypeValue,
|
||||||
|
shareWith: sharee.shareWith,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (share == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not share this item'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_shares = [..._shares, share];
|
||||||
|
_searchResults = [];
|
||||||
|
_searchController.clear();
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Shared with ${sharee.label}'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addEmailShare() async {
|
||||||
|
final email = _emailController.text.trim();
|
||||||
|
if (email.isEmpty) return;
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final share = await provider.createShare(
|
||||||
|
path: widget.item.path,
|
||||||
|
shareType: 4,
|
||||||
|
shareWith: email,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (share == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not create email share'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_shares = [..._shares, share];
|
||||||
|
_emailController.clear();
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Shared with $email'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPublicLink() async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final share = await provider.createShare(
|
||||||
|
path: widget.item.path,
|
||||||
|
shareType: 3,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (share == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not create public link'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _shares = [..._shares, share]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeShare(NextcloudShare share) async {
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final success = await provider.deleteShare(share);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (success) {
|
||||||
|
setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
|
||||||
|
} else {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not remove share'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copy(String text, String label) {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Copied $label'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(String label) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _shareRow(
|
||||||
|
NextcloudShare share, {
|
||||||
|
bool removable = true,
|
||||||
|
bool copyUrl = false,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final label = switch (share.shareType) {
|
||||||
|
ShareType.publicLink => 'Public link',
|
||||||
|
_ => share.sharedWithDisplayName ?? 'Shared',
|
||||||
|
};
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Icon(
|
||||||
|
_shareTypeIcon(share.shareType),
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(label),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (copyUrl && share.url != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy_rounded),
|
||||||
|
tooltip: 'Copy link',
|
||||||
|
onPressed: () => _copy(share.url!, 'link'),
|
||||||
|
),
|
||||||
|
if (removable)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close_rounded, color: colorScheme.error),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () => _removeShare(share),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_requested) {
|
||||||
|
_requested = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final userGroupShares = _shares
|
||||||
|
.where(
|
||||||
|
(s) =>
|
||||||
|
s.shareType == ShareType.user || s.shareType == ShareType.group,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final publicLinkShares = _shares
|
||||||
|
.where((s) => s.shareType == ShareType.publicLink)
|
||||||
|
.toList();
|
||||||
|
final emailShares = _shares
|
||||||
|
.where((s) => s.shareType == ShareType.email)
|
||||||
|
.toList();
|
||||||
|
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
|
||||||
|
|
||||||
|
return 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
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('Create public link'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
tooltip: 'Create public link',
|
||||||
|
onPressed: _createPublicLink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
|
||||||
|
String sortFieldLabel(FileSortField field) {
|
||||||
|
switch (field) {
|
||||||
|
case FileSortField.name:
|
||||||
|
return 'Name';
|
||||||
|
case FileSortField.dateCreated:
|
||||||
|
return 'Date created';
|
||||||
|
case FileSortField.dateModified:
|
||||||
|
return 'Date modified';
|
||||||
|
case FileSortField.size:
|
||||||
|
return 'Size';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dropdown trigger for choosing which field a list/grid is sorted by,
|
||||||
|
/// shared by the Files and Photos tabs.
|
||||||
|
class SortMenuButton extends StatelessWidget {
|
||||||
|
final FileSortField field;
|
||||||
|
final ValueChanged<FileSortField> onChanged;
|
||||||
|
|
||||||
|
const SortMenuButton({
|
||||||
|
super.key,
|
||||||
|
required this.field,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return PopupMenuButton<FileSortField>(
|
||||||
|
initialValue: field,
|
||||||
|
onSelected: onChanged,
|
||||||
|
offset: const Offset(0, 36),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
itemBuilder: (context) => FileSortField.values.map((f) {
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: f,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
child: f == field
|
||||||
|
? Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(sortFieldLabel(f)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
// A plain Text child in a Row always takes its full intrinsic
|
||||||
|
// width no matter what maxLines/overflow say - Flexible is what
|
||||||
|
// actually lets it shrink to whatever space this row has left
|
||||||
|
// (it's squeezed by favorite/hidden/storage/list-grid toggles
|
||||||
|
// alongside it) so the ellipsis can kick in instead of
|
||||||
|
// overflowing.
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
sortFieldLabel(field),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Wraps [child] as a sliver that pins to the top of the scroll view when
|
||||||
|
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
|
||||||
|
/// normally with `pinned: false`. [height] must match the child's actual
|
||||||
|
/// rendered height - Flutter's sliver protocol needs an extent up front,
|
||||||
|
/// not one measured after layout, so callers own getting this right for
|
||||||
|
/// whatever fixed-height content they pass in.
|
||||||
|
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
final double height;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const StickyHeaderDelegate({required this.height, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
double shrinkOffset,
|
||||||
|
bool overlapsContent,
|
||||||
|
) {
|
||||||
|
return Material(color: Theme.of(context).colorScheme.surface, child: child);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
||||||
|
return oldDelegate.height != height || oldDelegate.child != child;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
|
||||||
|
/// Wraps [child] with the user's configured swipe-left/swipe-right actions
|
||||||
|
/// (favorite, delete, share, or none) — shared between the Files list view
|
||||||
|
/// and the Photos grid so both behave identically. Callers own the actual
|
||||||
|
/// side effects (favoriting, sharing, deleting); this widget only decides
|
||||||
|
/// which action a given swipe direction maps to and drives the gesture.
|
||||||
|
class SwipeableItem extends StatelessWidget {
|
||||||
|
final Key itemKey;
|
||||||
|
final ServerProvider provider;
|
||||||
|
final Future<void> Function() onFavorite;
|
||||||
|
final Future<void> Function() onShare;
|
||||||
|
final Future<void> Function() onDelete;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SwipeableItem({
|
||||||
|
super.key,
|
||||||
|
required this.itemKey,
|
||||||
|
required this.provider,
|
||||||
|
required this.onFavorite,
|
||||||
|
required this.onShare,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static DismissDirection _directionFor(SwipeAction left, SwipeAction right) {
|
||||||
|
final hasLeft = left != SwipeAction.none;
|
||||||
|
final hasRight = right != SwipeAction.none;
|
||||||
|
if (hasLeft && hasRight) return DismissDirection.horizontal;
|
||||||
|
if (hasRight) return DismissDirection.startToEnd;
|
||||||
|
if (hasLeft) return DismissDirection.endToStart;
|
||||||
|
return DismissDirection.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _background(
|
||||||
|
BuildContext context,
|
||||||
|
SwipeAction action,
|
||||||
|
Alignment alignment,
|
||||||
|
) {
|
||||||
|
if (action == SwipeAction.none) return const SizedBox.shrink();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
switch (action) {
|
||||||
|
case SwipeAction.delete:
|
||||||
|
color = colorScheme.error;
|
||||||
|
icon = Icons.delete_rounded;
|
||||||
|
case SwipeAction.favorite:
|
||||||
|
color = Colors.red.shade400;
|
||||||
|
icon = Icons.favorite_rounded;
|
||||||
|
case SwipeAction.share:
|
||||||
|
color = colorScheme.primary;
|
||||||
|
icon = Icons.share_rounded;
|
||||||
|
case SwipeAction.none:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Icon(icon, color: Colors.white),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final rightAction = provider.swipeRightAction;
|
||||||
|
final leftAction = provider.swipeLeftAction;
|
||||||
|
final direction = _directionFor(leftAction, rightAction);
|
||||||
|
if (direction == DismissDirection.none) return child;
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: itemKey,
|
||||||
|
direction: direction,
|
||||||
|
background: _background(context, rightAction, Alignment.centerLeft),
|
||||||
|
secondaryBackground: _background(
|
||||||
|
context,
|
||||||
|
leftAction,
|
||||||
|
Alignment.centerRight,
|
||||||
|
),
|
||||||
|
confirmDismiss: (dir) async {
|
||||||
|
final action = dir == DismissDirection.startToEnd
|
||||||
|
? rightAction
|
||||||
|
: leftAction;
|
||||||
|
switch (action) {
|
||||||
|
case SwipeAction.favorite:
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
await onFavorite();
|
||||||
|
return false;
|
||||||
|
case SwipeAction.share:
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
await onShare();
|
||||||
|
return false;
|
||||||
|
case SwipeAction.delete:
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
return true; // Actual delete happens in onDismissed.
|
||||||
|
case SwipeAction.none:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissed: (dir) {
|
||||||
|
final action = dir == DismissDirection.startToEnd
|
||||||
|
? rightAction
|
||||||
|
: leftAction;
|
||||||
|
if (action == SwipeAction.delete) onDelete();
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../providers/server_provider.dart';
|
||||||
|
|
||||||
|
String formatBytes(int bytes) {
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "5.16 GB / 100 GB" — or "5.16 GB / ∞" when the server reports no quota
|
||||||
|
/// limit (or doesn't report one at all).
|
||||||
|
String formatQuota(NextcloudUserQuota quota) {
|
||||||
|
final total = quota.totalBytes > 0 ? formatBytes(quota.totalBytes) : '∞';
|
||||||
|
return '${formatBytes(quota.usedBytes)} / $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared "pull down to see sync status" scroll header used by both the
|
||||||
|
/// Files and Photos tabs: a compact sync-status chip that expands into a
|
||||||
|
/// storage-quota panel (Google Photos' "pull to see backup status"
|
||||||
|
/// pattern), sitting on a gray backdrop above a white rounded-top card that
|
||||||
|
/// holds the tab's own content slivers.
|
||||||
|
class SyncedHeaderScaffold extends StatefulWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final ServerProvider provider;
|
||||||
|
|
||||||
|
/// Trailing icons in the app bar (e.g. an upload button, the avatar).
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
/// The tab's own content, already built as slivers (a filter/sort row,
|
||||||
|
/// breadcrumbs, the grid/list, empty/loading/error states, etc.). Wrapped
|
||||||
|
/// internally in the white rounded-top card.
|
||||||
|
final List<Widget> contentSlivers;
|
||||||
|
|
||||||
|
/// What a deliberate pull-to-sync should trigger. Defaults to
|
||||||
|
/// [ServerProvider.refreshData] (the current folder's listing); pass e.g.
|
||||||
|
/// `provider.fetchTrash` for a tab backed by different data.
|
||||||
|
final Future<void> Function()? onRefresh;
|
||||||
|
|
||||||
|
const SyncedHeaderScaffold({
|
||||||
|
super.key,
|
||||||
|
required this.scrollController,
|
||||||
|
required this.provider,
|
||||||
|
required this.actions,
|
||||||
|
required this.contentSlivers,
|
||||||
|
this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SyncedHeaderScaffold> createState() => _SyncedHeaderScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
||||||
|
static const double _lockThreshold = 100;
|
||||||
|
static const double _syncThreshold = 14;
|
||||||
|
static const double _pullingEpsilon = 8;
|
||||||
|
|
||||||
|
bool _headerLocked = false;
|
||||||
|
bool _isPulling = false;
|
||||||
|
double _pullDistance = 0;
|
||||||
|
|
||||||
|
void _revealPanel() {
|
||||||
|
if (widget.scrollController.hasClients) {
|
||||||
|
widget.scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _lockOpen() {
|
||||||
|
if (!_headerLocked) setState(() => _headerLocked = true);
|
||||||
|
_revealPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleScrollNotification(ScrollNotification notification) {
|
||||||
|
final metrics = notification.metrics;
|
||||||
|
if (metrics.pixels < metrics.minScrollExtent) {
|
||||||
|
_pullDistance = metrics.minScrollExtent - metrics.pixels;
|
||||||
|
final pulling = _pullDistance > _pullingEpsilon;
|
||||||
|
if (pulling != _isPulling && !_headerLocked) {
|
||||||
|
setState(() => _isPulling = pulling);
|
||||||
|
}
|
||||||
|
} else if (_isPulling) {
|
||||||
|
setState(() => _isPulling = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollEndNotification) {
|
||||||
|
final pulled = _pullDistance;
|
||||||
|
_pullDistance = 0;
|
||||||
|
if (_isPulling) setState(() => _isPulling = false);
|
||||||
|
|
||||||
|
final refresh = widget.onRefresh ?? widget.provider.refreshData;
|
||||||
|
if (pulled >= _lockThreshold) {
|
||||||
|
_lockOpen();
|
||||||
|
refresh();
|
||||||
|
} else if (pulled >= _syncThreshold) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final provider = widget.provider;
|
||||||
|
|
||||||
|
final Widget leadingWidget;
|
||||||
|
if (_headerLocked) {
|
||||||
|
leadingWidget = IconButton(
|
||||||
|
icon: const Icon(Icons.keyboard_arrow_up_rounded),
|
||||||
|
tooltip: 'Collapse',
|
||||||
|
onPressed: () => setState(() => _headerLocked = false),
|
||||||
|
);
|
||||||
|
} else if (_isPulling) {
|
||||||
|
leadingWidget = const SizedBox.shrink();
|
||||||
|
} else {
|
||||||
|
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ColoredBox(
|
||||||
|
color: colorScheme.surfaceContainer,
|
||||||
|
child: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleScrollNotification,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact, always-visible summary of the same status shown by
|
||||||
|
/// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling
|
||||||
|
/// the header down to expand that same "Synced" view.
|
||||||
|
class _SyncStatusChip extends StatelessWidget {
|
||||||
|
final ServerProvider provider;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SyncStatusChip({required this.provider, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final quota = provider.quota;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
customBorder: const StadiumBorder(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
provider.isLoading
|
||||||
|
? Icons.cloud_sync_rounded
|
||||||
|
: Icons.cloud_done_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
quota != null ? formatQuota(quota) : 'Sync',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revealed by pulling the header down past its normal height (Google
|
||||||
|
/// Photos' "pull to see backup status" pattern). Shows storage quota;
|
||||||
|
/// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays
|
||||||
|
/// locked open.
|
||||||
|
class _SyncedStretchPanel extends StatelessWidget {
|
||||||
|
final ServerProvider provider;
|
||||||
|
|
||||||
|
/// Keeps the panel fully shown even without live overscroll — used once
|
||||||
|
/// the header has "locked" open after a deliberate pull-down.
|
||||||
|
final bool forceVisible;
|
||||||
|
|
||||||
|
const _SyncedStretchPanel({
|
||||||
|
required this.provider,
|
||||||
|
this.forceVisible = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final topInset = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final settings = context
|
||||||
|
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
||||||
|
final maxExtent = settings?.maxExtent ?? constraints.maxHeight;
|
||||||
|
final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
|
||||||
|
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
|
||||||
|
// before the 100px lock threshold.
|
||||||
|
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
|
||||||
|
final quota = provider.quota;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (progress > 0)
|
||||||
|
// Anchored below the toolbar row (icons), never the bottom,
|
||||||
|
// so it can never share space with the title above.
|
||||||
|
Positioned(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: topInset + kToolbarHeight + 4,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: progress,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
provider.isLoading ? 'Syncing…' : 'Synced',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.serverUrl.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
provider.serverUrl.replaceFirst(
|
||||||
|
RegExp(r'^https?://'),
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_done_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
quota != null
|
||||||
|
? formatQuota(quota)
|
||||||
|
: 'Pull to refresh',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: nextcloud_client
|
name: noo
|
||||||
description: "A new Flutter project."
|
description: "A new Flutter project."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
@@ -69,8 +69,9 @@ dev_dependencies:
|
|||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
image_path: "assets/icon/app_icon.png"
|
image_path: "assets/icon/app_icon.png"
|
||||||
adaptive_icon_background: "#3D5AD1"
|
adaptive_icon_background: "assets/icon/app_icon_background.png"
|
||||||
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
|
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
|
||||||
|
adaptive_icon_monochrome: "assets/icon/app_icon_monochrome.png"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:nextcloud_client/main.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:nextcloud_client/providers/server_provider.dart';
|
import 'package:noo/main.dart';
|
||||||
|
import 'package:noo/providers/server_provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
const secureStorageChannel = MethodChannel(
|
||||||
|
'plugins.it_nomads.com/flutter_secure_storage',
|
||||||
|
);
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(secureStorageChannel, (call) async {
|
||||||
|
if (call.method == 'readAll') return <String, String>{};
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('ServerProvider initializes unauthenticated', () {
|
test('ServerProvider initializes unauthenticated', () {
|
||||||
final provider = ServerProvider();
|
final provider = ServerProvider();
|
||||||
expect(provider.isLoggedIn, false);
|
expect(provider.isLoggedIn, false);
|
||||||
expect(provider.items.isEmpty, true);
|
expect(provider.items.isEmpty, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('App renders LoginView when not logged in', (WidgetTester tester) async {
|
testWidgets('App renders LoginView when not logged in', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
final provider = ServerProvider();
|
final provider = ServerProvider();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -21,10 +40,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Nextcloud Client'), findsOneWidget);
|
expect(find.text('Noo'), findsOneWidget);
|
||||||
expect(find.text('Server Address'), findsOneWidget);
|
expect(find.text('Server Address'), findsOneWidget);
|
||||||
expect(find.text('Username'), findsOneWidget);
|
expect(find.text('Continue'), findsOneWidget);
|
||||||
expect(find.text('Password or App Password'), findsOneWidget);
|
|
||||||
expect(find.text('Log In'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Project-level configuration.
|
# Project-level configuration.
|
||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.14)
|
||||||
project(nextcloud_client LANGUAGES CXX)
|
project(noo LANGUAGES CXX)
|
||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
# The name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
set(BINARY_NAME "nextcloud_client")
|
set(BINARY_NAME "noo")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "com.example" "\0"
|
VALUE "CompanyName", "dev.ayushya" "\0"
|
||||||
VALUE "FileDescription", "nextcloud_client" "\0"
|
VALUE "FileDescription", "noo" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "nextcloud_client" "\0"
|
VALUE "InternalName", "noo" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 dev.ayushya. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "nextcloud_client.exe" "\0"
|
VALUE "OriginalFilename", "noo.exe" "\0"
|
||||||
VALUE "ProductName", "nextcloud_client" "\0"
|
VALUE "ProductName", "noo" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"nextcloud_client", origin, size)) {
|
if (!window.Create(L"Noo", origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|||||||