mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
4c417472ec
commit
bbb7f0f0c0
@ -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.
|
||||
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<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.
|
||||
///
|
||||
/// 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<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
|
||||
/// null when the user cancelled.
|
||||
|
||||
@ -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<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.
|
||||
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`.
|
||||
DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform();
|
||||
@ -46,16 +54,18 @@ class IoDataTransferPlatform implements DataTransferPlatform {
|
||||
Future<bool> 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<bool> shareText({required String fileName, required String text}) =>
|
||||
_isMobile
|
||||
? _sheetSharer(fileName: fileName, text: text)
|
||||
: saveText(fileName: fileName, text: text);
|
||||
Future<bool> 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<String?> pickText() async {
|
||||
@ -81,11 +94,12 @@ class IoDataTransferPlatform implements DataTransferPlatform {
|
||||
Future<bool> _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<bool> _saveViaDocumentPicker({
|
||||
Future<bool> _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;
|
||||
}
|
||||
|
||||
@ -16,20 +16,24 @@ class WebDataTransferPlatform implements DataTransferPlatform {
|
||||
Future<bool> 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<bool> shareText({required String fileName, required String text}) =>
|
||||
saveText(fileName: fileName, text: text);
|
||||
Future<bool> shareText({
|
||||
required String fileName,
|
||||
required String text,
|
||||
String mimeType = 'application/json',
|
||||
}) => saveText(fileName: fileName, text: text, mimeType: mimeType);
|
||||
|
||||
@override
|
||||
Future<String?> pickText() async {
|
||||
|
||||
@ -21,7 +21,19 @@ Future<bool> exportJson({
|
||||
|
||||
/// [exportJson] for an already-encoded JSON string.
|
||||
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.
|
||||
Future<bool> shareJson({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<int>? 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 = <String>[];
|
||||
|
||||
/// `(fileName, text)` of every [shareText] call.
|
||||
final shares = <(String, String)>[];
|
||||
|
||||
@ -18,8 +21,10 @@ class FakePlatform implements DataTransferPlatform {
|
||||
Future<bool> 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<bool> shareText({
|
||||
required String fileName,
|
||||
required String text,
|
||||
String mimeType = 'application/json',
|
||||
}) async {
|
||||
shares.add((fileName, text));
|
||||
return accept;
|
||||
|
||||
@ -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});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -85,6 +85,7 @@ class _FakePlatform implements DataTransferPlatform {
|
||||
Future<bool> 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<bool> shareText({
|
||||
required String fileName,
|
||||
required String text,
|
||||
String mimeType = 'application/json',
|
||||
}) async => true;
|
||||
|
||||
@override
|
||||
|
||||
Loading…
Reference in New Issue
Block a user