diff --git a/example/lib/app_scaffold.dart b/example/lib/app_scaffold.dart index 001ba75..bb5aa4e 100644 --- a/example/lib/app_scaffold.dart +++ b/example/lib/app_scaffold.dart @@ -34,6 +34,7 @@ import 'package:solidui/solidui.dart'; import 'constants/app.dart'; import 'home.dart'; +import 'screens/all_pod_files_page.dart'; import 'screens/sample_page.dart'; final _scaffoldController = SolidScaffoldController(); @@ -73,6 +74,17 @@ class AppScaffold extends StatelessWidget { ''', child: SolidFile(), ), + SolidMenuItem( + icon: Icons.storage, + title: 'All POD Files', + tooltip: ''' + + **All POD Files:** Tap here to browse all folders on your POD + from the root. + + ''', + child: AllPodFilesPage(), + ), ], // APP BAR. diff --git a/example/lib/screens/all_pod_files_page.dart b/example/lib/screens/all_pod_files_page.dart new file mode 100644 index 0000000..e779af6 --- /dev/null +++ b/example/lib/screens/all_pod_files_page.dart @@ -0,0 +1,49 @@ +/// All POD Files page - Displays all folders on the POD for testing. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/solidui.dart'; + +/// A page that browses all folders and files on the POD from the root. + +class AllPodFilesPage extends StatelessWidget { + const AllPodFilesPage({super.key}); + + @override + Widget build(BuildContext context) { + return const SolidFile( + currentPath: SolidFile.podRoot, + friendlyFolderName: 'All POD Files', + showBackButton: true, + backButtonText: 'Back to POD Root', + ); + } +} diff --git a/lib/src/models/data_format_config.dart b/lib/src/models/data_format_config.dart index a0c53dd..0e3c223 100644 --- a/lib/src/models/data_format_config.dart +++ b/lib/src/models/data_format_config.dart @@ -97,45 +97,3 @@ class DataFormatConfig { description.hashCode; } } - -/// Pre-defined format configurations for common health data types. - -class SolidFileDataFormats { - static const bloodPressure = DataFormatConfig( - title: 'Blood Pressure CSV Format', - requiredFields: ['timestamp', 'systolic', 'diastolic', 'heart_rate'], - optionalFields: ['notes'], - ); - - static const vaccination = DataFormatConfig( - title: 'Vaccination CSV Format', - requiredFields: ['timestamp', 'name', 'type'], - optionalFields: ['location', 'notes', 'batch_number'], - ); - - static const medication = DataFormatConfig( - title: 'Medication CSV Format', - requiredFields: ['timestamp', 'name', 'dosage', 'frequency', 'start_date'], - optionalFields: ['notes'], - ); - - static const diary = DataFormatConfig( - title: 'Appointment CSV Format', - requiredFields: ['timestamp', 'content'], - optionalFields: ['mood', 'tags', 'notes'], - ); - - static const profile = DataFormatConfig( - title: 'Profile JSON Format', - requiredFields: [ - 'name', - 'address', - 'bestContactPhone', - 'alternativeContactNumber', - 'email', - 'dateOfBirth', - 'gender', - ], - isJson: true, - ); -} diff --git a/lib/src/models/file_type_config.dart b/lib/src/models/file_type_config.dart index 674d716..9b91f11 100644 --- a/lib/src/models/file_type_config.dart +++ b/lib/src/models/file_type_config.dart @@ -29,26 +29,29 @@ library; import 'package:solidui/src/models/data_format_config.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/widgets/solid_file_helpers.dart'; import 'package:solidui/src/widgets/solid_file_upload_config.dart'; -/// Predefined file types for different data categories. +/// A callback type for resolving file type configurations from a path. +/// +/// Applications can provide their own resolver to map directory paths to +/// specific [FileTypeConfig] instances. Return `null` to fall through to the +/// default generic behaviour. -enum SolidFileType { - bloodPressure, - vaccination, - medication, - diary, - profile, - general, -} +typedef FileTypeResolver = FileTypeConfig? Function( + String normalisedPath, + String? basePath, +); /// Configuration for different file types. class FileTypeConfig { - /// The file type. + /// A string identifier for this file type (e.g. 'general', 'blood_pressure'). + /// + /// Applications may define their own type identifiers. - final SolidFileType type; + final String typeId; /// Display name for the folder. @@ -83,7 +86,7 @@ class FileTypeConfig { final String uploadTooltip; const FileTypeConfig({ - required this.type, + this.typeId = 'general', required this.displayName, this.showCsvButtons = false, this.showProfileButtons = false, @@ -96,106 +99,57 @@ class FileTypeConfig { /// Gets the file type configuration based on the current path. - static FileTypeConfig fromPath(String currentPath, [String? basePath]) { - if (currentPath.contains('/blood_pressure')) { - return const FileTypeConfig( - type: SolidFileType.bloodPressure, - displayName: 'Blood Pressure Data', - showCsvButtons: true, - formatConfig: SolidFileDataFormats.bloodPressure, - uploadTooltip: ''' + static FileTypeConfig fromPath( + String currentPath, [ + String? basePath, + FileTypeResolver? resolver, + ]) { + // Normalise the path for consistent pattern matching. -**Upload**: Tap here to upload a file to your Solid Health Pod. - -''', - ); - } else if (currentPath.contains('/vaccination')) { - return const FileTypeConfig( - type: SolidFileType.vaccination, - displayName: 'Vaccination Data', - showCsvButtons: true, - formatConfig: SolidFileDataFormats.vaccination, - uploadTooltip: ''' + final normalisedPath = PathUtils.normalise(currentPath); -**Upload**: Tap here to upload a file to your Solid Health Pod. + // Attempt app-specific resolution first. -''', - ); - } else if (currentPath.contains('/medication')) { - return const FileTypeConfig( - type: SolidFileType.medication, - displayName: 'Medication Data', - showCsvButtons: true, - formatConfig: SolidFileDataFormats.medication, - uploadTooltip: ''' - -**Upload**: Tap here to upload a file to your Solid Health Pod. + if (resolver != null) { + final resolved = resolver(normalisedPath, basePath); + if (resolved != null) return resolved; + } -''', - ); - } else if (currentPath.contains('/diary')) { - return const FileTypeConfig( - type: SolidFileType.diary, - displayName: 'Appointments Data', - showCsvButtons: true, - formatConfig: SolidFileDataFormats.diary, - uploadTooltip: ''' + // Generic fallback — derive a friendly display name from the path. -**Upload**: Tap here to upload a file to your Solid Health Pod. + String effectiveBasePath = + basePath != null ? PathUtils.normalise(basePath) : ''; -''', - ); - } else if (currentPath.contains('/profile')) { - return const FileTypeConfig( - type: SolidFileType.profile, - displayName: 'Profile Data', - showProfileButtons: true, - formatConfig: SolidFileDataFormats.profile, - uploadTooltip: ''' + if (effectiveBasePath.isEmpty) { + final segments = + normalisedPath.split('/').where((s) => s.isNotEmpty).toList(); -**Upload**: Tap here to upload a file to your Solid Health Pod. + // Construct a reasonable base path — typically the first 2 segments + // for most cases. -''', - ); - } else { - // General case - use the existing friendly folder name logic for - // consistency. If basePath is provided, use it; otherwise, construct a - // reasonable default. - - String effectiveBasePath = basePath ?? ''; - - if (effectiveBasePath.isEmpty) { - final segments = - currentPath.split('/').where((s) => s.isNotEmpty).toList(); - - // Construct a reasonable base path - typically the first 2 segments - // for most cases. - - if (segments.length >= 2) { - effectiveBasePath = '/${segments[0]}/${segments[1]}'; - } else if (segments.length == 1) { - effectiveBasePath = '/${segments[0]}'; - } + if (segments.length >= 2) { + effectiveBasePath = '${segments[0]}/${segments[1]}'; + } else if (segments.length == 1) { + effectiveBasePath = segments[0]; } + } - final friendlyName = SolidFileHelpers.getFriendlyFolderName( - currentPath, - effectiveBasePath, - ); + final friendlyName = SolidFileHelpers.getFriendlyFolderName( + normalisedPath, + effectiveBasePath, + ); - String displayName = - friendlyName == 'Home' ? 'Home Folder' : friendlyName; + String displayName = friendlyName == 'Home' ? 'Home Folder' : friendlyName; - return FileTypeConfig( - type: SolidFileType.general, - displayName: displayName, - uploadTooltip: ''' + return FileTypeConfig( + typeId: 'general', + displayName: displayName, + uploadTooltip: ''' -**Upload**: Tap here to upload a file to your Solid Health Pod. +**Upload**: Tap here to upload a file to your Solid Pod. ''', - ); - } + ); } /// Creates the upload configuration for this file type. diff --git a/lib/src/utils/path_utils.dart b/lib/src/utils/path_utils.dart new file mode 100644 index 0000000..12d23c3 --- /dev/null +++ b/lib/src/utils/path_utils.dart @@ -0,0 +1,181 @@ +/// Path utilities for SolidUI. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +/// Utility class for path operations in SolidUI. +/// +/// This class provides methods to normalise and manipulate paths used in +/// file browsing operations. All paths are treated as relative to the Pod +/// root and should not have leading forward slashes. + +class PathUtils { + const PathUtils._(); + + /// Normalises a relative path by removing leading and trailing slashes. + /// + /// This ensures paths are consistently formatted for use with solidpod's + /// `PathType.relativeToPod` option, which expects paths without leading + /// slashes. + /// + /// Examples: + /// - `/myapp/data` becomes `myapp/data` + /// - `myapp/data/` becomes `myapp/data` + /// - `//myapp//data//` becomes `myapp/data` + /// - `` remains `` + /// - `/` becomes `` + + static String normalise(String path) { + if (path.isEmpty) return ''; + + // Remove leading slashes. + + String normalised = path; + while (normalised.startsWith('/')) { + normalised = normalised.substring(1); + } + + // Remove trailing slashes. + + while (normalised.endsWith('/')) { + normalised = normalised.substring(0, normalised.length - 1); + } + + // Remove consecutive slashes. + + normalised = normalised.replaceAll(RegExp(r'/+'), '/'); + + return normalised; + } + + /// Joins path segments into a normalised path. + /// + /// All segments are normalised and empty segments are filtered out. + /// + /// Examples: + /// - join(['myapp', 'data', 'file.ttl']) returns `myapp/data/file.ttl` + /// - join(['/myapp/', '/data/', 'file.ttl']) returns `myapp/data/file.ttl` + /// - join(['', 'myapp', '', 'data']) returns `myapp/data` + + static String join(List segments) { + final normalisedSegments = + segments.map(normalise).where((s) => s.isNotEmpty).toList(); + + return normalisedSegments.join('/'); + } + + /// Extracts the relative path from a full path given a root path. + /// + /// Both paths are normalised before comparison. If the full path does not + /// start with the root path, the full normalised path is returned. + /// + /// Examples: + /// - relativeTo('myapp/data/subfolder', 'myapp/data') returns `subfolder` + /// - relativeTo('/myapp/data/subfolder', '/myapp/data') returns `subfolder` + /// - relativeTo('myapp/data', 'myapp/data') returns `` + /// - relativeTo('other/path', 'myapp/data') returns `other/path` + + static String relativeTo(String fullPath, String rootPath) { + final normalisedFull = normalise(fullPath); + final normalisedRoot = normalise(rootPath); + + if (normalisedRoot.isEmpty) { + return normalisedFull; + } + + if (normalisedFull == normalisedRoot) { + return ''; + } + + if (normalisedFull.startsWith('$normalisedRoot/')) { + return normalisedFull.substring(normalisedRoot.length + 1); + } + + return normalisedFull; + } + + /// Combines a directory path and a file name into a full path. + /// + /// Both are normalised before joining. + /// + /// Examples: + /// - combine('myapp/data', 'file.ttl') returns `myapp/data/file.ttl` + /// - combine('/myapp/data/', '/file.ttl') returns `myapp/data/file.ttl` + /// - combine('', 'file.ttl') returns `file.ttl` + + static String combine(String directoryPath, String fileName) { + return join([directoryPath, fileName]); + } + + /// Checks if a path is the root (empty or just slashes). + /// + /// Examples: + /// - isRoot('') returns true + /// - isRoot('/') returns true + /// - isRoot('//') returns true + /// - isRoot('myapp') returns false + + static bool isRoot(String path) { + return normalise(path).isEmpty; + } + + /// Gets the parent directory of a path. + /// + /// Returns empty string if the path has no parent (is root or single + /// segment). + /// + /// Examples: + /// - parent('myapp/data/subfolder') returns `myapp/data` + /// - parent('myapp') returns `` + /// - parent('') returns `` + + static String parent(String path) { + final normalised = normalise(path); + final lastSlash = normalised.lastIndexOf('/'); + if (lastSlash == -1) { + return ''; + } + return normalised.substring(0, lastSlash); + } + + /// Gets the last segment (file or directory name) of a path. + /// + /// Examples: + /// - basename('myapp/data/file.ttl') returns `file.ttl` + /// - basename('myapp') returns `myapp` + /// - basename('') returns `` + + static String basename(String path) { + final normalised = normalise(path); + final lastSlash = normalised.lastIndexOf('/'); + if (lastSlash == -1) { + return normalised; + } + return normalised.substring(lastSlash + 1); + } +} diff --git a/lib/src/utils/solid_file_operations.dart b/lib/src/utils/solid_file_operations.dart index 27f00ba..4c440be 100644 --- a/lib/src/utils/solid_file_operations.dart +++ b/lib/src/utils/solid_file_operations.dart @@ -30,8 +30,6 @@ library; import 'package:flutter/material.dart'; -import 'package:solidpod/solidpod.dart'; - import 'package:solidui/src/utils/solid_file_operations_delete.dart'; import 'package:solidui/src/utils/solid_file_operations_download.dart'; import 'package:solidui/src/utils/solid_file_operations_upload.dart'; @@ -46,14 +44,12 @@ class SolidFileOperations { static Future downloadFile( BuildContext context, String fileName, - String filePath, { - PathType? pathType, - }) => + String filePath, + ) => SolidFileDownloadOperations.downloadFile( context, fileName, filePath, - pathType: pathType, ); /// Delete a file from the POD. @@ -62,14 +58,12 @@ class SolidFileOperations { BuildContext context, String fileName, String filePath, { - String? basePath, VoidCallback? onSuccess, }) => SolidFileDeleteOperations.deletePodFile( context, fileName, filePath, - basePath: basePath, onSuccess: onSuccess, ); diff --git a/lib/src/utils/solid_file_operations_delete.dart b/lib/src/utils/solid_file_operations_delete.dart index df34069..2ea76aa 100644 --- a/lib/src/utils/solid_file_operations_delete.dart +++ b/lib/src/utils/solid_file_operations_delete.dart @@ -32,18 +32,22 @@ import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart'; +import 'package:solidui/src/utils/path_utils.dart'; + /// Delete operations for SolidUI widgets. class SolidFileDeleteOperations { const SolidFileDeleteOperations._(); /// Default file deletion implementation. + /// + /// The [filePath] should be a directory path relative to the Pod root, + /// e.g., `myapp/data` or `myapp/data/subfolder`. static Future deletePodFile( BuildContext context, String fileName, String filePath, { - String? basePath, VoidCallback? onSuccess, }) async { try { @@ -92,9 +96,10 @@ class SolidFileDeleteOperations { try { // Construct the full file path by combining directory path and - // filename. + // filename. Use PathUtils to ensure no leading slashes, which would + // cause double slashes in the generated URL. - final fullFilePath = [filePath, fileName].join('/'); + final fullFilePath = PathUtils.combine(filePath, fileName); // Delete the file (this also handles the ACL file automatically). diff --git a/lib/src/utils/solid_file_operations_download.dart b/lib/src/utils/solid_file_operations_download.dart index 79739a0..dc71410 100644 --- a/lib/src/utils/solid_file_operations_download.dart +++ b/lib/src/utils/solid_file_operations_download.dart @@ -36,6 +36,7 @@ import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import 'package:solidpod/solidpod.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/utils/solid_pod_helpers.dart'; /// Download operations for SolidUI widgets. @@ -43,15 +44,151 @@ import 'package:solidui/src/utils/solid_pod_helpers.dart'; class SolidFileDownloadOperations { const SolidFileDownloadOperations._(); + /// Checks if a file is within the current app's folder on the POD. + /// + /// Returns `true` if the file belongs to the current app, indicating that + /// the current app can decrypt this file. + /// Returns `false` if the file is from another app's folder, meaning + /// decryption may fail as the security key is not available. + + static Future _isFileInCurrentAppFolder(String filePath) async { + try { + // Validate that the file path is a POD-relative path rather than an + // absolute URL or empty string. + + if (filePath.trim().isEmpty) { + debugPrint('Cannot check app folder ownership: file path is empty.'); + return false; + } + + if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + debugPrint( + 'Cannot check app folder ownership: expected a POD-relative ' + 'path but received an absolute URL: $filePath', + ); + return false; + } + + // Resolve the relative file path into a full URL for reliable + // comparison. + + final normalisedPath = PathUtils.normalise(filePath); + final fileUrl = await getFileUrl(normalisedPath); + + // Derive the current app name from getDataDirPath(), which returns + // "APP_NAME/data". The first segment is the app name. + + final appDataPath = await getDataDirPath(); + if (appDataPath.isEmpty) return false; + + final currentAppName = appDataPath.split('/').first; + if (currentAppName.isEmpty) return false; + + // Build the current app's root directory URL and check whether the + // file URL falls under it. getDirUrl appends a trailing slash, which + // prevents false positives (e.g., "myapp2" matching "myapp"). + + final appRootUrl = await getDirUrl(currentAppName); + + return fileUrl.startsWith(appRootUrl); + } catch (e) { + debugPrint('Error checking app folder ownership: $e'); + return false; + } + } + + /// Shows a warning dialogue when attempting to download an encrypted file + /// from another app's data folder. + /// + /// Returns `true` if the user chooses to proceed with the download. + /// Returns `false` if the user cancels. + + static Future _showCrossAppDownloadWarning( + BuildContext context, + ) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28), + SizedBox(width: 12), + Expanded(child: Text('Cross-App Download Warning')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This file belongs to another application\'s data folder.', + style: TextStyle(fontWeight: FontWeight.w500), + ), + SizedBox(height: 12), + Text( + 'The file browser can browse files across all app folders in ' + 'your POD, but can only decrypt files within the current app\'s ' + 'data folder.', + ), + SizedBox(height: 12), + Text( + 'The file content may be encrypted by the other application. ' + 'If so, the security key required to decrypt it is not ' + 'available, and the downloaded file might be unreadable.', + ), + SizedBox(height: 16), + Text( + 'Do you still wish to proceed with the download?', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + child: const Text('Download Anyway'), + ), + ], + ), + ); + + return result ?? false; + } + /// Default file download implementation. static Future downloadFile( BuildContext context, String fileName, - String filePath, { - PathType? pathType, - }) async { + String filePath, + ) async { try { + // Check if the file belongs to another app's folder. If so, warn the + // user that the file content may be encrypted. + + final isInCurrentAppFolder = await _isFileInCurrentAppFolder(filePath); + + if (!isInCurrentAppFolder) { + if (!context.mounted) return; + + final shouldProceed = await _showCrossAppDownloadWarning(context); + + if (!shouldProceed) { + return; + } + } + + if (!context.mounted) return; + // Let user choose where to save the file. final cleanFileName = fileName.replaceAll('.enc.ttl', ''); @@ -94,23 +231,13 @@ class SolidFileDownloadOperations { if (!context.mounted) return; - // Read file content from POD. - - // dc 20260107: the `basePath` is heavily involved in the file-browsing - // codebase, and this leads to a leading forward slash in `filePath`, - // e.g., /myapp/encryption/ind-keys.ttl. - // This format triggers an error when extracting data from the turtle - // content due to double `//` in the subject of triples. - // Below is a temporary workaround but a better solution is needed to - // fully resolve this issue (e.g., refactor the file-browsing code to - // use `PathType` instead of `basePath`). + // Read file content from POD. All paths are relative to the Pod + // root, so we always use PathType.relativeToPod. + final normalisedPath = PathUtils.combine(filePath, fileName); final fileContent = await readPod( - [ - filePath.startsWith('/') ? filePath.substring(1) : filePath, - fileName, - ].join('/'), - pathType: pathType ?? PathType.relativeToPod, + normalisedPath, + pathType: PathType.relativeToPod, ); if (!context.mounted) return; diff --git a/lib/src/utils/solid_file_operations_upload.dart b/lib/src/utils/solid_file_operations_upload.dart index 190d087..003ec18 100644 --- a/lib/src/utils/solid_file_operations_upload.dart +++ b/lib/src/utils/solid_file_operations_upload.dart @@ -38,6 +38,8 @@ import 'package:path/path.dart' as path; import 'package:solidpod/solidpod.dart'; import 'package:solidui/src/utils/is_text_file.dart'; +import 'package:solidui/src/utils/path_utils.dart'; +import 'package:solidui/src/utils/solid_pod_helpers.dart'; /// Upload operations for SolidUI widgets. @@ -102,23 +104,32 @@ class SolidFileUploadOperations { final remoteFileName = '$sanitizedFileName.enc.ttl'; - // Determine upload path. + // Construct the full upload path relative to the Pod root. - String uploadPath = remoteFileName; - if (currentPath.isNotEmpty && currentPath != '/') { - // Remove leading slash if present. + final normalisedCurrentPath = PathUtils.normalise(currentPath); + final uploadPath = normalisedCurrentPath.isNotEmpty + ? PathUtils.combine(normalisedCurrentPath, remoteFileName) + : remoteFileName; - final cleanPath = currentPath.startsWith('/') - ? currentPath.substring(1) - : currentPath; - uploadPath = '$cleanPath/$remoteFileName'; - } + if (!context.mounted) return; + + // Ensure the security key is available before writing encrypted data. + + await getKeyFromUserIfRequired( + context, + const Text('Please enter your security key to upload the file'), + ); if (!context.mounted) return; - // Upload file with encryption. + // Upload file with encryption using PathType.relativeToPod. - await writePod(uploadPath, fileContent, encrypted: true); + await writePod( + uploadPath, + fileContent, + encrypted: true, + pathType: PathType.relativeToPod, + ); if (!context.mounted) return; diff --git a/lib/src/widgets/security_key_buttons.dart b/lib/src/widgets/security_key_buttons.dart new file mode 100644 index 0000000..69b5788 --- /dev/null +++ b/lib/src/widgets/security_key_buttons.dart @@ -0,0 +1,96 @@ +/// Button row component for the SecurityKeyUI widget. +/// +/// Copyright (C) 2024-2025, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Ashley Tang + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/src/constants/ui.dart'; + +/// A button row widget for security key UI submit and cancel actions. + +class SecurityKeyButtons extends StatelessWidget { + /// Creates security key action buttons. + + const SecurityKeyButtons({ + required this.canSubmit, + required this.onSubmit, + required this.onCancel, + super.key, + }); + + /// Whether the submit button should be enabled. + + final bool canSubmit; + + /// Callback when the submit button is pressed. + + final VoidCallback onSubmit; + + /// Callback when the cancel button is pressed. + + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + final submitButton = ElevatedButton( + onPressed: canSubmit ? onSubmit : null, + style: ElevatedButton.styleFrom( + backgroundColor: SecurityThemeColors.primary(context), + padding: SecurityLayout.buttonPadding, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(SecurityLayout.buttonRadius), + ), + ), + child: const Text( + SecurityStrings.submit, + style: SecurityThemeTextStyles.button, + ), + ); + + final cancelButton = TextButton( + onPressed: onCancel, + style: TextButton.styleFrom( + padding: SecurityLayout.buttonPadding, + ), + child: Text( + SecurityStrings.cancel, + style: SecurityThemeTextStyles.cancelButton(context), + ), + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + cancelButton, + SecurityLayout.horizontalGap, + submitButton, + ], + ); + } +} diff --git a/lib/src/widgets/security_key_display_mode.dart b/lib/src/widgets/security_key_display_mode.dart new file mode 100644 index 0000000..08c64e2 --- /dev/null +++ b/lib/src/widgets/security_key_display_mode.dart @@ -0,0 +1,41 @@ +/// Display mode for the SecurityKeyUI widget. +/// +/// Copyright (C) 2024-2025, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Ashley Tang + +library; + +/// Display mode for the SecurityKeyUI widget. + +enum SecurityKeyDisplayMode { + /// Display as a fullscreen prompt with a scaffold. + + fullscreen, + + /// Display as an embedded dialog component. + + dialog +} diff --git a/lib/src/widgets/security_key_header.dart b/lib/src/widgets/security_key_header.dart new file mode 100644 index 0000000..d280bf6 --- /dev/null +++ b/lib/src/widgets/security_key_header.dart @@ -0,0 +1,111 @@ +/// Header component for the SecurityKeyUI widget. +/// +/// Copyright (C) 2024-2025, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Ashley Tang + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/src/constants/ui.dart'; + +/// A header widget displaying title, WebID, and message for security key UI. + +class SecurityKeyHeader extends StatelessWidget { + /// Creates a security key header. + + const SecurityKeyHeader({ + required this.title, + required this.webId, + required this.message, + super.key, + }); + + /// The title to display. + + final String title; + + /// The WebID to display (null if not logged in). + + final String? webId; + + /// The instructional message to display. + + final String message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: SecurityLayout.contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title heading. + + Text( + title, + style: SecurityThemeTextStyles.heading(context), + ), + + // Green divider under heading. + + Container( + height: SecurityLayout.dividerHeight, + color: SecurityThemeColors.accent(context), + margin: SecurityLayout.dividerMargin, + ), + + // "Currently logged in as:" label. + + Text( + SecurityStrings.webIdLabel, + style: SecurityThemeTextStyles.label(context), + ), + + // WebID on separate line. + + Padding( + padding: SecurityLayout.webIdPadding, + child: Text( + webId ?? SecurityStrings.notLoggedIn, + style: SecurityThemeTextStyles.webId( + context, + isLoggedIn: webId != null, + ), + ), + ), + + // Instructions text. + + Text( + message, + style: SecurityThemeTextStyles.body(context), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/security_key_ui.dart b/lib/src/widgets/security_key_ui.dart index e763f79..ff6a0ff 100644 --- a/lib/src/widgets/security_key_ui.dart +++ b/lib/src/widgets/security_key_ui.dart @@ -35,28 +35,16 @@ import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:solidui/src/constants/ui.dart'; import 'package:solidui/src/widgets/secret_text_field.dart'; +import 'package:solidui/src/widgets/security_key_buttons.dart'; +import 'package:solidui/src/widgets/security_key_display_mode.dart'; +import 'package:solidui/src/widgets/security_key_header.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; import 'package:solidui/src/widgets/solid_theme_notifier.dart'; -/// Display mode for the SecurityKeyUI widget. -/// -/// This enum determines whether the widget should be displayed as a fullscreen prompt -/// or as an embedded dialog component. - -enum SecurityKeyDisplayMode { - /// Display as a fullscreen prompt with a scaffold. - - fullscreen, - - /// Display as an embedded dialog component. - - dialog -} +export 'package:solidui/src/widgets/security_key_display_mode.dart'; -/// A flexible [StatefulWidget] for security key operations with improved UI and WebID display. -/// -/// This widget can be used for both simple security key prompts (single input field) -/// and more complex dialogs (multiple input fields) by providing different configurations. +/// A flexible [StatefulWidget] for security key operations with improved UI and +/// WebID display. class SecurityKeyUI extends StatefulWidget { /// Constructor for the SecurityKeyUI widget. @@ -231,7 +219,11 @@ class _SecurityKeyUIState extends State { children: [ // Header section. - _buildHeader(context), + SecurityKeyHeader( + title: widget.title, + webId: widget.webId, + message: widget.message, + ), // Separator. @@ -251,7 +243,17 @@ class _SecurityKeyUIState extends State { Padding( padding: SecurityLayout.buttonsPadding, - child: _buildButtons(context), + child: SecurityKeyButtons( + canSubmit: _canSubmit, + onSubmit: () async => _submit(context), + onCancel: () { + if (widget.displayMode == SecurityKeyDisplayMode.dialog) { + Navigator.pop(context); + } else { + pushReplacement(context, widget.child); + } + }, + ), ), ], ), @@ -271,60 +273,6 @@ class _SecurityKeyUIState extends State { ); } - /// Builds the header section with title, WebID, and message. - - Widget _buildHeader(BuildContext context) { - return Padding( - padding: SecurityLayout.contentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title heading. - - Text( - widget.title, - style: SecurityThemeTextStyles.heading(context), - ), - - // Green divider under heading. - - Container( - height: SecurityLayout.dividerHeight, - color: SecurityThemeColors.accent(context), - margin: SecurityLayout.dividerMargin, - ), - - // "Currently logged in as:" label. - - Text( - SecurityStrings.webIdLabel, - style: SecurityThemeTextStyles.label(context), - ), - - // WebID on separate line. - - Padding( - padding: SecurityLayout.webIdPadding, - child: Text( - widget.webId ?? SecurityStrings.notLoggedIn, - style: SecurityThemeTextStyles.webId( - context, - isLoggedIn: widget.webId != null, - ), - ), - ), - - // Instructions text. - - Text( - widget.message, - style: SecurityThemeTextStyles.body(context), - ), - ], - ), - ); - } - /// Builds the form with input fields. Widget _buildForm() { @@ -390,49 +338,4 @@ class _SecurityKeyUIState extends State { ), ); } - - /// Builds the buttons for submit and cancel. - - Widget _buildButtons(BuildContext context) { - final submitButton = ElevatedButton( - onPressed: _canSubmit ? () async => _submit(context) : null, - style: ElevatedButton.styleFrom( - backgroundColor: SecurityThemeColors.primary(context), - padding: SecurityLayout.buttonPadding, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(SecurityLayout.buttonRadius), - ), - ), - child: const Text( - SecurityStrings.submit, - style: SecurityThemeTextStyles.button, - ), - ); - - final cancelButton = TextButton( - onPressed: () { - if (widget.displayMode == SecurityKeyDisplayMode.dialog) { - Navigator.pop(context); - } else { - pushReplacement(context, widget.child); - } - }, - style: TextButton.styleFrom( - padding: SecurityLayout.buttonPadding, - ), - child: Text( - SecurityStrings.cancel, - style: SecurityThemeTextStyles.cancelButton(context), - ), - ); - - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - cancelButton, - SecurityLayout.horizontalGap, - submitButton, - ], - ); - } } diff --git a/lib/src/widgets/solid_file.dart b/lib/src/widgets/solid_file.dart index f50b61c..bf85f34 100644 --- a/lib/src/widgets/solid_file.dart +++ b/lib/src/widgets/solid_file.dart @@ -32,6 +32,8 @@ import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart' show getDataDirPath; +import 'package:solidui/src/models/file_type_config.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/widgets/solid_file_browser.dart'; import 'package:solidui/src/widgets/solid_file_browser_builder.dart'; import 'package:solidui/src/widgets/solid_file_callbacks.dart'; @@ -44,13 +46,6 @@ import 'package:solidui/src/widgets/solid_file_upload_config.dart'; /// functionality. class SolidFile extends StatefulWidget { - /// Base path for file operations. - /// - /// If null, defaults to the app data directory path (e.g. 'appname/data'). - /// If the app data directory does not exist, falls back to the pod root (''). - - final String? basePath; - /// Current path in the file browser. final String? currentPath; @@ -129,9 +124,25 @@ class SolidFile extends StatefulWidget { final bool autoConfig; + /// Optional resolver for mapping paths to file type configurations. + /// + /// Applications can supply their own resolver to provide custom upload + /// configurations, display names, and format configs based on the current + /// directory path. When the resolver returns `null`, the generic default + /// behaviour is used. + + final FileTypeResolver? fileTypeResolver; + + /// Optional map of directory basenames to display names. + /// + /// When provided, the file browser uses these overrides to display + /// user-friendly folder names (e.g. `{'blood_pressure': 'Blood Pressure + /// Data'}`). Entries not found in the map fall back to generic formatting. + + final Map? folderNameOverrides; + const SolidFile({ super.key, - this.basePath, this.currentPath, this.friendlyFolderName, this.showBackButton = true, @@ -151,6 +162,8 @@ class SolidFile extends StatefulWidget { this.uploadState, this.browserKey, this.autoConfig = true, + this.fileTypeResolver, + this.folderNameOverrides, }); /// Legacy constructor for backward compatibility. @@ -161,8 +174,9 @@ class SolidFile extends StatefulWidget { required SolidFileCallbacks callbacks, required SolidFileState state, this.browserKey, - }) : basePath = config.basePath, - currentPath = state.currentPath, + this.fileTypeResolver, + this.folderNameOverrides, + }) : currentPath = state.currentPath, friendlyFolderName = state.friendlyFolderName, showBackButton = config.showBackButton, backButtonText = config.backButtonText, @@ -181,7 +195,7 @@ class SolidFile extends StatefulWidget { uploadState = state.uploadState, autoConfig = false; // Legacy mode does not use auto-config. - /// Default base path constant representing the pod root. + /// Default path constant representing the POD root. static const String podRoot = ''; @@ -193,7 +207,7 @@ class _SolidFileState extends State { late GlobalKey _browserKey; late String _currentPath; - /// The resolved base path (either from widget or computed default). + /// The resolved base path. String? _resolvedBasePath; @@ -210,35 +224,32 @@ class _SolidFileState extends State { /// Resolves the base path asynchronously. /// - /// If basePath is provided, uses it directly. - /// Otherwise, defaults to the app data directory path. - /// Falls back to pod root if the app data directory cannot be determined. + /// If [widget.currentPath] is explicitly provided, it is used as the base + /// path. This allows the widget to start from any location on the POD, + /// including the root. Otherwise, defaults to the app data directory path + /// via [getDataDirPath]. Falls back to POD root if the app data directory + /// cannot be determined. Future _resolveBasePath() async { - if (widget.basePath != null) { - // Use the provided basePath directly. - - _resolvedBasePath = widget.basePath; - _currentPath = widget.currentPath ?? _resolvedBasePath!; - setState(() { - _isResolvingBasePath = false; - }); - return; - } - - // Attempt to get the app data directory path. - - try { - final appDataPath = await getDataDirPath(); - _resolvedBasePath = appDataPath; - } catch (e) { - // Fall back to pod root if getDataDirPath fails. - - debugPrint('Failed to get app data path, falling back to pod root: $e'); - _resolvedBasePath = SolidFile.podRoot; + if (widget.currentPath != null) { + // Use the explicitly provided path as the base. + + _resolvedBasePath = PathUtils.normalise(widget.currentPath!); + } else { + try { + final appDataPath = await getDataDirPath(); + _resolvedBasePath = PathUtils.normalise(appDataPath); + } catch (e) { + // Fall back to POD root if getDataDirPath fails. + + debugPrint( + 'Failed to get app data path, falling back to POD root: $e', + ); + _resolvedBasePath = SolidFile.podRoot; + } } - _currentPath = widget.currentPath ?? _resolvedBasePath!; + _currentPath = _resolvedBasePath!; setState(() { _isResolvingBasePath = false; }); @@ -252,13 +263,6 @@ class _SolidFileState extends State { void didUpdateWidget(covariant SolidFile oldWidget) { super.didUpdateWidget(oldWidget); - // Handle basePath changes. - - if (oldWidget.basePath != widget.basePath) { - _resolveBasePath(); - return; - } - final oldPath = oldWidget.currentPath ?? _effectiveBasePath; final newPath = widget.currentPath ?? _effectiveBasePath; @@ -289,13 +293,16 @@ class _SolidFileState extends State { /// Handles directory changes and updates internal state. void _handleDirectoryChanged(String path) { + // Normalise the path to ensure consistent handling. + + final normalisedPath = PathUtils.normalise(path); setState(() { - _currentPath = path; + _currentPath = normalisedPath; }); // Call the external callback if provided. - widget.onDirectoryChanged?.call(path); + widget.onDirectoryChanged?.call(normalisedPath); } @override @@ -370,6 +377,7 @@ class _SolidFileState extends State { widget.autoConfig, widget.showUpload, widget.uploadConfig, + widget.fileTypeResolver, ), uploadCallbacks: _getEffectiveUploadCallbacks(), uploadState: widget.uploadState ?? @@ -387,6 +395,7 @@ class _SolidFileState extends State { widget.autoConfig, widget.showUpload, widget.uploadConfig, + widget.fileTypeResolver, ), uploadCallbacks: _getEffectiveUploadCallbacks(), uploadState: widget.uploadState ?? @@ -403,23 +412,28 @@ class _SolidFileState extends State { } /// Builds the file browser widget. + /// + /// Passes the resolved [_currentPath] as the initial path so the browser + /// starts from the correct location (e.g., POD root for "All POD Files"). Widget _buildFileBrowser() { return SolidFileBrowserBuilder.build( browserKey: _browserKey, - basePath: _effectiveBasePath, friendlyFolderName: SolidFileHelpers.getEffectiveFriendlyFolderName( _currentPath, _effectiveBasePath, widget.autoConfig, widget.friendlyFolderName, + widget.fileTypeResolver, ), + initialPath: widget.currentPath, onFileSelected: widget.onFileSelected, onFileDownload: widget.onFileDownload, onFileDelete: widget.onFileDelete, onImportCsv: widget.onImportCsv, onDirectoryChanged: _handleDirectoryChanged, uploadCallbacks: widget.uploadCallbacks, + folderNameOverrides: widget.folderNameOverrides, ); } } diff --git a/lib/src/widgets/solid_file_browser.dart b/lib/src/widgets/solid_file_browser.dart index b8fd1cf..e0f937b 100644 --- a/lib/src/widgets/solid_file_browser.dart +++ b/lib/src/widgets/solid_file_browser.dart @@ -34,6 +34,7 @@ import 'package:solidpod/solidpod.dart'; import 'package:solidui/src/models/file_item.dart'; import 'package:solidui/src/utils/file_operations.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/widgets/solid_file_browser_content.dart'; import 'package:solidui/src/widgets/solid_file_browser_loading_state.dart'; import 'package:solidui/src/widgets/solid_file_browser_not_logged_in.dart'; @@ -72,9 +73,21 @@ class SolidFileBrowser extends StatefulWidget { final String friendlyFolderName; - /// The base path for the file browser. + /// Optional initial path for the browser to start from. + /// + /// If provided, the browser will start from this path instead of the + /// default app data directory path. Use an empty string to start from + /// the POD root. - final String basePath; + final String? initialPath; + + /// Optional map of directory basenames to display names. + /// + /// When provided, these overrides are used to display user-friendly folder + /// names in the path bar. Entries not found in the map fall back to generic + /// formatting. + + final Map? folderNameOverrides; const SolidFileBrowser({ super.key, @@ -85,7 +98,8 @@ class SolidFileBrowser extends StatefulWidget { required this.onImportCsv, required this.onDirectoryChanged, required this.friendlyFolderName, - required this.basePath, + this.initialPath, + this.folderNameOverrides, }); @override @@ -135,11 +149,43 @@ class SolidFileBrowserState extends State { bool isLoggedIn = false; + /// The home path resolved from [getDataDirPath]. + + String _homePath = ''; + @override void initState() { super.initState(); - currentPath = widget.basePath; - pathHistory = [widget.basePath]; + _resolveHomePath(); + } + + /// Resolves the home path internally via [getDataDirPath]. + /// + /// If [widget.initialPath] is provided, it is used as both the starting + /// path and the home path for navigation. This allows browsing from any + /// location on the POD, including the root. + + Future _resolveHomePath() async { + if (widget.initialPath != null) { + // Use the explicitly provided initial path as both the home path and + // the starting path. This ensures the "back to root" navigation and + // path history work correctly for non-default starting locations. + + _homePath = PathUtils.normalise(widget.initialPath!); + } else { + try { + final appDataPath = await getDataDirPath(); + _homePath = PathUtils.normalise(appDataPath); + } catch (e) { + debugPrint( + 'Failed to get app data path, falling back to POD root: $e', + ); + _homePath = ''; + } + } + + currentPath = _homePath; + pathHistory = [currentPath]; _checkLoginStatus(); } @@ -178,7 +224,10 @@ class SolidFileBrowserState extends State { Future navigateToDirectory(String dirName) async { if (!mounted) return; setState(() { - currentPath = '$currentPath/$dirName'; + // Use PathUtils.combine to ensure consistent path joining without + // double slashes. + + currentPath = PathUtils.combine(currentPath, dirName); pathHistory.add(currentPath); }); await refreshFiles(); @@ -265,32 +314,40 @@ class SolidFileBrowserState extends State { /// Navigate to a specific path in the file browser. void navigateToPath(String path) { + // Normalise the target path to ensure consistent comparison and prevent + // issues with leading slashes. + + final normalisedPath = PathUtils.normalise(path); + setState(() { - currentPath = path; - if (path == widget.basePath) { - pathHistory = [widget.basePath]; + currentPath = normalisedPath; + if (normalisedPath == _homePath || normalisedPath.isEmpty) { + pathHistory = [_homePath]; } else { - if (pathHistory.isEmpty || pathHistory.last != path) { - if (path.startsWith(widget.basePath)) { - pathHistory = [widget.basePath]; - final relativePath = path.substring(widget.basePath.length); - if (relativePath.isNotEmpty && relativePath != '/') { + if (pathHistory.isEmpty || pathHistory.last != normalisedPath) { + // Check if the path is under the home path. + + if (_homePath.isEmpty || normalisedPath.startsWith('$_homePath/')) { + pathHistory = [_homePath]; + final relativePath = + PathUtils.relativeTo(normalisedPath, _homePath); + if (relativePath.isNotEmpty) { final segments = relativePath.split('/').where((s) => s.isNotEmpty); - var currentBuildPath = widget.basePath; + var currentBuildPath = _homePath; for (final segment in segments) { - currentBuildPath = [currentBuildPath, segment].join('/'); + currentBuildPath = PathUtils.combine(currentBuildPath, segment); pathHistory.add(currentBuildPath); } } } else { - pathHistory.add(path); + pathHistory.add(normalisedPath); } } } refreshFiles(); }); - widget.onDirectoryChanged.call(path); + widget.onDirectoryChanged.call(normalisedPath); } /// Gets the effective friendly folder name based on the current path. @@ -299,7 +356,8 @@ class SolidFileBrowserState extends State { String _getEffectiveFriendlyFolderName() { return SolidFileOperations.getFriendlyFolderName( currentPath, - widget.basePath, + _homePath, + widget.folderNameOverrides, ); } @@ -340,7 +398,6 @@ class SolidFileBrowserState extends State { currentDirFileCount: currentDirFileCount, currentDirDirectoryCount: currentDirDirectoryCount, friendlyFolderName: _getEffectiveFriendlyFolderName(), - basePath: widget.basePath, ), if (isLoggedIn) const SizedBox(height: 12), Expanded(child: _buildContent()), diff --git a/lib/src/widgets/solid_file_browser_builder.dart b/lib/src/widgets/solid_file_browser_builder.dart index 6dccc54..c1efd35 100644 --- a/lib/src/widgets/solid_file_browser_builder.dart +++ b/lib/src/widgets/solid_file_browser_builder.dart @@ -30,8 +30,6 @@ library; import 'package:flutter/material.dart'; -import 'package:solidpod/solidpod.dart'; - import 'package:solidui/src/utils/solid_file_operations.dart'; import 'package:solidui/src/widgets/solid_file_browser.dart'; import 'package:solidui/src/widgets/solid_file_upload_config.dart'; @@ -43,20 +41,22 @@ class SolidFileBrowserBuilder { static Widget build({ required GlobalKey browserKey, - required String basePath, required String friendlyFolderName, + String? initialPath, Function(String fileName, String filePath)? onFileSelected, Function(String fileName, String filePath)? onFileDownload, Function(String fileName, String filePath)? onFileDelete, Function(String fileName, String filePath)? onImportCsv, required Function(String path) onDirectoryChanged, SolidFileUploadCallbacks? uploadCallbacks, + Map? folderNameOverrides, }) { return SolidFileBrowser( key: browserKey, browserKey: browserKey, - basePath: basePath, friendlyFolderName: friendlyFolderName, + initialPath: initialPath, + folderNameOverrides: folderNameOverrides, onFileSelected: onFileSelected ?? (fileName, filePath) { debugPrint('File selected: $fileName at $filePath'); @@ -67,16 +67,17 @@ class SolidFileBrowserBuilder { browserKey.currentContext!, fileName, filePath, - pathType: basePath.trim().isEmpty ? PathType.relativeToPod : null, ); }, onFileDelete: onFileDelete ?? (fileName, filePath) { + // filePath is already relative to the Pod root + // (e.g., 'myapp/data/subfolder'). + SolidFileOperations.deletePodFile( browserKey.currentContext!, fileName, filePath, - basePath: basePath, onSuccess: () { // Refresh the browser after successful deletion. diff --git a/lib/src/widgets/solid_file_callbacks.dart b/lib/src/widgets/solid_file_callbacks.dart index 2c6454f..10eff9e 100644 --- a/lib/src/widgets/solid_file_callbacks.dart +++ b/lib/src/widgets/solid_file_callbacks.dart @@ -30,6 +30,7 @@ library; import 'package:flutter/material.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/utils/solid_file_operations.dart'; import 'package:solidui/src/widgets/solid_file_browser.dart'; import 'package:solidui/src/widgets/solid_file_upload_config.dart'; @@ -44,11 +45,17 @@ class SolidFileDefaultCallbacks { String currentPath, GlobalKey browserKey, ) { + // Normalise paths to ensure consistent handling without leading slashes. + + final normalisedCurrentPath = PathUtils.normalise(currentPath); + return SolidFileUploadCallbacks( onUpload: () { + // Upload using the normalised current path directly. + SolidFileOperations.uploadFile( context, - currentPath, + normalisedCurrentPath, onSuccess: () { // Refresh the file browser after successful upload. diff --git a/lib/src/widgets/solid_file_helpers.dart b/lib/src/widgets/solid_file_helpers.dart index 4e12126..992c22e 100644 --- a/lib/src/widgets/solid_file_helpers.dart +++ b/lib/src/widgets/solid_file_helpers.dart @@ -33,6 +33,7 @@ import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:solidui/src/models/file_type_config.dart'; +import 'package:solidui/src/utils/path_utils.dart'; import 'package:solidui/src/widgets/solid_file_upload_config.dart'; /// Helper class for SolidFile widget utilities. @@ -64,14 +65,23 @@ class SolidFileHelpers { String basePath, bool autoConfig, bool showUpload, - SolidFileUploadConfig? uploadConfig, - ) { + SolidFileUploadConfig? uploadConfig, [ + FileTypeResolver? fileTypeResolver, + ]) { if (uploadConfig != null) { return uploadConfig; } if (autoConfig && showUpload) { - final typeConfig = FileTypeConfig.fromPath(currentPath, basePath); + // Normalise paths for consistent handling. + + final normalisedCurrentPath = PathUtils.normalise(currentPath); + final normalisedBasePath = PathUtils.normalise(basePath); + final typeConfig = FileTypeConfig.fromPath( + normalisedCurrentPath, + normalisedBasePath, + fileTypeResolver, + ); return typeConfig.createUploadConfig(); } @@ -85,14 +95,23 @@ class SolidFileHelpers { String currentPath, String basePath, bool autoConfig, - String? friendlyFolderName, - ) { + String? friendlyFolderName, [ + FileTypeResolver? fileTypeResolver, + ]) { if (friendlyFolderName != null) { return friendlyFolderName; } if (autoConfig) { - final typeConfig = FileTypeConfig.fromPath(currentPath, basePath); + // Normalise paths for consistent handling. + + final normalisedCurrentPath = PathUtils.normalise(currentPath); + final normalisedBasePath = PathUtils.normalise(basePath); + final typeConfig = FileTypeConfig.fromPath( + normalisedCurrentPath, + normalisedBasePath, + fileTypeResolver, + ); return typeConfig.displayName; } @@ -101,49 +120,43 @@ class SolidFileHelpers { /// Helper function to get a user-friendly name from the path. - static String getFriendlyFolderName(String pathValue, String basePath) { - final String root = basePath; - if (pathValue.isEmpty || pathValue == root) { + static String getFriendlyFolderName( + String pathValue, + String basePath, [ + Map? folderNameOverrides, + ]) { + // Normalise paths for consistent comparison. + + final normalisedPath = PathUtils.normalise(pathValue); + final normalisedRoot = PathUtils.normalise(basePath); + if (normalisedPath.isEmpty || normalisedPath == normalisedRoot) { return 'Home Folder'; } // Use path.basename to safely get the last component. - final dirName = path.basename(pathValue); - - switch (dirName) { - case 'diary': - return 'Appointments Data'; - case 'blood_pressure': - return 'Blood Pressure Data'; - case 'medication': - return 'Medication Data'; - case 'vaccination': - return 'Vaccination Data'; - case 'profile': - return 'Profile Data'; - case 'health_plan': - return 'Health Plan Data'; - case 'pathology': - return 'Pathology Data'; - case 'tv_shows': - return 'TV Shows'; - - default: - // Basic formatting for unknown folders: - // capitalise first letter, replace underscores. - - if (dirName.isEmpty) return 'Folder'; - String formattedName = dirName.replaceAll('_', ' ').trim(); - formattedName = formattedName - .split(RegExp(r'\s+')) - .map( - (w) => w.isEmpty - ? w - : '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}', - ) - .join(' '); - return formattedName; + final dirName = path.basename(normalisedPath); + + // Check application-specific overrides first. + + if (folderNameOverrides != null && + folderNameOverrides.containsKey(dirName)) { + return folderNameOverrides[dirName]!; } + + // Generic formatting for all folders: + // capitalise first letter, replace underscores. + + if (dirName.isEmpty) return 'Folder'; + String formattedName = dirName.replaceAll('_', ' ').trim(); + formattedName = formattedName + .split(RegExp(r'\s+')) + .map( + (w) => w.isEmpty + ? w + : '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}', + ) + .join(' '); + return formattedName; } } diff --git a/lib/src/widgets/solid_file_models.dart b/lib/src/widgets/solid_file_models.dart index 3942e2c..efc0bdb 100644 --- a/lib/src/widgets/solid_file_models.dart +++ b/lib/src/widgets/solid_file_models.dart @@ -35,13 +35,6 @@ import 'package:solidui/src/widgets/solid_file_upload_config.dart'; /// Configuration for the SolidFile widget. class SolidFileConfig { - /// Base path for file operations. - /// - /// If null, defaults to the app data directory path (e.g. 'appname/data'). - /// If the app data directory does not exist, falls back to the pod root. - - final String? basePath; - /// Whether to show the back button. final bool showBackButton; @@ -59,7 +52,6 @@ class SolidFileConfig { final double? browserHeight; const SolidFileConfig({ - this.basePath, this.showBackButton = true, this.backButtonText = 'Back to Home Folder', this.forceWideScreen, diff --git a/lib/src/widgets/solid_file_operations.dart b/lib/src/widgets/solid_file_operations.dart index c53deff..c7b427d 100644 --- a/lib/src/widgets/solid_file_operations.dart +++ b/lib/src/widgets/solid_file_operations.dart @@ -39,12 +39,16 @@ import 'package:solidpod/solidpod.dart'; import 'package:solidui/src/models/file_state.dart'; import 'package:solidui/src/utils/is_text_file.dart'; +import 'package:solidui/src/utils/path_utils.dart'; /// Helper class for file operations in SolidFile widget. class SolidFileOperations { /// Handles file upload by reading its contents and encrypting it for upload. + @Deprecated( + 'Use SolidFileUploadOperations.uploadFile with PathType.relativeToPod', + ) static Future handleUpload( FileState fileState, String basePath, @@ -84,13 +88,18 @@ class SolidFileOperations { final remoteFileName = '$sanitizedFileName.enc.ttl'; final cleanFileName = sanitizedFileName; - // Extract the subdirectory path. + // Extract the subdirectory path using PathUtils for consistent + // normalisation (no leading slashes). - String? subPath = - fileState.currentPath?.replaceFirst(basePath, '').trim(); - String uploadPath = subPath == null || subPath.isEmpty + final normalisedBasePath = PathUtils.normalise(basePath); + final normalisedCurrentPath = fileState.currentPath != null + ? PathUtils.normalise(fileState.currentPath!) + : ''; + final subPath = + PathUtils.relativeTo(normalisedCurrentPath, normalisedBasePath); + final uploadPath = subPath.isEmpty ? remoteFileName - : '${subPath.startsWith("/") ? subPath.substring(1) : subPath}/$remoteFileName'; + : PathUtils.combine(subPath, remoteFileName); // Upload file with encryption. @@ -110,6 +119,9 @@ class SolidFileOperations { /// Handles the download and decryption of files from the POD. + @Deprecated( + 'Use SolidFileDownloadOperations.downloadFile with PathType.relativeToPod', + ) static Future handleDownload( FileState fileState, String basePath, @@ -138,10 +150,14 @@ class SolidFileOperations { return fileState.copyWith(downloadInProgress: false); } - final baseDir = basePath; - final relativePath = fileState.currentPath == baseDir - ? '$baseDir/${fileState.remoteFileName}' - : '${fileState.currentPath}/${fileState.remoteFileName}'; + // Use PathUtils for consistent path handling without leading slashes. + + final normalisedBasePath = PathUtils.normalise(basePath); + final normalisedCurrentPath = fileState.currentPath != null + ? PathUtils.normalise(fileState.currentPath!) + : normalisedBasePath; + final relativePath = + PathUtils.combine(normalisedCurrentPath, fileState.remoteFileName!); await promptForKeyFunction(); @@ -168,6 +184,9 @@ class SolidFileOperations { /// Handles file deletion from the POD. + @Deprecated( + 'Use SolidFileDeleteOperations.deletePodFile with PathType.relativeToPod', + ) static Future handleDelete( FileState fileState, String basePath, @@ -182,10 +201,14 @@ class SolidFileOperations { deleteDone: false, ); - final baseDir = basePath; - final filePath = fileState.currentPath == baseDir - ? '$baseDir/${fileState.remoteFileName}' - : '${fileState.currentPath}/${fileState.remoteFileName}'; + // Use PathUtils for consistent path handling without leading slashes. + + final normalisedBasePath = PathUtils.normalise(basePath); + final normalisedCurrentPath = fileState.currentPath != null + ? PathUtils.normalise(fileState.currentPath!) + : normalisedBasePath; + final filePath = + PathUtils.combine(normalisedCurrentPath, fileState.remoteFileName!); // Delete the file (this also handles the ACL file automatically). @@ -212,7 +235,11 @@ class SolidFileOperations { /// Helper function to get a user-friendly name from the path. - static String getFriendlyFolderName(String pathValue, String basePath) { + static String getFriendlyFolderName( + String pathValue, + String basePath, [ + Map? folderNameOverrides, + ]) { final String root = basePath; if (pathValue.isEmpty || pathValue == root) { return 'Home Folder'; @@ -222,40 +249,27 @@ class SolidFileOperations { final dirName = path.basename(pathValue); - switch (dirName) { - case 'diary': - return 'Appointments Data'; - case 'blood_pressure': - return 'Blood Pressure Data'; - case 'medication': - return 'Medication Data'; - case 'vaccination': - return 'Vaccination Data'; - case 'profile': - return 'Profile Data'; - case 'health_plan': - return 'Health Plan Data'; - case 'pathology': - return 'Pathology Data'; - case 'tv_shows': - return 'TV Shows'; - - default: - // Basic formatting for unknown folders: - // capitalise first letter, replace underscores. - - if (dirName.isEmpty) return 'Folder'; - String formattedName = dirName.replaceAll('_', ' ').trim(); - formattedName = formattedName - .split(RegExp(r'\s+')) - .map( - (w) => w.isEmpty - ? w - : '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}', - ) - .join(' '); - return formattedName; + // Check application-specific overrides first. + + if (folderNameOverrides != null && + folderNameOverrides.containsKey(dirName)) { + return folderNameOverrides[dirName]!; } + + // Generic formatting for all folders: + // capitalise first letter, replace underscores. + + if (dirName.isEmpty) return 'Folder'; + String formattedName = dirName.replaceAll('_', ' ').trim(); + formattedName = formattedName + .split(RegExp(r'\s+')) + .map( + (w) => w.isEmpty + ? w + : '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}', + ) + .join(' '); + return formattedName; } /// Shows an alert dialog with the given message. diff --git a/lib/src/widgets/solid_file_path_bar.dart b/lib/src/widgets/solid_file_path_bar.dart index b5752ac..11f94ac 100644 --- a/lib/src/widgets/solid_file_path_bar.dart +++ b/lib/src/widgets/solid_file_path_bar.dart @@ -66,10 +66,6 @@ class PathBar extends StatelessWidget { final String friendlyFolderName; - /// Base path of the file browser. - - final String basePath; - const PathBar({ super.key, required this.currentPath, @@ -80,7 +76,6 @@ class PathBar extends StatelessWidget { required this.currentDirFileCount, required this.currentDirDirectoryCount, required this.friendlyFolderName, - required this.basePath, }); @override diff --git a/lib/src/widgets/solid_file_uploader.dart b/lib/src/widgets/solid_file_uploader.dart index 49aba45..8b079b4 100644 --- a/lib/src/widgets/solid_file_uploader.dart +++ b/lib/src/widgets/solid_file_uploader.dart @@ -42,14 +42,12 @@ class SolidFileUploader extends StatefulWidget { final Future Function() onUpload; final void Function(String?) onFileSelected; final void Function(String) onPreviewRequested; - final String basePath; const SolidFileUploader({ super.key, required this.fileState, required this.onUpload, required this.onFileSelected, required this.onPreviewRequested, - required this.basePath, }); @override State createState() => _SolidFileUploaderState();