data_transfer_flutter 0.2.0: exportTextFile + mime types

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 19:11:07 +01:00
parent 4c417472ec
commit bbb7f0f0c0
12 changed files with 140 additions and 30 deletions

View File

@ -11,7 +11,7 @@ dependencies:
data_transfer_flutter: data_transfer_flutter:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.1.0 ref: data_transfer_flutter-v0.2.0
path: data_transfer_flutter 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. use the system "Save as" document picker (`file_picker` `saveFile`, i.e.
ACTION_CREATE_DOCUMENT), desktop a save dialog (`file_selector`), web a ACTION_CREATE_DOCUMENT), desktop a save dialog (`file_selector`), web a
browser download. Returns false when cancelled. 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 - `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. only way out: a stock phone's share sheet has no local save target.
- `pickJson` / `pickJsonText` use the system open-document picker. - `pickJson` / `pickJsonText` use the system open-document picker.

View File

@ -9,3 +9,19 @@ const jsonTypeGroup = XTypeGroup(
uniformTypeIdentifiers: ['public.json'], uniformTypeIdentifiers: ['public.json'],
mimeTypes: ['application/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],
);
}

View File

@ -9,14 +9,22 @@ abstract interface class DataTransferPlatform {
/// Android/iOS: the system "Save as" document picker (device storage). /// Android/iOS: the system "Save as" document picker (device storage).
/// Desktop: a save dialog. Web: a browser download. Returns false when the /// Desktop: a save dialog. Web: a browser download. Returns false when the
/// user cancelled. /// user cancelled.
Future<bool> saveText({required String fileName, required String text}); Future<bool> saveText({
required String fileName,
required String text,
String mimeType = 'application/json',
});
/// Offers [text] as [fileName] to the share sheet, where the host has one. /// 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 /// 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 /// it never replaces [saveText]. Hosts without a sheet fall back to
/// [saveText]. /// [saveText].
Future<bool> shareText({required String fileName, required String text}); Future<bool> shareText({
required String fileName,
required String text,
String mimeType = 'application/json',
});
/// Opens the system open-document picker and returns the file's text, or /// Opens the system open-document picker and returns the file's text, or
/// null when the user cancelled. /// null when the user cancelled.

View File

@ -12,11 +12,19 @@ import 'package:share_plus/share_plus.dart';
/// Writes [bytes] through the system "Save as" picker; false on cancel. /// Writes [bytes] through the system "Save as" picker; false on cancel.
typedef DocumentSaver = typedef DocumentSaver =
Future<bool> Function({required String fileName, required Uint8List bytes}); Future<bool> Function({
required String fileName,
required Uint8List bytes,
required String mimeType,
});
/// Offers [text] as a file named [fileName] to the share sheet. /// Offers [text] as a file named [fileName] to the share sheet.
typedef SheetSharer = typedef SheetSharer =
Future<bool> Function({required String fileName, required String text}); Future<bool> Function({
required String fileName,
required String text,
required String mimeType,
});
/// The platform for hosts that have `dart:io`. /// The platform for hosts that have `dart:io`.
DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform(); DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform();
@ -46,16 +54,18 @@ class IoDataTransferPlatform implements DataTransferPlatform {
Future<bool> saveText({ Future<bool> saveText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async { }) async {
if (_isMobile) { if (_isMobile) {
return await _documentSaver( return await _documentSaver(
fileName: fileName, fileName: fileName,
bytes: Uint8List.fromList(utf8.encode(text)), bytes: Uint8List.fromList(utf8.encode(text)),
mimeType: mimeType,
); );
} }
final location = await getSaveLocation( final location = await getSaveLocation(
suggestedName: fileName, suggestedName: fileName,
acceptedTypeGroups: const [jsonTypeGroup], acceptedTypeGroups: [saveTypeGroup(fileName, mimeType)],
); );
if (location == null) return false; if (location == null) return false;
await File(location.path).writeAsString(text); await File(location.path).writeAsString(text);
@ -63,10 +73,13 @@ class IoDataTransferPlatform implements DataTransferPlatform {
} }
@override @override
Future<bool> shareText({required String fileName, required String text}) => Future<bool> shareText({
_isMobile required String fileName,
? _sheetSharer(fileName: fileName, text: text) required String text,
: saveText(fileName: fileName, text: text); String mimeType = 'application/json',
}) => _isMobile
? _sheetSharer(fileName: fileName, text: text, mimeType: mimeType)
: saveText(fileName: fileName, text: text, mimeType: mimeType);
@override @override
Future<String?> pickText() async { Future<String?> pickText() async {
@ -81,11 +94,12 @@ class IoDataTransferPlatform implements DataTransferPlatform {
Future<bool> _saveViaDocumentPicker({ Future<bool> _saveViaDocumentPicker({
required String fileName, required String fileName,
required Uint8List bytes, required Uint8List bytes,
required String mimeType,
}) async { }) async {
final saved = await FilePicker.saveFile( final saved = await FilePicker.saveFile(
fileName: fileName, fileName: fileName,
bytes: bytes, bytes: bytes,
mimeType: 'application/json', mimeType: mimeType,
); );
return saved != null; return saved != null;
} }
@ -93,12 +107,13 @@ Future<bool> _saveViaDocumentPicker({
Future<bool> _shareViaSheet({ Future<bool> _shareViaSheet({
required String fileName, required String fileName,
required String text, required String text,
required String mimeType,
}) async { }) async {
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final file = File(p.join(dir.path, fileName)); final file = File(p.join(dir.path, fileName));
await file.writeAsString(text); await file.writeAsString(text);
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams(files: [XFile(file.path, mimeType: 'application/json')]), ShareParams(files: [XFile(file.path, mimeType: mimeType)]),
); );
return true; return true;
} }

View File

@ -16,20 +16,24 @@ class WebDataTransferPlatform implements DataTransferPlatform {
Future<bool> saveText({ Future<bool> saveText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async { }) async {
// `saveTo` on web clicks a synthetic anchor: the path is ignored and the // `saveTo` on web clicks a synthetic anchor: the path is ignored and the
// browser's download UI decides where the file lands. // browser's download UI decides where the file lands.
await XFile.fromData( await XFile.fromData(
utf8.encode(text), utf8.encode(text),
mimeType: 'application/json', mimeType: mimeType,
name: fileName, name: fileName,
).saveTo(''); ).saveTo('');
return true; return true;
} }
@override @override
Future<bool> shareText({required String fileName, required String text}) => Future<bool> shareText({
saveText(fileName: fileName, text: text); required String fileName,
required String text,
String mimeType = 'application/json',
}) => saveText(fileName: fileName, text: text, mimeType: mimeType);
@override @override
Future<String?> pickText() async { Future<String?> pickText() async {

View File

@ -21,7 +21,19 @@ Future<bool> exportJson({
/// [exportJson] for an already-encoded JSON string. /// [exportJson] for an already-encoded JSON string.
Future<bool> exportJsonText({required String fileName, required String text}) => Future<bool> 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<bool> 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. /// Optional second action: offers [json] to the share sheet.
Future<bool> shareJson({ Future<bool> shareJson({

View File

@ -4,7 +4,7 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save 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. dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none' publish_to: 'none'
version: 0.1.0 version: 0.2.0
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; 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:data_transfer_flutter/src/platform_io.dart';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -46,6 +47,17 @@ void main() {
group('desktop', () { group('desktop', () {
final desktop = IoDataTransferPlatform(isMobile: false); 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 { test('save writes to the chosen path and suggests the name', () async {
final path = '${temp.path}/out.json'; final path = '${temp.path}/out.json';
selector.saveLocation = FileSaveLocation(path); selector.saveLocation = FileSaveLocation(path);
@ -91,20 +103,28 @@ void main() {
'save hands UTF-8 bytes to the document saver, not file_selector', 'save hands UTF-8 bytes to the document saver, not file_selector',
() async { () async {
String? name; String? name;
String? mime;
List<int>? captured; List<int>? captured;
final mobile = IoDataTransferPlatform( final mobile = IoDataTransferPlatform(
isMobile: true, isMobile: true,
documentSaver: ({required fileName, required bytes}) async { documentSaver:
name = fileName; ({required fileName, required bytes, required mimeType}) async {
captured = bytes; name = fileName;
return true; 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(saved, isTrue);
expect(name, 'm.json'); expect(name, 'm.json');
expect(mime, 'text/csv');
expect(utf8.decode(captured!), 'zażółć'); expect(utf8.decode(captured!), 'zażółć');
expect(selector.saveRequests, isEmpty); expect(selector.saveRequests, isEmpty);
}, },
@ -113,7 +133,9 @@ void main() {
test('a cancelled document picker reports false', () async { test('a cancelled document picker reports false', () async {
final mobile = IoDataTransferPlatform( final mobile = IoDataTransferPlatform(
isMobile: true, 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); expect(await mobile.saveText(fileName: 'm.json', text: '{}'), isFalse);
@ -121,16 +143,20 @@ void main() {
test('share goes through the share sheet seam', () async { test('share goes through the share sheet seam', () async {
(String, String)? seen; (String, String)? seen;
String? sharedMime;
final mobile = IoDataTransferPlatform( final mobile = IoDataTransferPlatform(
isMobile: true, isMobile: true,
sheetSharer: ({required fileName, required text}) async { sheetSharer:
seen = (fileName, text); ({required fileName, required text, required mimeType}) async {
return true; seen = (fileName, text);
}, sharedMime = mimeType;
return true;
},
); );
expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue); expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue);
expect(seen, ('s.json', '1')); expect(seen, ('s.json', '1'));
expect(sharedMime, 'application/json');
}); });
}); });
} }

View File

@ -11,6 +11,9 @@ class FakePlatform implements DataTransferPlatform {
/// `(fileName, text)` of every [saveText] call. /// `(fileName, text)` of every [saveText] call.
final saves = <(String, String)>[]; final saves = <(String, String)>[];
/// The mime type of every [saveText] call, in order.
final saveMimes = <String>[];
/// `(fileName, text)` of every [shareText] call. /// `(fileName, text)` of every [shareText] call.
final shares = <(String, String)>[]; final shares = <(String, String)>[];
@ -18,8 +21,10 @@ class FakePlatform implements DataTransferPlatform {
Future<bool> saveText({ Future<bool> saveText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async { }) async {
saves.add((fileName, text)); saves.add((fileName, text));
saveMimes.add(mimeType);
return accept; return accept;
} }
@ -27,6 +32,7 @@ class FakePlatform implements DataTransferPlatform {
Future<bool> shareText({ Future<bool> shareText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async { }) async {
shares.add((fileName, text)); shares.add((fileName, text));
return accept; return accept;

View File

@ -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', () { group('shareJson', () {
test('uses the share action, not save', () async { test('uses the share action, not save', () async {
final shared = await shareJson(fileName: 'c.json', json: {'k': 2}); final shared = await shareJson(fileName: 'c.json', json: {'k': 2});

View File

@ -5,7 +5,7 @@ description: >-
injected closures -- no keystore or platform plugin access lives in this injected closures -- no keystore or platform plugin access lives in this
package itself. package itself.
publish_to: 'none' publish_to: 'none'
version: 0.5.0 version: 0.5.1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@ -19,7 +19,7 @@ dependencies:
data_transfer_flutter: data_transfer_flutter:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.1.0 ref: data_transfer_flutter-v0.2.0
path: data_transfer_flutter path: data_transfer_flutter
flutter: flutter:
sdk: flutter sdk: flutter

View File

@ -85,6 +85,7 @@ class _FakePlatform implements DataTransferPlatform {
Future<bool> saveText({ Future<bool> saveText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async { }) async {
saved.add((fileName, text)); saved.add((fileName, text));
return true; return true;
@ -94,6 +95,7 @@ class _FakePlatform implements DataTransferPlatform {
Future<bool> shareText({ Future<bool> shareText({
required String fileName, required String fileName,
required String text, required String text,
String mimeType = 'application/json',
}) async => true; }) async => true;
@override @override