mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 18:07:09 +01:00
billsplit: build a real APK by dropping file_picker for file_selector
billsplit had never produced an Android APK. file_picker 11.0.3 — the latest release on pub.dev — is wedged under AGP 9 in both directions: it skips applying the Kotlin Gradle Plugin when AGP >= 9, so with built-in Kotlin off its own Kotlin never compiles (`cannot find symbol: FilePickerPlugin`), and with built-in Kotlin on its buildscript leaks KGP onto the global classpath via rootProject.allprojects, which AGP then rejects for every module. The app's Gradle config was never at fault: android/app/build.gradle.kts and settings.gradle.kts are byte-identical to habit_stack, home_inventory and diet_guard_app, which all build. Only the dependency differed, so the fix is the dependency — the toolchain stays on the house-standard AGP 9. Ported both call sites to the official file_selector. Its save flow returns a destination and leaves the write to the caller, where file_picker wrote the bytes inside the plugin; that write now goes through an injectable seam because a real dart:io future never completes inside testWidgets' fake-async zone, so a widget test could otherwise never observe the resulting snackbar. The test double moves from a MethodChannel mock to a FileSelectorPlatform fake (file_selector speaks Pigeon, so a channel mock would not intercept it), and a plain non-widget test covers the real on-disk writer to hold 100% coverage without an ignore. The launcher icon is now verified from the built artifact: the ic_launcher assets unzipped from the APK render as the charcoal + gold perforated receipt, not the stock Flutter beach. Peak build footprint is 1.86 GiB, inside budget. Also rewrites ci_mirror.sh to run its gates in a disposable worktree at HEAD. `pre-commit run --all-files` stashes the live tree, and in a shared checkout that made an unrelated agent's uncommitted work collide with hook auto-fixes, failing the push while blaming an innocent hook — and putting that work inside a stash window, which memories/git-rules.md forbids. A clean worktree needs no stash and is a truer mirror of CI, which never sees a dirty file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ecf6ad594
commit
5c52de0682
@ -6,7 +6,7 @@ import 'package:billsplit/domain/models.dart';
|
||||
import 'package:billsplit/state/app_state.dart';
|
||||
import 'package:billsplit/ui/people_screen.dart';
|
||||
import 'package:billsplit/ui/receipt_screen.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@ -122,13 +122,17 @@ class HomeScreen extends StatelessWidget {
|
||||
final state = context.read<AppState>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nav = Navigator.of(context);
|
||||
final picked = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
withData: true,
|
||||
const jsonGroup = XTypeGroup(
|
||||
label: 'e-paragon',
|
||||
extensions: ['json'],
|
||||
// Android matches on MIME rather than extension; without this the
|
||||
// picker greys out every file.
|
||||
mimeTypes: ['application/json'],
|
||||
uniformTypeIdentifiers: ['public.json'],
|
||||
);
|
||||
final data = picked?.files.firstOrNull?.bytes;
|
||||
if (data == null) return;
|
||||
final picked = await openFile(acceptedTypeGroups: [jsonGroup]);
|
||||
if (picked == null) return;
|
||||
final data = await picked.readAsBytes();
|
||||
try {
|
||||
final result = parseEParagonDetailed(utf8.decode(data));
|
||||
state.addReceipt(result.receipt);
|
||||
|
||||
@ -4,11 +4,40 @@ import 'package:billsplit/domain/xlsx_export.dart';
|
||||
import 'package:billsplit/state/app_state.dart';
|
||||
import 'package:billsplit/ui/assignment_sheet.dart';
|
||||
import 'package:billsplit/ui/item_edit.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Writes exported [bytes] to [path]; the seam the export flow writes through.
|
||||
///
|
||||
/// Kept injectable because `file_selector` only returns a destination — the
|
||||
/// app performs the write itself, where `file_picker` did it inside the
|
||||
/// plugin. Real `dart:io` futures never complete inside `testWidgets`'
|
||||
/// fake-async zone, so a widget test that exercised the real write could never
|
||||
/// observe the resulting snackbar. Tests swap this for an in-memory stub.
|
||||
typedef ExportWriter = Future<void> Function(
|
||||
String path,
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
);
|
||||
|
||||
/// The active export writer. Replaced in tests via [debugSetExportWriter].
|
||||
ExportWriter writeExport = _writeExportToDisk;
|
||||
|
||||
Future<void> _writeExportToDisk(
|
||||
String path,
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) =>
|
||||
XFile.fromData(bytes, mimeType: mimeType).saveTo(path);
|
||||
|
||||
/// Overrides [writeExport]; pass null to restore the real disk writer.
|
||||
@visibleForTesting
|
||||
void debugSetExportWriter(ExportWriter? writer) {
|
||||
writeExport = writer ?? _writeExportToDisk;
|
||||
}
|
||||
|
||||
/// One receipt: item list with assignments, totals, participants, export.
|
||||
class ReceiptScreen extends StatefulWidget {
|
||||
/// Creates the screen for the receipt with [receiptId].
|
||||
@ -154,16 +183,23 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
|
||||
RegExp(r'[^\w\d ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'),
|
||||
'_',
|
||||
);
|
||||
final path = await FilePicker.saveFile(
|
||||
dialogTitle: 'Save split as .xlsx',
|
||||
fileName: '$safe split.xlsx',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx'],
|
||||
bytes: bytes,
|
||||
const xlsxGroup = XTypeGroup(
|
||||
label: 'Excel workbook',
|
||||
extensions: ['xlsx'],
|
||||
mimeTypes: [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
);
|
||||
if (path != null) {
|
||||
final location = await getSaveLocation(
|
||||
suggestedName: '$safe split.xlsx',
|
||||
acceptedTypeGroups: [xlsxGroup],
|
||||
);
|
||||
if (location != null) {
|
||||
// file_selector hands back a destination and leaves the writing to the
|
||||
// caller, unlike file_picker which wrote the bytes inside the plugin.
|
||||
await writeExport(location.path, bytes, xlsxGroup.mimeTypes!.first);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Saved: ${path.split('/').last}')),
|
||||
SnackBar(content: Text('Saved: ${location.path.split('/').last}')),
|
||||
);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
}
|
||||
|
||||
@ -97,14 +97,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -137,14 +129,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file_picker:
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e"
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.3"
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+9"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+5"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_platform_interface:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -166,14 +214,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.35"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -192,6 +232,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -517,14 +573,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -551,4 +599,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
@ -8,13 +8,14 @@ environment:
|
||||
|
||||
dependencies:
|
||||
excel: ^4.0.6
|
||||
file_picker: ^11.0.0
|
||||
file_selector: ^1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
path_provider: ^2.1.4
|
||||
provider: ^6.1.2
|
||||
|
||||
dev_dependencies:
|
||||
file_selector_platform_interface: ^2.7.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_test:
|
||||
|
||||
@ -1,19 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:billsplit/state/app_state.dart';
|
||||
import 'package:billsplit/ui/receipt_screen.dart';
|
||||
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _pickerChannel = MethodChannel('miguelruivo.flutter.plugins.filepicker');
|
||||
const _pathChannel = MethodChannel('plugins.flutter.io/path_provider');
|
||||
|
||||
/// Mocks the file_picker method channel.
|
||||
/// Fake [FileSelectorPlatform] driving open/save dialogs in tests.
|
||||
///
|
||||
/// `pickResult`: bytes returned by pickFiles (null → user cancelled).
|
||||
/// `saveResult`: path returned by saveFile (null → user cancelled).
|
||||
/// `saveThrows`: saveFile raises a PlatformException.
|
||||
/// file_selector is swapped at the platform-interface level rather than at the
|
||||
/// method channel: the real implementation talks Pigeon, so a MethodChannel
|
||||
/// mock would not intercept it.
|
||||
class _FakeFileSelector extends FileSelectorPlatform {
|
||||
_FakeFileSelector({
|
||||
this.pickResult,
|
||||
this.pickName,
|
||||
this.saveResult,
|
||||
this.saveThrows = false,
|
||||
});
|
||||
|
||||
final Uint8List? pickResult;
|
||||
final String? pickName;
|
||||
final String? saveResult;
|
||||
final bool saveThrows;
|
||||
|
||||
@override
|
||||
Future<XFile?> openFile({
|
||||
List<XTypeGroup>? acceptedTypeGroups,
|
||||
String? initialDirectory,
|
||||
String? confirmButtonText,
|
||||
}) async {
|
||||
if (pickResult == null) return null;
|
||||
return XFile.fromData(pickResult!, name: pickName ?? 'picked.json');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FileSaveLocation?> getSaveLocation({
|
||||
List<XTypeGroup>? acceptedTypeGroups,
|
||||
SaveDialogOptions options = const SaveDialogOptions(),
|
||||
}) async {
|
||||
if (saveThrows) {
|
||||
throw PlatformException(code: 'boom', message: 'disk full');
|
||||
}
|
||||
if (saveResult == null) return null;
|
||||
return FileSaveLocation(saveResult!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a fake file_selector platform for the current test.
|
||||
///
|
||||
/// `pickResult`: bytes returned by openFile (null → user cancelled).
|
||||
/// `saveResult`: path returned by getSaveLocation (null → user cancelled).
|
||||
/// `saveThrows`: getSaveLocation raises a PlatformException.
|
||||
void mockFilePicker({
|
||||
Uint8List? pickResult,
|
||||
String? pickName,
|
||||
@ -21,27 +63,24 @@ void mockFilePicker({
|
||||
bool saveThrows = false,
|
||||
}) {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_pickerChannel, (call) async {
|
||||
if (call.method == 'save') {
|
||||
if (saveThrows) {
|
||||
throw PlatformException(code: 'boom', message: 'disk full');
|
||||
}
|
||||
return saveResult;
|
||||
}
|
||||
// pickFiles ('custom'/'any'/...)
|
||||
if (pickResult == null) return null;
|
||||
return <Map<dynamic, dynamic>>[
|
||||
{
|
||||
'path': '/tmp/${pickName ?? 'picked.json'}',
|
||||
'name': pickName ?? 'picked.json',
|
||||
'size': pickResult.length,
|
||||
'bytes': pickResult,
|
||||
},
|
||||
];
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(
|
||||
pickResult: pickResult,
|
||||
pickName: pickName,
|
||||
saveResult: saveResult,
|
||||
saveThrows: saveThrows,
|
||||
);
|
||||
// Record the write instead of touching the disk: a real dart:io future never
|
||||
// completes inside testWidgets' fake-async zone, so the export flow would
|
||||
// stall before showing its snackbar.
|
||||
exportedFiles.clear();
|
||||
debugSetExportWriter((path, bytes, mimeType) async {
|
||||
exportedFiles[path] = bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/// Paths and payloads captured by the stub export writer.
|
||||
final Map<String, Uint8List> exportedFiles = <String, Uint8List>{};
|
||||
|
||||
/// Points path_provider's documents directory at [dir].
|
||||
void mockPathProvider(Directory dir) {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@ -49,11 +88,12 @@ void mockPathProvider(Directory dir) {
|
||||
.setMockMethodCallHandler(_pathChannel, (call) async => dir.path);
|
||||
}
|
||||
|
||||
/// Clears both channel mocks.
|
||||
/// Clears the path_provider channel mock and the file_selector fake.
|
||||
void clearChannelMocks() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
..setMockMethodCallHandler(_pickerChannel, null)
|
||||
..setMockMethodCallHandler(_pathChannel, null);
|
||||
.setMockMethodCallHandler(_pathChannel, null);
|
||||
FileSelectorPlatform.instance = _FakeFileSelector();
|
||||
debugSetExportWriter(null);
|
||||
}
|
||||
|
||||
/// Creates an [AppState] persisted under a fresh temp directory.
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:billsplit/domain/models.dart';
|
||||
import 'package:billsplit/domain/xlsx_export.dart';
|
||||
import 'package:billsplit/ui/receipt_screen.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@ -33,4 +37,20 @@ void main() {
|
||||
expect(bytes.length, greaterThan(1000));
|
||||
expect(bytes[0], 0x50);
|
||||
});
|
||||
|
||||
// Exercises the REAL disk writer, deliberately as a plain `test` rather than
|
||||
// a `testWidgets`: a real dart:io future never completes inside the
|
||||
// fake-async zone, which is the whole reason the export flow writes through
|
||||
// an injectable seam.
|
||||
test('the real export writer puts the bytes on disk', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('billsplit_export');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
final target = '${dir.path}/split.xlsx';
|
||||
final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]);
|
||||
|
||||
debugSetExportWriter(null); // restore the real writer
|
||||
await writeExport(target, payload, 'application/vnd.ms-excel');
|
||||
|
||||
expect(File(target).readAsBytesSync(), payload);
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Loading…
Reference in New Issue
Block a user