mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
888e8ae57d
commit
5be9a22970
36
.github/workflows/data-transfer-flutter-tests.yml
vendored
Normal file
36
.github/workflows/data-transfer-flutter-tests.yml
vendored
Normal 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
6
data_transfer_flutter/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
|
coverage/
|
||||||
|
*.iml
|
||||||
66
data_transfer_flutter/.pre-commit-config.yaml
Normal file
66
data_transfer_flutter/.pre-commit-config.yaml
Normal 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]
|
||||||
37
data_transfer_flutter/README.md
Normal file
37
data_transfer_flutter/README.md
Normal 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`.
|
||||||
38
data_transfer_flutter/analysis_options.yaml
Normal file
38
data_transfer_flutter/analysis_options.yaml
Normal 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
|
||||||
7
data_transfer_flutter/lib/data_transfer_flutter.dart
Normal file
7
data_transfer_flutter/lib/data_transfer_flutter.dart
Normal 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';
|
||||||
118
data_transfer_flutter/lib/src/envelope.dart
Normal file
118
data_transfer_flutter/lib/src/envelope.dart
Normal 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);
|
||||||
|
}
|
||||||
11
data_transfer_flutter/lib/src/json_type_group.dart
Normal file
11
data_transfer_flutter/lib/src/json_type_group.dart
Normal 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'],
|
||||||
|
);
|
||||||
24
data_transfer_flutter/lib/src/platform.dart
Normal file
24
data_transfer_flutter/lib/src/platform.dart
Normal 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();
|
||||||
|
}
|
||||||
3
data_transfer_flutter/lib/src/platform_default.dart
Normal file
3
data_transfer_flutter/lib/src/platform_default.dart
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export 'platform_io.dart'
|
||||||
|
if (dart.library.js_interop) 'platform_web.dart'
|
||||||
|
show createDefaultPlatform;
|
||||||
105
data_transfer_flutter/lib/src/platform_io.dart
Normal file
105
data_transfer_flutter/lib/src/platform_io.dart
Normal 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
|
||||||
39
data_transfer_flutter/lib/src/platform_web.dart
Normal file
39
data_transfer_flutter/lib/src/platform_web.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
data_transfer_flutter/lib/src/transfer.dart
Normal file
53
data_transfer_flutter/lib/src/transfer.dart
Normal 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();
|
||||||
28
data_transfer_flutter/pubspec.yaml
Normal file
28
data_transfer_flutter/pubspec.yaml
Normal 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:
|
||||||
32
data_transfer_flutter/scripts/check_coverage.sh
Executable file
32
data_transfer_flutter/scripts/check_coverage.sh
Executable 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 "$@"
|
||||||
182
data_transfer_flutter/test/envelope_test.dart
Normal file
182
data_transfer_flutter/test/envelope_test.dart
Normal 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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
136
data_transfer_flutter/test/io_platform_test.dart
Normal file
136
data_transfer_flutter/test/io_platform_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
37
data_transfer_flutter/test/support/fake_platform.dart
Normal file
37
data_transfer_flutter/test/support/fake_platform.dart
Normal 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;
|
||||||
|
}
|
||||||
93
data_transfer_flutter/test/transfer_test.dart
Normal file
93
data_transfer_flutter/test/transfer_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user