From bbb7f0f0c0cd891518418472955c8a2ac6cbca4d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 19:11:07 +0100 Subject: [PATCH] data_transfer_flutter 0.2.0: exportTextFile + mime types Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4 --- data_transfer_flutter/README.md | 3 +- .../lib/src/json_type_group.dart | 16 +++++++ data_transfer_flutter/lib/src/platform.dart | 12 ++++- .../lib/src/platform_io.dart | 33 +++++++++---- .../lib/src/platform_web.dart | 10 ++-- data_transfer_flutter/lib/src/transfer.dart | 14 +++++- data_transfer_flutter/pubspec.yaml | 2 +- .../test/io_platform_test.dart | 48 ++++++++++++++----- .../test/support/fake_platform.dart | 6 +++ data_transfer_flutter/test/transfer_test.dart | 20 ++++++++ sync_settings_ui/pubspec.yaml | 4 +- sync_settings_ui/test/backup_slot_test.dart | 2 + 12 files changed, 140 insertions(+), 30 deletions(-) diff --git a/data_transfer_flutter/README.md b/data_transfer_flutter/README.md index c62ac54..83df58f 100644 --- a/data_transfer_flutter/README.md +++ b/data_transfer_flutter/README.md @@ -11,7 +11,7 @@ dependencies: data_transfer_flutter: git: url: https://github.com/kuhyx/utils - ref: data_transfer_flutter-v0.1.0 + ref: data_transfer_flutter-v0.2.0 path: data_transfer_flutter ``` @@ -26,6 +26,7 @@ final map = await pickJson(); // null when cancelled use the system "Save as" document picker (`file_picker` `saveFile`, i.e. ACTION_CREATE_DOCUMENT), desktop a save dialog (`file_selector`), web a browser download. Returns false when cancelled. +- `exportTextFile` does the same for any text (`text/csv`, `text/calendar`). - `shareJson` is an optional second action (share sheet). It is never the only way out: a stock phone's share sheet has no local save target. - `pickJson` / `pickJsonText` use the system open-document picker. diff --git a/data_transfer_flutter/lib/src/json_type_group.dart b/data_transfer_flutter/lib/src/json_type_group.dart index 004f765..be3e38c 100644 --- a/data_transfer_flutter/lib/src/json_type_group.dart +++ b/data_transfer_flutter/lib/src/json_type_group.dart @@ -9,3 +9,19 @@ const jsonTypeGroup = XTypeGroup( uniformTypeIdentifiers: ['public.json'], mimeTypes: ['application/json'], ); + +/// The save-dialog filter for [fileName] holding [mimeType] data. +/// +/// Keyed on the file's own extension so a CSV export is not offered under a +/// JSON filter; a name with no extension accepts any type. +XTypeGroup saveTypeGroup(String fileName, String mimeType) { + final dot = fileName.lastIndexOf('.'); + final extension = dot > 0 && dot < fileName.length - 1 + ? fileName.substring(dot + 1).toLowerCase() + : null; + return XTypeGroup( + label: extension?.toUpperCase(), + extensions: extension == null ? null : [extension], + mimeTypes: [mimeType], + ); +} diff --git a/data_transfer_flutter/lib/src/platform.dart b/data_transfer_flutter/lib/src/platform.dart index 2fd986d..3da3b22 100644 --- a/data_transfer_flutter/lib/src/platform.dart +++ b/data_transfer_flutter/lib/src/platform.dart @@ -9,14 +9,22 @@ abstract interface class DataTransferPlatform { /// Android/iOS: the system "Save as" document picker (device storage). /// Desktop: a save dialog. Web: a browser download. Returns false when the /// user cancelled. - Future saveText({required String fileName, required String text}); + Future saveText({ + required String fileName, + required String text, + String mimeType = 'application/json', + }); /// Offers [text] as [fileName] to the share sheet, where the host has one. /// /// A second action only: a share sheet can route the data to cloud apps, so /// it never replaces [saveText]. Hosts without a sheet fall back to /// [saveText]. - Future shareText({required String fileName, required String text}); + Future shareText({ + required String fileName, + required String text, + String mimeType = 'application/json', + }); /// Opens the system open-document picker and returns the file's text, or /// null when the user cancelled. diff --git a/data_transfer_flutter/lib/src/platform_io.dart b/data_transfer_flutter/lib/src/platform_io.dart index 32fa4b1..5537bef 100644 --- a/data_transfer_flutter/lib/src/platform_io.dart +++ b/data_transfer_flutter/lib/src/platform_io.dart @@ -12,11 +12,19 @@ import 'package:share_plus/share_plus.dart'; /// Writes [bytes] through the system "Save as" picker; false on cancel. typedef DocumentSaver = - Future Function({required String fileName, required Uint8List bytes}); + Future Function({ + required String fileName, + required Uint8List bytes, + required String mimeType, + }); /// Offers [text] as a file named [fileName] to the share sheet. typedef SheetSharer = - Future Function({required String fileName, required String text}); + Future Function({ + required String fileName, + required String text, + required String mimeType, + }); /// The platform for hosts that have `dart:io`. DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform(); @@ -46,16 +54,18 @@ class IoDataTransferPlatform implements DataTransferPlatform { Future saveText({ required String fileName, required String text, + String mimeType = 'application/json', }) async { if (_isMobile) { return await _documentSaver( fileName: fileName, bytes: Uint8List.fromList(utf8.encode(text)), + mimeType: mimeType, ); } final location = await getSaveLocation( suggestedName: fileName, - acceptedTypeGroups: const [jsonTypeGroup], + acceptedTypeGroups: [saveTypeGroup(fileName, mimeType)], ); if (location == null) return false; await File(location.path).writeAsString(text); @@ -63,10 +73,13 @@ class IoDataTransferPlatform implements DataTransferPlatform { } @override - Future shareText({required String fileName, required String text}) => - _isMobile - ? _sheetSharer(fileName: fileName, text: text) - : saveText(fileName: fileName, text: text); + Future shareText({ + required String fileName, + required String text, + String mimeType = 'application/json', + }) => _isMobile + ? _sheetSharer(fileName: fileName, text: text, mimeType: mimeType) + : saveText(fileName: fileName, text: text, mimeType: mimeType); @override Future pickText() async { @@ -81,11 +94,12 @@ class IoDataTransferPlatform implements DataTransferPlatform { Future _saveViaDocumentPicker({ required String fileName, required Uint8List bytes, + required String mimeType, }) async { final saved = await FilePicker.saveFile( fileName: fileName, bytes: bytes, - mimeType: 'application/json', + mimeType: mimeType, ); return saved != null; } @@ -93,12 +107,13 @@ Future _saveViaDocumentPicker({ Future _shareViaSheet({ required String fileName, required String text, + required String mimeType, }) async { final dir = await getTemporaryDirectory(); final file = File(p.join(dir.path, fileName)); await file.writeAsString(text); await SharePlus.instance.share( - ShareParams(files: [XFile(file.path, mimeType: 'application/json')]), + ShareParams(files: [XFile(file.path, mimeType: mimeType)]), ); return true; } diff --git a/data_transfer_flutter/lib/src/platform_web.dart b/data_transfer_flutter/lib/src/platform_web.dart index dff5312..31b0ca0 100644 --- a/data_transfer_flutter/lib/src/platform_web.dart +++ b/data_transfer_flutter/lib/src/platform_web.dart @@ -16,20 +16,24 @@ class WebDataTransferPlatform implements DataTransferPlatform { Future saveText({ required String fileName, required String text, + String mimeType = 'application/json', }) async { // `saveTo` on web clicks a synthetic anchor: the path is ignored and the // browser's download UI decides where the file lands. await XFile.fromData( utf8.encode(text), - mimeType: 'application/json', + mimeType: mimeType, name: fileName, ).saveTo(''); return true; } @override - Future shareText({required String fileName, required String text}) => - saveText(fileName: fileName, text: text); + Future shareText({ + required String fileName, + required String text, + String mimeType = 'application/json', + }) => saveText(fileName: fileName, text: text, mimeType: mimeType); @override Future pickText() async { diff --git a/data_transfer_flutter/lib/src/transfer.dart b/data_transfer_flutter/lib/src/transfer.dart index c7b010f..96616f3 100644 --- a/data_transfer_flutter/lib/src/transfer.dart +++ b/data_transfer_flutter/lib/src/transfer.dart @@ -21,7 +21,19 @@ Future exportJson({ /// [exportJson] for an already-encoded JSON string. Future exportJsonText({required String fileName, required String text}) => - DataTransfer.platform.saveText(fileName: fileName, text: text); + exportTextFile(fileName: fileName, text: text); + +/// Saves any text file ([mimeType], e.g. `text/csv`) to device storage, with +/// the same host behaviour as [exportJson]. False when cancelled. +Future exportTextFile({ + required String fileName, + required String text, + String mimeType = 'application/json', +}) => DataTransfer.platform.saveText( + fileName: fileName, + text: text, + mimeType: mimeType, +); /// Optional second action: offers [json] to the share sheet. Future shareJson({ diff --git a/data_transfer_flutter/pubspec.yaml b/data_transfer_flutter/pubspec.yaml index af1fd9c..6410959 100644 --- a/data_transfer_flutter/pubspec.yaml +++ b/data_transfer_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: >- Flutter apps: system Save-as on Android/iOS (never cloud-only), a save dialog on desktop, a download on web, plus a versioned export envelope. publish_to: 'none' -version: 0.1.0 +version: 0.2.0 environment: sdk: ^3.12.2 diff --git a/data_transfer_flutter/test/io_platform_test.dart b/data_transfer_flutter/test/io_platform_test.dart index fd8e9ee..94b45b5 100644 --- a/data_transfer_flutter/test/io_platform_test.dart +++ b/data_transfer_flutter/test/io_platform_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:data_transfer_flutter/src/json_type_group.dart'; import 'package:data_transfer_flutter/src/platform_io.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -46,6 +47,17 @@ void main() { group('desktop', () { final desktop = IoDataTransferPlatform(isMobile: false); + test('the save dialog is typed by the file name and mime type', () { + final group = saveTypeGroup('punchme.csv', 'text/csv'); + + expect(group.extensions, ['csv']); + expect(group.mimeTypes, ['text/csv']); + }); + + test('a name without an extension accepts any type', () { + expect(saveTypeGroup('README', 'text/plain').extensions, isNull); + }); + test('save writes to the chosen path and suggests the name', () async { final path = '${temp.path}/out.json'; selector.saveLocation = FileSaveLocation(path); @@ -91,20 +103,28 @@ void main() { 'save hands UTF-8 bytes to the document saver, not file_selector', () async { String? name; + String? mime; List? captured; final mobile = IoDataTransferPlatform( isMobile: true, - documentSaver: ({required fileName, required bytes}) async { - name = fileName; - captured = bytes; - return true; - }, + documentSaver: + ({required fileName, required bytes, required mimeType}) async { + name = fileName; + mime = mimeType; + captured = bytes; + return true; + }, ); - final saved = await mobile.saveText(fileName: 'm.json', text: 'zażółć'); + final saved = await mobile.saveText( + fileName: 'm.json', + text: 'zażółć', + mimeType: 'text/csv', + ); expect(saved, isTrue); expect(name, 'm.json'); + expect(mime, 'text/csv'); expect(utf8.decode(captured!), 'zażółć'); expect(selector.saveRequests, isEmpty); }, @@ -113,7 +133,9 @@ void main() { test('a cancelled document picker reports false', () async { final mobile = IoDataTransferPlatform( isMobile: true, - documentSaver: ({required fileName, required bytes}) async => false, + documentSaver: + ({required fileName, required bytes, required mimeType}) async => + false, ); expect(await mobile.saveText(fileName: 'm.json', text: '{}'), isFalse); @@ -121,16 +143,20 @@ void main() { test('share goes through the share sheet seam', () async { (String, String)? seen; + String? sharedMime; final mobile = IoDataTransferPlatform( isMobile: true, - sheetSharer: ({required fileName, required text}) async { - seen = (fileName, text); - return true; - }, + sheetSharer: + ({required fileName, required text, required mimeType}) async { + seen = (fileName, text); + sharedMime = mimeType; + return true; + }, ); expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue); expect(seen, ('s.json', '1')); + expect(sharedMime, 'application/json'); }); }); } diff --git a/data_transfer_flutter/test/support/fake_platform.dart b/data_transfer_flutter/test/support/fake_platform.dart index 0fd10a7..ab89f74 100644 --- a/data_transfer_flutter/test/support/fake_platform.dart +++ b/data_transfer_flutter/test/support/fake_platform.dart @@ -11,6 +11,9 @@ class FakePlatform implements DataTransferPlatform { /// `(fileName, text)` of every [saveText] call. final saves = <(String, String)>[]; + /// The mime type of every [saveText] call, in order. + final saveMimes = []; + /// `(fileName, text)` of every [shareText] call. final shares = <(String, String)>[]; @@ -18,8 +21,10 @@ class FakePlatform implements DataTransferPlatform { Future saveText({ required String fileName, required String text, + String mimeType = 'application/json', }) async { saves.add((fileName, text)); + saveMimes.add(mimeType); return accept; } @@ -27,6 +32,7 @@ class FakePlatform implements DataTransferPlatform { Future shareText({ required String fileName, required String text, + String mimeType = 'application/json', }) async { shares.add((fileName, text)); return accept; diff --git a/data_transfer_flutter/test/transfer_test.dart b/data_transfer_flutter/test/transfer_test.dart index d3399a0..1806160 100644 --- a/data_transfer_flutter/test/transfer_test.dart +++ b/data_transfer_flutter/test/transfer_test.dart @@ -45,6 +45,26 @@ void main() { }); }); + group('exportTextFile', () { + test('saves non-JSON text with its own mime type', () async { + final saved = await exportTextFile( + fileName: 'a.csv', + text: 'a,b\n', + mimeType: 'text/csv', + ); + + expect(saved, isTrue); + expect(fake.saves.single, ('a.csv', 'a,b\n')); + expect(fake.saveMimes.single, 'text/csv'); + }); + + test('exportJsonText keeps the JSON mime type', () async { + await exportJsonText(fileName: 'a.json', text: '{}'); + + expect(fake.saveMimes.single, 'application/json'); + }); + }); + group('shareJson', () { test('uses the share action, not save', () async { final shared = await shareJson(fileName: 'c.json', json: {'k': 2}); diff --git a/sync_settings_ui/pubspec.yaml b/sync_settings_ui/pubspec.yaml index 340fd41..6f5161f 100644 --- a/sync_settings_ui/pubspec.yaml +++ b/sync_settings_ui/pubspec.yaml @@ -5,7 +5,7 @@ description: >- injected closures -- no keystore or platform plugin access lives in this package itself. publish_to: 'none' -version: 0.5.0 +version: 0.5.1 environment: sdk: ^3.12.2 @@ -19,7 +19,7 @@ dependencies: data_transfer_flutter: git: url: https://github.com/kuhyx/utils - ref: data_transfer_flutter-v0.1.0 + ref: data_transfer_flutter-v0.2.0 path: data_transfer_flutter flutter: sdk: flutter diff --git a/sync_settings_ui/test/backup_slot_test.dart b/sync_settings_ui/test/backup_slot_test.dart index defdd51..1ce2655 100644 --- a/sync_settings_ui/test/backup_slot_test.dart +++ b/sync_settings_ui/test/backup_slot_test.dart @@ -85,6 +85,7 @@ class _FakePlatform implements DataTransferPlatform { Future saveText({ required String fileName, required String text, + String mimeType = 'application/json', }) async { saved.add((fileName, text)); return true; @@ -94,6 +95,7 @@ class _FakePlatform implements DataTransferPlatform { Future shareText({ required String fileName, required String text, + String mimeType = 'application/json', }) async => true; @override