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>
This commit is contained in:
2026-09-15 22:44:42 -04:00
co-authored by Claude Sonnet 5
parent f62d912ef7
commit d68cc43368
75 changed files with 6491 additions and 1213 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# nextcloud_client # Noo
A new Flutter project. A new Flutter project.
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+3
View File
@@ -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:
+6 -6
View File
@@ -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;
+2 -2
View File
@@ -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>
+34 -21
View File
@@ -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: () {
+47
View File
@@ -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;
}
}
}
+16
View File
@@ -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,
});
}
+11
View File
@@ -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,
); );
} }
+42
View File
@@ -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;
}
+30
View File
@@ -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;
}
}
}
+18
View File
@@ -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,
});
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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();
+930 -1
View File
@@ -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,
+39 -1
View File
@@ -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(
+406 -1
View File
@@ -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),
],
),
);
}
}
+20 -21
View File
@@ -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,
); );
} }
} }
+158 -32
View File
@@ -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'),
),
], ],
), ),
), ),
+466 -665
View File
File diff suppressed because it is too large Load Diff
+424 -24
View File
@@ -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,
),
);
}
} }
+262
View File
@@ -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),
);
}
}
+17 -18
View File
@@ -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)),
],
),
),
); );
} }
+318
View File
@@ -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,
),
);
}
}
+289
View File
@@ -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,
),
);
}
}
+28
View File
@@ -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);
}
}
+72
View File
@@ -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,
),
),
],
),
),
],
),
),
);
},
);
}
}
+64
View File
@@ -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,
),
),
),
],
),
);
},
);
}
}
+135
View File
@@ -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),
),
),
],
),
),
);
},
);
}
}
+60
View File
@@ -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)),
],
),
);
},
);
},
);
}
+50
View File
@@ -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())),
),
);
}
}
+1 -1
View File
@@ -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,
+18 -15
View File
@@ -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;
} }
} }
+53
View File
@@ -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);
}
}
+406
View File
@@ -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,
),
),
],
);
}
}
+90
View File
@@ -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,
),
],
),
),
);
}
}
+34
View File
@@ -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;
}
}
+113
View File
@@ -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,
);
}
}
+336
View File
@@ -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,
),
),
),
],
),
),
],
),
),
),
],
);
},
);
}
}
+3 -2
View File
@@ -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
+24 -7
View File
@@ -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);
}); });
} }
+2 -2
View File
@@ -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.
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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);