diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt index aca2a1b..0559b9c 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/DocumentsContractApi.kt @@ -35,30 +35,31 @@ internal class DocumentsContractApi(private val plugin: SharedStoragePlugin) : val width = call.argument("width")!! val height = call.argument("height")!! - val bitmap = - DocumentsContract.getDocumentThumbnail( - plugin.context.contentResolver, - uri, - Point(width, height), - null - ) + val bitmap = DocumentsContract.getDocumentThumbnail( + plugin.context.contentResolver, + uri, + Point(width, height), + null + ) - CoroutineScope(Dispatchers.Default).launch { - if (bitmap != null) { + if (bitmap != null) { + CoroutineScope(Dispatchers.Default).launch { val base64 = bitmapToBase64(bitmap) val data = - mapOf( - "base64" to base64, - "uri" to "$uri", - "width" to bitmap.width, - "height" to bitmap.height, - "byteCount" to bitmap.byteCount, - "density" to bitmap.density - ) + mapOf( + "base64" to base64, + "uri" to "$uri", + "width" to bitmap.width, + "height" to bitmap.height, + "byteCount" to bitmap.byteCount, + "density" to bitmap.density + ) launch(Dispatchers.Main) { result.success(data) } } + } else { + result.success(null) } } else { result.notSupported(call.method, API_21) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt index d072e48..ca5733f 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentCommon.kt @@ -39,74 +39,58 @@ fun documentFromUri( /** - * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` - * from plugin results, like: - * ```dart - * result.success(createDocumentFileMap(documentFile)) - * ``` + * Convert a [DocumentFile] using the default method for map encoding */ fun createDocumentFileMap(documentFile: DocumentFile?): Map? { if (documentFile == null) return null - return mapOf( - "isDirectory" to documentFile.isDirectory, - "isFile" to documentFile.isFile, - "isVirtual" to documentFile.isVirtual, - "name" to (documentFile.name ?: ""), - "type" to (documentFile.type ?: ""), - "uri" to "${documentFile.uri}", - "exists" to "${documentFile.exists()}" + return createDocumentFileMap( + DocumentsContract.getDocumentId(documentFile.uri), + parentUri = documentFile.parentFile?.uri, + isDirectory = documentFile.isDirectory, + isFile = documentFile.isFile, + isVirtual = documentFile.isVirtual, + name = documentFile.name, + type = documentFile.type, + uri = documentFile.uri, + exists = documentFile.exists(), + size = documentFile.length(), + lastModified = documentFile.lastModified() ) } - /** - * Standard map encoding of a row result of a `DocumentFile` - * ```kt + * Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile` + * from plugin results, like: + * ```dart * result.success(createDocumentFileMap(documentFile)) * ``` - * - * Example: - * ```py - * input = { - * "last_modified": 2939496, # Key from DocumentsContract.Document.COLUMN_LAST_MODIFIED - * "_display_name": "MyFile" # Key from DocumentsContract.Document.COLUMN_DISPLAY_NAME - * } - * - * output = createCursorRowMap(input) - * - * print(output) - * { - * "lastModified": 2939496, - * "displayName": "MyFile" - * } - * ``` */ -fun createCursorRowMap( - parentUri: Uri, +fun createDocumentFileMap( + id: String?, + parentUri: Uri?, + isDirectory: Boolean?, + isFile: Boolean?, + isVirtual: Boolean?, + name: String?, + type: String?, uri: Uri, - data: Map, - isDirectory: Boolean? -): Map { - val values = DocumentFileColumn.values() - - val formattedData = mutableMapOf() - - for (value in values) { - val key = parseDocumentFileColumn(value) - - if (data[key] != null) { - formattedData[documentFileColumnToRawString(value)!!] = data[key]!! - } - } - + exists: Boolean?, + size: Long?, + lastModified: Long? +): Map { return mapOf( - "data" to formattedData, - "metadata" to mapOf( - "parentUri" to "$parentUri", - "isDirectory" to isDirectory, - "uri" to "$uri" - ) + "id" to id, + "parentUri" to "$parentUri", + "isDirectory" to isDirectory, + "isFile" to isFile, + "isVirtual" to isVirtual, + "name" to name, + "type" to type, + "uri" to "$uri", + "exists" to exists, + "size" to size, + "lastModified" to lastModified ) } @@ -130,7 +114,7 @@ fun traverseDirectoryEntries( targetUri: Uri, columns: Array, rootOnly: Boolean, - block: (data: Map, isLast: Boolean) -> Unit + block: (data: Map, isLast: Boolean) -> Unit ): Boolean { val documentId = try { DocumentsContract.getDocumentId(targetUri) @@ -158,7 +142,10 @@ fun traverseDirectoryEntries( if (rootOnly) emptyArray() else arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) val intrinsicColumns = - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_FLAGS + ) val projection = arrayOf( *columns, @@ -215,11 +202,22 @@ fun traverseDirectoryEntries( } block( - createCursorRowMap( - parent, - uri, - data, - isDirectory = isDirectory + createDocumentFileMap( + parentUri = parent, + uri = uri, + name = data[DocumentsContract.Document.COLUMN_DISPLAY_NAME] as String?, + exists = true, + id = data[DocumentsContract.Document.COLUMN_DOCUMENT_ID] as String, + isDirectory = isDirectory == true, + isFile = isDirectory == false, + isVirtual = if (Build.VERSION.SDK_INT >= API_24) { + (data[DocumentsContract.Document.COLUMN_FLAGS] as Int and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0 + } else { + false + }, + type = data[DocumentsContract.Document.COLUMN_MIME_TYPE] as String?, + size = data[DocumentsContract.Document.COLUMN_SIZE] as Long?, + lastModified = data[DocumentsContract.Document.COLUMN_LAST_MODIFIED] as Long? ), dirNodes.isEmpty() && cursor.isLast ) diff --git a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt index 6a178ff..7d54089 100644 --- a/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt +++ b/android/src/main/kotlin/io/lakscastro/sharedstorage/storageaccessframework/lib/DocumentFileColumn.kt @@ -16,7 +16,8 @@ enum class DocumentFileColumn { enum class DocumentFileColumnType { LONG, - STRING + STRING, + INT } fun parseDocumentFileColumn(column: String): DocumentFileColumn? { @@ -66,7 +67,8 @@ fun typeOfColumn(column: String): DocumentFileColumnType? { DocumentsContract.Document.COLUMN_MIME_TYPE to DocumentFileColumnType.STRING, DocumentsContract.Document.COLUMN_SIZE to DocumentFileColumnType.LONG, DocumentsContract.Document.COLUMN_SUMMARY to DocumentFileColumnType.STRING, - DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG + DocumentsContract.Document.COLUMN_LAST_MODIFIED to DocumentFileColumnType.LONG, + DocumentsContract.Document.COLUMN_FLAGS to DocumentFileColumnType.INT ) return values[column] @@ -76,5 +78,6 @@ fun cursorHandlerOf(type: DocumentFileColumnType): (Cursor, Int) -> Any { when(type) { DocumentFileColumnType.LONG -> { return { cursor, index -> cursor.getLong(index) } } DocumentFileColumnType.STRING -> { return { cursor, index -> cursor.getString(index) } } + DocumentFileColumnType.INT -> { return { cursor, index -> cursor.getInt(index) } } } } diff --git a/example/lib/screens/file_explorer/file_explorer_card.dart b/example/lib/screens/file_explorer/file_explorer_card.dart index 07d40bb..35bdf7d 100644 --- a/example/lib/screens/file_explorer/file_explorer_card.dart +++ b/example/lib/screens/file_explorer/file_explorer_card.dart @@ -1,55 +1,138 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; +import 'package:fl_toast/fl_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; +import '../../utils/apply_if_not_null.dart'; +import '../../utils/confirm_decorator.dart'; +import '../../utils/disabled_text_style.dart'; +import '../../utils/format_bytes.dart'; +import '../../utils/inline_span.dart'; +import '../../utils/mime_types.dart'; import '../../widgets/buttons.dart'; import '../../widgets/key_value_text.dart'; import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; import 'file_explorer_page.dart'; class FileExplorerCard extends StatefulWidget { const FileExplorerCard({ Key? key, - required this.partialFile, + required this.documentFile, required this.didUpdateDocument, }) : super(key: key); - final PartialDocumentFile partialFile; - final void Function(PartialDocumentFile?) didUpdateDocument; + final DocumentFile documentFile; + final void Function(DocumentFile?) didUpdateDocument; @override _FileExplorerCardState createState() => _FileExplorerCardState(); } class _FileExplorerCardState extends State { - PartialDocumentFile get file => widget.partialFile; + DocumentFile get _file => widget.documentFile; - static const _size = Size.square(150); + static const _expandedThumbnailSize = Size.square(150); - Uint8List? imageBytes; + Uint8List? _thumbnailImageBytes; + Size? _thumbnailSize; - Future _loadThumbnailIfAvailable() async { - final uri = file.metadata?.uri; + int get _sizeInBytes => _file.size ?? 0; + + bool _expanded = false; + String? get _displayName => _file.name; - if (uri == null) return; + Future _loadThumbnailIfAvailable() async { + final uri = _file.uri; final bitmap = await getDocumentThumbnail( uri: uri, - width: _size.width, - height: _size.height, + width: _expandedThumbnailSize.width, + height: _expandedThumbnailSize.height, ); - if (bitmap == null || !mounted) return; + if (bitmap == null) { + _thumbnailImageBytes = Uint8List.fromList([]); + _thumbnailSize = Size.zero; + } else { + _thumbnailImageBytes = bitmap.bytes; + _thumbnailSize = Size(bitmap.width! / 1, bitmap.height! / 1); + } - setState(() => imageBytes = bitmap.bytes); + if (mounted) setState(() {}); } StreamSubscription? _subscription; + Future Function() _fileConfirmation( + String action, + VoidCallback callback, + ) { + return confirm( + context, + action, + callback, + message: [ + normal('This action '), + bold('writes'), + normal(' to this file, '), + bold('it can '), + bold(red('corrupt the file ')), + normal('or'), + bold(red(' even lose your data')), + normal(', be cautious.'), + ], + ); + } + + VoidCallback _directoryConfirmation(String action, VoidCallback callback) { + return confirm( + context, + action, + callback, + message: [ + normal('This action '), + bold('writes'), + normal(' to this file, '), + bold('it can '), + bold(red('corrupt the file ')), + normal('or'), + bold(red(' even lose your data')), + normal(', be cautious.'), + ], + ); + } + + Widget _buildMimeTypeIconThumbnail(String mimeType, {double? size}) { + if (mimeType == kDirectoryMime) { + return Icon(Icons.folder, size: size, color: Colors.blueGrey); + } + + if (mimeType == kApkMime) { + return Icon(Icons.android, color: const Color(0xff3AD17D), size: size); + } + + if (mimeType == kTextPlainMime) { + return Icon(Icons.description, size: size, color: Colors.blue); + } + + if (mimeType.startsWith(kVideoMime)) { + return Icon(Icons.movie, size: size, color: Colors.deepOrange); + } + + return Icon( + Icons.browser_not_supported_outlined, + size: size, + color: disabledColor(), + ); + } + @override void initState() { super.initState(); @@ -61,9 +144,9 @@ class _FileExplorerCardState extends State { void didUpdateWidget(covariant FileExplorerCard oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.partialFile.data?[DocumentFileColumn.id] != - widget.partialFile.data?[DocumentFileColumn.id]) { + if (oldWidget.documentFile.id != widget.documentFile.id) { _loadThumbnailIfAvailable(); + if (mounted) setState(() => _expanded = false); } } @@ -83,130 +166,306 @@ class _FileExplorerCardState extends State { Uint8List? content; - bool get _isDirectory => file.metadata?.isDirectory ?? false; + bool get _isDirectory => _file.isDirectory == true; - @override - Widget build(BuildContext context) { - return SimpleCard( - onTap: () async { - if (file.metadata?.isDirectory == false) { - content = await getDocumentContent(file.metadata!.uri!); - - final mimeType = - file.data![DocumentFileColumn.mimeType] as String? ?? ''; - - if (content != null) { - final isImage = mimeType.startsWith('image/'); - - await showModalBottomSheet( - context: context, - builder: (context) { - if (isImage) { - return Image.memory(content!); - } - - return Container( - padding: k8dp.all, - child: Text(String.fromCharCodes(content!)), - ); - }, - ); + int _generateLuckNumber() { + final random = Random(); + + return random.nextInt(1000); + } + + Widget _buildThumbnail({double? size}) { + late Widget thumbnail; + + if (_thumbnailImageBytes == null) { + thumbnail = const CircularProgressIndicator(); + } else if (_thumbnailImageBytes!.isEmpty) { + thumbnail = _buildMimeTypeIconThumbnail( + _mimeTypeOrEmpty, + size: size, + ); + } else { + thumbnail = Image.memory( + _thumbnailImageBytes!, + fit: BoxFit.contain, + ); + + if (!_expanded) { + final width = _thumbnailSize?.width; + final height = _thumbnailSize?.height; + + final aspectRatio = + width != null && height != null ? width / height : 1.0; + + thumbnail = AspectRatio( + aspectRatio: aspectRatio, + child: thumbnail, + ); + } + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: _expanded ? MainAxisSize.max : MainAxisSize.min, + children: [ + Align( + alignment: _expanded ? Alignment.centerLeft : Alignment.center, + child: thumbnail, + ), + if (_expanded) _buildExpandButton(), + ], + ), + ); + } + + Widget _buildExpandButton() { + return IconButton( + onPressed: () => setState(() => _expanded = !_expanded), + icon: _expanded + ? const Icon(Icons.expand_less, color: Colors.grey) + : const Icon(Icons.expand_more, color: Colors.grey), + ); + } + + Uri get _currentUri => widget.documentFile.uri; + + Widget _buildNotAvailableText() { + return Text('Not available', style: disabledTextStyle()); + } + + Widget _buildOpenWithButton() => + Button('Open with', onTap: _openFileWithExternalApp); + + Widget _buildDocumentSimplifiedTile() { + return ListTile( + dense: true, + leading: _buildThumbnail(size: 25), + title: Text( + '$_displayName', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(formatBytes(_sizeInBytes, 2)), + trailing: _buildExpandButton(), + ); + } + + Widget _buildDocumentMetadata() { + return KeyValueText( + entries: { + 'name': '$_displayName', + 'type': '${_file.type}', + 'isVirtual': '${_file.isVirtual}', + 'isDirectory': '${_file.isDirectory}', + 'isFile': '${_file.isFile}', + 'size': '${formatBytes(_sizeInBytes, 2)} ($_sizeInBytes bytes)', + 'lastModified': '${(() { + if (_file.lastModified == null) { + return null; } - } + + return _file.lastModified!.toIso8601String(); + })()}', + 'id': '${_file.id}', + 'parentUri': _file.parentUri?.apply((u) => Uri.decodeFull('$u')) ?? + _buildNotAvailableText(), + 'uri': Uri.decodeFull('${_file.uri}'), }, + ); + } + + Widget _buildAvailableActions() { + return Wrap( children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: imageBytes == null - ? Container( - height: _size.height, - width: _size.width, - color: Colors.grey, - ) - : Image.memory( - imageBytes!, - height: _size.height, - width: _size.width, - fit: BoxFit.contain, - ), + if (_isDirectory) + ActionButton( + 'Open Directory', + onTap: _openDirectory, + ), + _buildOpenWithButton(), + DangerButton( + 'Delete ${_isDirectory ? 'Directory' : 'File'}', + onTap: _isDirectory + ? _directoryConfirmation('Delete', _deleteDocument) + : _fileConfirmation('Delete', _deleteDocument), ), - KeyValueText( - entries: { - 'name': '${file.data?[DocumentFileColumn.displayName]}', - 'type': '${file.data?[DocumentFileColumn.mimeType]}', - 'size': '${file.data?[DocumentFileColumn.size]}', - 'lastModified': '${(() { - if (file.data?[DocumentFileColumn.lastModified] == null) { - return null; - } - - final millisecondsSinceEpoch = - file.data?[DocumentFileColumn.lastModified] as int; - - final date = - DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch); - - return date.toIso8601String(); - })()}', - 'summary': '${file.data?[DocumentFileColumn.summary]}', - 'id': '${file.data?[DocumentFileColumn.id]}', - 'parentUri': '${file.metadata?.parentUri}', - 'uri': '${file.metadata?.uri}', - }, - ), - Wrap( - children: [ - if (_isDirectory) - ActionButton( - 'Open Directory', - onTap: () async { - if (_isDirectory) { - _openFolderFileListPage( - file.metadata!.uri!, - ); - } - }, - ), - ActionButton( - 'Open With', - onTap: () async { - final uri = widget.partialFile.metadata!.uri!; - - try { - // OpenFile.open('/sdcard/example.txt'); - final launched = await openDocumentFile(uri); - - if (launched ?? false) { - print('Successfully opened $uri'); - } else { - print('Failed to launch $uri'); - } - } on PlatformException { - print( - "There's no activity associated with the file type of this Uri: $uri", - ); - } - }, - ), - DangerButton( - 'Delete ${_isDirectory ? 'Directory' : 'File'}', - onTap: () async { - final deleted = await delete(widget.partialFile.metadata!.uri!); - - if (deleted ?? false) { - widget.didUpdateDocument(null); - } - }, + if (!_isDirectory) ...[ + DangerButton( + 'Write to File', + onTap: _fileConfirmation('Overwite', _overwriteFileContents), + ), + DangerButton( + 'Append to file', + onTap: _fileConfirmation('Append', _appendFileContents), + ), + DangerButton( + 'Erase file content', + onTap: _fileConfirmation('Erase', _eraseFileContents), + ), + DangerButton( + 'Edit file contents', + onTap: _editFileContents, + ), + ], + ], + ); + } + + String get _mimeTypeOrEmpty => _file.type ?? ''; + + Future _showFileContents() async { + if (_isDirectory) return; + + const k10mb = 1024 * 1024 * 10; + + if (!_mimeTypeOrEmpty.startsWith(kTextMime) && + !_mimeTypeOrEmpty.startsWith(kImageMime)) { + if (_mimeTypeOrEmpty == kApkMime) { + return showTextToast( + text: + 'Requesting to install a package (.apk) is not currently supported, to request this feature open an issue at github.com/lakscastro/shared-storage/issues', + context: context, + ); + } + + return _openFileWithExternalApp(); + } + + // Too long, will take too much time to read + if (_sizeInBytes > k10mb) { + return showTextToast( + text: 'File too long to open', + context: context, + ); + } + + content = await getDocumentContent(_file.uri); + + if (content != null) { + final isImage = _mimeTypeOrEmpty.startsWith(kImageMime); + + await showModalBottomSheet( + context: context, + builder: (context) { + if (isImage) { + return Image.memory(content!); + } + + final contentAsString = String.fromCharCodes(content!); + + final fileIsEmpty = contentAsString.isEmpty; + + return Container( + padding: k8dp.all, + child: Text( + fileIsEmpty ? 'This file is empty' : contentAsString, + style: fileIsEmpty ? disabledTextStyle() : null, ), - if (!_isDirectory) - DangerButton( - 'Write to File', - onTap: () async { - await writeToFile(widget.partialFile.metadata!.uri!, content: 'Hello World!'); - }, - ), - ], + ); + }, + ); + } + } + + Future _deleteDocument() async { + final deleted = await delete(_currentUri); + + if (deleted ?? false) { + widget.didUpdateDocument(null); + } + } + + Future _overwriteFileContents() async { + await writeToFile( + _currentUri, + content: 'Hello World! Your luck number is: ${_generateLuckNumber()}', + mode: FileMode.write, + ); + } + + Future _appendFileContents() async { + final contents = await getDocumentContentAsString( + _currentUri, + ); + + final prependWithNewLine = contents?.isNotEmpty ?? true; + + await writeToFile( + _currentUri, + content: + "${prependWithNewLine ? '\n' : ''}You file got bigger! Here's your luck number: ${_generateLuckNumber()}", + mode: FileMode.append, + ); + } + + Future _eraseFileContents() async { + await writeToFile( + _currentUri, + content: '', + mode: FileMode.write, + ); + } + + Future _editFileContents() async { + final content = await showDialog( + context: context, + builder: (context) { + return const TextFieldDialog( + labelText: 'New file content:', + hintText: 'Writing to this file', + actionText: 'Edit', + ); + }, + ); + + if (content != null) { + _fileConfirmation( + 'Overwrite', + () => writeToFileAsString( + _currentUri, + content: content, + mode: FileMode.write, ), + )(); + } + } + + Future _openFileWithExternalApp() async { + final uri = _currentUri; + + try { + final launched = await openDocumentFile(uri); + + if (launched ?? false) { + print('Successfully opened $uri'); + } else { + print('Failed to launch $uri'); + } + } on PlatformException { + print( + "There's no activity associated with the file type of this Uri: $uri", + ); + } + } + + Future _openDirectory() async { + if (_isDirectory) { + _openFolderFileListPage(_file.uri); + } + } + + @override + Widget build(BuildContext context) { + return SimpleCard( + onTap: _isDirectory ? _openDirectory : _showFileContents, + children: [ + if (_expanded) ...[ + _buildThumbnail(size: 50), + _buildDocumentMetadata(), + _buildAvailableActions() + ] else + _buildDocumentSimplifiedTile(), ], ); } diff --git a/example/lib/screens/file_explorer/file_explorer_page.dart b/example/lib/screens/file_explorer/file_explorer_page.dart index 91fc1e1..4c7094a 100644 --- a/example/lib/screens/file_explorer/file_explorer_page.dart +++ b/example/lib/screens/file_explorer/file_explorer_page.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; + import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; import '../../widgets/buttons.dart'; import '../../widgets/light_text.dart'; import '../../widgets/simple_card.dart'; +import '../../widgets/text_field_dialog.dart'; import 'file_explorer_card.dart'; class FileExplorerPage extends StatefulWidget { @@ -22,11 +24,11 @@ class FileExplorerPage extends StatefulWidget { } class _FileExplorerPageState extends State { - List? _files; + List? _files; late bool _hasPermission; - StreamSubscription? _listener; + StreamSubscription? _listener; Future _grantAccess() async { final uri = await openDocumentTree(initialUri: widget.uri); @@ -38,98 +40,142 @@ class _FileExplorerPageState extends State { _loadFiles(); } - Widget _buildFileList() { - return CustomScrollView( - slivers: [ - if (!_hasPermission) - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: [ - Center( - child: LightText( - 'No permission granted to this folder\n\n${widget.uri}\n', - ), - ), - Center( - child: ActionButton( - 'Grant Access', - onTap: _grantAccess, - ), - ), - ], + Widget _buildNoPermissionWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: [ + Center( + child: LightText( + 'No permission granted to this folder\n\n${widget.uri}\n', + ), + ), + Center( + child: ActionButton( + 'Grant Access', + onTap: _grantAccess, ), - ], + ), + ], + ), + ], + ), + ), + ); + } + + Future _createCustomDocument() async { + final filename = await showDialog( + context: context, + builder: (context) => const TextFieldDialog( + hintText: 'File name:', + labelText: 'My Text File', + suffixText: '.txt', + actionText: 'Create', + ), + ); + + if (filename == null) return; + + final createdFile = await createFile( + widget.uri, + mimeType: 'text/plain', + displayName: filename, + ); + + if (createdFile != null) { + _files?.add(createdFile); + + if (mounted) setState(() {}); + } + } + + Widget _buildCreateDocumentButton() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + Center( + child: ActionButton( + 'Create a custom document', + onTap: _createCustomDocument, ), ), - ) - else ...[ - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - Center( - child: ActionButton( - 'Create a custom document', - onTap: () => {}, - ), + ], + ), + ), + ); + } + + void _didUpdateDocument( + DocumentFile before, + DocumentFile? after, + ) { + if (after == null) { + _files?.removeWhere((doc) => doc.id == before.id); + + if (mounted) setState(() {}); + } + } + + Widget _buildDocumentList() { + return SliverPadding( + padding: k6dp.et, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final file = _files![index]; + + return FileExplorerCard( + documentFile: file, + didUpdateDocument: (document) => + _didUpdateDocument(file, document), + ); + }, + childCount: _files!.length, + ), + ), + ); + } + + Widget _buildEmptyFolderWarning() { + return SliverPadding( + padding: k6dp.eb, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + SimpleCard( + onTap: () => {}, + children: const [ + Center( + child: LightText( + 'Empty folder', ), - ], - ), + ), + ], ), - ), + ], + ), + ), + ); + } + + Widget _buildFileList() { + return CustomScrollView( + slivers: [ + if (!_hasPermission) + _buildNoPermissionWarning() + else ...[ + _buildCreateDocumentButton(), if (_files!.isNotEmpty) - SliverPadding( - padding: k6dp.et, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final file = _files![index]; - - return FileExplorerCard( - partialFile: file, - didUpdateDocument: (document) { - if (document == null) { - _files?.removeWhere( - (doc) => - doc.data?[DocumentFileColumn.id] == - file.data?[DocumentFileColumn.id], - ); - - if (mounted) setState(() {}); - } - }, - ); - }, - childCount: _files!.length, - ), - ), - ) + _buildDocumentList() else - SliverPadding( - padding: k6dp.eb, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - SimpleCard( - onTap: () => {}, - children: const [ - Center( - child: LightText( - 'Empty folder', - ), - ), - ], - ), - ], - ), - ), - ) + _buildEmptyFolderWarning(), ] ], ); diff --git a/example/lib/screens/granted_uris/granted_uri_card.dart b/example/lib/screens/granted_uris/granted_uri_card.dart index 55c3aa4..42e9cc6 100644 --- a/example/lib/screens/granted_uris/granted_uri_card.dart +++ b/example/lib/screens/granted_uris/granted_uri_card.dart @@ -55,41 +55,48 @@ class _GrantedUriCardState extends State { ); } + Widget _buildAvailableActions() { + return Wrap( + children: [ + ActionButton( + 'Create Sample File', + onTap: () => _appendSampleFile( + widget.permissionUri.uri, + ), + ), + ActionButton( + 'Open Tree Here', + onTap: () => openDocumentTree(initialUri: widget.permissionUri.uri), + ), + Padding(padding: k2dp.all), + DangerButton( + 'Revoke', + onTap: () => _revokeUri( + widget.permissionUri.uri, + ), + ), + ], + ); + } + + Widget _buildGrantedUriMetadata() { + return KeyValueText( + entries: { + 'isWritePermission': '${widget.permissionUri.isWritePermission}', + 'isReadPermission': '${widget.permissionUri.isReadPermission}', + 'persistedTime': '${widget.permissionUri.persistedTime}', + 'uri': Uri.decodeFull('${widget.permissionUri.uri}'), + }, + ); + } + @override Widget build(BuildContext context) { return SimpleCard( onTap: _openListFilesPage, children: [ - KeyValueText( - entries: { - 'isWritePermission': '${widget.permissionUri.isWritePermission}', - 'isReadPermission': '${widget.permissionUri.isReadPermission}', - 'persistedTime': '${widget.permissionUri.persistedTime}', - 'uri': '${widget.permissionUri.uri}', - }, - ), - Wrap( - children: [ - ActionButton( - 'Create Sample File', - onTap: () => _appendSampleFile( - widget.permissionUri.uri, - ), - ), - ActionButton( - 'Open Tree Here', - onTap: () => - openDocumentTree(initialUri: widget.permissionUri.uri), - ), - Padding(padding: k2dp.all), - DangerButton( - 'Revoke', - onTap: () => _revokeUri( - widget.permissionUri.uri, - ), - ), - ], - ), + _buildGrantedUriMetadata(), + _buildAvailableActions(), ], ); } diff --git a/example/lib/screens/granted_uris/granted_uris_page.dart b/example/lib/screens/granted_uris/granted_uris_page.dart index fa9ea92..62f5739 100644 --- a/example/lib/screens/granted_uris/granted_uris_page.dart +++ b/example/lib/screens/granted_uris/granted_uris_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:shared_storage/saf.dart'; import '../../theme/spacing.dart'; @@ -24,13 +23,9 @@ class _GrantedUrisPageState extends State { } Future _loadPersistedUriPermissions() async { - final status = await Permission.storage.request(); + persistedPermissionUris = await persistedUriPermissions(); - if (status.isGranted) { - persistedPermissionUris = await persistedUriPermissions(); - - setState(() => {}); - } + if (mounted) setState(() => {}); } /// Prompt user with a folder picker (Available for Android 5.0+) diff --git a/example/lib/theme/spacing.dart b/example/lib/theme/spacing.dart index 5113633..3a36206 100644 --- a/example/lib/theme/spacing.dart +++ b/example/lib/theme/spacing.dart @@ -1,18 +1,22 @@ import 'package:flutter/cupertino.dart'; -extension EdgeInsetsAlias on double { - EdgeInsets get all => EdgeInsets.all(this); - EdgeInsets get lr => EdgeInsets.symmetric(horizontal: this); - EdgeInsets get tb => EdgeInsets.symmetric(vertical: this); - EdgeInsets get ol => EdgeInsets.only(left: this); - EdgeInsets get or => EdgeInsets.only(left: this); - EdgeInsets get lb => EdgeInsets.only(left: this, bottom: this); - EdgeInsets get lt => EdgeInsets.only(left: this, top: this); - EdgeInsets get rt => EdgeInsets.only(right: this, top: this); - EdgeInsets get et => EdgeInsets.only(left: this, right: this, bottom: this); - EdgeInsets get eb => EdgeInsets.only(left: this, right: this, top: this); - EdgeInsets get el => EdgeInsets.only(right: this, top: this, bottom: this); - EdgeInsets get er => EdgeInsets.only(left: this, top: this, bottom: this); +extension EdgeInsetsAlias on num { + EdgeInsets get all => EdgeInsets.all(this / 1); + EdgeInsets get lr => EdgeInsets.symmetric(horizontal: this / 1); + EdgeInsets get tb => EdgeInsets.symmetric(vertical: this / 1); + EdgeInsets get ol => EdgeInsets.only(left: this / 1); + EdgeInsets get or => EdgeInsets.only(left: this / 1); + EdgeInsets get lb => EdgeInsets.only(left: this / 1, bottom: this / 1); + EdgeInsets get lt => EdgeInsets.only(left: this / 1, top: this / 1); + EdgeInsets get rt => EdgeInsets.only(right: this / 1, top: this / 1); + EdgeInsets get et => + EdgeInsets.only(left: this / 1, right: this / 1, bottom: this / 1); + EdgeInsets get eb => + EdgeInsets.only(left: this / 1, right: this / 1, top: this / 1); + EdgeInsets get el => + EdgeInsets.only(right: this / 1, top: this / 1, bottom: this / 1); + EdgeInsets get er => + EdgeInsets.only(left: this / 1, top: this / 1, bottom: this / 1); } const k8dp = 16.0; diff --git a/example/lib/utils/apply_if_not_null.dart b/example/lib/utils/apply_if_not_null.dart new file mode 100644 index 0000000..ae12359 --- /dev/null +++ b/example/lib/utils/apply_if_not_null.dart @@ -0,0 +1,8 @@ +extension ApplyIfNotNull on T? { + R? apply(R Function(T) f) { + // Local variable to allow automatic type promotion. Also see: + // + final T? self = this; + return (self == null) ? null : f(self); + } +} diff --git a/example/lib/utils/confirm_decorator.dart b/example/lib/utils/confirm_decorator.dart new file mode 100644 index 0000000..88f5926 --- /dev/null +++ b/example/lib/utils/confirm_decorator.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../widgets/confirmation_dialog.dart'; +import 'inline_span.dart'; + +Future Function() confirm( + BuildContext context, + String action, + VoidCallback callback, { + List? message, + String? text, +}) { + assert( + text != null || message != null, + '''You should provide at least one [message] or [text]''', + ); + Future openConfirmationDialog() async { + final result = await showDialog( + context: context, + builder: (context) => ConfirmationDialog( + color: Colors.red, + actionName: action, + body: Text.rich( + TextSpan( + children: [ + if (text != null) normal(text) else ...message!, + ], + ), + ), + ), + ); + + final confirmed = result == true; + + if (confirmed) callback(); + + return confirmed; + } + + return openConfirmationDialog; +} diff --git a/example/lib/utils/disabled_text_style.dart b/example/lib/utils/disabled_text_style.dart new file mode 100644 index 0000000..61e9530 --- /dev/null +++ b/example/lib/utils/disabled_text_style.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +TextStyle disabledTextStyle() { + return TextStyle( + color: disabledColor(), + fontStyle: FontStyle.italic, + ); +} + +Color disabledColor() { + return Colors.black26; +} diff --git a/example/lib/utils/format_bytes.dart b/example/lib/utils/format_bytes.dart new file mode 100644 index 0000000..a0d1948 --- /dev/null +++ b/example/lib/utils/format_bytes.dart @@ -0,0 +1,11 @@ +import 'dart:math'; + +String formatBytes(int bytes, int decimals) { + if (bytes <= 0) return '0 B'; + + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + final i = (log(bytes) / log(1024)).floor(); + + return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; +} diff --git a/example/lib/utils/inline_span.dart b/example/lib/utils/inline_span.dart new file mode 100644 index 0000000..115fea7 --- /dev/null +++ b/example/lib/utils/inline_span.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +InlineSpan Function(Object) customStyleDecorator(TextStyle textStyle) { + InlineSpan applyStyles(Object data) { + if (data is String) { + return TextSpan( + text: data, + style: textStyle, + ); + } + + if (data is TextSpan) { + return TextSpan( + text: data.text, + style: (data.style ?? const TextStyle()).merge(textStyle), + ); + } + + return data as InlineSpan; + } + + return applyStyles; +} + +final bold = customStyleDecorator(const TextStyle(fontWeight: FontWeight.bold)); +final italic = + customStyleDecorator(const TextStyle(fontStyle: FontStyle.italic)); +final red = customStyleDecorator(const TextStyle(color: Colors.red)); +final normal = customStyleDecorator(const TextStyle()); diff --git a/example/lib/utils/mime_types.dart b/example/lib/utils/mime_types.dart new file mode 100644 index 0000000..6a6df5b --- /dev/null +++ b/example/lib/utils/mime_types.dart @@ -0,0 +1,6 @@ +const kTextPlainMime = 'text/plain'; +const kApkMime = 'application/vnd.android.package-archive'; +const kImageMime = 'image/'; +const kTextMime = 'text/'; +const kDirectoryMime = 'vnd.android.document/directory'; +const kVideoMime = 'video/'; diff --git a/example/lib/utils/take_if.dart b/example/lib/utils/take_if.dart new file mode 100644 index 0000000..447844f --- /dev/null +++ b/example/lib/utils/take_if.dart @@ -0,0 +1,7 @@ +extension TakeIf on T { + T? takeIf(bool Function(T) predicate) { + final T self = this; + + return predicate(self) ? this : null; + } +} diff --git a/example/lib/widgets/confirmation_dialog.dart b/example/lib/widgets/confirmation_dialog.dart new file mode 100644 index 0000000..8fe9c1e --- /dev/null +++ b/example/lib/widgets/confirmation_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'buttons.dart'; + +class ConfirmationDialog extends StatefulWidget { + const ConfirmationDialog({ + Key? key, + required this.color, + this.message, + this.body, + required this.actionName, + }) : assert( + message != null || body != null, + '''You should at least provde [message] or body to explain to the user the context of this confirmation''', + ), + super(key: key); + + final Color color; + final String? message; + final Widget? body; + final String actionName; + + @override + State createState() => _ConfirmationDialogState(); +} + +class _ConfirmationDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + content: widget.body ?? Text(widget.message!), + title: const Text('Are you sure?'), + actions: [ + Button('Cancel', onTap: () => Navigator.pop(context, false)), + DangerButton( + widget.actionName, + onTap: () { + Navigator.pop(context, true); + }, + ), + ], + ); + } +} diff --git a/example/lib/widgets/key_value_text.dart b/example/lib/widgets/key_value_text.dart index 1fce051..db600fc 100644 --- a/example/lib/widgets/key_value_text.dart +++ b/example/lib/widgets/key_value_text.dart @@ -1,28 +1,36 @@ import 'package:flutter/material.dart'; +/// Use the entry value as [Widget] to use a [WidgetSpan] and [Text] to use a [TextSpan] class KeyValueText extends StatefulWidget { const KeyValueText({Key? key, required this.entries}) : super(key: key); - final Map entries; + final Map entries; @override _KeyValueTextState createState() => _KeyValueTextState(); } class _KeyValueTextState extends State { - TextSpan _buildTextSpan(String key, String value) { + TextSpan _buildTextSpan(String key, Object value) { return TextSpan( children: [ TextSpan( text: '$key: ', ), - TextSpan( - text: '$value\n', - style: const TextStyle( - fontWeight: FontWeight.bold, - decoration: TextDecoration.underline, + if (value is Widget) + WidgetSpan( + child: value, + alignment: PlaceholderAlignment.middle, + ) + else if (value is String) + TextSpan( + text: value, + style: const TextStyle( + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), ), - ), + const TextSpan(text: '\n'), ], ); } @@ -35,7 +43,7 @@ class _KeyValueTextState extends State { for (final key in widget.entries.keys) _buildTextSpan( key, - '${widget.entries[key]}', + widget.entries[key]!, ), ], ), diff --git a/example/lib/widgets/text_field_dialog.dart b/example/lib/widgets/text_field_dialog.dart new file mode 100644 index 0000000..0ea4eb4 --- /dev/null +++ b/example/lib/widgets/text_field_dialog.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../utils/disabled_text_style.dart'; +import 'buttons.dart'; + +class TextFieldDialog extends StatefulWidget { + const TextFieldDialog({ + Key? key, + required this.labelText, + required this.hintText, + this.suffixText, + required this.actionText, + }) : super(key: key); + + final String labelText; + final String hintText; + final String? suffixText; + final String actionText; + + @override + _TextFieldDialogState createState() => _TextFieldDialogState(); +} + +class _TextFieldDialogState extends State { + late TextEditingController _textFieldController = TextEditingController(); + + @override + void initState() { + super.initState(); + + _textFieldController = TextEditingController(); + } + + @override + void dispose() { + _textFieldController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: TextField( + controller: _textFieldController, + decoration: InputDecoration( + labelText: widget.labelText, + hintText: widget.hintText, + suffixText: widget.suffixText, + suffixStyle: disabledTextStyle(), + ), + ), + actions: [ + DangerButton( + 'Cancel', + onTap: () => Navigator.pop(context), + ), + Button( + widget.actionText, + onTap: () => + Navigator.pop(context, _textFieldController.text), + ), + ], + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 870f4f2..a5c3e65 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,6 +9,7 @@ environment: sdk: ">=2.12.0 <3.0.0" dependencies: + fl_toast: ^3.1.0 flutter: sdk: flutter lint: ^1.8.2 diff --git a/lib/saf.dart b/lib/saf.dart index 1f9768e..191cd91 100644 --- a/lib/saf.dart +++ b/lib/saf.dart @@ -3,6 +3,5 @@ library shared_storage; export './src/saf/document_bitmap.dart'; export './src/saf/document_file.dart'; export './src/saf/document_file_column.dart'; -export './src/saf/partial_document_file.dart'; export './src/saf/saf.dart'; export './src/saf/uri_permission.dart'; diff --git a/lib/src/saf/document_file.dart b/lib/src/saf/document_file.dart index 2e87f81..38ea27c 100644 --- a/lib/src/saf/document_file.dart +++ b/lib/src/saf/document_file.dart @@ -17,38 +17,58 @@ extension UriDocumentFileUtils on Uri { /// [Refer to details](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) class DocumentFile { const DocumentFile({ + required this.id, + required this.parentUri, + required this.size, required this.name, required this.type, required this.uri, required this.isDirectory, required this.isFile, required this.isVirtual, + required this.lastModified, }); factory DocumentFile.fromMap(Map map) { return DocumentFile( - isDirectory: map['isDirectory'] as bool, - isFile: map['isFile'] as bool, - isVirtual: map['isVirtual'] as bool, - name: map['name'] as String, + parentUri: (map['parentUri'] as String?)?.apply((p) => Uri.parse(p)), + id: map['id'] as String?, + isDirectory: map['isDirectory'] as bool?, + isFile: map['isFile'] as bool?, + isVirtual: map['isVirtual'] as bool?, + name: map['name'] as String?, type: map['type'] as String?, uri: Uri.parse(map['uri'] as String), + size: map['size'] as int?, + lastModified: (map['lastModified'] as int?) + ?.apply((l) => DateTime.fromMillisecondsSinceEpoch(l)), ); } - /// Display name of this document file, useful to show as a title in a list of files - final String name; + /// Display name of this document file, useful to show as a title in a list of files. + final String? name; - /// Mimetype of this document file, useful to determine how to display it + /// Mimetype of this document file, useful to determine how to display it. final String? type; - /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API + /// Path, URI, location of this document, it can exists or not, you should check by using `exists()` API. final Uri uri; - /// Whether this document is a directory or not + /// Uri of the parent document of [this] document. + final Uri? parentUri; + + /// Generally represented as `primary:/Some/Resource` and can be used to identify the current document file. /// - /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file - final bool isDirectory; + /// See [this diagram](https://raw.githubusercontent.com/anggrayudi/SimpleStorage/master/art/terminology.png) for details, source: [anggrayudi/SimpleStorage](https://github.com/anggrayudi/SimpleStorage). + final String? id; + + /// Size of a document in bytes + final int? size; + + /// Whether this document is a directory or not. + /// + /// Since it's a [DocumentFile], it can represent a folder/directory rather than a file. + final bool? isDirectory; /// Indicates if this [DocumentFile] represents a _file_. /// @@ -62,14 +82,14 @@ class DocumentFile { /// This identifier is an opaque implementation detail of the provider, and as such it must not be parsed. /// /// [Android Reference](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile#:~:text=androidx.documentfile.provider.DocumentFile,but%20it%20has%20substantial%20overhead.() - final bool isFile; + final bool? isFile; /// Indicates if this file represents a virtual document. /// /// What is a virtual document? /// - [Video answer](https://www.youtube.com/watch?v=4h7yCZt231Y) /// - [Text docs answer](https://developer.android.com/about/versions/nougat/android-7.0#virtual_files) - final bool isVirtual; + final bool? isVirtual; /// {@macro sharedstorage.saf.fromTreeUri} static Future fromTreeUri(Uri uri) => saf.fromTreeUri(uri); @@ -133,7 +153,7 @@ class DocumentFile { Future createFile({ required String mimeType, required String displayName, - String? content, + String content = '', Uint8List? bytes, }) => saf.createFile( @@ -156,7 +176,7 @@ class DocumentFile { displayName: displayName, content: content, ); - + /// {@macro sharedstorage.saf.writeToFileAsBytes} Future writeToFileAsBytes({ required Uint8List bytes, @@ -192,11 +212,8 @@ class DocumentFile { mode: mode, ); - /// {@macro sharedstorage.saf.length} - Future get length => saf.documentLength(uri); - /// {@macro sharedstorage.saf.lastModified} - Future get lastModified => saf.lastModified(uri); + final DateTime? lastModified; /// {@macro sharedstorage.saf.findFile} Future findFile(String displayName) => @@ -211,12 +228,16 @@ class DocumentFile { Map toMap() { return { + 'id': id, + 'uri': '$uri', + 'parentUri': '$parentUri', 'isDirectory': isDirectory, 'isFile': isFile, 'isVirtual': isVirtual, 'name': name, 'type': type, - 'uri': '$uri', + 'size': size, + 'lastModified': lastModified?.millisecondsSinceEpoch, }; } @@ -224,7 +245,9 @@ class DocumentFile { bool operator ==(Object other) { if (other is! DocumentFile) return false; - return isDirectory == other.isDirectory && + return id == other.id && + parentUri == other.parentUri && + isDirectory == other.isDirectory && isFile == other.isFile && isVirtual == other.isVirtual && name == other.name && diff --git a/lib/src/saf/partial_document_file.dart b/lib/src/saf/partial_document_file.dart deleted file mode 100644 index 0efdc4a..0000000 --- a/lib/src/saf/partial_document_file.dart +++ /dev/null @@ -1,96 +0,0 @@ -import '../../saf.dart'; - -/// Represent the same entity as `DocumentFile` but will be lazily loaded -/// by `listFiles` method with dynamic -/// properties and query metadata context -/// -/// _Note: Can't be instantiated_ -class PartialDocumentFile { - const PartialDocumentFile._({required this.data, required this.metadata}); - - factory PartialDocumentFile.fromMap(Map map) { - return PartialDocumentFile._( - data: (() { - final data = map['data'] as Map?; - - if (data == null) return null; - - return { - for (final value in DocumentFileColumn.values) - if (data['$value'] != null) value: data['$value'], - }; - })(), - metadata: QueryMetadata.fromMap(Map.from(map['metadata'] as Map)), - ); - } - - final Map? data; - final QueryMetadata? metadata; - - Map toMap() { - return { - 'data': data, - if (metadata != null) 'metadata': metadata?.toMap(), - }; - } - - @override - bool operator ==(Object other) { - if (other is! PartialDocumentFile) return false; - - return other.data == data && other.metadata == metadata; - } - - @override - int get hashCode => Object.hash(data, metadata); -} - -/// Represents the metadata that the given `PartialDocumentFile` was got by -/// the `contentResolver.query(uri, ...metadata)` method -/// -/// _Note: Can't be instantiated_ -class QueryMetadata { - const QueryMetadata._({ - required this.parentUri, - required this.isDirectory, - required this.uri, - }); - - factory QueryMetadata.fromMap(Map map) { - return QueryMetadata._( - parentUri: _parseUri(map['parentUri'] as String?), - isDirectory: map['isDirectory'] as bool?, - uri: _parseUri(map['uri'] as String?), - ); - } - - final Uri? parentUri; - final bool? isDirectory; - final Uri? uri; - - static Uri? _parseUri(String? uri) { - if (uri == null) return null; - - return Uri.parse(uri); - } - - Map toMap() { - return { - 'parentUri': '$parentUri', - 'isDirectory': isDirectory, - 'uri': uri, - }; - } - - @override - bool operator ==(Object other) { - if (other is! QueryMetadata) return false; - - return other.parentUri == parentUri && - other.isDirectory == isDirectory && - other.uri == uri; - } - - @override - int get hashCode => Object.hash(parentUri, isDirectory, uri); -} diff --git a/lib/src/saf/saf.dart b/lib/src/saf/saf.dart index cea8c55..5532021 100644 --- a/lib/src/saf/saf.dart +++ b/lib/src/saf/saf.dart @@ -144,7 +144,7 @@ Future getDocumentThumbnail({ /// /// [Refer to details](https://stackoverflow.com/questions/41096332/issues-traversing-through-directory-hierarchy-with-android-storage-access-framew). /// {@endtemplate} -Stream listFiles( +Stream listFiles( Uri uri, { required List columns, }) { @@ -157,9 +157,7 @@ Stream listFiles( final onCursorRowResult = kDocumentFileEventChannel.receiveBroadcastStream(args); - return onCursorRowResult - .map((e) => PartialDocumentFile.fromMap(Map.from(e as Map))) - .cast(); + return onCursorRowResult.map((e) => DocumentFile.fromMap(Map.from(e as Map))); } /// {@template sharedstorage.saf.exists} @@ -214,13 +212,8 @@ Future createFile( required String mimeType, required String displayName, Uint8List? bytes, - String? content, + String content = '', }) { - assert( - bytes != null || content != null, - '''Either [bytes] or [content] should be provided''', - ); - return bytes != null ? createFileAsBytes( parentUri, @@ -232,7 +225,7 @@ Future createFile( parentUri, mimeType: mimeType, displayName: displayName, - content: content!, + content: content, ); }