diff --git a/lib/main.dart b/lib/main.dart index 9510f1c..a65a396 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'scanner_screen.dart'; // ── Supabase credentials ────────────────────────────────────────────── const _supabaseUrl = 'https://yaopcyubateifnicpywp.supabase.co'; -// TODO: Paste your anon key from Supabase Dashboard → Settings → API. -const _supabaseAnonKey = 'YOUR_ANON_KEY'; +const _supabaseAnonKey = 'sb_publishable_a7czIl7-TGeBJvid9z2XZA_3ElImliL'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -298,7 +298,7 @@ class _LoginScreenState extends State { } } -// ── Home Screen (placeholder) ───────────────────────────────────────── +// ── Home Screen ─────────────────────────────────────────────────────── class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -319,11 +319,121 @@ class HomeScreen extends StatelessWidget { ], ), body: Center( - child: Text( - 'Signed in as ${user?.email ?? 'unknown'}', - style: Theme.of(context).textTheme.titleMedium, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Signed in as ${user?.email ?? 'unknown'}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 32), + const Icon(Icons.directions_car, size: 80, color: Colors.deepPurple), + const SizedBox(height: 16), + const Text( + 'Scan or enter a Hot Wheels ID\nto check your collection.', + textAlign: TextAlign.center, + ), + ], ), ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.camera_alt), + label: const Text('Scan'), + onPressed: () => _openScanner(context), + ), + ); + } + + /// Opens the scanner, gets the hw_id, then queries the DB. + Future _openScanner(BuildContext context) async { + // 1. Navigate to the scanner screen and wait for the result. + final hwId = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ScannerScreen()), + ); + + // User cancelled / went back without selecting an ID. + if (hwId == null || !context.mounted) return; + + // 2. Query the database. + try { + final data = await supabase + .from('hotwheels') + .select() + .eq('hw_id', hwId) + .maybeSingle(); + + if (!context.mounted) return; + + if (data != null) { + // ── Car already exists ── + _showAlreadyExistsDialog(context, hwId); + } else { + // ── Car is new — offer to add it ── + _showAddDialog(context, hwId); + } + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('DB error: $e'), backgroundColor: Colors.red), + ); + } + } + + /// Shows an alert: this car is already in the collection. + void _showAlreadyExistsDialog(BuildContext context, String hwId) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.check_circle, color: Colors.green, size: 48), + title: const Text('Already in Collection!'), + content: Text('$hwId is already in your shared garage.'), + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + + /// Shows a confirmation dialog to add a new car. + void _showAddDialog(BuildContext context, String hwId) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.add_circle_outline, color: Colors.deepPurple, size: 48), + title: const Text('New Car Found!'), + content: Text('$hwId is not in your collection yet.\nAdd it now?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + try { + await supabase.from('hotwheels').insert({ + 'hw_id': hwId, + 'user_id': supabase.auth.currentUser!.id, + }); + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$hwId added to your collection! 🎉')), + ); + } catch (e) { + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to add: $e'), backgroundColor: Colors.red), + ); + } + }, + child: const Text('Add to Collection'), + ), + ], + ), ); } } diff --git a/lib/scanner_screen.dart b/lib/scanner_screen.dart new file mode 100644 index 0000000..537ed11 --- /dev/null +++ b/lib/scanner_screen.dart @@ -0,0 +1,252 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:camera/camera.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; + +/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package +/// and extract the hw_id (e.g. "JKF21"). +/// +/// The detected ID is returned via Navigator.pop(context, hwId). +class ScannerScreen extends StatefulWidget { + const ScannerScreen({super.key}); + + @override + State createState() => _ScannerScreenState(); +} + +class _ScannerScreenState extends State { + CameraController? _cameraController; + late final TextRecognizer _textRecognizer; + bool _isBusy = false; + bool _cameraReady = false; + String? _lastDetected; + + // Matches typical Hot Wheels model IDs: 2–5 uppercase letters followed by + // 2–4 digits, e.g. JKF21, HCV73, GRX33, FYD83. + final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b'); + + @override + void initState() { + super.initState(); + _textRecognizer = TextRecognizer(); + _initCamera(); + } + + Future _initCamera() async { + final cameras = await availableCameras(); + if (cameras.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No camera available')), + ); + return; + } + + // Use the first back-facing camera. + final backCamera = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + + _cameraController = CameraController( + backCamera, + ResolutionPreset.high, + enableAudio: false, + ); + + await _cameraController!.initialize(); + if (!mounted) return; + setState(() => _cameraReady = true); + } + + /// Capture a photo, run OCR, and look for a Hot Wheels ID. + Future _captureAndScan() async { + if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; + + setState(() => _isBusy = true); + + try { + final xFile = await _cameraController!.takePicture(); + final inputImage = InputImage.fromFilePath(xFile.path); + final recognized = await _textRecognizer.processImage(inputImage); + + // Search all recognized text blocks for something matching the HW ID pattern. + String? found; + for (final block in recognized.blocks) { + for (final line in block.lines) { + final match = _hwIdPattern.firstMatch(line.text.toUpperCase()); + if (match != null) { + found = match.group(1); + break; + } + } + if (found != null) break; + } + + // Clean up the temp image. + try { + await File(xFile.path).delete(); + } catch (_) {} + + if (!mounted) return; + + if (found != null) { + setState(() => _lastDetected = found); + } else { + // Show all detected text so user knows what was seen. + final allText = recognized.blocks.map((b) => b.text).join('\n'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + allText.isEmpty + ? 'No text detected — try again closer.' + : 'No HW ID found. Detected:\n$allText', + ), + duration: const Duration(seconds: 4), + ), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Scan error: $e'), backgroundColor: Colors.red), + ); + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + @override + void dispose() { + _cameraController?.dispose(); + _textRecognizer.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Scan Hot Wheels ID')), + body: Column( + children: [ + // ── Camera preview ── + Expanded( + child: _cameraReady + ? ClipRect( + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _cameraController!.value.previewSize!.height, + height: _cameraController!.value.previewSize!.width, + child: CameraPreview(_cameraController!), + ), + ), + ), + ) + : const Center(child: CircularProgressIndicator()), + ), + + // ── Detected ID confirmation area ── + if (_lastDetected != null) + Container( + width: double.infinity, + color: Colors.green.shade50, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Detected: $_lastDetected', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(_lastDetected), + child: const Text('Use'), + ), + ], + ), + ), + + // ── Bottom controls ── + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Manual entry button + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.keyboard), + label: const Text('Enter manually'), + onPressed: () => _showManualEntry(context), + ), + ), + const SizedBox(width: 12), + // Capture / scan button + Expanded( + child: ElevatedButton.icon( + icon: _isBusy + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.camera_alt), + label: Text(_isBusy ? 'Scanning...' : 'Scan'), + onPressed: _isBusy ? null : _captureAndScan, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// Fallback: let the user type the HW ID manually. + Future _showManualEntry(BuildContext context) async { + final controller = TextEditingController(); + + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Enter HW ID'), + content: TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + hintText: 'e.g. JKF21', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final value = controller.text.trim().toUpperCase(); + if (value.isNotEmpty) Navigator.of(ctx).pop(value); + }, + child: const Text('OK'), + ), + ], + ), + ); + + controller.dispose(); + + if (result != null && context.mounted) { + Navigator.of(context).pop(result); + } + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..e12c657 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #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); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5d07423..4453582 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a620c94..4110229 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 60d5d7b..b8b4acc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" characters: dependency: transitive description: @@ -97,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -153,6 +201,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + 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: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -174,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -200,6 +288,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "7e9a6d6e66b44aa8cfe944bda9bc3346c52486dd890ca49e5bc98845cda40d7f" + url: "https://pub.dev" + source: hosted + version: "0.9.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: e7609cec8de3022680a36ead8a8bafa9fd2360ea018a728feaad12dcb0e3c177 + url: "https://pub.dev" + source: hosted + version: "0.14.0" gotrue: dependency: transitive description: @@ -240,6 +344,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" jwt_decode: dependency: transitive description: @@ -392,6 +560,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" platform: dependency: transitive description: @@ -549,6 +765,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0cca785..fb98d20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,10 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 supabase_flutter: ^2.8.0 + camera: ^0.11.1 + google_mlkit_text_recognition: ^0.14.0 + image_picker: ^1.1.2 + permission_handler: ^11.3.1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 785a046..89c8493 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8f8ee4f..2dfad4f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows + permission_handler_windows url_launcher_windows )