diff --git a/README.md b/README.md index a6255e9..74d6907 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# nextcloud_client +# Noo A new Flutter project. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cb7c742..d6e7d5a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "family.nasa.nextcloud_client" + namespace = "dev.ayushya.noo" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,7 +15,7 @@ android { } defaultConfig { - applicationId = "family.nasa.nextcloud_client" + applicationId = "dev.ayushya.noo" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0687e83..4a84133 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:networkSecurityConfig="@xml/network_security_config" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:enableOnBackInvokedCallback="true"> - + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index aebae73..6ee490e 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 3fc910f..92e9c09 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index b34def6..f3980b4 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 0202602..e8c8526 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 38a0bcf..6c4a824 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml deleted file mode 100644 index 497fb16..0000000 --- a/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #3D5AD1 - \ No newline at end of file diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png index 4dea52a..15df0ef 100644 Binary files a/assets/icon/app_icon.png and b/assets/icon/app_icon.png differ diff --git a/assets/icon/app_icon_background.png b/assets/icon/app_icon_background.png new file mode 100644 index 0000000..7c4c1a6 Binary files /dev/null and b/assets/icon/app_icon_background.png differ diff --git a/assets/icon/app_icon_foreground.png b/assets/icon/app_icon_foreground.png index d900399..15199f9 100644 Binary files a/assets/icon/app_icon_foreground.png and b/assets/icon/app_icon_foreground.png differ diff --git a/assets/icon/app_icon_monochrome.png b/assets/icon/app_icon_monochrome.png new file mode 100644 index 0000000..10b7ac6 Binary files /dev/null and b/assets/icon/app_icon_monochrome.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -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: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4c21634..f833847 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -382,7 +382,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -398,7 +398,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -415,7 +415,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -430,7 +430,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -561,7 +561,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -583,7 +583,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.nextcloudClient; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 73a81c6..29e8c6b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Nextcloud Client + Noo CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - nextcloud_client + noo CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/main.dart b/lib/main.dart index 150e27a..aa5c59f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,12 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'models/app_tab.dart'; import 'providers/server_provider.dart'; import 'theme/app_theme.dart'; -import 'views/activity_view.dart'; -import 'views/files_view.dart'; import 'views/login_view.dart'; -import 'views/photos_view.dart'; import 'views/search_view.dart'; +import 'widgets/app_tab_view_builder.dart'; import 'widgets/floating_bottom_bar.dart'; void main() { @@ -41,6 +40,7 @@ class NextcloudApp extends StatelessWidget { provider.seedColor, dynamicScheme: darkDynamic, useDynamicColor: provider.useDynamicColor, + amoled: provider.amoledDark, ), themeMode: provider.themeMode, home: provider.isRestoringSession @@ -83,19 +83,24 @@ class MainShellView extends StatefulWidget { } class _MainShellViewState extends State { - int _currentIndex = 0; - - late final List _scrollControllers; + late AppTab _currentTab; + late final Map _scrollControllers; @override void 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().defaultTab; } @override void dispose() { - for (var c in _scrollControllers) { + for (final c in _scrollControllers.values) { c.dispose(); } super.dispose(); @@ -104,31 +109,39 @@ class _MainShellViewState extends State { @override Widget build(BuildContext context) { final provider = context.watch(); - final navItems = const [ - FloatingNavItem(label: 'Files', icon: Icons.folder_rounded), - FloatingNavItem(label: 'Photos', icon: Icons.photo_library_rounded), - FloatingNavItem(label: 'Activity', icon: Icons.history_rounded), - ]; + final visible = provider.visibleTabs; + final selectedTab = visible.contains(_currentTab) + ? _currentTab + : (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( body: Stack( children: [ IndexedStack( - index: _currentIndex, - children: [ - FilesView(scrollController: _scrollControllers[0]), - PhotosView(scrollController: _scrollControllers[1]), - ActivityView(scrollController: _scrollControllers[2]), - ], + index: selectedIndex, + children: visible + .map((tab) => buildAppTabView(tab, _scrollControllers[tab]!)) + .toList(), ), + // 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( - selectedIndex: _currentIndex, + selectedIndex: selectedIndex, items: navItems, opacity: provider.bottomBarOpacity, blurSigma: provider.bottomBarBlur, onDestinationSelected: (index) { setState(() { - _currentIndex = index; + _currentTab = visible[index]; }); }, onSearchTap: () { diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart new file mode 100644 index 0000000..77c9f8e --- /dev/null +++ b/lib/models/app_tab.dart @@ -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; + } + } +} diff --git a/lib/models/nextcloud_file_version.dart b/lib/models/nextcloud_file_version.dart new file mode 100644 index 0000000..47cb749 --- /dev/null +++ b/lib/models/nextcloud_file_version.dart @@ -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, + }); +} diff --git a/lib/models/nextcloud_item.dart b/lib/models/nextcloud_item.dart index 0fedcf0..a0f8ae8 100644 --- a/lib/models/nextcloud_item.dart +++ b/lib/models/nextcloud_item.dart @@ -18,6 +18,11 @@ class NextcloudItem { final int? imageHeight; 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({ required this.id, required this.name, @@ -33,6 +38,8 @@ class NextcloudItem { this.imageWidth, this.imageHeight, this.mountType, + this.originalLocation, + this.deletedAt, }) : dateCreated = dateCreated ?? lastModified; bool get isFolder => type == NextcloudItemType.folder; @@ -55,6 +62,8 @@ class NextcloudItem { int? imageWidth, int? imageHeight, String? mountType, + String? originalLocation, + DateTime? deletedAt, }) { return NextcloudItem( id: id ?? this.id, @@ -71,6 +80,8 @@ class NextcloudItem { imageWidth: imageWidth ?? this.imageWidth, imageHeight: imageHeight ?? this.imageHeight, mountType: mountType ?? this.mountType, + originalLocation: originalLocation ?? this.originalLocation, + deletedAt: deletedAt ?? this.deletedAt, ); } diff --git a/lib/models/nextcloud_share.dart b/lib/models/nextcloud_share.dart new file mode 100644 index 0000000..073fbde --- /dev/null +++ b/lib/models/nextcloud_share.dart @@ -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; +} diff --git a/lib/models/nextcloud_sharee.dart b/lib/models/nextcloud_sharee.dart new file mode 100644 index 0000000..09058f0 --- /dev/null +++ b/lib/models/nextcloud_sharee.dart @@ -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; + } + } +} diff --git a/lib/models/selection_action.dart b/lib/models/selection_action.dart new file mode 100644 index 0000000..b07da88 --- /dev/null +++ b/lib/models/selection_action.dart @@ -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, + }); +} diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 4ed9a71..c7f21a9 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -1,9 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../models/app_tab.dart'; +import '../models/nextcloud_file_version.dart'; import '../models/nextcloud_item.dart'; +import '../models/nextcloud_share.dart'; +import '../models/nextcloud_sharee.dart'; import '../services/login_flow_service.dart'; import '../services/nextcloud_service.dart'; import '../theme/app_theme.dart'; @@ -12,7 +17,44 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } enum FileSortField { name, dateCreated, dateModified, size } -class ServerProvider extends ChangeNotifier { +/// Which storage a listing shows — always exactly one, like the list/grid +/// view toggle, not an optional filter. +enum StorageScope { cloud, external } + +/// What swiping a Files list-view item left/right does, user-configurable +/// in Settings. +enum SwipeAction { none, favorite, delete, share } + +/// How aggressively the Files tab's folder listings (not thumbnails/file +/// content - just the list of names/sizes/dates) are reused across +/// navigation instead of refetched from the server every time. +enum CachePolicy { + /// Every navigation refetches - today's behavior. + never, + + /// A listing is reused until it's older than [ServerProvider. + /// cacheIntervalMinutes]; a timer also proactively refreshes the current + /// folder on that same interval while the app is in the foreground. + interval, + + /// A listing is reused indefinitely until the user pulls to refresh. + manual, +} + +/// One cached folder listing and when it was fetched. +class _CachedDirectory { + final List items; + final DateTime fetchedAt; + + const _CachedDirectory(this.items, this.fetchedAt); +} + +/// The two linear progress indicator styles Material 3 offers for the video +/// player's seek bar: an animated travelling-wave line (matching Android's +/// current media player), or a plain flat classic bar. +enum MediaProgressBarStyle { wavy, classic } + +class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { static const _storage = FlutterSecureStorage(); static const _keyServerUrl = 'nc_server_url'; static const _keyLoginName = 'nc_login_name'; @@ -25,11 +67,22 @@ class ServerProvider extends ChangeNotifier { static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity'; static const _prefBottomBarBlur = 'ui_bottom_bar_blur'; static const _prefGridView = 'ui_grid_view'; - static const _prefShowFavoritesOnly = 'ui_show_favorites_only'; - static const _prefShowExternalOnly = 'ui_show_external_only'; - static const _prefShowHidden = 'ui_show_hidden'; + static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only'; + static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; + static const _prefStorageScope = 'ui_storage_scope'; + static const _prefShowHiddenFiles = 'ui_show_hidden'; + static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; static const _prefSortField = 'ui_sort_field'; static const _prefSortAscending = 'ui_sort_ascending'; + static const _prefTabOrder = 'ui_tab_order'; + static const _prefHiddenTabs = 'ui_hidden_tabs'; + static const _prefDefaultTab = 'ui_default_tab'; + static const _prefSwipeLeftAction = 'ui_swipe_left_action'; + static const _prefSwipeRightAction = 'ui_swipe_right_action'; + static const _prefAmoledDark = 'ui_amoled_dark'; + static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style'; + static const _prefCachePolicy = 'ui_cache_policy'; + static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; final Future _prefsFuture = SharedPreferences.getInstance(); @@ -52,6 +105,8 @@ class ServerProvider extends ChangeNotifier { Color _seedColor = AppTheme.defaultNextcloudBlue; ThemeMode _themeMode = ThemeMode.system; bool _useDynamicColor = true; + bool _amoledDark = false; + MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy; // UI settings double _bottomBarOpacity = 0.55; @@ -61,24 +116,86 @@ class ServerProvider extends ChangeNotifier { String _currentFolderPath = '/'; List _pathStack = ['/']; bool _isGridView = false; - bool _showFavoritesOnly = false; - bool _showExternalOnly = false; - bool _showHidden = false; + bool _showFavoritesOnlyFiles = false; + bool _showFavoritesOnlyPhotos = false; + StorageScope _storageScope = StorageScope.cloud; + bool _showHiddenFiles = false; + bool _showHiddenPhotos = false; FileSortField _sortField = FileSortField.name; bool _sortAscending = true; + // Bottom nav tab configuration + List _tabOrder = AppTab.values.toList(); + Set _hiddenTabs = {}; + AppTab _defaultTab = AppTab.files; + + // Files list-view swipe actions + SwipeAction _swipeLeftAction = SwipeAction.delete; + SwipeAction _swipeRightAction = SwipeAction.favorite; + + // Folder-listing cache: keyed by folder path, holds nothing but the item + // list itself (no thumbnails/content) plus when it was fetched. + CachePolicy _cachePolicy = CachePolicy.never; + int _cacheIntervalMinutes = 5; + final Map _directoryCache = {}; + Timer? _cacheRefreshTimer; + // Data state List _items = []; NextcloudUserQuota? _quota; List _activities = []; + // All-account media (the Photos tab) — fetched separately from [_items] + // since it spans every folder, not just the currently browsed one. + List _allMedia = []; + bool _isMediaLoading = false; + String? _mediaErrorMessage; + + // Trash state — kept separate from the regular folder-loading/error state + // above so a trash-fetch failure can't bleed a stale error into Files. + List _trashItems = []; + bool _isTrashLoading = false; + String? _trashErrorMessage; + + // Shares state — likewise kept separate. + List _shares = []; + bool _isSharesLoading = false; + String? _sharesErrorMessage; + bool _sharesWithMe = false; + + // Recent-files state — likewise kept separate. + List _recentItems = []; + bool _isRecentLoading = false; + String? _recentErrorMessage; + NextcloudService? _service; ServerProvider() { + WidgetsBinding.instance.addObserver(this); _restoreSession(); _loadPreferences(); } + /// Starts/stops the periodic folder-listing refresh as the app leaves and + /// returns to the foreground - only relevant under [CachePolicy.interval]. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _startCacheRefreshTimerIfNeeded(); + } else if (state == AppLifecycleState.paused) { + _cacheRefreshTimer?.cancel(); + } + } + + void _startCacheRefreshTimerIfNeeded() { + _cacheRefreshTimer?.cancel(); + if (_cachePolicy != CachePolicy.interval || !_isLoggedIn) return; + _cacheRefreshTimer = Timer.periodic( + Duration(minutes: _cacheIntervalMinutes), + (_) => refreshData(), + ); + } + // Getters String get serverUrl => _serverUrl; String get username => _username; @@ -93,63 +210,135 @@ class ServerProvider extends ChangeNotifier { Color get seedColor => _seedColor; ThemeMode get themeMode => _themeMode; bool get useDynamicColor => _useDynamicColor; + bool get amoledDark => _amoledDark; + MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle; double get bottomBarOpacity => _bottomBarOpacity; double get bottomBarBlur => _bottomBarBlur; String get currentFolderPath => _currentFolderPath; List get pathStack => _pathStack; bool get isGridView => _isGridView; - bool get showFavoritesOnly => _showFavoritesOnly; - bool get showExternalOnly => _showExternalOnly; - bool get showHidden => _showHidden; + bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles; + bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos; + StorageScope get storageScope => _storageScope; + bool get showHiddenFiles => _showHiddenFiles; + bool get showHiddenPhotos => _showHiddenPhotos; FileSortField get sortField => _sortField; bool get sortAscending => _sortAscending; + CachePolicy get cachePolicy => _cachePolicy; + int get cacheIntervalMinutes => _cacheIntervalMinutes; + + /// Every tab in the user's configured order, including hidden ones — used + /// by the reorder/visibility settings UI. + List get tabOrder => _tabOrder; + Set get hiddenTabs => _hiddenTabs; + AppTab get defaultTab => _defaultTab; + + /// The tabs the bottom nav bar should actually show, in order. + List get visibleTabs => + _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); + + SwipeAction get swipeLeftAction => _swipeLeftAction; + SwipeAction get swipeRightAction => _swipeRightAction; /// Exposes the active service so views can build file URLs, auth headers, /// and trigger downloads/previews directly. NextcloudService? get service => _service; - List get items { - var filtered = _items; - if (_showFavoritesOnly) { + int _compareItems(NextcloudItem a, NextcloudItem b) { + switch (_sortField) { + case FileSortField.name: + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case FileSortField.dateCreated: + return a.dateCreated.compareTo(b.dateCreated); + case FileSortField.dateModified: + return a.lastModified.compareTo(b.lastModified); + case FileSortField.size: + return a.size.compareTo(b.size); + } + } + + /// True if [item] or any ancestor folder in its path is a dotfile/dotfolder. + bool _isHiddenItem(NextcloudItem item) { + return item.path + .split('/') + .where((segment) => segment.isNotEmpty) + .any((segment) => segment.startsWith('.')); + } + + /// Applies the shared favorites-only/hidden-files/storage-scope toggles, + /// used by both [items] and [photoItems]. `showFavoritesOnly`/`showHidden` + /// are passed in separately since Files and Photos each have their own + /// independent favorites-only and hidden-files toggles. + List _applyCommonFilters( + List source, { + required bool showFavoritesOnly, + required bool showHidden, + }) { + var filtered = source; + if (showFavoritesOnly) { filtered = filtered.where((item) => item.isFavorite).toList(); } - if (_showExternalOnly) { - filtered = filtered.where((item) => item.isExternalStorage).toList(); - } - if (!_showHidden) { - filtered = filtered.where((item) => !item.name.startsWith('.')).toList(); + if (!showHidden) { + filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); } + filtered = filtered + .where( + (item) => _storageScope == StorageScope.external + ? item.isExternalStorage + : !item.isExternalStorage, + ) + .toList(); + return filtered; + } - int compare(NextcloudItem a, NextcloudItem b) { - switch (_sortField) { - case FileSortField.name: - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - case FileSortField.dateCreated: - return a.dateCreated.compareTo(b.dateCreated); - case FileSortField.dateModified: - return a.lastModified.compareTo(b.lastModified); - case FileSortField.size: - return a.size.compareTo(b.size); - } - } + List get items { + final filtered = _applyCommonFilters( + _items, + showFavoritesOnly: _showFavoritesOnlyFiles, + showHidden: _showHiddenFiles, + ); - final folders = filtered.where((i) => i.isFolder).toList()..sort(compare); - final files = filtered.where((i) => !i.isFolder).toList()..sort(compare); + final folders = filtered.where((i) => i.isFolder).toList() + ..sort(_compareItems); + final files = filtered.where((i) => !i.isFolder).toList() + ..sort(_compareItems); return _sortAscending ? [...folders, ...files] : [...folders.reversed, ...files.reversed]; } + /// All images/videos across the whole account (not just the currently + /// browsed folder) — see [fetchAllMedia]. List get photoItems { - return _items - .where((i) => i.isMedia || i.type == NextcloudItemType.image) - .toList(); + final media = _allMedia.where((i) => i.isMedia).toList(); + final filtered = _applyCommonFilters( + media, + showFavoritesOnly: _showFavoritesOnlyPhotos, + showHidden: _showHiddenPhotos, + )..sort(_compareItems); + return _sortAscending ? filtered : filtered.reversed.toList(); } + bool get isMediaLoading => _isMediaLoading; + String? get mediaErrorMessage => _mediaErrorMessage; + NextcloudUserQuota? get quota => _quota; List get activities => _activities; + List get trashItems => _trashItems; + bool get isTrashLoading => _isTrashLoading; + String? get trashErrorMessage => _trashErrorMessage; + + List get shares => _shares; + bool get isSharesLoading => _isSharesLoading; + String? get sharesErrorMessage => _sharesErrorMessage; + bool get sharesWithMe => _sharesWithMe; + + List get recentItems => _recentItems; + bool get isRecentLoading => _isRecentLoading; + String? get recentErrorMessage => _recentErrorMessage; + Future _restoreSession() async { try { final server = await _storage.read(key: _keyServerUrl); @@ -180,17 +369,36 @@ class ServerProvider extends ChangeNotifier { } _useDynamicColor = prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor; + _amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark; + final progressBarStyleName = prefs.getString(_prefMediaProgressBarStyle); + if (progressBarStyleName != null) { + _mediaProgressBarStyle = MediaProgressBarStyle.values.firstWhere( + (s) => s.name == progressBarStyleName, + orElse: () => _mediaProgressBarStyle, + ); + } final seedColorValue = prefs.getInt(_prefSeedColor); if (seedColorValue != null) _seedColor = Color(seedColorValue); _bottomBarOpacity = prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity; _bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur; _isGridView = prefs.getBool(_prefGridView) ?? _isGridView; - _showFavoritesOnly = - prefs.getBool(_prefShowFavoritesOnly) ?? _showFavoritesOnly; - _showExternalOnly = - prefs.getBool(_prefShowExternalOnly) ?? _showExternalOnly; - _showHidden = prefs.getBool(_prefShowHidden) ?? _showHidden; + _showFavoritesOnlyFiles = + prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles; + _showFavoritesOnlyPhotos = + prefs.getBool(_prefShowFavoritesOnlyPhotos) ?? + _showFavoritesOnlyPhotos; + final storageScopeName = prefs.getString(_prefStorageScope); + if (storageScopeName != null) { + _storageScope = StorageScope.values.firstWhere( + (s) => s.name == storageScopeName, + orElse: () => StorageScope.cloud, + ); + } + _showHiddenFiles = + prefs.getBool(_prefShowHiddenFiles) ?? _showHiddenFiles; + _showHiddenPhotos = + prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos; final sortFieldName = prefs.getString(_prefSortField); if (sortFieldName != null) { _sortField = FileSortField.values.firstWhere( @@ -200,6 +408,75 @@ class ServerProvider extends ChangeNotifier { } _sortAscending = prefs.getBool(_prefSortAscending) ?? _sortAscending; + final savedOrderNames = prefs.getStringList(_prefTabOrder); + if (savedOrderNames != null) { + final order = []; + for (final name in savedOrderNames) { + final match = AppTab.values.where((t) => t.name == name).firstOrNull; + if (match != null) order.add(match); + } + // Forward-compat: a tab added in a later app update won't be in an + // older saved order yet, so append anything missing. + for (final tab in AppTab.values) { + if (!order.contains(tab)) order.add(tab); + } + _tabOrder = order; + } + + final savedHiddenNames = prefs.getStringList(_prefHiddenTabs); + if (savedHiddenNames != null) { + _hiddenTabs = savedHiddenNames + .map( + (name) => AppTab.values.where((t) => t.name == name).firstOrNull, + ) + .whereType() + .toSet(); + // Never let every tab end up hidden. + if (_hiddenTabs.length >= AppTab.values.length) { + _hiddenTabs = {}; + } + } + + final savedDefaultName = prefs.getString(_prefDefaultTab); + if (savedDefaultName != null) { + final match = AppTab.values + .where((t) => t.name == savedDefaultName) + .firstOrNull; + if (match != null) _defaultTab = match; + } + if (_hiddenTabs.contains(_defaultTab)) { + _defaultTab = _tabOrder.firstWhere( + (t) => !_hiddenTabs.contains(t), + orElse: () => _tabOrder.first, + ); + } + _enforceMaxVisibleTabs(); + + final swipeLeftName = prefs.getString(_prefSwipeLeftAction); + if (swipeLeftName != null) { + _swipeLeftAction = SwipeAction.values.firstWhere( + (a) => a.name == swipeLeftName, + orElse: () => _swipeLeftAction, + ); + } + final swipeRightName = prefs.getString(_prefSwipeRightAction); + if (swipeRightName != null) { + _swipeRightAction = SwipeAction.values.firstWhere( + (a) => a.name == swipeRightName, + orElse: () => _swipeRightAction, + ); + } + final cachePolicyName = prefs.getString(_prefCachePolicy); + if (cachePolicyName != null) { + _cachePolicy = CachePolicy.values.firstWhere( + (c) => c.name == cachePolicyName, + orElse: () => _cachePolicy, + ); + } + _cacheIntervalMinutes = + prefs.getInt(_prefCacheIntervalMinutes) ?? _cacheIntervalMinutes; + _startCacheRefreshTimerIfNeeded(); + notifyListeners(); } catch (e) { debugPrint('[ServerProvider] Preference restore failed: $e'); @@ -251,6 +528,7 @@ class ServerProvider extends ChangeNotifier { if (persist) { await _persistCredentials(_serverUrl, _username, _password); } + _startCacheRefreshTimerIfNeeded(); await refreshData(); _isLoading = false; notifyListeners(); @@ -284,9 +562,17 @@ class ServerProvider extends ChangeNotifier { final init = await LoginFlowService.initiate(serverUrl); _pendingLoginUrl = init.loginUrl; + // Chrome Custom Tabs (inAppBrowserView): real Chrome, so the saved + // passwords/autofill service works normally, unlike Flutter's own + // embedded web view. The tradeoff is that - like Login Flow v2's + // grant page itself, which never redirects back into the app on its + // own (unlike the old nc:// Flow v1) - a Custom Tab belongs to + // Chrome's own task, not ours, so we have no way to close it + // automatically once polling below detects success; the user has to + // switch back manually, same as with the external browser. final opened = await launchUrl( init.loginUrl, - mode: LaunchMode.externalApplication, + mode: LaunchMode.inAppBrowserView, ); if (!opened) { throw Exception('Could not open the browser for login.'); @@ -321,6 +607,14 @@ class ServerProvider extends ChangeNotifier { result.appPassword, ); } + } on http.ClientException catch (e) { + // A single dropped connection (e.g. the network briefly + // reconfiguring right as the login browser opens) used to abort + // the whole flow, forcing the user to start over even though + // they'd already granted access - just skip this tick and let + // the next scheduled poll (2s later) try again. The 10-minute + // timeout above is still the real ceiling. + debugPrint('[ServerProvider] Poll network hiccup, retrying: $e'); } catch (e) { timer.cancel(); _pollTimeoutTimer?.cancel(); @@ -338,7 +632,7 @@ class ServerProvider extends ChangeNotifier { Future reopenLoginBrowser() async { if (_pendingLoginUrl != null) { - await launchUrl(_pendingLoginUrl!, mode: LaunchMode.externalApplication); + await launchUrl(_pendingLoginUrl!, mode: LaunchMode.inAppBrowserView); } } @@ -361,7 +655,13 @@ class ServerProvider extends ChangeNotifier { _items = []; _quota = null; _activities = []; + _allMedia = []; + _trashItems = []; + _shares = []; + _recentItems = []; _service = null; + _directoryCache.clear(); + _cacheRefreshTimer?.cancel(); await _clearPersistedCredentials(); notifyListeners(); } @@ -384,6 +684,10 @@ class ServerProvider extends ChangeNotifier { try { _items = await _service!.fetchDirectory(_currentFolderPath); + _directoryCache[_currentFolderPath] = _CachedDirectory( + _items, + DateTime.now(), + ); debugPrint( '[ServerProvider] Loaded ${_items.length} items for $_currentFolderPath', ); @@ -417,17 +721,35 @@ class ServerProvider extends ChangeNotifier { return _service!.searchFiles(query); } + /// Loads every image/video across the whole account for the Photos tab. + Future fetchAllMedia() async { + if (!_isLoggedIn || _service == null) return; + + _isMediaLoading = true; + _mediaErrorMessage = null; + notifyListeners(); + + try { + _allMedia = await _service!.fetchAllMedia(); + debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items'); + } catch (e) { + debugPrint('[ServerProvider] Error fetching all media: $e'); + _mediaErrorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + _isMediaLoading = false; + notifyListeners(); + } + } + Future navigateToFolder(String path) async { - _currentFolderPath = path; _pathStack.add(path); - await refreshData(); + await _navigateTo(path); } Future navigateUp() async { if (_pathStack.length > 1) { _pathStack.removeLast(); - _currentFolderPath = _pathStack.last; - await refreshData(); + await _navigateTo(_pathStack.last); } } @@ -436,7 +758,35 @@ class ServerProvider extends ChangeNotifier { Future navigateToPathIndex(int index) async { if (index < 0 || index >= _pathStack.length - 1) return; _pathStack = _pathStack.sublist(0, index + 1); - _currentFolderPath = _pathStack.last; + await _navigateTo(_pathStack.last); + } + + /// True if [path]'s cached listing (if any) is still usable under the + /// current [CachePolicy] - never for [CachePolicy.never], indefinitely + /// for [CachePolicy.manual], and until it's older than + /// [cacheIntervalMinutes] for [CachePolicy.interval]. + bool _isCacheFresh(String path) { + if (_cachePolicy == CachePolicy.never) return false; + final cached = _directoryCache[path]; + if (cached == null) return false; + if (_cachePolicy == CachePolicy.manual) return true; + return DateTime.now().difference(cached.fetchedAt) < + Duration(minutes: _cacheIntervalMinutes); + } + + /// Switches the current folder to [path], serving its listing straight + /// from the cache when that's still fresh (instant, no network call at + /// all) and only falling back to [refreshData] otherwise. + Future _navigateTo(String path) async { + _currentFolderPath = path; + final cached = _directoryCache[path]; + if (cached != null && _isCacheFresh(path)) { + debugPrint('[ServerProvider] Serving cached listing for $path'); + _items = cached.items; + _errorMessage = null; + notifyListeners(); + return; + } await refreshData(); } @@ -447,26 +797,41 @@ class ServerProvider extends ChangeNotifier { _prefsFuture.then((p) => p.setBool(_prefGridView, value)); } - void toggleFavoritesFilter() { - _showFavoritesOnly = !_showFavoritesOnly; + void toggleFavoritesFilterFiles() { + _showFavoritesOnlyFiles = !_showFavoritesOnlyFiles; notifyListeners(); _prefsFuture.then( - (p) => p.setBool(_prefShowFavoritesOnly, _showFavoritesOnly), + (p) => p.setBool(_prefShowFavoritesOnlyFiles, _showFavoritesOnlyFiles), ); } - void toggleExternalStorageFilter() { - _showExternalOnly = !_showExternalOnly; + void toggleFavoritesFilterPhotos() { + _showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos; notifyListeners(); _prefsFuture.then( - (p) => p.setBool(_prefShowExternalOnly, _showExternalOnly), + (p) => p.setBool(_prefShowFavoritesOnlyPhotos, _showFavoritesOnlyPhotos), ); } - void toggleShowHidden() { - _showHidden = !_showHidden; + void setStorageScope(StorageScope scope) { + if (_storageScope == scope) return; + _storageScope = scope; notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefShowHidden, _showHidden)); + _prefsFuture.then((p) => p.setString(_prefStorageScope, scope.name)); + } + + void toggleShowHiddenFiles() { + _showHiddenFiles = !_showHiddenFiles; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefShowHiddenFiles, _showHiddenFiles)); + } + + void toggleShowHiddenPhotos() { + _showHiddenPhotos = !_showHiddenPhotos; + notifyListeners(); + _prefsFuture.then( + (p) => p.setBool(_prefShowHiddenPhotos, _showHiddenPhotos), + ); } void setSortField(FileSortField field) { @@ -482,6 +847,101 @@ class ServerProvider extends ChangeNotifier { _prefsFuture.then((p) => p.setBool(_prefSortAscending, _sortAscending)); } + void setTabOrder(List order) { + _tabOrder = order; + notifyListeners(); + _prefsFuture.then( + (p) => p.setStringList(_prefTabOrder, order.map((t) => t.name).toList()), + ); + } + + /// Shows or hides a tab in the bottom nav bar. Refuses to hide the last + /// remaining visible tab. If the tab being hidden is the current default, + /// the default falls back to the next visible tab. + /// Returns null on success, or a user-facing reason the change was + /// refused (hiding the last visible tab, or showing a 6th). + String? setTabHidden(AppTab tab, bool hidden) { + if (hidden) { + final remaining = _tabOrder + .where((t) => t != tab && !_hiddenTabs.contains(t)) + .length; + if (remaining == 0) return 'At least one tab must stay visible'; + _hiddenTabs = {..._hiddenTabs, tab}; + if (_defaultTab == tab) { + _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); + _prefsFuture.then( + (p) => p.setString(_prefDefaultTab, _defaultTab.name), + ); + } + } else { + final currentlyVisible = _tabOrder + .where((t) => !_hiddenTabs.contains(t)) + .length; + if (currentlyVisible >= maxVisibleTabs) { + return 'You can only show up to $maxVisibleTabs tabs at once'; + } + _hiddenTabs = {..._hiddenTabs}..remove(tab); + } + notifyListeners(); + _prefsFuture.then( + (p) => p.setStringList( + _prefHiddenTabs, + _hiddenTabs.map((t) => t.name).toList(), + ), + ); + return null; + } + + /// If more than [maxVisibleTabs] end up visible (e.g. a fresh install, or + /// an existing saved config from before a new tab was added to the app), + /// hide the overflow automatically rather than exceeding the cap. + void _enforceMaxVisibleTabs() { + final visible = _tabOrder.where((t) => !_hiddenTabs.contains(t)).toList(); + if (visible.length <= maxVisibleTabs) return; + _hiddenTabs = {..._hiddenTabs, ...visible.sublist(maxVisibleTabs)}; + if (_hiddenTabs.contains(_defaultTab)) { + _defaultTab = _tabOrder.firstWhere((t) => !_hiddenTabs.contains(t)); + } + } + + void setDefaultTab(AppTab tab) { + if (_hiddenTabs.contains(tab) || _defaultTab == tab) return; + _defaultTab = tab; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefDefaultTab, tab.name)); + } + + void setSwipeLeftAction(SwipeAction action) { + if (_swipeLeftAction == action) return; + _swipeLeftAction = action; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefSwipeLeftAction, action.name)); + } + + void setSwipeRightAction(SwipeAction action) { + if (_swipeRightAction == action) return; + _swipeRightAction = action; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name)); + } + + void setCachePolicy(CachePolicy policy) { + if (_cachePolicy == policy) return; + _cachePolicy = policy; + notifyListeners(); + _prefsFuture.then((p) => p.setString(_prefCachePolicy, policy.name)); + _startCacheRefreshTimerIfNeeded(); + } + + void setCacheIntervalMinutes(int minutes) { + final clamped = minutes.clamp(1, 60); + if (_cacheIntervalMinutes == clamped) return; + _cacheIntervalMinutes = clamped; + notifyListeners(); + _prefsFuture.then((p) => p.setInt(_prefCacheIntervalMinutes, clamped)); + _startCacheRefreshTimerIfNeeded(); + } + Future uploadFileFromPath( String name, String localFilePath, { @@ -504,11 +964,107 @@ class ServerProvider extends ChangeNotifier { if (_service == null) return false; final success = await _service!.deleteItem(itemPath); if (success) { + _allMedia = _allMedia.where((i) => i.path != itemPath).toList(); await refreshData(); } return success; } + Future fetchTrash() async { + if (!_isLoggedIn || _service == null) return; + + _isTrashLoading = true; + _trashErrorMessage = null; + notifyListeners(); + + try { + _trashItems = await _service!.fetchTrash(); + } catch (e) { + debugPrint('[ServerProvider] Error fetching trash: $e'); + _trashErrorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + _isTrashLoading = false; + notifyListeners(); + } + } + + /// Restores a trashed item (identified by [NextcloudItem.path], the + /// trash-relative name) back to where it was deleted from. + Future restoreTrashItem(NextcloudItem item) async { + if (_service == null) return false; + final success = await _service!.restoreTrashItem(item.path); + if (success) { + _trashItems = _trashItems.where((i) => i.id != item.id).toList(); + notifyListeners(); + } + return success; + } + + /// Permanently deletes a trashed item. Cannot be undone. + Future deleteTrashItemForever(NextcloudItem item) async { + if (_service == null) return false; + final success = await _service!.deleteTrashItemForever(item.path); + if (success) { + _trashItems = _trashItems.where((i) => i.id != item.id).toList(); + notifyListeners(); + } + return success; + } + + Future fetchShares() async { + if (!_isLoggedIn || _service == null) return; + + _isSharesLoading = true; + _sharesErrorMessage = null; + notifyListeners(); + + try { + _shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe); + } catch (e) { + debugPrint('[ServerProvider] Error fetching shares: $e'); + _sharesErrorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + _isSharesLoading = false; + notifyListeners(); + } + } + + /// Switches between "shared by me" and "shared with me" and refetches. + void setSharesWithMe(bool value) { + if (_sharesWithMe == value) return; + _sharesWithMe = value; + notifyListeners(); + fetchShares(); + } + + Future deleteShare(NextcloudShare share) async { + if (_service == null) return false; + final success = await _service!.deleteShare(share.id); + if (success) { + _shares = _shares.where((s) => s.id != share.id).toList(); + notifyListeners(); + } + return success; + } + + Future fetchRecent() async { + if (!_isLoggedIn || _service == null) return; + + _isRecentLoading = true; + _recentErrorMessage = null; + notifyListeners(); + + try { + _recentItems = await _service!.fetchRecentFiles(); + } catch (e) { + debugPrint('[ServerProvider] Error fetching recent files: $e'); + _recentErrorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + _isRecentLoading = false; + notifyListeners(); + } + } + Future createFolder(String folderName) async { if (_service == null) return false; final success = await _service!.createFolder( @@ -525,11 +1081,119 @@ class ServerProvider extends ChangeNotifier { if (_service == null) return; final success = await _service!.toggleFavorite(item.path, item.isFavorite); if (success) { + final updated = item.copyWith(isFavorite: !item.isFavorite); final index = _items.indexWhere((i) => i.id == item.id); - if (index != -1) { - _items[index] = item.copyWith(isFavorite: !item.isFavorite); - notifyListeners(); - } + if (index != -1) _items[index] = updated; + final mediaIndex = _allMedia.indexWhere((i) => i.id == item.id); + if (mediaIndex != -1) _allMedia[mediaIndex] = updated; + notifyListeners(); + } + } + + /// Creates a public link share for [item] and returns its URL, or null on + /// failure (server error, unsupported item, etc). + Future createShareLink(NextcloudItem item) async { + if (_service == null) return null; + try { + return await _service!.createPublicShareLink(item.path); + } catch (e) { + debugPrint('[ServerProvider] createShareLink failed: $e'); + return null; + } + } + + // --- Details sheet: per-item sharing/activity/versions --- + // + // These are thin pass-throughs (no stored state, no notifyListeners) - + // unlike the flat account-wide lists above (`_shares`, `_trashItems`, + // ...), the data here is transient and scoped to whichever item's + // Details sheet happens to be open, so it lives in that sheet's own + // local widget state rather than bloating this provider. + + Future> fetchItemShares(NextcloudItem item) async { + if (_service == null) return []; + try { + return await _service!.fetchSharesForPath(item.path); + } catch (e) { + debugPrint('[ServerProvider] fetchItemShares failed: $e'); + return []; + } + } + + Future> fetchInheritedShares(NextcloudItem item) async { + if (_service == null) return []; + return _service!.fetchInheritedShares(item.path); + } + + Future> searchSharees(String query) async { + if (_service == null) return []; + try { + return await _service!.searchSharees(query); + } catch (e) { + debugPrint('[ServerProvider] searchSharees failed: $e'); + return []; + } + } + + Future createShare({ + required String path, + required int shareType, + String? shareWith, + String? password, + DateTime? expireDate, + }) async { + if (_service == null) return null; + try { + return await _service!.createShare( + path: path, + shareType: shareType, + shareWith: shareWith, + password: password, + expireDate: expireDate, + ); + } catch (e) { + debugPrint('[ServerProvider] createShare failed: $e'); + return null; + } + } + + Future> fetchFileActivity(NextcloudItem item) async { + if (_service == null) return []; + return _service!.fetchFileActivity(item.id); + } + + Future> fetchFileVersions( + NextcloudItem item, + ) async { + if (_service == null) return []; + return _service!.fetchFileVersions(item.id); + } + + Future restoreFileVersion( + NextcloudItem item, + String versionLabel, + ) async { + if (_service == null) return false; + try { + return await _service!.restoreFileVersion(item.id, versionLabel); + } catch (e) { + debugPrint('[ServerProvider] restoreFileVersion failed: $e'); + return false; + } + } + + Future downloadVersion( + NextcloudItem item, + String versionLabel, + String savePath, + ) async { + if (_service == null) return false; + try { + await _service!.downloadVersionToFile(item.id, versionLabel, savePath); + return true; + } catch (e) { + debugPrint('[ServerProvider] downloadVersion failed: $e'); + return false; } } @@ -549,6 +1213,20 @@ class ServerProvider extends ChangeNotifier { _prefsFuture.then((p) => p.setBool(_prefUseDynamicColor, value)); } + void setAmoledDark(bool value) { + _amoledDark = value; + notifyListeners(); + _prefsFuture.then((p) => p.setBool(_prefAmoledDark, value)); + } + + void setMediaProgressBarStyle(MediaProgressBarStyle style) { + _mediaProgressBarStyle = style; + notifyListeners(); + _prefsFuture.then( + (p) => p.setString(_prefMediaProgressBarStyle, style.name), + ); + } + void setBottomBarOpacity(double value) { _bottomBarOpacity = value; notifyListeners(); @@ -569,8 +1247,10 @@ class ServerProvider extends ChangeNotifier { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _pollTimer?.cancel(); _pollTimeoutTimer?.cancel(); + _cacheRefreshTimer?.cancel(); super.dispose(); } } diff --git a/lib/services/login_flow_service.dart b/lib/services/login_flow_service.dart index 0c2dc1a..f9e970d 100644 --- a/lib/services/login_flow_service.dart +++ b/lib/services/login_flow_service.dart @@ -32,7 +32,7 @@ class LoginFlowResult { /// back a scoped app password. /// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 class LoginFlowService { - static const _userAgent = 'Nextcloud-Flutter-Client/1.0'; + static const _userAgent = 'Noo/1.0'; static String normalizeServerUrl(String input) { var url = input.trim(); diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart index 0165458..dcd18b0 100644 --- a/lib/services/nextcloud_service.dart +++ b/lib/services/nextcloud_service.dart @@ -4,7 +4,10 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart' as xml; +import '../models/nextcloud_file_version.dart'; import '../models/nextcloud_item.dart'; +import '../models/nextcloud_share.dart'; +import '../models/nextcloud_sharee.dart'; extension XmlElementHelper on xml.XmlNode { Iterable findLocalChildren(String localName) { @@ -80,7 +83,7 @@ class NextcloudService { 'Authorization': 'Basic $credentials', 'OCS-APIRequest': 'true', '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; } + /// 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> fetchAllMedia() async { + final url = '$_cleanServerUrl/remote.php/dav/'; + debugPrint('[Nextcloud DAV] SEARCH for all media from $url'); + + final body = + ''' + + + + + + + + + + + + + + + + + + /files/$username + infinity + + + + + + + image/% + + + + video/% + + + + + + + + + + + 2000 + + +'''; + + 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 = []; + 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> fetchRecentFiles() async { + final url = '$_cleanServerUrl/remote.php/dav/'; + debugPrint('[Nextcloud DAV] SEARCH for recent files from $url'); + + final body = + ''' + + + + + + + + + + + + + + + + + /files/$username + infinity + + + + + + % + + + + + + + + + + 200 + + +'''; + + 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 = []; + 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 fetchUserQuota() async { final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json'; debugPrint('[Nextcloud OCS] Fetching user quota from $url'); @@ -665,6 +994,606 @@ class NextcloudService { return response.statusCode == 201; } + /// Lists the contents of the Nextcloud trash bin (flat — deleted folders + /// appear as a single entry, not expanded). + Future> 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 = ''' + + + + + + + + + + + +'''; + + 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 = []; + 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 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 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> 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((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 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 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> 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((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> 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( + (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> 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 = []; + final seen = {}; + 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 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> fetchFileActivity(String fileId) async { + List parse(String body) { + final data = jsonDecode(body); + final rawData = data['ocs']?['data']; + List 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> 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 = ''' + + + + + +'''; + + 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 = []; + 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 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 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 toggleFavorite( String itemPath, bool currentFavoriteState, diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 31fb1bf..fe42c81 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -4,6 +4,24 @@ import 'package:google_fonts/google_fonts.dart'; class AppTheme { 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 seedColors = [ Color(0xFF0082C9), // Nextcloud Blue Color(0xFF009688), // Ocean Teal @@ -29,6 +47,8 @@ class AppTheme { brightness: Brightness.light, colorScheme: colorScheme, scaffoldBackgroundColor: colorScheme.surface, + pageTransitionsTheme: _pageTransitionsTheme, + sliderTheme: _sliderTheme, textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme) .copyWith( headlineMedium: GoogleFonts.inter( @@ -64,19 +84,37 @@ class AppTheme { Color seedColor, { ColorScheme? dynamicScheme, bool useDynamicColor = true, + bool amoled = false, }) { - final colorScheme = (useDynamicColor && dynamicScheme != null) + var colorScheme = (useDynamicColor && dynamicScheme != null) ? dynamicScheme : ColorScheme.fromSeed( seedColor: seedColor, 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( useMaterial3: true, brightness: Brightness.dark, colorScheme: colorScheme, scaffoldBackgroundColor: colorScheme.surface, + pageTransitionsTheme: _pageTransitionsTheme, + sliderTheme: _sliderTheme, textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) .copyWith( headlineMedium: GoogleFonts.inter( diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index bc93365..3c65735 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/app_tab.dart'; import '../providers/server_provider.dart'; import '../theme/app_theme.dart'; import '../widgets/frosted_glass_container.dart'; @@ -22,7 +23,7 @@ class AccountView extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Account & Storage'), + title: const Text('Settings'), actions: [ IconButton( onPressed: () => _showLogoutConfirmation(context, provider), @@ -316,6 +317,16 @@ class AccountView extends StatelessWidget { 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 _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(); + + 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 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( + 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(); + + 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( + 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(); + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: [ + RadioGroup( + groupValue: provider.cachePolicy, + onChanged: (value) { + if (value != null) provider.setCachePolicy(value); + }, + child: Column( + children: [ + for (final policy in CachePolicy.values) + RadioListTile( + 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(); + + return Card( + clipBehavior: Clip.antiAlias, + child: ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorderItem: (oldIndex, newIndex) { + final order = List.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 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), + ], + ), + ); + } +} diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 7100e90..3705240 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../providers/server_provider.dart'; +import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; +import '../widgets/synced_header_scaffold.dart'; class ActivityView extends StatelessWidget { final ScrollController scrollController; @@ -16,77 +18,65 @@ class ActivityView extends StatelessWidget { final provider = context.watch(); final activities = provider.activities; - return CustomScrollView( - controller: scrollController, - 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)), + final List contentSlivers = [ + const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (activities.isEmpty) - SliverFillRemaining( - child: Center( - child: Text( - 'No recent activity', - style: theme.textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + if (activities.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + 'No recent activity', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final act = activities[index]; + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((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, + 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, ), ), - ), - 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, - children: [ - Text( + 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( @@ -94,45 +84,54 @@ class ActivityView extends StatelessWidget { fontSize: 14, ), ), - Text( - DateFormat.jm().format(act.timestamp), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, - ), + ), + const SizedBox(width: 8), + Text( + DateFormat.jm().format(act.timestamp), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, ), - ], - ), - const SizedBox(height: 4), - Text( - act.subject, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 13, ), + ], + ), + const SizedBox(height: 4), + Text( + act.subject, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 13, ), - const SizedBox(height: 6), - Text( - 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w500, - fontSize: 11, - ), + ), + const SizedBox(height: 6), + Text( + 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w500, + fontSize: 11, ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), - ); - }, childCount: activities.length), - ), + ), + ); + }, childCount: activities.length), ), + ), - 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, ); } } diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 062635b..89f5fa9 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -8,11 +8,13 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:pdfx/pdfx.dart'; import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:video_player/video_player.dart'; import '../models/nextcloud_item.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/share_sheet.dart'; const _textPreviewExtensions = { '.txt', @@ -55,15 +57,15 @@ class FileViewerScreen extends StatefulWidget { /// Opens the viewer with no page transition — media should appear /// 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 route({ required NextcloudItem item, List? siblings, }) { - return PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => + return _InstantOpenPageRoute( + pageBuilder: (context) => FileViewerScreen(item: item, siblings: siblings), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, ); } @@ -71,6 +73,30 @@ class FileViewerScreen extends StatefulWidget { State 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 extends PageRoute + with MaterialRouteTransitionMixin { + _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 { double? _downloadProgress; bool _isBusy = false; @@ -190,23 +216,6 @@ class _FileViewerScreenState extends State { } } - Future _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 _toggleFavorite(ServerProvider provider) async { final item = _currentItem; await provider.toggleItemFavorite(item); @@ -328,28 +337,48 @@ class _FileViewerScreenState extends State { ), ), ), - Align( - alignment: Alignment.bottomCenter, - child: AnimatedSlide( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOutCubic, - offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4), - child: IgnorePointer( - ignoring: !_controlsVisible, - child: _MediaActionBar( - isFavorite: _currentItem.isFavorite, - isBusy: _isBusy, - opacity: provider.bottomBarOpacity, - blurSigma: provider.bottomBarBlur, - onShare: () => _shareCurrentItem(provider), - onFavorite: () => _toggleFavorite(provider), - onDelete: () => _deleteCurrentItem(provider), - onOpenExternally: () => _openExternally(provider), - onDownload: () => _downloadToDevice(provider), + // 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, + child: AnimatedSlide( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOutCubic, + offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4), + child: IgnorePointer( + ignoring: !_controlsVisible, + child: _MediaActionBar( + isFavorite: _currentItem.isFavorite, + isBusy: _isBusy, + opacity: provider.bottomBarOpacity, + blurSigma: provider.bottomBarBlur, + onShare: () => ShareSheet.show(context, _currentItem), + onFavorite: () => _toggleFavorite(provider), + onDelete: () => _deleteCurrentItem(provider), + onOpenExternally: () => _openExternally(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 { item: widget.item, isBusy: _isBusy, onOpenExternally: () => _openExternally(provider), + onOpenDetails: () => DetailsSheet.show(context, widget.item), ); } } @@ -421,6 +451,7 @@ class _MediaActionBar extends StatelessWidget { final VoidCallback onDelete; final VoidCallback onOpenExternally; final VoidCallback onDownload; + final VoidCallback onDetails; const _MediaActionBar({ required this.isFavorite, @@ -432,6 +463,7 @@ class _MediaActionBar extends StatelessWidget { required this.onDelete, required this.onOpenExternally, required this.onDownload, + required this.onDetails, }); @override @@ -458,10 +490,10 @@ class _MediaActionBar extends StatelessWidget { ), _ActionIconButton( icon: isFavorite - ? Icons.star_rounded - : Icons.star_outline_rounded, + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, tooltip: 'Favorite', - color: isFavorite ? Colors.amber : null, + color: isFavorite ? Colors.red : null, onTap: onFavorite, ), _ActionIconButton( @@ -480,6 +512,11 @@ class _MediaActionBar extends StatelessWidget { color: colorScheme.error, onTap: isBusy ? null : onDelete, ), + _ActionIconButton( + icon: Icons.info_outline_rounded, + tooltip: 'Details', + onTap: onDetails, + ), ], ), ), @@ -524,35 +561,102 @@ class _ActionIconButton extends StatelessWidget { } } -class _ImagePreview extends StatelessWidget { +class _ImagePreview extends StatefulWidget { final String url; final Map 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? _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 Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return SizedBox.expand( - child: InteractiveViewer( - minScale: 0.8, - maxScale: 5.0, - child: Center( - child: Image.network( - url, - headers: headers, - fit: BoxFit.contain, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return Center( - child: CircularProgressIndicator(color: colorScheme.primary), - ); - }, - errorBuilder: (context, error, stack) => Center( - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: 64, + child: GestureDetector( + onDoubleTapDown: _onDoubleTapDown, + onDoubleTap: _onDoubleTap, + child: InteractiveViewer( + transformationController: _transformController, + minScale: 0.8, + maxScale: 5.0, + child: Center( + child: Image.network( + widget.url, + headers: widget.headers, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Center( + child: CircularProgressIndicator(color: colorScheme.primary), + ); + }, + errorBuilder: (context, error, stack) => Center( + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: 64, + ), ), ), ), @@ -647,6 +751,9 @@ class _VideoPreviewState extends State<_VideoPreview> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final progressBarStyle = context + .watch() + .mediaProgressBarStyle; if (_error != null) { return Center( child: Text( @@ -737,6 +844,7 @@ class _VideoPreviewState extends State<_VideoPreview> { controller: controller, playedColor: fg, trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, ), ), ], @@ -760,11 +868,13 @@ class _WavySeekBar extends StatefulWidget { final VideoPlayerController controller; final Color playedColor; final Color trackColor; + final MediaProgressBarStyle style; const _WavySeekBar({ required this.controller, required this.playedColor, required this.trackColor, + required this.style, }); @override @@ -793,7 +903,8 @@ class _WavySeekBarState extends State<_WavySeekBar> @override Widget build(BuildContext context) { 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) { _waveController.repeat(); } else if (!isPlaying && _waveController.isAnimating) { @@ -976,7 +1087,14 @@ class _PdfPreviewState extends State<_PdfPreview> { 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 bool isBusy; final VoidCallback onOpenExternally; + final VoidCallback onOpenDetails; const _UnsupportedPreview({ required this.item, required this.isBusy, required this.onOpenExternally, + required this.onOpenDetails, }); @override @@ -1094,6 +1214,12 @@ class _UnsupportedPreview extends StatelessWidget { icon: const Icon(Icons.open_in_new_rounded), label: const Text('Open with...'), ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onOpenDetails, + icon: const Icon(Icons.info_outline_rounded), + label: const Text('Details'), + ), ], ), ), diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 6141ba3..fc3a948 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -1,27 +1,29 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:intl/intl.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:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; +import '../models/selection_action.dart'; import '../providers/server_provider.dart'; +import '../widgets/breadcrumbs.dart'; +import '../widgets/details/details_sheet.dart'; +import '../widgets/more_tabs_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/swipeable_item.dart'; +import '../widgets/synced_header_scaffold.dart'; import 'file_viewer_screen.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'; -} - class FilesView extends StatefulWidget { final ScrollController scrollController; @@ -161,301 +163,253 @@ class _ItemThumbnail extends StatelessWidget { } } -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 (replacing the old "My files" label) for choosing which -/// field the file list is sorted by. -class _SortMenuButton extends StatelessWidget { - final FileSortField field; - final ValueChanged onChanged; - - const _SortMenuButton({required this.field, required this.onChanged}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return PopupMenuButton( - 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( - mainAxisSize: MainAxisSize.min, - children: [ - 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, - ), - ], - ), - ), - ); - } -} - -/// Tappable "Home / Folder / Sub-folder" trail shown under the sort/filter -/// row whenever the user has navigated below the root. -class _Breadcrumbs extends StatelessWidget { - final List pathStack; - final ValueChanged onTap; - - const _Breadcrumbs({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) - Text( - _labelFor(i), - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w700, - color: colorScheme.onSurface, - ), - ) - else - InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => onTap(i), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 2, - vertical: 2, - ), - child: Text( - _labelFor(i), - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.primary, - ), - ), - ), - ), - ], - ], - ), - ); - } -} - class _FilesViewState extends State { - static const double _lockThreshold = 100; - static const double _syncThreshold = 14; - static const double _pullingEpsilon = 8; + final Set _selectedIds = {}; - bool _headerLocked = false; - bool _isPulling = false; - double _pullDistance = 0; + bool get _isSelecting => _selectedIds.isNotEmpty; - void _revealPanel() { - if (widget.scrollController.hasClients) { - widget.scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, + 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 _buildSelectionActions( + ServerProvider provider, + List 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), + ), + ]; + } + + Future _shareSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar( + content: Text('Creating share link(s)…'), + behavior: SnackBarBehavior.floating, + ), + ); + + final lines = []; + 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, + ), ); } } - 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); - - if (pulled >= _lockThreshold) { - _lockOpen(); - context.read().refreshData(); - } else if (pulled >= _syncThreshold) { - context.read().refreshData(); - } - } - return false; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final provider = context.watch(); - 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); - } + final hasBreadcrumbs = provider.pathStack.length > 1; + final selectedItems = provider.items + .where((i) => _selectedIds.contains(i.id)) + .toList(); - final List contentSlivers = [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - icon: Icon( - provider.sortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, + final controlsRow = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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.showFavoritesOnlyFiles + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + isSelected: provider.showFavoritesOnlyFiles, + onTap: provider.toggleFavoritesFilterFiles, + tooltip: 'Favorites only', + ), + const SizedBox(width: 4), + ToggleIconButton( + icon: provider.showHiddenFiles + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + isSelected: provider.showHiddenFiles, + onTap: provider.toggleShowHiddenFiles, + 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', ), - visualDensity: VisualDensity.compact, - tooltip: provider.sortAscending - ? 'Ascending' - : 'Descending', - onPressed: provider.toggleSortOrder, - ), - Expanded( - child: _SortMenuButton( - field: provider.sortField, - onChanged: provider.setSortField, + ToggleIconButton( + icon: Symbols.hard_drive_rounded, + isSelected: + provider.storageScope == StorageScope.external, + onTap: () => + provider.setStorageScope(StorageScope.external), + tooltip: 'External storage', ), - ), - ToggleIconButton( - icon: Icons.star_rounded, - isSelected: provider.showFavoritesOnly, - onTap: provider.toggleFavoritesFilter, - tooltip: 'Favorites only', - ), - const SizedBox(width: 4), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: provider.showExternalOnly, - onTap: provider.toggleExternalStorageFilter, - tooltip: 'External storage only', - ), - const SizedBox(width: 4), - ToggleIconButton( - icon: Icons.visibility_rounded, - isSelected: provider.showHidden, - onTap: provider.toggleShowHidden, - tooltip: 'Show hidden files', - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), - ], - ), - if (provider.pathStack.length > 1) ...[ - const SizedBox(height: 10), - _Breadcrumbs( - pathStack: provider.pathStack, - onTap: (index) => provider.navigateToPathIndex(index), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.view_list_rounded, + isSelected: !provider.isGridView, + onTap: () => provider.setGridView(false), + tooltip: 'List view', + ), + ToggleIconButton( + icon: Icons.grid_view_rounded, + isSelected: provider.isGridView, + onTap: () => provider.setGridView(true), + tooltip: 'Grid view', + ), + ], ), ], - ], + ), ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( + height: 32, + child: Breadcrumbs( + pathStack: provider.pathStack, + onTap: (index) => provider.navigateToPathIndex(index), + ), + ), + ], + ], + ), + ); + + final List 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: hasBreadcrumbs ? 114 : 72, + child: controlsRow, ), ), + if (_isSelecting) + SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Cancel selection', + onPressed: _clearSelection, + ), + 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), + ], + ), // Files List / Grid if (provider.isLoading) @@ -567,56 +521,130 @@ class _FilesViewState extends State { ]; return PopScope( - canPop: provider.pathStack.length <= 1, + canPop: !_isSelecting && provider.pathStack.length <= 1, onPopInvokedWithResult: (didPop, result) { - if (!didPop) provider.navigateUp(); + if (didPop) return; + if (_isSelecting) { + _clearSelection(); + } else { + provider.navigateUp(); + } }, - child: ColoredBox( - color: colorScheme.surfaceContainer, - child: NotificationListener( - onNotification: _handleScrollNotification, - child: CustomScrollView( - controller: widget.scrollController, - physics: const 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: [ - IconButton( - icon: const Icon(Icons.add_rounded), - tooltip: 'New', - onPressed: () => _showCreateMenu(context, provider), - ), - const ProfileAvatarButton(), - ], - flexibleSpace: FlexibleSpaceBar( - background: _FilesStretchPanel( - provider: provider, - forceVisible: _headerLocked, - ), - ), - ), - DecoratedSliver( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(28), - ), - ), - sliver: SliverMainAxisGroup(slivers: contentSlivers), - ), - ], + child: SyncedHeaderScaffold( + scrollController: widget.scrollController, + provider: provider, + actions: [ + IconButton( + icon: const Icon(Icons.add_rounded), + tooltip: 'New', + onPressed: () => _showCreateMenu(context, provider), ), - ), + const MoreTabsButton(), + const ProfileAvatarButton(), + ], + contentSlivers: contentSlivers, + ), + ); + } + + Future _favoriteSelected( + ServerProvider provider, + List items, + ) async { + final allFavorited = items.every((i) => i.isFavorite); + for (final item in items) { + if (item.isFavorite == allFavorited) { + await provider.toggleItemFavorite(item); + } + } + _clearSelection(); + } + + Future _downloadSelected( + BuildContext context, + ServerProvider provider, + List 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) { + if (item.isFolder) continue; + 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 _confirmDeleteSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final confirmed = await showDialog( + 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, ), ); } @@ -628,85 +656,104 @@ class _FilesViewState extends State { ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final isSelected = _selectedIds.contains(item.id); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () { - if (item.isFolder) { - provider.navigateToFolder(item.path); - } else { - _openFile(context, item, provider); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - _ItemThumbnail( + // No border radius here — the outer ClipRRect below is the only place + // that rounds this tile's corners. Rounding it here too would give the + // tile its own independent rounded edge, which becomes visible as a + // stray floating corner while it slides during a swipe. + final card = Material( + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.5) + : colorScheme.surfaceContainerLow, + child: InkWell( + onTap: () { + if (_isSelecting) { + _toggleSelection(item); + } else if (item.isFolder) { + provider.navigateToFolder(item.path); + } else { + _openFile(context, item, provider); + } + }, + onLongPress: () => _toggleSelection(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + SelectableThumbnail( + isSelected: isSelected, + size: 44, + checkmarkSize: 24, + child: _ItemThumbnail( item: item, provider: provider, size: 44, borderRadius: 12, iconSize: 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(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( - item.isFolder - ? 'Folder' - : '${_formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ), + const SizedBox(height: 2), + Text( + item.isFolder + ? 'Folder' + : '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - ], - ), + ), + ], ), - IconButton( - icon: Icon( - item.isFavorite - ? Icons.star_rounded - : Icons.star_outline_rounded, - color: item.isFavorite - ? Colors.amber.shade700 - : colorScheme.outlineVariant, - size: 20, - ), - onPressed: () => provider.toggleItemFavorite(item), - ), - IconButton( - icon: Icon( - Icons.more_vert_rounded, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - onPressed: () => - _showFileDetailsSheet(context, item, provider), - ), - ], - ), + ), + ], ), ), ), ); + + final content = _isSelecting + ? card + : SwipeableItem( + itemKey: ValueKey('file-${item.id}'), + provider: provider, + onFavorite: () => provider.toggleItemFavorite(item), + onShare: () => ShareSheet.show(context, item), + onDelete: () => _deleteViaSwipe(provider, item), + child: card, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ClipRRect(borderRadius: BorderRadius.circular(16), child: content), + ); + } + + Future _deleteViaSwipe( + ServerProvider provider, + NextcloudItem item, + ) async { + final success = await provider.deleteItem(item.path); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success ? 'Deleted ${item.name}' : 'Failed to delete ${item.name}', + ), + behavior: SnackBarBehavior.floating, + ), + ); } Widget _buildGridCard( @@ -714,76 +761,164 @@ class _FilesViewState extends State { NextcloudItem item, ServerProvider provider, ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final isSelected = _selectedIds.contains(item.id); + final isMedia = + (item.type == NextcloudItemType.image || + item.type == NextcloudItemType.video) && + item.previewUrl != null; return Material( - color: colorScheme.surfaceContainerLow, + color: Theme.of(context).colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, child: InkWell( - borderRadius: BorderRadius.circular(20), onTap: () { - if (item.isFolder) { + if (_isSelecting) { + _toggleSelection(item); + } else if (item.isFolder) { provider.navigateToFolder(item.path); } else { _openFile(context, item, provider); } }, - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _ItemThumbnail( - item: item, - provider: provider, - size: 40, - borderRadius: 12, - iconSize: 24, - ), - IconButton( - icon: Icon( - item.isFavorite - ? Icons.star_rounded - : Icons.star_outline_rounded, - color: item.isFavorite - ? Colors.amber.shade700 - : colorScheme.outlineVariant, - size: 20, - ), - onPressed: () => provider.toggleItemFavorite(item), - ), - ], + onLongPress: () => _toggleSelection(item), + child: SelectableThumbnail( + isSelected: isSelected, + checkmarkSize: 32, + child: isMedia + ? _buildMediaGridContent(context, item, provider) + : _buildPlainGridContent(context, item), + ), + ), + ); + } + + /// Grid content for images/videos: the actual preview fills the whole + /// card as a background, with the name/size legible over a bottom scrim + /// — matching a Google Photos-style grid instead of a small icon badge. + Widget _buildMediaGridContent( + BuildContext context, + NextcloudItem item, + ServerProvider provider, + ) { + final theme = Theme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final cachePixels = + (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio) + .round(); + return Stack( + fit: StackFit.expand, + children: [ + Image.network( + item.previewUrl!, + headers: provider.service?.authHeaders, + fit: BoxFit.cover, + cacheWidth: cachePixels, + cacheHeight: cachePixels, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => + _buildPlainGridContent(context, item), + ), + if (item.type == NextcloudItemType.video) + const Center( + child: Icon( + Icons.play_circle_fill_rounded, + color: Colors.white, + size: 36, + ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - fontSize: 13, - ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(10, 20, 10, 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], ), - const SizedBox(height: 2), - Text( - item.isFolder ? 'Folder' : _formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), ), - ), - ], + Text( + formatBytes(item.size), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white70, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + /// Grid content for folders and non-previewable files: an icon badge + /// with name/size below, since there's no meaningful preview to show. + Widget _buildPlainGridContent(BuildContext context, NextcloudItem item) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final iconColor = _getIconColor(context, item.type); + + return Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(_getItemIcon(item.type), color: iconColor, size: 24), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + Text( + item.isFolder ? 'Folder' : formatBytes(item.size), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), ], ), - ), + ], ), ); } @@ -799,193 +934,12 @@ class _FilesViewState extends State { ); } - Future _downloadItem( - BuildContext context, - ServerProvider provider, - NextcloudItem item, - ) async { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - content: Text('Downloading ${item.name}...'), - behavior: SnackBarBehavior.floating, - ), - ); - 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, - ); - messenger.showSnackBar( - SnackBar( - content: Text('Saved ${item.name} to Downloads'), - behavior: SnackBarBehavior.floating, - ), - ); - } catch (e) { - messenger.showSnackBar( - SnackBar( - content: Text('Download failed: $e'), - behavior: SnackBarBehavior.floating, - ), - ); - } - } - - void _showFileDetailsSheet( - BuildContext context, - NextcloudItem item, - ServerProvider provider, - ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 20), - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _getIconColor( - context, - item.type, - ).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - _getItemIcon(item.type), - color: _getIconColor(context, item.type), - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - item.path, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 24), - const Divider(height: 1), - const SizedBox(height: 16), - if (!item.isFolder) ...[ - ListTile( - leading: const Icon(Icons.open_in_new_rounded), - title: const Text('Open'), - onTap: () { - Navigator.pop(context); - _openFile(context, item, provider); - }, - ), - ListTile( - leading: const Icon(Icons.download_rounded), - title: const Text('Download'), - onTap: () { - Navigator.pop(context); - _downloadItem(context, provider, item); - }, - ), - ], - ListTile( - leading: Icon( - item.isFavorite - ? Icons.star_rounded - : Icons.star_outline_rounded, - color: item.isFavorite ? Colors.amber.shade700 : null, - ), - title: Text( - item.isFavorite - ? 'Remove from Favorites' - : 'Add to Favorites', - ), - onTap: () { - provider.toggleItemFavorite(item); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon( - Icons.delete_outline_rounded, - color: colorScheme.error, - ), - title: Text( - 'Delete from Server', - style: TextStyle(color: colorScheme.error), - ), - onTap: () async { - Navigator.pop(context); - final success = await provider.deleteItem(item.path); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - success - ? 'Deleted ${item.name}' - : 'Failed to delete ${item.name}', - ), - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - ), - ], - ), - ); - }, - ); - } - void _showCreateMenu(BuildContext context, ServerProvider provider) { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, + showDragHandle: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), @@ -996,15 +950,6 @@ class _FilesViewState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), ListTile( leading: const Icon(Icons.upload_file_rounded), title: const Text('Upload File'), @@ -1132,147 +1077,3 @@ class _FilesViewState extends State { } } } - -/// A compact, always-visible summary of the same status shown by -/// [_FilesStretchPanel] — 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 ? _formatBytes(quota.usedBytes) : 'Sync', - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Revealed by pulling the "Files" header down past its normal height -/// (Google Photos' "pull to see backup status" pattern). Shows storage quota; -/// [_FilesViewState] decides via [forceVisible] whether it stays locked open. -class _FilesStretchPanel 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 _FilesStretchPanel({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(); - 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 stretchTriggerOffset that locks the header open. - 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, - ), - ), - 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 - ? (quota.totalBytes > 0 - ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)} used' - : '${_formatBytes(quota.usedBytes)} used') - : 'Pull to refresh', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index b7654ed..26977b2 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -1,78 +1,314 @@ +import 'package:file_saver/file_saver.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:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; +import '../models/selection_action.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/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'; -class PhotosView extends StatelessWidget { +class PhotosView extends StatefulWidget { final ScrollController scrollController; const PhotosView({super.key, required this.scrollController}); + @override + State createState() => _PhotosViewState(); +} + +class _PhotosViewState extends State { + bool _requested = false; + final Set _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 _buildSelectionActions( + ServerProvider provider, + List 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 Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final provider = context.watch(); - final photos = provider.photoItems; - return CustomScrollView( - controller: scrollController, - physics: const BouncingScrollPhysics(), - slivers: [ + if (!_requested) { + _requested = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => 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 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( pinned: true, - expandedHeight: kToolbarHeight, - collapsedHeight: kToolbarHeight, - backgroundColor: colorScheme.surface, - surfaceTintColor: colorScheme.surface, - scrolledUnderElevation: 0, - actions: const [ProfileAvatarButton()], + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Cancel selection', + onPressed: _clearSelection, + ), + 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) - SliverFillRemaining( - child: Center( + if (provider.isMediaLoading && photos.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (provider.mediaErrorMessage != null) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.photo_library_outlined, + Icons.error_outline_rounded, size: 64, - color: colorScheme.outlineVariant, + color: colorScheme.error, ), - const SizedBox(height: 12), + const SizedBox(height: 16), Text( - 'No photos found in Nextcloud library', - style: theme.textTheme.titleMedium?.copyWith( + '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 - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 1.0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final photo = photos[index]; - return _buildPhotoTile(context, photo, provider); - }, childCount: photos.length), + ), + ) + else if (photos.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.photo_library_outlined, + size: 64, + color: colorScheme.outlineVariant, + ), + const SizedBox(height: 12), + Text( + 'No photos found in Nextcloud library', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], ), ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1.0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final photo = photos[index]; + return _buildPhotoTile(context, photo, provider); + }, childCount: photos.length), + ), + ), - const SliverToBoxAdapter(child: SizedBox(height: 100)), - ], + // Fixed clearance so the last row isn't hidden behind the floating + // nav bar, regardless of grid length. + 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,71 +317,87 @@ class PhotosView extends StatelessWidget { NextcloudItem photo, ServerProvider provider, ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final colorScheme = Theme.of(context).colorScheme; + final isSelected = _selectedIds.contains(photo.id); - return GestureDetector( - onTap: () => _openLightbox(context, photo, provider), - child: Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - fit: StackFit.expand, - children: [ - photo.previewUrl != null - ? LayoutBuilder( - builder: (context, constraints) { - // Decode at the tile's actual rendered size rather - // than the full 500x500 preview the server returns — - // cheaper to decode/cache while scrolling a grid. - final cachePixels = - (constraints.maxWidth * - MediaQuery.of(context).devicePixelRatio) - .round(); - return Image.network( - photo.previewUrl!, - headers: provider.service?.authHeaders, - fit: BoxFit.cover, - cacheWidth: cachePixels, - cacheHeight: cachePixels, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - errorBuilder: (ctx, err, stack) => - _buildFallbackTile(context, photo), - ); - }, - ) - : _buildFallbackTile(context, photo), - if (photo.type == NextcloudItemType.video) - Positioned( - bottom: 8, - right: 8, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 16, + // No border radius on the tile itself — the outer ClipRRect below is + // the only place that rounds it, so a swipe doesn't reveal a stray + // floating rounded corner mid-drag (see the same fix in files_view). + final tile = GestureDetector( + onTap: () { + if (_isSelecting) { + _toggleSelection(photo); + } else { + _openLightbox(context, photo, provider); + } + }, + onLongPress: () => _toggleSelection(photo), + child: ColoredBox( + color: colorScheme.surfaceContainerHigh, + child: SelectableThumbnail( + isSelected: isSelected, + checkmarkSize: 32, + child: Stack( + fit: StackFit.expand, + children: [ + photo.previewUrl != null + ? LayoutBuilder( + builder: (context, constraints) { + // Decode at the tile's actual rendered size rather + // than the full 500x500 preview the server returns — + // cheaper to decode/cache while scrolling a grid. + final cachePixels = + (constraints.maxWidth * + MediaQuery.of(context).devicePixelRatio) + .round(); + return Image.network( + photo.previewUrl!, + headers: provider.service?.authHeaders, + fit: BoxFit.cover, + cacheWidth: cachePixels, + cacheHeight: cachePixels, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => + _buildFallbackTile(context, photo), + ); + }, + ) + : _buildFallbackTile(context, photo), + if (photo.type == NextcloudItemType.video) + Positioned( + bottom: 8, + right: 8, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.white, + size: 16, + ), ), ), - ), - if (photo.isFavorite) - const Positioned( - top: 8, - right: 8, - child: Icon(Icons.star_rounded, color: Colors.amber, size: 18), - ), - ], + if (photo.isFavorite) + const Positioned( + top: 8, + right: 8, + child: Icon( + Icons.favorite_rounded, + color: Colors.red, + size: 18, + ), + ), + ], + ), ), ), ); + + return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile); } Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) { @@ -174,4 +426,152 @@ class PhotosView extends StatelessWidget { FileViewerScreen.route(item: photo, siblings: provider.photoItems), ); } + + Future _favoriteSelected( + ServerProvider provider, + List items, + ) async { + final allFavorited = items.every((i) => i.isFavorite); + for (final item in items) { + if (item.isFavorite == allFavorited) { + await provider.toggleItemFavorite(item); + } + } + _clearSelection(); + } + + Future _shareSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar( + content: Text('Creating share link(s)…'), + behavior: SnackBarBehavior.floating, + ), + ); + + final lines = []; + 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 _downloadSelected( + BuildContext context, + ServerProvider provider, + List 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 _confirmDeleteSelected( + BuildContext context, + ServerProvider provider, + List items, + ) async { + final confirmed = await showDialog( + 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, + ), + ); + } } diff --git a/lib/views/recent_view.dart b/lib/views/recent_view.dart new file mode 100644 index 0000000..6ed726b --- /dev/null +++ b/lib/views/recent_view.dart @@ -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 createState() => _RecentViewState(); +} + +class _RecentViewState extends State { + bool _requested = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final provider = context.watch(); + + if (!_requested) { + _requested = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => provider.fetchRecent(), + ); + } + + final items = provider.recentItems; + + final List 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), + ); + } +} diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart index 9c3bc12..260d52f 100644 --- a/lib/views/search_view.dart +++ b/lib/views/search_view.dart @@ -23,14 +23,6 @@ class _SearchViewState extends State { String? _error; bool _hasSearched = false; - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback( - (_) => _focusNode.requestFocus(), - ); - } - @override void dispose() { _debounce?.cancel(); @@ -104,30 +96,37 @@ class _SearchViewState extends State { final colorScheme = theme.colorScheme; return Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: TextField( - controller: _controller, - focusNode: _focusNode, - onChanged: _onChanged, - decoration: const InputDecoration( - hintText: 'Search your files...', - border: InputBorder.none, - ), - style: theme.textTheme.titleMedium, - ), - actions: [ - if (_controller.text.isNotEmpty) - IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () { - _controller.clear(); - _onChanged(''); - }, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: SearchBar( + controller: _controller, + focusNode: _focusNode, + autoFocus: true, + hintText: 'Search your files...', + onChanged: _onChanged, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => Navigator.pop(context), + ), + trailing: [ + if (_controller.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _controller.clear(); + _onChanged(''); + }, + ), + ], + ), ), - ], + Expanded(child: _buildBody(context, colorScheme, theme)), + ], + ), ), - body: _buildBody(context, colorScheme, theme), ); } diff --git a/lib/views/shares_view.dart b/lib/views/shares_view.dart new file mode 100644 index 0000000..09b9358 --- /dev/null +++ b/lib/views/shares_view.dart @@ -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 createState() => _SharesViewState(); +} + +class _SharesViewState extends State { + bool _requested = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final provider = context.watch(); + + if (!_requested) { + _requested = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => provider.fetchShares(), + ); + } + + final shares = provider.shares; + + final List contentSlivers = [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: SegmentedButton( + 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 _confirmUnshare( + BuildContext context, + ServerProvider provider, + NextcloudShare share, + ) async { + final confirmed = await showDialog( + 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, + ), + ); + } +} diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart new file mode 100644 index 0000000..77fb04b --- /dev/null +++ b/lib/views/trash_view.dart @@ -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 createState() => _TrashViewState(); +} + +class _TrashViewState extends State { + bool _requested = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final provider = context.watch(); + + if (!_requested) { + _requested = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => provider.fetchTrash(), + ); + } + + final trash = provider.trashItems; + + final List 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 _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 _confirmDeleteForever( + BuildContext context, + ServerProvider provider, + NextcloudItem item, + ) async { + final confirmed = await showDialog( + 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, + ), + ); + } +} diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart new file mode 100644 index 0000000..a736078 --- /dev/null +++ b/lib/widgets/app_tab_view_builder.dart @@ -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); + } +} diff --git a/lib/widgets/breadcrumbs.dart b/lib/widgets/breadcrumbs.dart new file mode 100644 index 0000000..23c6843 --- /dev/null +++ b/lib/widgets/breadcrumbs.dart @@ -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 pathStack; + final ValueChanged 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), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/details/details_activity_tab.dart b/lib/widgets/details/details_activity_tab.dart new file mode 100644 index 0000000..1238752 --- /dev/null +++ b/lib/widgets/details/details_activity_tab.dart @@ -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 createState() => _DetailsActivityTabState(); +} + +class _DetailsActivityTabState extends State { + bool _requested = false; + bool _isLoading = true; + List _activities = []; + + Future _load() async { + final provider = context.read(); + 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, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/details/details_info_tab.dart b/lib/widgets/details/details_info_tab.dart new file mode 100644 index 0000000..821441c --- /dev/null +++ b/lib/widgets/details/details_info_tab.dart @@ -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, + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/details/details_sheet.dart b/lib/widgets/details/details_sheet.dart new file mode 100644 index 0000000..9726499 --- /dev/null +++ b/lib/widgets/details/details_sheet.dart @@ -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 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/details/details_versions_tab.dart b/lib/widgets/details/details_versions_tab.dart new file mode 100644 index 0000000..c276d82 --- /dev/null +++ b/lib/widgets/details/details_versions_tab.dart @@ -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 createState() => _DetailsVersionsTabState(); +} + +class _DetailsVersionsTabState extends State { + bool _requested = false; + bool _isLoading = true; + List _versions = []; + + Future _load() async { + final provider = context.read(); + final result = await provider.fetchFileVersions(widget.item); + if (!mounted) return; + setState(() { + _versions = result; + _isLoading = false; + }); + } + + Future _restore(NextcloudFileVersion version) async { + final provider = context.read(); + 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 _download(NextcloudFileVersion version) async { + final provider = context.read(); + 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( + onSelected: (value) => value == 'restore' + ? _restore(version) + : _download(version), + itemBuilder: (context) => const [ + PopupMenuItem(value: 'restore', child: Text('Restore')), + PopupMenuItem(value: 'download', child: Text('Download')), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/details/peek_details_sheet.dart b/lib/widgets/details/peek_details_sheet.dart new file mode 100644 index 0000000..89f1982 --- /dev/null +++ b/lib/widgets/details/peek_details_sheet.dart @@ -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 createState() => _PeekDetailsSheetState(); +} + +class _PeekDetailsSheetState extends State { + 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( + 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), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/gradual_bottom_sheet.dart b/lib/widgets/gradual_bottom_sheet.dart new file mode 100644 index 0000000..5f5fe35 --- /dev/null +++ b/lib/widgets/gradual_bottom_sheet.dart @@ -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 showGradualBottomSheet( + 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( + 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)), + ], + ), + ); + }, + ); + }, + ); +} diff --git a/lib/widgets/more_tabs_button.dart b/lib/widgets/more_tabs_button.dart new file mode 100644 index 0000000..3940090 --- /dev/null +++ b/lib/widgets/more_tabs_button.dart @@ -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(); + + return PopupMenuButton( + 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())), + ), + ); + } +} diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart index 3febee4..e802e1d 100644 --- a/lib/widgets/profile_avatar_button.dart +++ b/lib/widgets/profile_avatar_button.dart @@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget { return Padding( padding: const EdgeInsets.only(right: 8), child: IconButton( - tooltip: 'Account', + tooltip: 'Settings', onPressed: () { Navigator.push( context, diff --git a/lib/widgets/segmented_icon_toggle.dart b/lib/widgets/segmented_icon_toggle.dart index 3364e02..ae78b10 100644 --- a/lib/widgets/segmented_icon_toggle.dart +++ b/lib/widgets/segmented_icon_toggle.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; /// 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 { final IconData icon; final bool isSelected; @@ -20,26 +23,26 @@ class ToggleIconButton extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final button = Material( - color: isSelected ? colorScheme.primaryContainer : Colors.transparent, - shape: const CircleBorder(), - child: InkWell( - onTap: onTap, - customBorder: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - icon, - size: 20, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), + return IconButton( + isSelected: isSelected, + icon: Icon(icon, size: 20), + tooltip: tooltip, + onPressed: onTap, + visualDensity: VisualDensity.compact, + style: ButtonStyle( + shape: const WidgetStatePropertyAll(CircleBorder()), + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + backgroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? colorScheme.primaryContainer + : Colors.transparent, ), ), ); - - return tooltip != null ? Tooltip(message: tooltip!, child: button) : button; } } diff --git a/lib/widgets/selectable_thumbnail.dart b/lib/widgets/selectable_thumbnail.dart new file mode 100644 index 0000000..2a6d71e --- /dev/null +++ b/lib/widgets/selectable_thumbnail.dart @@ -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); + } +} diff --git a/lib/widgets/share_sheet.dart b/lib/widgets/share_sheet.dart new file mode 100644 index 0000000..a9267a7 --- /dev/null +++ b/lib/widgets/share_sheet.dart @@ -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 show(BuildContext context, NextcloudItem item) { + return showGradualBottomSheet( + context, + builder: (context, scrollController) => + ShareSheet(item: item, scrollController: scrollController), + ); + } + + @override + State createState() => _ShareSheetState(); +} + +class _ShareSheetState extends State { + bool _requested = false; + bool _isLoading = true; + List _shares = []; + List _inherited = []; + List _searchResults = []; + bool _isSearching = false; + final _searchController = TextEditingController(); + final _emailController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + Future _load() async { + final provider = context.read(); + 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 _search(String query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) { + setState(() => _searchResults = []); + return; + } + setState(() => _isSearching = true); + final provider = context.read(); + final results = await provider.searchSharees(trimmed); + if (!mounted) return; + setState(() { + _searchResults = results; + _isSearching = false; + }); + } + + Future _addSharee(NextcloudSharee sharee) async { + final provider = context.read(); + 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 _addEmailShare() async { + final email = _emailController.text.trim(); + if (email.isEmpty) return; + final provider = context.read(); + 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 _createPublicLink() async { + final provider = context.read(); + 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 _removeShare(NextcloudShare share) async { + final provider = context.read(); + 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(); + + 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, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/sort_menu_button.dart b/lib/widgets/sort_menu_button.dart new file mode 100644 index 0000000..da889f6 --- /dev/null +++ b/lib/widgets/sort_menu_button.dart @@ -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 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( + 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sticky_header_delegate.dart b/lib/widgets/sticky_header_delegate.dart new file mode 100644 index 0000000..4559422 --- /dev/null +++ b/lib/widgets/sticky_header_delegate.dart @@ -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; + } +} diff --git a/lib/widgets/swipeable_item.dart b/lib/widgets/swipeable_item.dart new file mode 100644 index 0000000..67df977 --- /dev/null +++ b/lib/widgets/swipeable_item.dart @@ -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 Function() onFavorite; + final Future Function() onShare; + final Future 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, + ); + } +} diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart new file mode 100644 index 0000000..6f54211 --- /dev/null +++ b/lib/widgets/synced_header_scaffold.dart @@ -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 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 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 Function()? onRefresh; + + const SyncedHeaderScaffold({ + super.key, + required this.scrollController, + required this.provider, + required this.actions, + required this.contentSlivers, + this.onRefresh, + }); + + @override + State createState() => _SyncedHeaderScaffoldState(); +} + +class _SyncedHeaderScaffoldState extends State { + 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( + 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(); + 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, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9481993..bc390c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -name: nextcloud_client +name: noo description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -69,8 +69,9 @@ dev_dependencies: flutter_launcher_icons: android: true 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_monochrome: "assets/icon/app_icon_monochrome.png" # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/widget_test.dart b/test/widget_test.dart index 5fe638a..b977d71 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,16 +1,35 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; -import 'package:nextcloud_client/main.dart'; -import 'package:nextcloud_client/providers/server_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:noo/main.dart'; +import 'package:noo/providers/server_provider.dart'; 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 {}; + return null; + }); + }); + test('ServerProvider initializes unauthenticated', () { final provider = ServerProvider(); expect(provider.isLoggedIn, false); 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(); await tester.pumpWidget( @@ -21,10 +40,8 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Nextcloud Client'), findsOneWidget); + expect(find.text('Noo'), findsOneWidget); expect(find.text('Server Address'), findsOneWidget); - expect(find.text('Username'), findsOneWidget); - expect(find.text('Password or App Password'), findsOneWidget); - expect(find.text('Log In'), findsOneWidget); + expect(find.text('Continue'), findsOneWidget); }); } diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index eb428c6..e91e15e 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. 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 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 # versions of CMake. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index aa82f9b..f5284f8 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,13 +89,13 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "nextcloud_client" "\0" + VALUE "CompanyName", "dev.ayushya" "\0" + VALUE "FileDescription", "noo" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "nextcloud_client" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" - VALUE "OriginalFilename", "nextcloud_client.exe" "\0" - VALUE "ProductName", "nextcloud_client" "\0" + VALUE "InternalName", "noo" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.ayushya. All rights reserved." "\0" + VALUE "OriginalFilename", "noo.exe" "\0" + VALUE "ProductName", "noo" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index f07d180..00946da 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"nextcloud_client", origin, size)) { + if (!window.Create(L"Noo", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);