diff --git a/README.md b/README.md index 9f6ea37..098b007 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# Receipity +# Groci -Single-screen Flutter app that proves **capture → OCR**: take a photo of a shopping receipt (or pick one from the gallery), run Google ML Kit Text Recognition, and show the raw extracted text. - -No database, no saving, no other screens. +Flutter app for scanning shopping receipts, tracking products and prices, shopping lists, and bonus cards. ## Run @@ -11,4 +9,4 @@ flutter pub get flutter run ``` -Use a physical Android device or emulator with Google Play. ML Kit text recognition is on-device and does not require a network call. +Use a physical Android device or emulator with Google Play for receipt OCR. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0f0f20d..94e23c3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ { Future _saveAndPop(Uint8List bytes) async { try { final file = File( - '${Directory.systemTemp.path}/receipity_crop_${DateTime.now().millisecondsSinceEpoch}.jpg', + '${Directory.systemTemp.path}/groci_crop_${DateTime.now().millisecondsSinceEpoch}.jpg', ); await file.writeAsBytes(bytes, flush: true); if (!mounted) return; diff --git a/lib/main.dart b/lib/main.dart index dc62225..c31d6e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,20 +13,20 @@ import 'utils/constants.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await ReceiptParser.loadConfig(); - runApp(ReceipityApp()); + runApp(GrociApp()); } -class ReceipityApp extends StatefulWidget { - ReceipityApp({super.key, ProductRepository? repository}) +class GrociApp extends StatefulWidget { + GrociApp({super.key, ProductRepository? repository}) : repository = repository ?? ProductRepository(); final ProductRepository repository; @override - State createState() => _ReceipityAppState(); + State createState() => _GrociAppState(); } -class _ReceipityAppState extends State { +class _GrociAppState extends State { late final AppSettings _settings; @override diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart index 1dddf42..312d681 100644 --- a/lib/scan_screen.dart +++ b/lib/scan_screen.dart @@ -69,7 +69,7 @@ class _ScanScreenState extends State { try { final data = await rootBundle.load(kSampleReceiptAsset); final file = File( - '${Directory.systemTemp.path}/receipity_sample_receipt.jpeg', + '${Directory.systemTemp.path}/groci_sample_receipt.jpeg', ); await file.writeAsBytes(data.buffer.asUint8List(), flush: true); if (!mounted) return; diff --git a/lib/services/backup_format.dart b/lib/services/backup_format.dart index b2712c3..89bd276 100644 --- a/lib/services/backup_format.dart +++ b/lib/services/backup_format.dart @@ -5,6 +5,9 @@ import 'dart:convert'; const int kBackupVersion = 3; +const String kBackupFormat = 'groci-backup'; +const String kLegacyBackupFormat = 'receipity-backup'; + class BackupDocument { const BackupDocument({ required this.version, @@ -33,7 +36,7 @@ class BackupDocument { final Map images; Map toJson() => { - 'format': 'receipity-backup', + 'format': kBackupFormat, 'version': version, 'exportedAt': exportedAt, 'darkMode': darkMode, @@ -54,8 +57,9 @@ class BackupDocument { if (decoded is! Map) { throw const FormatException('Backup is not a JSON object.'); } - if (decoded['format'] != 'receipity-backup') { - throw const FormatException('This file is not a Receipity backup.'); + final format = decoded['format']; + if (format != kBackupFormat && format != kLegacyBackupFormat) { + throw const FormatException('This file is not a Groci backup.'); } final version = decoded['version']; if (version is! int || version < 1 || version > kBackupVersion) { diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 5dd4b1a..a1b8d75 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -1,4 +1,4 @@ -/// Build, share, and restore a Receipity JSON backup. +/// Build, share, and restore a Groci JSON backup. library; import 'dart:convert'; @@ -68,11 +68,11 @@ class BackupService { final document = await buildBackup(); final bytes = Uint8List.fromList(utf8.encode(document.encode())); final stamp = DateTime.now().toIso8601String().split('T').first; - final fileName = 'receipity-backup-$stamp.json'; + final fileName = 'groci-backup-$stamp.json'; try { final saved = await FilePicker.saveFile( - dialogTitle: 'Save Receipity backup', + dialogTitle: 'Save Groci backup', fileName: fileName, bytes: bytes, mimeType: 'application/json', @@ -90,8 +90,8 @@ class BackupService { final result = await SharePlus.instance.share( ShareParams( files: [XFile(file.path, mimeType: 'application/json')], - subject: 'Receipity backup', - text: 'Receipity backup $stamp', + subject: 'Groci backup', + text: 'Groci backup $stamp', ), ); return result.status != ShareResultStatus.dismissed; @@ -99,7 +99,7 @@ class BackupService { Future importBackup() async { final picked = await FilePicker.pickFile( - dialogTitle: 'Import Receipity backup', + dialogTitle: 'Import Groci backup', type: FileType.custom, allowedExtensions: const ['json'], ); diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index c414e00..7f31a09 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -31,10 +31,18 @@ class ProductRepository extends ChangeNotifier { return _opening!; } + Future _defaultDatabasePath() async { + final dir = (await getApplicationDocumentsDirectory()).path; + final groci = p.join(dir, 'groci.db'); + final legacy = p.join(dir, 'receipity.db'); + if (!File(groci).existsSync() && File(legacy).existsSync()) { + await File(legacy).rename(groci); + } + return groci; + } + Future _open() async { - final path = - databasePath ?? - p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db'); + final path = databasePath ?? await _defaultDatabasePath(); return openDatabase( path, version: 9, diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 3b76a91..4a4589b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,7 +1,7 @@ /// Shared strings used across the app. library; -const String kAppName = 'Receipity'; +const String kAppName = 'Groci'; /// Bundled Jumbo receipt used to test OCR without the camera. const String kSampleReceiptAsset = 'assets/receipt.jpeg'; diff --git a/pubspec.yaml b/pubspec.yaml index 4ee794d..28139ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -name: receipity +name: groci description: Capture a shopping receipt and show Google ML Kit text recognition output. publish_to: 'none' version: 1.0.0+1 diff --git a/test/backup_format_test.dart b/test/backup_format_test.dart index e7dbdef..2d57aca 100644 --- a/test/backup_format_test.dart +++ b/test/backup_format_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/services/backup_format.dart'; +import 'package:groci/services/backup_format.dart'; void main() { test('round-trips a backup document', () { @@ -81,9 +81,10 @@ void main() { expect(parsed.shoppingLists.single['name'], 'Weekly'); expect(parsed.shoppingListItems.single['name'], 'Milk'); expect(parsed.images['products/product_1.jpg'], 'abc123'); + expect(parsed.encode(), contains('"format": "groci-backup"')); }); - test('accepts a version 1 backup without shopping lists', () { + test('accepts legacy receipity backup format', () { const json = ''' { "format": "receipity-backup", @@ -104,7 +105,7 @@ void main() { expect(parsed.shopTags, isEmpty); }); - test('rejects a file that is not a Receipity backup', () { + test('rejects a file that is not a Groci backup', () { expect( () => BackupDocument.parse('{"hello":"world"}'), throwsFormatException, diff --git a/test/catalog_test.dart b/test/catalog_test.dart index 7ea4a8c..7fc12d9 100644 --- a/test/catalog_test.dart +++ b/test/catalog_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/models/line_item.dart'; +import 'package:groci/models/line_item.dart'; import 'test_helpers.dart'; diff --git a/test/fuzzy_test.dart b/test/fuzzy_test.dart index 86f7880..597d3cb 100644 --- a/test/fuzzy_test.dart +++ b/test/fuzzy_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/utils/fuzzy.dart'; +import 'package:groci/utils/fuzzy.dart'; void main() { test('matches substrings and character subsequences', () { diff --git a/test/product_names_test.dart b/test/product_names_test.dart index 6f8ee87..f20d0b1 100644 --- a/test/product_names_test.dart +++ b/test/product_names_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/models/line_item.dart'; -import 'package:receipity/models/product.dart'; -import 'package:receipity/utils/product_names.dart'; +import 'package:groci/models/line_item.dart'; +import 'package:groci/models/product.dart'; +import 'package:groci/utils/product_names.dart'; import 'test_helpers.dart'; diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index f137add..1a652ed 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/services/receipt_parser.dart'; +import 'package:groci/services/receipt_parser.dart'; void main() { test('parses name and price at the end of a line', () { diff --git a/test/shop_log_test.dart b/test/shop_log_test.dart index 7bd78b9..ece9460 100644 --- a/test/shop_log_test.dart +++ b/test/shop_log_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/models/shop.dart'; -import 'package:receipity/utils/shop_calendar.dart'; -import 'package:receipity/utils/shop_log_filter.dart'; +import 'package:groci/models/shop.dart'; +import 'package:groci/utils/shop_calendar.dart'; +import 'package:groci/utils/shop_log_filter.dart'; Shop _shop({ required int id, diff --git a/test/supermarket_test.dart b/test/supermarket_test.dart index 87b064a..41d4bdc 100644 --- a/test/supermarket_test.dart +++ b/test/supermarket_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/models/supermarket.dart'; -import 'package:receipity/utils/supermarkets.dart'; +import 'package:groci/models/supermarket.dart'; +import 'package:groci/utils/supermarkets.dart'; void main() { test('detects Jumbo and Lidl from receipt text', () { diff --git a/test/test_helpers.dart b/test/test_helpers.dart index 6bd54c5..81527b0 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; -import 'package:receipity/services/product_repository.dart'; +import 'package:groci/services/product_repository.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; var _dbSeq = 0; @@ -18,7 +18,7 @@ void ensureTestSqlite() { ProductRepository memoryRepository() { ensureTestSqlite(); - final dir = Directory(p.join('.dart_tool', 'receipity_test_db')) + final dir = Directory(p.join('.dart_tool', 'groci_test_db')) ..createSync(recursive: true); final name = 'db_${DateTime.now().microsecondsSinceEpoch}_${_dbSeq++}_${Random().nextInt(1 << 32)}.sqlite'; diff --git a/test/widget_test.dart b/test/widget_test.dart index cef3381..bbeca55 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:receipity/main.dart'; -import 'package:receipity/models/line_item.dart'; -import 'package:receipity/review_screen.dart'; -import 'package:receipity/widgets/shop_heatmap.dart'; +import 'package:groci/main.dart'; +import 'package:groci/models/line_item.dart'; +import 'package:groci/review_screen.dart'; +import 'package:groci/widgets/shop_heatmap.dart'; import 'test_helpers.dart'; @@ -13,7 +13,7 @@ void main() { testWidgets('home screen shows overview and bottom navigation', ( tester, ) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Nothing logged yet.')); expect(find.text('Home'), findsWidgets); @@ -27,12 +27,12 @@ void main() { }); testWidgets('scan screen shows capture actions', (tester) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.byTooltip('Add')); await tester.pump(); - expect(find.text('Receipity'), findsOneWidget); + expect(find.text('Groci'), findsOneWidget); expect(find.text('Take photo'), findsOneWidget); expect(find.text('Pick from gallery'), findsOneWidget); expect(find.text('Enter manually'), findsOneWidget); @@ -44,7 +44,7 @@ void main() { }); testWidgets('empty shop log hides edit and disables search', (tester) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Shop log')); await pumpUntilFound( @@ -80,7 +80,7 @@ void main() { ); }); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Shop log')); await pumpUntilFound(tester, find.text('Weekly shop')); @@ -116,7 +116,7 @@ void main() { ); }); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Products').last); await pumpUntilFound(tester, find.text('Milk')); @@ -142,7 +142,7 @@ void main() { }); testWidgets('profile screen is a placeholder', (tester) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.byTooltip('Profile')); await pumpUntilFound(tester, find.text('Guest')); @@ -172,7 +172,7 @@ void main() { }); testWidgets('enter manually opens a blank trip', (tester) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.byTooltip('Add')); await tester.pump(); @@ -182,7 +182,7 @@ void main() { }); testWidgets('lists tab shows shopping lists', (tester) async { - await tester.pumpWidget(ReceipityApp(repository: memoryRepository())); + await tester.pumpWidget(GrociApp(repository: memoryRepository())); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Lists')); await tester.pump(); @@ -264,7 +264,7 @@ void main() { ); }); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Products').last); await pumpUntilFound(tester, find.text('Milk')); @@ -286,7 +286,7 @@ void main() { final repository = memoryRepository(); await tester.runAsync(() => seedCaseConflictCatalog(repository)); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Products').last); await pumpUntilFound(tester, find.text('1 duplicate product')); @@ -313,7 +313,7 @@ void main() { ); }); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Products').last); await pumpUntilFound(tester, find.text('1 product, multiple spellings')); @@ -328,7 +328,7 @@ void main() { final repository = memoryRepository(); await tester.runAsync(() => seedCaseConflictCatalog(repository)); - await tester.pumpWidget(ReceipityApp(repository: repository)); + await tester.pumpWidget(GrociApp(repository: repository)); await pumpUntilFound(tester, find.text('Overview')); await tester.tap(find.text('Products').last); await pumpUntilFound(tester, find.text('1 duplicate product'));