diff --git a/billsplit/lib/ui/home_screen.dart b/billsplit/lib/ui/home_screen.dart index dcdec10..04c4e81 100644 --- a/billsplit/lib/ui/home_screen.dart +++ b/billsplit/lib/ui/home_screen.dart @@ -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(); 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); diff --git a/billsplit/lib/ui/receipt_screen.dart b/billsplit/lib/ui/receipt_screen.dart index 0f881eb..c307d21 100644 --- a/billsplit/lib/ui/receipt_screen.dart +++ b/billsplit/lib/ui/receipt_screen.dart @@ -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 Function( + String path, + Uint8List bytes, + String mimeType, +); + +/// The active export writer. Replaced in tests via [debugSetExportWriter]. +ExportWriter writeExport = _writeExportToDisk; + +Future _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 { 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) { diff --git a/billsplit/linux/flutter/generated_plugin_registrant.cc b/billsplit/linux/flutter/generated_plugin_registrant.cc index e71a16d..64a0ece 100644 --- a/billsplit/linux/flutter/generated_plugin_registrant.cc +++ b/billsplit/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/billsplit/linux/flutter/generated_plugins.cmake b/billsplit/linux/flutter/generated_plugins.cmake index be1ee3e..a630fa9 100644 --- a/billsplit/linux/flutter/generated_plugins.cmake +++ b/billsplit/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift index 774a6b8..14b5f7c 100644 --- a/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/billsplit/pubspec.lock b/billsplit/pubspec.lock index 141cfb7..9593591 100644 --- a/billsplit/pubspec.lock +++ b/billsplit/pubspec.lock @@ -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" diff --git a/billsplit/pubspec.yaml b/billsplit/pubspec.yaml index c940289..869f870 100644 --- a/billsplit/pubspec.yaml +++ b/billsplit/pubspec.yaml @@ -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: diff --git a/billsplit/test/helpers.dart b/billsplit/test/helpers.dart index 597fc2a..d341bb1 100644 --- a/billsplit/test/helpers.dart +++ b/billsplit/test/helpers.dart @@ -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 openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async { + if (pickResult == null) return null; + return XFile.fromData(pickResult!, name: pickName ?? 'picked.json'); + } + + @override + Future getSaveLocation({ + List? 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 >[ - { - '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 exportedFiles = {}; + /// 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. diff --git a/billsplit/test/xlsx_extra_test.dart b/billsplit/test/xlsx_extra_test.dart index 9d5516f..fcf912d 100644 --- a/billsplit/test/xlsx_extra_test.dart +++ b/billsplit/test/xlsx_extra_test.dart @@ -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); + }); } diff --git a/billsplit/windows/flutter/generated_plugin_registrant.cc b/billsplit/windows/flutter/generated_plugin_registrant.cc index 8b6d468..77ab7a0 100644 --- a/billsplit/windows/flutter/generated_plugin_registrant.cc +++ b/billsplit/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); } diff --git a/billsplit/windows/flutter/generated_plugins.cmake b/billsplit/windows/flutter/generated_plugins.cmake index 3ad69c6..277a56f 100644 --- a/billsplit/windows/flutter/generated_plugins.cmake +++ b/billsplit/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST