data_transfer_flutter 0.1.0: shared JSON export/import

Save-as on mobile (SAF), save dialog on desktop, download on web; versioned envelope helper.

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:04:32 +01:00
parent 888e8ae57d
commit 5be9a22970
19 changed files with 1051 additions and 0 deletions

View File

@ -0,0 +1,36 @@
name: data_transfer_flutter tests
on:
push:
branches: [main]
paths: ['data_transfer_flutter/**']
pull_request:
branches: [main]
paths: ['data_transfer_flutter/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: data_transfer_flutter
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

6
data_transfer_flutter/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml

View File

@ -0,0 +1,66 @@
# Pre-commit Configuration for data_transfer_flutter
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config data_transfer_flutter/.pre-commit-config.yaml --all-files
files: ^data_transfer_flutter/
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh data_transfer_flutter
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: no-crdt-sync-override
name: no local dependency_overrides on a utils git dependency
entry: scripts/check_no_crdt_sync_override.sh data_transfer_flutter/pubspec.yaml
language: script
pass_filenames: false
- id: dart-format
name: dart format --set-exit-if-changed
entry: bash -c 'cd data_transfer_flutter && dart format --set-exit-if-changed lib test'
language: system
pass_filenames: false
- id: flutter-analyze
name: flutter analyze
entry: bash -c 'cd data_transfer_flutter && flutter analyze'
language: system
pass_filenames: false
- id: flutter-test-coverage
name: flutter test --coverage with 100% line-coverage gate
entry: >-
bash -c 'cd data_transfer_flutter && flutter test --coverage &&
scripts/check_coverage.sh coverage/lcov.info 100'
language: system
pass_filenames: false
require_serial: true
stages: [pre-push]

View File

@ -0,0 +1,37 @@
# data_transfer_flutter
Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's
Flutter apps. Apps keep their own export CONTENT; this owns the file plumbing
and the envelope.
## Install
```yaml
dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.1.0
path: data_transfer_flutter
```
## Use
```dart
final saved = await exportJson(fileName: 'x.json', json: envelope);
final map = await pickJson(); // null when cancelled
```
- `exportJson` / `exportJsonText` save to **device storage**: Android and iOS
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.
- `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.
- `buildEnvelope` / `validateEnvelope` / `parseEnvelope` write and check
`{format, version, exported_at, ...}`; mismatches throw a typed
`EnvelopeException` (always a `FormatException`).
Tests fake `DataTransfer.platform` (a `DataTransferPlatform`); restore it in
`tearDown`.

View File

@ -0,0 +1,38 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -0,0 +1,7 @@
/// Shared JSON export/import plumbing: save, share, pick and a versioned
/// envelope.
library;
export 'src/envelope.dart';
export 'src/platform.dart';
export 'src/transfer.dart';

View File

@ -0,0 +1,118 @@
import 'dart:convert';
/// Why a file is not an acceptable export envelope.
///
/// Always a [FormatException], so callers that only care about "bad file"
/// can keep catching that.
sealed class EnvelopeException implements FormatException {
const EnvelopeException(this.message);
@override
final String message;
@override
dynamic get source => null;
@override
int? get offset => null;
@override
String toString() => 'EnvelopeException: $message';
}
/// The text is not JSON at all.
final class EnvelopeInvalidJson extends EnvelopeException {
/// Creates the error.
const EnvelopeInvalidJson() : super('not valid JSON');
}
/// The JSON is valid but its top level is not an object.
final class EnvelopeNotObject extends EnvelopeException {
/// Creates the error.
const EnvelopeNotObject() : super('export is not a JSON object');
}
/// The `format` marker is missing or names another kind of file.
final class EnvelopeFormatMismatch extends EnvelopeException {
/// Creates the error for a file marked [actual] when [expected] was needed.
EnvelopeFormatMismatch({required this.expected, required this.actual})
: super('not a $expected file (found "$actual")');
/// The format the caller accepts.
final String expected;
/// The format the file declared, `null` when it declared none.
final Object? actual;
}
/// The `version` is missing or differs from the one the caller reads.
final class EnvelopeVersionMismatch extends EnvelopeException {
/// Creates the error for a file at version [actual] when [expected] was
/// needed.
EnvelopeVersionMismatch({required this.expected, required this.actual})
: super('unsupported version $actual (expected $expected)');
/// The version the caller reads.
final int expected;
/// The version the file declared, `null` when it declared none.
final Object? actual;
}
/// Builds `{format, version, exported_at, ...body}`, in that key order.
///
/// [now] is converted to UTC. Throws [ArgumentError] when [body] reuses an
/// envelope key, which would silently corrupt the marker.
Map<String, Object?> buildEnvelope({
required String format,
required int version,
required DateTime now,
Map<String, Object?> body = const {},
}) {
const reserved = {'format', 'version', 'exported_at'};
final clash = body.keys.where(reserved.contains).toList();
if (clash.isNotEmpty) {
throw ArgumentError.value(body, 'body', 'reuses envelope keys: $clash');
}
return <String, Object?>{
'format': format,
'version': version,
'exported_at': now.toUtc().toIso8601String(),
...body,
};
}
/// Returns [decoded] when it is an object marked [format] at [version].
///
/// Throws an [EnvelopeException] subtype otherwise.
Map<String, Object?> validateEnvelope(
Object? decoded, {
required String format,
required int version,
}) {
if (decoded is! Map<String, Object?>) throw const EnvelopeNotObject();
final actualFormat = decoded['format'];
if (actualFormat != format) {
throw EnvelopeFormatMismatch(expected: format, actual: actualFormat);
}
final actualVersion = decoded['version'];
if (actualVersion != version) {
throw EnvelopeVersionMismatch(expected: version, actual: actualVersion);
}
return decoded;
}
/// Decodes [text] and runs [validateEnvelope] on it.
Map<String, Object?> parseEnvelope(
String text, {
required String format,
required int version,
}) {
final Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException {
throw const EnvelopeInvalidJson();
}
return validateEnvelope(decoded, format: format, version: version);
}

View File

@ -0,0 +1,11 @@
import 'package:file_selector/file_selector.dart';
/// The type group the open and save dialogs restrict to.
const jsonTypeGroup = XTypeGroup(
label: 'JSON',
extensions: ['json'],
// Needed by the macOS/iOS pickers and harmless elsewhere; a group with no
// recognised identifier silently matches nothing on those hosts.
uniformTypeIdentifiers: ['public.json'],
mimeTypes: ['application/json'],
);

View File

@ -0,0 +1,24 @@
/// What differs per host: where the bytes of an export go and come from.
///
/// The seam tests fake. Real implementations live in `platform_io.dart`
/// (Android, iOS, desktop) and `platform_web.dart`, chosen by
/// `platform_default.dart` so no `dart:io` import reaches a web build.
abstract interface class DataTransferPlatform {
/// Saves [text] as [fileName] where the user can find it again.
///
/// 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});
/// 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});
/// Opens the system open-document picker and returns the file's text, or
/// null when the user cancelled.
Future<String?> pickText();
}

View File

@ -0,0 +1,3 @@
export 'platform_io.dart'
if (dart.library.js_interop) 'platform_web.dart'
show createDefaultPlatform;

View File

@ -0,0 +1,105 @@
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.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
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});
/// Offers [text] as a file named [fileName] to the share sheet.
typedef SheetSharer =
Future<bool> Function({required String fileName, required String text});
/// The platform for hosts that have `dart:io`.
DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform();
/// Android, iOS and desktop.
///
/// Mobile saves through the Storage Access Framework / document picker
/// (`file_picker`'s `saveFile`), because the share sheet on a stock phone has
/// no local save target and an export that can only be shared is an upload.
/// Desktop uses `file_selector`'s save dialog. Opening is `file_selector`'s
/// `openFile` everywhere: ACTION_OPEN_DOCUMENT on Android.
class IoDataTransferPlatform implements DataTransferPlatform {
/// Creates the platform; every argument is a test seam.
IoDataTransferPlatform({
bool? isMobile,
DocumentSaver? documentSaver,
SheetSharer? sheetSharer,
}) : _isMobile = isMobile ?? (Platform.isAndroid || Platform.isIOS),
_documentSaver = documentSaver ?? _saveViaDocumentPicker,
_sheetSharer = sheetSharer ?? _shareViaSheet;
final bool _isMobile;
final DocumentSaver _documentSaver;
final SheetSharer _sheetSharer;
@override
Future<bool> saveText({
required String fileName,
required String text,
}) async {
if (_isMobile) {
return await _documentSaver(
fileName: fileName,
bytes: Uint8List.fromList(utf8.encode(text)),
);
}
final location = await getSaveLocation(
suggestedName: fileName,
acceptedTypeGroups: const [jsonTypeGroup],
);
if (location == null) return false;
await File(location.path).writeAsString(text);
return true;
}
@override
Future<bool> shareText({required String fileName, required String text}) =>
_isMobile
? _sheetSharer(fileName: fileName, text: text)
: saveText(fileName: fileName, text: text);
@override
Future<String?> pickText() async {
final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]);
return await file?.readAsString();
}
}
// coverage:ignore-start
// Real plugin calls: unreachable on the Linux test host (a platform channel
// answers nothing there) and faked at the typedef seams above instead.
Future<bool> _saveViaDocumentPicker({
required String fileName,
required Uint8List bytes,
}) async {
final saved = await FilePicker.saveFile(
fileName: fileName,
bytes: bytes,
mimeType: 'application/json',
);
return saved != null;
}
Future<bool> _shareViaSheet({
required String fileName,
required String text,
}) 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')]),
);
return true;
}
// coverage:ignore-end

View File

@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:data_transfer_flutter/src/json_type_group.dart';
import 'package:data_transfer_flutter/src/platform.dart';
import 'package:file_selector/file_selector.dart';
/// The browser's platform: downloads and the open-file dialog.
DataTransferPlatform createDefaultPlatform() => const WebDataTransferPlatform();
/// Browser implementation. No share sheet; download covers both actions.
class WebDataTransferPlatform implements DataTransferPlatform {
/// Creates the platform.
const WebDataTransferPlatform();
@override
Future<bool> saveText({
required String fileName,
required String text,
}) 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',
name: fileName,
).saveTo('');
return true;
}
@override
Future<bool> shareText({required String fileName, required String text}) =>
saveText(fileName: fileName, text: text);
@override
Future<String?> pickText() async {
final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]);
return await file?.readAsString();
}
}

View File

@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:data_transfer_flutter/src/envelope.dart';
import 'package:data_transfer_flutter/src/platform.dart';
import 'package:data_transfer_flutter/src/platform_default.dart';
/// Holds the platform the top-level functions use; tests replace it.
abstract final class DataTransfer {
/// The active platform. Defaults to the host's real implementation.
static DataTransferPlatform platform = createDefaultPlatform();
}
const _pretty = JsonEncoder.withIndent(' ');
/// Saves [json] as [fileName] to device storage (see
/// [DataTransferPlatform.saveText]). False when the user cancelled.
Future<bool> exportJson({
required String fileName,
required Map<String, Object?> json,
}) => exportJsonText(fileName: fileName, text: _pretty.convert(json));
/// [exportJson] for an already-encoded JSON string.
Future<bool> exportJsonText({required String fileName, required String text}) =>
DataTransfer.platform.saveText(fileName: fileName, text: text);
/// Optional second action: offers [json] to the share sheet.
Future<bool> shareJson({
required String fileName,
required Map<String, Object?> json,
}) => DataTransfer.platform.shareText(
fileName: fileName,
text: _pretty.convert(json),
);
/// Lets the user pick a JSON file and decodes it; null when cancelled.
///
/// Throws [EnvelopeInvalidJson] / [EnvelopeNotObject] for a file that is not
/// a JSON object.
Future<Map<String, Object?>?> pickJson() async {
final text = await pickJsonText();
if (text == null) return null;
final Object? decoded;
try {
decoded = jsonDecode(text);
} on FormatException {
throw const EnvelopeInvalidJson();
}
if (decoded is! Map<String, Object?>) throw const EnvelopeNotObject();
return decoded;
}
/// Lets the user pick a file and returns its raw text; null when cancelled.
Future<String?> pickJsonText() => DataTransfer.platform.pickText();

View File

@ -0,0 +1,28 @@
name: data_transfer_flutter
description: >-
Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's
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
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.1
file_selector: 1.1.0
flutter:
sdk: flutter
path: 1.9.1
path_provider: 2.1.6
share_plus: 13.3.1
dev_dependencies:
file_selector_platform_interface: 2.7.0
flutter_test:
sdk: flutter
plugin_platform_interface: 2.1.8
very_good_analysis: ^11.0.0
flutter:

View File

@ -0,0 +1,32 @@
#!/bin/bash
# ============================================================================
# Fail if lcov line coverage drops below a threshold.
#
# Thin delegate to the shared gate in ~/src/utils/scripts, which owns the parsing
# and the threshold comparison. This file used to be a byte-identical copy of
# that logic; three such copies existed, which is how one package's idea of
# "covered" drifts from every other package's.
#
# Usage: check_coverage.sh <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
# Repo-relative, resolved from this script's own location: an absolute
# /home/... path exits 127 on an Actions runner.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh"
main() {
if [[ ! -f "$SHARED_GATE" ]]; then
echo "Error: shared coverage gate not found at $SHARED_GATE" >&2
echo " It lives in the utils repo at scripts/check_coverage.sh." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,182 @@
import 'dart:convert';
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final now = DateTime.utc(2026, 11, 1, 12, 30);
group('buildEnvelope', () {
test('puts format, version and a UTC timestamp first', () {
final envelope = buildEnvelope(
format: 'demo_export',
version: 2,
now: now,
body: {
'items': <Object?>[1, 2],
},
);
expect(envelope.keys, ['format', 'version', 'exported_at', 'items']);
expect(envelope['format'], 'demo_export');
expect(envelope['version'], 2);
expect(envelope['exported_at'], '2026-11-01T12:30:00.000Z');
expect(envelope['items'], [1, 2]);
});
test('converts a local clock to UTC', () {
final local = DateTime.parse('2026-11-01T12:30:00+02:00');
final envelope = buildEnvelope(format: 'f', version: 1, now: local);
expect(envelope['exported_at'], '2026-11-01T10:30:00.000Z');
});
test('refuses a body that shadows an envelope key', () {
expect(
() => buildEnvelope(
format: 'f',
version: 1,
now: now,
body: {'version': 9},
),
throwsArgumentError,
);
});
});
group('validateEnvelope', () {
final good = <String, Object?>{
'format': 'demo_export',
'version': 1,
'exported_at': 'x',
'rest': true,
};
test('returns the same map when format and version match', () {
final out = validateEnvelope(good, format: 'demo_export', version: 1);
expect(out, same(good));
});
test('rejects a non-object as not an object', () {
for (final value in <Object?>[null, 3, 'text', <Object?>[]]) {
expect(
() => validateEnvelope(value, format: 'demo_export', version: 1),
throwsA(isA<EnvelopeNotObject>()),
);
}
});
test('rejects a foreign format and reports both names', () {
expect(
() => validateEnvelope(
{...good, 'format': 'other'},
format: 'demo_export',
version: 1,
),
throwsA(
isA<EnvelopeFormatMismatch>()
.having((e) => e.expected, 'expected', 'demo_export')
.having((e) => e.actual, 'actual', 'other')
.having((e) => e.message, 'message', contains('demo_export')),
),
);
});
test('rejects a missing format', () {
expect(
() =>
validateEnvelope({'version': 1}, format: 'demo_export', version: 1),
throwsA(isA<EnvelopeFormatMismatch>()),
);
});
test('rejects another version and reports both numbers', () {
expect(
() => validateEnvelope(
{...good, 'version': 7},
format: 'demo_export',
version: 1,
),
throwsA(
isA<EnvelopeVersionMismatch>()
.having((e) => e.expected, 'expected', 1)
.having((e) => e.actual, 'actual', 7)
.having((e) => e.message, 'message', contains('7')),
),
);
});
test('rejects a missing version', () {
expect(
() => validateEnvelope(
{'format': 'demo_export'},
format: 'demo_export',
version: 1,
),
throwsA(isA<EnvelopeVersionMismatch>()),
);
});
test('every error is a FormatException', () {
expect(
() => validateEnvelope(null, format: 'f', version: 1),
throwsFormatException,
);
expect(
() => validateEnvelope({'format': 'g'}, format: 'f', version: 1),
throwsFormatException,
);
expect(
() => validateEnvelope(
{'format': 'f', 'version': 2},
format: 'f',
version: 1,
),
throwsFormatException,
);
});
});
group('EnvelopeException', () {
test('behaves as a FormatException with no source position', () {
const FormatException error = EnvelopeInvalidJson();
expect(error.message, 'not valid JSON');
expect(error.source, isNull);
expect(error.offset, isNull);
expect(error.toString(), contains('not valid JSON'));
});
});
group('parseEnvelope', () {
test('round-trips what buildEnvelope produced', () {
final text = jsonEncode(
buildEnvelope(format: 'f', version: 1, now: now, body: {'n': 1}),
);
final parsed = parseEnvelope(text, format: 'f', version: 1);
expect(parsed['n'], 1);
});
test('reports malformed JSON as an invalid-JSON error', () {
expect(
() => parseEnvelope('{oops', format: 'f', version: 1),
throwsA(isA<EnvelopeInvalidJson>()),
);
});
test('runs the format and version checks on valid JSON', () {
expect(
() => parseEnvelope(
'{"format":"g","version":1}',
format: 'f',
version: 1,
),
throwsA(isA<EnvelopeFormatMismatch>()),
);
});
});
}

View File

@ -0,0 +1,136 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class _FakeSelector extends FileSelectorPlatform
with MockPlatformInterfaceMixin {
FileSaveLocation? saveLocation;
XFile? fileToOpen;
final saveRequests = <String?>[];
@override
Future<FileSaveLocation?> getSaveLocation({
List<XTypeGroup>? acceptedTypeGroups,
SaveDialogOptions options = const SaveDialogOptions(),
}) async {
saveRequests.add(options.suggestedName);
return saveLocation;
}
@override
Future<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async => fileToOpen;
}
void main() {
late _FakeSelector selector;
late Directory temp;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
selector = _FakeSelector();
FileSelectorPlatform.instance = selector;
temp = await Directory.systemTemp.createTemp('data_transfer_io');
});
tearDown(() => temp.delete(recursive: true));
group('desktop', () {
final desktop = IoDataTransferPlatform(isMobile: false);
test('save writes to the chosen path and suggests the name', () async {
final path = '${temp.path}/out.json';
selector.saveLocation = FileSaveLocation(path);
final saved = await desktop.saveText(fileName: 'x.json', text: '{}');
expect(saved, isTrue);
expect(File(path).readAsStringSync(), '{}');
expect(selector.saveRequests, ['x.json']);
});
test('a cancelled save writes nothing and returns false', () async {
final saved = await desktop.saveText(fileName: 'x.json', text: '{}');
expect(saved, isFalse);
expect(temp.listSync(), isEmpty);
});
test('share falls back to the save dialog', () async {
final path = '${temp.path}/shared.json';
selector.saveLocation = FileSaveLocation(path);
final shared = await desktop.shareText(fileName: 'x.json', text: '[]');
expect(shared, isTrue);
expect(File(path).readAsStringSync(), '[]');
});
test('pick reads the chosen file, null when cancelled', () async {
expect(await desktop.pickText(), isNull);
selector.fileToOpen = XFile.fromData(
Uint8List.fromList('{"a":1}'.codeUnits),
name: 'in.json',
);
expect(await desktop.pickText(), '{"a":1}');
});
});
group('mobile', () {
test(
'save hands UTF-8 bytes to the document saver, not file_selector',
() async {
String? name;
List<int>? captured;
final mobile = IoDataTransferPlatform(
isMobile: true,
documentSaver: ({required fileName, required bytes}) async {
name = fileName;
captured = bytes;
return true;
},
);
final saved = await mobile.saveText(fileName: 'm.json', text: 'zażółć');
expect(saved, isTrue);
expect(name, 'm.json');
expect(utf8.decode(captured!), 'zażółć');
expect(selector.saveRequests, isEmpty);
},
);
test('a cancelled document picker reports false', () async {
final mobile = IoDataTransferPlatform(
isMobile: true,
documentSaver: ({required fileName, required bytes}) async => false,
);
expect(await mobile.saveText(fileName: 'm.json', text: '{}'), isFalse);
});
test('share goes through the share sheet seam', () async {
(String, String)? seen;
final mobile = IoDataTransferPlatform(
isMobile: true,
sheetSharer: ({required fileName, required text}) async {
seen = (fileName, text);
return true;
},
);
expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue);
expect(seen, ('s.json', '1'));
});
});
}

View File

@ -0,0 +1,37 @@
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
/// Records what the facade asked the platform to do.
class FakePlatform implements DataTransferPlatform {
/// What every save/share returns; false stands for a cancelled dialog.
bool accept = true;
/// What [pickText] returns; null stands for a cancelled picker.
String? picked;
/// `(fileName, text)` of every [saveText] call.
final saves = <(String, String)>[];
/// `(fileName, text)` of every [shareText] call.
final shares = <(String, String)>[];
@override
Future<bool> saveText({
required String fileName,
required String text,
}) async {
saves.add((fileName, text));
return accept;
}
@override
Future<bool> shareText({
required String fileName,
required String text,
}) async {
shares.add((fileName, text));
return accept;
}
@override
Future<String?> pickText() async => picked;
}

View File

@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'support/fake_platform.dart';
void main() {
late FakePlatform fake;
late DataTransferPlatform original;
setUp(() {
original = DataTransfer.platform;
fake = FakePlatform();
DataTransfer.platform = fake;
});
tearDown(() => DataTransfer.platform = original);
group('exportJson', () {
test('saves the map as indented JSON under the given name', () async {
final saved = await exportJson(fileName: 'a.json', json: {'k': 1});
expect(saved, isTrue);
expect(fake.saves.single.$1, 'a.json');
expect(
fake.saves.single.$2,
const JsonEncoder.withIndent(' ').convert({'k': 1}),
);
expect(fake.shares, isEmpty);
});
test('returns false when the dialog was cancelled', () async {
fake.accept = false;
expect(await exportJson(fileName: 'a.json', json: {}), isFalse);
});
});
group('exportJsonText', () {
test('passes an already-encoded string through untouched', () async {
await exportJsonText(fileName: 'b.json', text: '{"x":1}');
expect(fake.saves.single, ('b.json', '{"x":1}'));
});
});
group('shareJson', () {
test('uses the share action, not save', () async {
final shared = await shareJson(fileName: 'c.json', json: {'k': 2});
expect(shared, isTrue);
expect(fake.shares.single.$1, 'c.json');
expect(jsonDecode(fake.shares.single.$2), {'k': 2});
expect(fake.saves, isEmpty);
});
});
group('pickJson', () {
test('decodes the picked file', () async {
fake.picked = '{"a":[1,2]}';
expect(await pickJson(), {
'a': [1, 2],
});
});
test('returns null when the picker was cancelled', () async {
expect(await pickJson(), isNull);
expect(await pickJsonText(), isNull);
});
test('rejects a file that is not JSON', () async {
fake.picked = '{oops';
await expectLater(pickJson, throwsA(isA<EnvelopeInvalidJson>()));
});
test('rejects JSON that is not an object', () async {
fake.picked = '[1]';
await expectLater(pickJson, throwsA(isA<EnvelopeNotObject>()));
});
});
group('pickJsonText', () {
test('returns the raw text without decoding it', () async {
fake.picked = 'not even json';
expect(await pickJsonText(), 'not even json');
});
});
}