diff --git a/CHANGELOG.md b/CHANGELOG.md index bf1afaf..2300746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed +- Database now stored in `Application Support` directory instead of `Documents`; `drift_flutter` defaulted to `getApplicationDocumentsDirectory()` on desktop, placing `bms_local.sqlite` in `~/Documents/` which is inappropriate for app data; switched to `LazyDatabase` + `NativeDatabase` with `getApplicationSupportDirectory()` (macOS: `~/Library/Application Support/lk.getbms.bms/bms_local.sqlite`) +- Import Database file picker no longer causes macOS window to go black and become unresponsive; inverted flow so `NSOpenPanel` opens first, then a confirmation dialog shows the selected filename - avoids the dialog-to-native-panel transition that caused the Metal renderer to lose focus +- MySQL sync `suppliers` table: removed incorrect `credit_limit` column (belongs to customers, not suppliers), added `payment_terms` and `is_active` to match the Drift schema +- MySQL sync `customers` table: added missing `is_active` column to match the Drift schema - EULA screen now always displays in English regardless of the selected app language; all UI strings (title, buttons, checkbox, dialogs) are hardcoded English so the legal agreement is consistent across locales - Replaced placeholder briefcase icon in EULA header with the actual BMS SVG logo - Expanded EULA from 10 to 15 sections: added Definitions, Grant of License, License Tiers, Restrictions, IP Ownership, License Key enforcement, User Data and Privacy, Updates and Support, Warranty Disclaimer, Liability Cap, Indemnification, Termination, Governing Law, Severability, and Entire Agreement diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index adca6fd..7cb05c2 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -1,4 +1,6 @@ import 'package:bcrypt/bcrypt.dart'; +import 'package:bms/data/database/connection_web.dart' + if (dart.library.io) 'package:bms/data/database/connection_native.dart'; import 'package:bms/data/database/daos/audit_log_dao.dart'; import 'package:bms/data/database/daos/cheques_dao.dart'; import 'package:bms/data/database/daos/customers_dao.dart'; @@ -19,8 +21,6 @@ import 'package:bms/data/database/tables/returns_table.dart'; import 'package:bms/data/database/tables/suppliers_table.dart'; import 'package:bms/data/database/tables/users_table.dart'; import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:uuid/uuid.dart'; part 'app_database.g.dart'; @@ -63,7 +63,7 @@ part 'app_database.g.dart'; ], ) class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); + AppDatabase() : super(openAppDatabaseConnection()); // Used in tests with NativeDatabase.memory() so no disk I/O is needed. AppDatabase.forTesting(super.e); @@ -162,15 +162,3 @@ class AppDatabase extends _$AppDatabase { ); } } - -QueryExecutor _openConnection() { - return driftDatabase( - name: 'bms_local', - web: kIsWeb - ? DriftWebOptions( - sqlite3Wasm: Uri.parse('sqlite3.wasm'), - driftWorker: Uri.parse('drift_worker.js'), - ) - : null, - ); -} diff --git a/lib/data/database/connection_native.dart b/lib/data/database/connection_native.dart new file mode 100644 index 0000000..f13a87f --- /dev/null +++ b/lib/data/database/connection_native.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +QueryExecutor openAppDatabaseConnection() { + return LazyDatabase(() async { + final dir = await getApplicationSupportDirectory(); + final file = File(p.join(dir.path, 'bms_local.sqlite')); + // One-time migration: drift_flutter previously defaulted to Documents/. + // Copy the legacy file on first launch if the new location is empty. + if (!file.existsSync()) { + final docsDir = await getApplicationDocumentsDirectory(); + final legacy = File(p.join(docsDir.path, 'bms_local.sqlite')); + if (legacy.existsSync()) { + await legacy.copy(file.path); + // Copy WAL sidecars so committed-but-uncheckpointed data is not lost. + // SQLite replays and checkpoints the WAL automatically on next open. + for (final suffix in ['-wal', '-shm']) { + final sidecar = File('${legacy.path}$suffix'); + if (sidecar.existsSync()) { + await sidecar.copy('${file.path}$suffix'); + } + } + } + } + return NativeDatabase.createInBackground(file); + }); +} diff --git a/lib/data/database/connection_web.dart b/lib/data/database/connection_web.dart new file mode 100644 index 0000000..c022126 --- /dev/null +++ b/lib/data/database/connection_web.dart @@ -0,0 +1,12 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; + +QueryExecutor openAppDatabaseConnection() { + return driftDatabase( + name: 'bms_local', + web: DriftWebOptions( + sqlite3Wasm: Uri.parse('sqlite3.wasm'), + driftWorker: Uri.parse('drift_worker.js'), + ), + ); +} diff --git a/lib/data/sync/sync_tables_registry.dart b/lib/data/sync/sync_tables_registry.dart index a5c3e59..ca7dfdc 100644 --- a/lib/data/sync/sync_tables_registry.dart +++ b/lib/data/sync/sync_tables_registry.dart @@ -79,8 +79,9 @@ const _customers = SyncTable( SyncColumn('name', _txt), SyncColumn('phone', _txt, nullable: true), SyncColumn('address', _txt, nullable: true), + SyncColumn('credit_limit', _real), SyncColumn('balance', _real), - SyncColumn('credit_limit', _real, nullable: true), + SyncColumn('is_active', _ts), SyncColumn('created_at', _ts), SyncColumn('updated_at', _ts), ], @@ -108,8 +109,9 @@ const _suppliers = SyncTable( SyncColumn('name', _txt), SyncColumn('phone', _txt, nullable: true), SyncColumn('address', _txt, nullable: true), + SyncColumn('payment_terms', _txt, nullable: true), SyncColumn('balance', _real), - SyncColumn('credit_limit', _real, nullable: true), + SyncColumn('is_active', _ts), SyncColumn('created_at', _ts), SyncColumn('updated_at', _ts), ], diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index acb4f8e..a87de0e 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -183,11 +183,16 @@ class SettingsScreen extends ConsumerWidget { } Future _importDb(BuildContext context, WidgetRef ref) async { + // Open file picker first - avoids the dialog->NSOpenPanel transition + // that causes the macOS window to go black and become unresponsive. + final picked = await ref.read(settingsActionsProvider).pickDatabaseJsonFile(); + if (picked == null || !context.mounted) return; + final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: Text(context.l10n.importDatabase), - content: Text(context.l10n.importDatabaseMessage), + content: Text('${context.l10n.importDatabaseMessage}\n\n${context.l10n.fileLabel}: ${picked.name}'), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.cancel)), ElevatedButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.importDatabaseConfirm)), @@ -195,11 +200,7 @@ class SettingsScreen extends ConsumerWidget { ), ); if (confirmed != true || !context.mounted) return; - // Give Flutter one frame to repaint before NSOpenPanel captures focus, - // otherwise the macOS window goes black. - await Future.delayed(const Duration(milliseconds: 100)); - if (!context.mounted) return; - final msg = await ref.read(settingsActionsProvider).importDatabaseFromJson(); + final msg = await ref.read(settingsActionsProvider).importDatabaseFromJson(picked); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd7a0ec..3a9df5e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -392,6 +392,7 @@ "skipped": "Skipped:", "errors": "Errors:", "importDatabaseConfirm": "Import", + "fileLabel": "File", "importDatabaseMessage": "This will add records from the backup file. Existing records will not be overwritten. Continue?", "dbBackend": "Backend", "dbSqliteLocal": "SQLite (local)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index db2c99e..7427a6f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2260,6 +2260,12 @@ abstract class AppLocalizations { /// **'Import'** String get importDatabaseConfirm; + /// No description provided for @fileLabel. + /// + /// In en, this message translates to: + /// **'File'** + String get fileLabel; + /// No description provided for @importDatabaseMessage. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8b16334..6f51ba0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1117,6 +1117,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get importDatabaseConfirm => 'Import'; + @override + String get fileLabel => 'File'; + @override String get importDatabaseMessage => 'This will add records from the backup file. Existing records will not be overwritten. Continue?'; diff --git a/lib/l10n/app_localizations_si.dart b/lib/l10n/app_localizations_si.dart index ac64ff0..72adc47 100644 --- a/lib/l10n/app_localizations_si.dart +++ b/lib/l10n/app_localizations_si.dart @@ -1115,6 +1115,9 @@ class AppLocalizationsSi extends AppLocalizations { @override String get importDatabaseConfirm => 'ආයාත කරන්න'; + @override + String get fileLabel => 'ගොනුව'; + @override String get importDatabaseMessage => 'සටහන් ගොනුවෙන් ඇතුළු කෙරේ. පවතින සටහන් නොවෙනස්. ඉදිරියට යන්නද?'; diff --git a/lib/l10n/app_localizations_ta.dart b/lib/l10n/app_localizations_ta.dart index df84cd9..6f5e204 100644 --- a/lib/l10n/app_localizations_ta.dart +++ b/lib/l10n/app_localizations_ta.dart @@ -1120,6 +1120,9 @@ class AppLocalizationsTa extends AppLocalizations { @override String get importDatabaseConfirm => 'இறக்குமதி'; + @override + String get fileLabel => 'கோப்பு'; + @override String get importDatabaseMessage => 'காப்பு கோப்பில் இருந்து பதிவுகள் சேர்க்கப்படும். தற்போதைய பதிவுகள் மாற்றப்படாது. தொடரவா?'; diff --git a/lib/l10n/app_si.arb b/lib/l10n/app_si.arb index 9fca919..f75e670 100644 --- a/lib/l10n/app_si.arb +++ b/lib/l10n/app_si.arb @@ -381,6 +381,7 @@ "skipped": "මඟ හැරියේ:", "errors": "දෝෂ:", "importDatabaseConfirm": "ආයාත කරන්න", + "fileLabel": "ගොනුව", "importDatabaseMessage": "සටහන් ගොනුවෙන් ඇතුළු කෙරේ. පවතින සටහන් නොවෙනස්. ඉදිරියට යන්නද?", "dbBackend": "ද්විතියික", "dbSqliteLocal": "SQLite (දේශීය)", diff --git a/lib/l10n/app_ta.arb b/lib/l10n/app_ta.arb index 8f1912f..de7ddba 100644 --- a/lib/l10n/app_ta.arb +++ b/lib/l10n/app_ta.arb @@ -381,6 +381,7 @@ "skipped": "தவிர்க்கப்பட்டது:", "errors": "பிழைகள்:", "importDatabaseConfirm": "இறக்குமதி", + "fileLabel": "கோப்பு", "importDatabaseMessage": "காப்பு கோப்பில் இருந்து பதிவுகள் சேர்க்கப்படும். தற்போதைய பதிவுகள் மாற்றப்படாது. தொடரவா?", "dbBackend": "நுட்பம்", "dbSqliteLocal": "SQLite (உள்ளூர்)", diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 89aed05..d7f36eb 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -337,15 +337,18 @@ class SettingsActions { return Uint8List.fromList(utf8.encode(const JsonEncoder.withIndent(' ').convert(payload))); } - Future importDatabaseFromJson() async { + Future pickDatabaseJsonFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['json'], withData: true, ); - if (result == null || result.files.isEmpty) return 'Cancelled'; + if (result == null || result.files.isEmpty) return null; + return result.files.first; + } - final bytes = await _readPickedFile(result.files.first); + Future importDatabaseFromJson(PlatformFile picked) async { + final bytes = await _readPickedFile(picked); if (bytes == null) return 'Could not read file'; try { @@ -397,7 +400,7 @@ class SettingsActions { action: 'create', userId: _actorId, userName: _actorName, - newValue: {'format': 'json', 'source': result.files.first.name}, + newValue: {'format': 'json', 'source': picked.name}, ); return 'Import complete';