diff --git a/README.md b/README.md index 8055eea..9f6ea37 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,14 @@ -# MyApp +# Receipity -Generic Flutter Android starter built with Flutter **3.47** (Dart **3.13**), Material 3, null safety, and [Provider](https://pub.dev/packages/provider) for state management. +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. -Package name: `com.example.myapp` +No database, no saving, no other screens. -## Features - -- Splash screen that fades into the main shell -- Bottom navigation: Home, Search, Profile, Settings -- `ListView.builder` catalog with rounded, shadowed cards -- Detail screen with a matching `Hero` animation -- Light / dark / system theme, persisted with `shared_preferences` -- Add-item form with validation -- Mock catalog plus an `http` client aimed at JSONPlaceholder (toggle in Settings) - -## Folder structure - -``` -android-flutter-app-generic/ -├── android/ # Android embedding, Gradle, manifest -├── lib/ -│ ├── main.dart # Entry point, Provider scope, MaterialApp -│ ├── models/ -│ │ └── item.dart # Item + ItemCategory -│ ├── providers/ -│ │ ├── item_provider.dart # Catalog ChangeNotifier -│ │ └── theme_provider.dart # ThemeMode ChangeNotifier -│ ├── screens/ -│ │ ├── splash_screen.dart -│ │ ├── main_shell.dart # NavigationBar host -│ │ ├── home_screen.dart -│ │ ├── search_screen.dart -│ │ ├── profile_screen.dart -│ │ ├── settings_screen.dart -│ │ ├── item_detail_screen.dart -│ │ └── add_item_screen.dart -│ ├── services/ -│ │ ├── storage_service.dart # shared_preferences wrapper -│ │ ├── item_api.dart # http placeholder -│ │ ├── fake_item_data.dart # Offline sample catalog -│ │ └── item_repository.dart # Mock vs remote switch -│ ├── utils/ -│ │ ├── app_theme.dart # Material 3 light/dark themes -│ │ ├── constants.dart -│ │ └── helpers.dart -│ └── widgets/ -│ ├── app_text_field.dart -│ ├── empty_state.dart -│ ├── item_card.dart -│ ├── item_hero_badge.dart -│ ├── loading_view.dart -│ └── responsive_body.dart -├── test/ -│ └── widget_test.dart -└── pubspec.yaml -``` - -## Run the app - -You need the [Flutter SDK](https://docs.flutter.dev/get-started/install) (3.47 or newer) and an Android emulator or device. +## Run ```bash -cd android-flutter-app-generic flutter pub get flutter run ``` -Useful extras: - -```bash -flutter devices # list emulators / devices -flutter run -d android # target Android explicitly -flutter analyze # static analysis -flutter test # widget + unit tests -``` - -On first Android run, Flutter will download a Gradle distribution and compile the app. Accept any Android licenses with `flutter doctor --android-licenses` if prompted. - -## Theme and data - -- Open **Settings** to switch System / Light / Dark. The choice is stored locally. -- Turn on **Use remote sample API** to fetch posts from `https://jsonplaceholder.typicode.com/posts` through the `http` package. If the request fails, the app falls back to the bundled mock list. +Use a physical Android device or emulator with Google Play. ML Kit text recognition is on-device and does not require a network call. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d8e2f52..0f0f20d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - + + + + + diff --git a/assets/receipt.jpeg b/assets/receipt.jpeg new file mode 100644 index 0000000..397c9db Binary files /dev/null and b/assets/receipt.jpeg differ diff --git a/lib/main.dart b/lib/main.dart index 850cfb3..41cacaa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,74 +1,28 @@ -/// Application entry point: wires services, Provider, and [MaterialApp]. +/// Application entry point: a single receipt-capture screen. library; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'providers/item_provider.dart'; -import 'providers/theme_provider.dart'; -import 'screens/splash_screen.dart'; -import 'services/item_repository.dart'; -import 'services/storage_service.dart'; +import 'scan_screen.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; -Future main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); - - final storage = StorageService(); - await storage.init(); - - runApp( - MyApp( - storage: storage, - repository: ItemRepository(useRemoteData: storage.useRemoteData), - ), - ); + runApp(const ReceipityApp()); } -/// Root widget. Theme and catalog controllers live here so every route -/// can `context.watch` them. -class MyApp extends StatelessWidget { - const MyApp({ - super.key, - required this.storage, - required this.repository, - }); - - final StorageService storage; - final ItemRepository repository; +class ReceipityApp extends StatelessWidget { + const ReceipityApp({super.key}); @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => ThemeController(storage)), - ChangeNotifierProvider( - create: (_) => ItemController( - repository: repository, - storage: storage, - )..loadItems(), - ), - ], - child: const _MaterialRoot(), - ); - } -} - -class _MaterialRoot extends StatelessWidget { - const _MaterialRoot(); - - @override - Widget build(BuildContext context) { - final themeMode = context.watch().themeMode; - return MaterialApp( title: kAppName, debugShowCheckedModeBanner: false, theme: AppTheme.light(), darkTheme: AppTheme.dark(), - themeMode: themeMode, - home: const SplashScreen(), + home: const ScanScreen(), ); } } diff --git a/lib/models/item.dart b/lib/models/item.dart deleted file mode 100644 index e5e06ad..0000000 --- a/lib/models/item.dart +++ /dev/null @@ -1,130 +0,0 @@ -/// Domain models for catalog items shown in lists and detail screens. -library; - -import 'package:flutter/material.dart'; - -/// High-level grouping used for filters, icons, and accent colors. -enum ItemCategory { - design, - development, - productivity, - lifestyle, -} - -extension ItemCategoryX on ItemCategory { - String get label => switch (this) { - ItemCategory.design => 'Design', - ItemCategory.development => 'Development', - ItemCategory.productivity => 'Productivity', - ItemCategory.lifestyle => 'Lifestyle', - }; - - IconData get icon => switch (this) { - ItemCategory.design => Icons.palette_outlined, - ItemCategory.development => Icons.code, - ItemCategory.productivity => Icons.bolt_outlined, - ItemCategory.lifestyle => Icons.spa_outlined, - }; - - /// Stable accent used by [Hero] tiles so the animation has a colored target. - Color get accent => switch (this) { - ItemCategory.design => const Color(0xFF7C3AED), - ItemCategory.development => const Color(0xFF0F766E), - ItemCategory.productivity => const Color(0xFFC2410C), - ItemCategory.lifestyle => const Color(0xFF0369A1), - }; - - static ItemCategory fromName(String name) { - return ItemCategory.values.firstWhere( - (category) => category.name == name, - orElse: () => ItemCategory.productivity, - ); - } - - static ItemCategory fromIndex(int index) { - return ItemCategory.values[index.abs() % ItemCategory.values.length]; - } -} - -/// A catalog entry displayed on Home / Search and opened on the detail route. -class Item { - const Item({ - required this.id, - required this.title, - required this.subtitle, - required this.description, - required this.category, - required this.createdAt, - }); - - final String id; - final String title; - final String subtitle; - final String description; - final ItemCategory category; - final DateTime createdAt; - - /// Tag used by the list-to-detail [Hero] animation. - String get heroTag => 'item-hero-$id'; - - Item copyWith({ - String? id, - String? title, - String? subtitle, - String? description, - ItemCategory? category, - DateTime? createdAt, - }) { - return Item( - id: id ?? this.id, - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - description: description ?? this.description, - category: category ?? this.category, - createdAt: createdAt ?? this.createdAt, - ); - } - - /// Maps a JSONPlaceholder `/posts` payload (or any similar JSON object). - factory Item.fromJson(Map json) { - final id = json['id']?.toString() ?? '0'; - final title = (json['title'] as String? ?? 'Untitled').trim(); - final body = (json['body'] as String? ?? '').trim(); - final numericId = int.tryParse(id) ?? 0; - - return Item( - id: 'remote-$id', - title: _titleCase(title), - subtitle: _firstSentence(body), - description: body.replaceAll('\n', ' '), - category: ItemCategoryX.fromIndex(numericId), - createdAt: DateTime(2026, 1, 1).add(Duration(days: numericId)), - ); - } - - Map toJson() { - return { - 'id': id, - 'title': title, - 'subtitle': subtitle, - 'description': description, - 'category': category.name, - 'createdAt': createdAt.toIso8601String(), - }; - } - - static String _titleCase(String value) { - if (value.isEmpty) return value; - return value[0].toUpperCase() + value.substring(1); - } - - static String _firstSentence(String value) { - if (value.isEmpty) return 'No summary available.'; - final cleaned = value.replaceAll('\n', ' '); - final period = cleaned.indexOf('.'); - if (period <= 0) { - return cleaned.length > 80 ? '${cleaned.substring(0, 77)}…' : cleaned; - } - return cleaned.substring(0, period + 1); - } -} diff --git a/lib/providers/item_provider.dart b/lib/providers/item_provider.dart deleted file mode 100644 index e44e7ea..0000000 --- a/lib/providers/item_provider.dart +++ /dev/null @@ -1,93 +0,0 @@ -/// [ChangeNotifier] that loads, filters, and mutates the in-memory item list. -library; - -import 'package:flutter/foundation.dart'; - -import '../models/item.dart'; -import '../services/item_repository.dart'; -import '../services/storage_service.dart'; - -/// App-wide catalog state. Screens call [loadItems], [addItem], and [search]. -class ItemController extends ChangeNotifier { - ItemController({ - required this._repository, - required this._storage, - }) { - _repository.useRemoteData = _storage.useRemoteData; - } - - final ItemRepository _repository; - final StorageService _storage; - - List _items = const []; - bool _isLoading = false; - String? _error; - - List get items => List.unmodifiable(_items); - bool get isLoading => _isLoading; - String? get error => _error; - bool get useRemoteData => _repository.useRemoteData; - int get count => _items.length; - - Future loadItems() async { - _isLoading = true; - _error = null; - notifyListeners(); - - try { - // Small delay so the loading indicator is visible with local data. - await Future.delayed(const Duration(milliseconds: 250)); - _items = await _repository.fetchItems(); - } catch (error, stackTrace) { - _error = 'Could not load items.'; - debugPrint('ItemController.loadItems failed: $error\n$stackTrace'); - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future setUseRemoteData(bool value) async { - if (value == _repository.useRemoteData) return; - _repository.useRemoteData = value; - await _storage.setUseRemoteData(value); - notifyListeners(); - await loadItems(); - } - - /// Case-insensitive match against title, subtitle, and category. - List search(String query) { - final needle = query.trim().toLowerCase(); - if (needle.isEmpty) return items; - - return _items.where((item) { - return item.title.toLowerCase().contains(needle) || - item.subtitle.toLowerCase().contains(needle) || - item.category.label.toLowerCase().contains(needle); - }).toList(growable: false); - } - - Item addItem({ - required String title, - required String subtitle, - required String description, - required ItemCategory category, - }) { - final created = _repository.createLocal( - title: title, - subtitle: subtitle, - description: description, - category: category, - ); - _items = [created, ..._items]; - notifyListeners(); - return created; - } - - Item? findById(String id) { - for (final item in _items) { - if (item.id == id) return item; - } - return null; - } -} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart deleted file mode 100644 index 1cecdcd..0000000 --- a/lib/providers/theme_provider.dart +++ /dev/null @@ -1,37 +0,0 @@ -/// [ChangeNotifier] that owns [ThemeMode] and persists it locally. -library; - -import 'package:flutter/material.dart'; - -import '../services/storage_service.dart'; -import '../utils/helpers.dart'; - -/// Provides [themeMode] to [MaterialApp] and writes changes to storage. -class ThemeController extends ChangeNotifier { - ThemeController(this._storage) { - _themeMode = themeModeFromName(_storage.themeModeName); - } - - final StorageService _storage; - late ThemeMode _themeMode; - - ThemeMode get themeMode => _themeMode; - - bool get isDark => _themeMode == ThemeMode.dark; - - Future setThemeMode(ThemeMode mode) async { - if (mode == _themeMode) return; - _themeMode = mode; - notifyListeners(); - await _storage.setThemeModeName(nameFromThemeMode(mode)); - } - - Future cycleThemeMode() { - final next = switch (_themeMode) { - ThemeMode.system => ThemeMode.light, - ThemeMode.light => ThemeMode.dark, - ThemeMode.dark => ThemeMode.system, - }; - return setThemeMode(next); - } -} diff --git a/lib/scan_screen.dart b/lib/scan_screen.dart new file mode 100644 index 0000000..69ffc80 --- /dev/null +++ b/lib/scan_screen.dart @@ -0,0 +1,192 @@ +/// Single screen: capture or pick a receipt photo, then show raw OCR text. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'utils/constants.dart'; + +class ScanScreen extends StatefulWidget { + const ScanScreen({super.key}); + + @override + State createState() => _ScanScreenState(); +} + +class _ScanScreenState extends State { + final ImagePicker _picker = ImagePicker(); + + String _extractedText = ''; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + _recoverLostImage(); + } + + /// Android may kill the activity while the camera app is open. + Future _recoverLostImage() async { + try { + final response = await _picker.retrieveLostData(); + if (!mounted || response.isEmpty) return; + final file = response.file; + if (file != null) { + await _runOcr(file.path); + } + } on UnimplementedError { + // Desktop / platforms that do not support lost-data recovery. + } + } + + Future _capture(ImageSource source) async { + final file = await _picker.pickImage( + source: source, + imageQuality: 95, + ); + if (file == null || !mounted) return; + await _runOcr(file.path); + } + + Future _scanSampleReceipt() async { + try { + final data = await rootBundle.load(kSampleReceiptAsset); + final file = File( + '${Directory.systemTemp.path}/receipity_sample_receipt.jpeg', + ); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + if (!mounted) return; + await _runOcr(file.path); + } catch (_) { + if (!mounted) return; + setState(() { + _error = 'Could not load the sample receipt.'; + }); + } + } + + Future _runOcr(String imagePath) async { + setState(() { + _busy = true; + _error = null; + _extractedText = ''; + }); + + final recognizer = TextRecognizer(script: TextRecognitionScript.latin); + try { + final inputImage = InputImage.fromFilePath(imagePath); + final recognized = await recognizer.processImage(inputImage); + if (!mounted) return; + setState(() { + _extractedText = recognized.text.trim(); + if (_extractedText.isEmpty) { + _error = 'No text found in this image.'; + } + }); + } catch (error) { + if (!mounted) return; + setState(() { + _error = 'Could not read text from this image.'; + }); + } finally { + await recognizer.close(); + if (mounted) { + setState(() => _busy = false); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text(kAppName)), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: _busy ? null : () => _capture(ImageSource.camera), + icon: const Icon(Icons.photo_camera_outlined), + label: const Text('Take photo'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy ? null : () => _capture(ImageSource.gallery), + icon: const Icon(Icons.photo_library_outlined), + label: const Text('Pick from gallery'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _busy ? null : _scanSampleReceipt, + icon: const Icon(Icons.receipt_long_outlined), + label: const Text('Use sample receipt'), + ), + if (_busy) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), + const SizedBox(height: 8), + Text( + 'Reading text…', + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 16), + Expanded(child: _ResultPane(text: _extractedText, error: _error)), + ], + ), + ), + ), + ); + } +} + +class _ResultPane extends StatelessWidget { + const _ResultPane({required this.text, required this.error}); + + final String text; + final String? error; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (error != null && text.isEmpty) { + return Center( + child: Text( + error!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + if (text.isEmpty) { + return Center( + child: Text( + 'Take a photo of a shopping receipt, or pick one from the gallery.\n\nExtracted text will show up here.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ); + } + + return SingleChildScrollView( + child: SelectableText( + text, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.4, + ), + ), + ); + } +} diff --git a/lib/screens/add_item_screen.dart b/lib/screens/add_item_screen.dart deleted file mode 100644 index f77f91e..0000000 --- a/lib/screens/add_item_screen.dart +++ /dev/null @@ -1,142 +0,0 @@ -/// Validated form that inserts a new [Item] into [ItemController]. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../models/item.dart'; -import '../providers/item_provider.dart'; -import '../widgets/app_text_field.dart'; -import '../widgets/responsive_body.dart'; - -class AddItemScreen extends StatefulWidget { - const AddItemScreen({super.key}); - - @override - State createState() => _AddItemScreenState(); -} - -class _AddItemScreenState extends State { - final _formKey = GlobalKey(); - final _title = TextEditingController(); - final _subtitle = TextEditingController(); - final _description = TextEditingController(); - ItemCategory? _category; - bool _submitted = false; - - @override - void dispose() { - _title.dispose(); - _subtitle.dispose(); - _description.dispose(); - super.dispose(); - } - - String? _requiredLength(String? value, {required int min, String? label}) { - final text = value?.trim() ?? ''; - if (text.isEmpty) return 'Enter a ${label ?? 'value'}.'; - if (text.length < min) { - return '${label ?? 'This field'} must be at least $min characters.'; - } - return null; - } - - void _submit() { - setState(() => _submitted = true); - final form = _formKey.currentState; - if (form == null || !form.validate() || _category == null) return; - - context.read().addItem( - title: _title.text, - subtitle: _subtitle.text, - description: _description.text, - category: _category!, - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added “${_title.text.trim()}”')), - ); - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar(title: const Text('Add item')), - body: ResponsiveBody( - child: Form( - key: _formKey, - autovalidateMode: _submitted - ? AutovalidateMode.onUserInteraction - : AutovalidateMode.disabled, - child: ListView( - children: [ - const SizedBox(height: 8), - Text( - 'Create a catalog entry. All fields are required.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 20), - AppTextField( - controller: _title, - label: 'Title', - hint: 'Give it a short name', - autofocus: true, - textInputAction: TextInputAction.next, - validator: (value) => - _requiredLength(value, min: 3, label: 'Title'), - ), - const SizedBox(height: 16), - AppTextField( - controller: _subtitle, - label: 'Summary', - hint: 'One sentence overview', - textInputAction: TextInputAction.next, - validator: (value) => - _requiredLength(value, min: 8, label: 'Summary'), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _category, - decoration: const InputDecoration(labelText: 'Category'), - items: [ - for (final category in ItemCategory.values) - DropdownMenuItem( - value: category, - child: Text(category.label), - ), - ], - onChanged: (value) => setState(() => _category = value), - validator: (value) => - value == null ? 'Choose a category.' : null, - ), - const SizedBox(height: 16), - AppTextField( - controller: _description, - label: 'Description', - hint: 'A longer note the detail screen will show', - maxLines: 5, - keyboardType: TextInputType.multiline, - validator: (value) => - _requiredLength(value, min: 16, label: 'Description'), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _submit, - child: const Text('Save item'), - ), - const SizedBox(height: 12), - OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart deleted file mode 100644 index a713562..0000000 --- a/lib/screens/home_screen.dart +++ /dev/null @@ -1,212 +0,0 @@ -/// Home tab: greeting, stats, and a [ListView.builder] of catalog items. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../providers/item_provider.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/item_card.dart'; -import '../widgets/loading_view.dart'; -import '../widgets/responsive_body.dart'; -import 'add_item_screen.dart'; - -class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context) { - final controller = context.watch(); - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar(title: const Text('Home')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AddItemScreen()), - ); - }, - icon: const Icon(Icons.add), - label: const Text('Add item'), - ), - body: RefreshIndicator( - onRefresh: controller.loadItems, - child: ResponsiveBody( - child: _HomeBody(controller: controller, theme: theme), - ), - ), - ); - } -} - -class _HomeBody extends StatelessWidget { - const _HomeBody({ - required this.controller, - required this.theme, - }); - - final ItemController controller; - final ThemeData theme; - - @override - Widget build(BuildContext context) { - if (controller.isLoading && controller.items.isEmpty) { - return const LoadingView(message: 'Fetching catalog…'); - } - - if (controller.error != null && controller.items.isEmpty) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - EmptyState( - icon: Icons.error_outline, - title: 'Something went wrong', - message: controller.error!, - actionLabel: 'Retry', - onAction: controller.loadItems, - ), - ], - ); - } - - if (controller.items.isEmpty) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - EmptyState( - icon: Icons.inbox_outlined, - title: 'No items yet', - message: 'Add your first item with the button below.', - ), - ], - ); - } - - // Header occupies the first slot; remaining slots are catalog cards. - return ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 96), - itemCount: controller.items.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _WelcomeBanner(itemCount: controller.count), - const SizedBox(height: 20), - Text('Featured items', style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - ], - ); - } - - final item = controller.items[index - 1]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: ItemCard(item: item, heroScope: 'home'), - ); - }, - ); - } -} - -class _WelcomeBanner extends StatelessWidget { - const _WelcomeBanner({required this.itemCount}); - - final int itemCount; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - colors: [colors.primary, colors.tertiary], - ), - boxShadow: [ - BoxShadow( - color: colors.primary.withValues(alpha: 0.28), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Welcome back', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 4), - Text( - 'Your catalog at a glance', - style: theme.textTheme.headlineMedium?.copyWith( - color: colors.onPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - _StatPill( - label: 'Items', - value: '$itemCount', - icon: Icons.collections_bookmark_outlined, - ), - const SizedBox(width: 10), - const _StatPill( - label: 'Theme', - value: 'M3', - icon: Icons.palette_outlined, - ), - ], - ), - ], - ), - ); - } -} - -class _StatPill extends StatelessWidget { - const _StatPill({ - required this.label, - required this.value, - required this.icon, - }); - - final String label; - final String value; - final IconData icon; - - @override - Widget build(BuildContext context) { - final onPrimary = Theme.of(context).colorScheme.onPrimary; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: onPrimary.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(icon, size: 16, color: onPrimary), - const SizedBox(width: 8), - Text( - '$value $label', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: onPrimary, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/item_detail_screen.dart b/lib/screens/item_detail_screen.dart deleted file mode 100644 index 794fc15..0000000 --- a/lib/screens/item_detail_screen.dart +++ /dev/null @@ -1,82 +0,0 @@ -/// Detail route opened from a list card, with a [Hero] matching the list badge. -library; - -import 'package:flutter/material.dart'; - -import '../models/item.dart'; -import '../utils/helpers.dart'; -import '../widgets/item_hero_badge.dart'; -import '../widgets/responsive_body.dart'; - -class ItemDetailScreen extends StatelessWidget { - const ItemDetailScreen({ - super.key, - required this.item, - required this.heroTag, - }); - - final Item item; - final String heroTag; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return Scaffold( - appBar: AppBar(title: const Text('Details')), - body: ResponsiveBody( - child: ListView( - children: [ - const SizedBox(height: 16), - Center( - child: ItemHeroBadge(item: item, size: 96, heroTag: heroTag), - ), - const SizedBox(height: 24), - Text(item.title, style: theme.textTheme.headlineMedium), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Chip( - avatar: Icon( - item.category.icon, - size: 16, - color: item.category.accent, - ), - label: Text(item.category.label), - side: BorderSide( - color: item.category.accent.withValues(alpha: 0.4), - ), - ), - Chip( - avatar: Icon( - Icons.calendar_today_outlined, - size: 16, - color: colors.onSurfaceVariant, - ), - label: Text(formatDate(item.createdAt)), - ), - ], - ), - const SizedBox(height: 20), - Text('Summary', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text(item.subtitle, style: theme.textTheme.bodyLarge), - const SizedBox(height: 20), - Text('Description', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text(item.description, style: theme.textTheme.bodyLarge), - const SizedBox(height: 32), - FilledButton.tonal( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Back to list'), - ), - const SizedBox(height: 24), - ], - ), - ), - ); - } -} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart deleted file mode 100644 index 5878dbd..0000000 --- a/lib/screens/main_shell.dart +++ /dev/null @@ -1,64 +0,0 @@ -/// Host scaffold that owns the bottom navigation bar and its four tabs. -library; - -import 'package:flutter/material.dart'; - -import 'home_screen.dart'; -import 'profile_screen.dart'; -import 'search_screen.dart'; -import 'settings_screen.dart'; - -/// Switches between Home, Search, Profile, and Settings without stacking routes. -class MainShell extends StatefulWidget { - const MainShell({super.key}); - - @override - State createState() => _MainShellState(); -} - -class _MainShellState extends State { - int _index = 0; - - static const _pages = [ - HomeScreen(), - SearchScreen(), - ProfileScreen(), - SettingsScreen(), - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack( - index: _index, - children: _pages, - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: (index) => setState(() => _index = index), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: 'Home', - ), - NavigationDestination( - icon: Icon(Icons.search_outlined), - selectedIcon: Icon(Icons.search), - label: 'Search', - ), - NavigationDestination( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: 'Profile', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], - ), - ); - } -} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart deleted file mode 100644 index b727639..0000000 --- a/lib/screens/profile_screen.dart +++ /dev/null @@ -1,142 +0,0 @@ -/// Profile tab: avatar, stats, and a shortcut to the add-item form. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../providers/item_provider.dart'; -import '../providers/theme_provider.dart'; -import '../utils/constants.dart'; -import '../utils/helpers.dart'; -import '../widgets/responsive_body.dart'; -import 'add_item_screen.dart'; - -class ProfileScreen extends StatelessWidget { - const ProfileScreen({super.key}); - - @override - Widget build(BuildContext context) { - final items = context.watch(); - final themeController = context.watch(); - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return Scaffold( - appBar: AppBar(title: const Text('Profile')), - body: ResponsiveBody( - child: ListView( - children: [ - const SizedBox(height: 12), - Center( - child: CircleAvatar( - radius: 44, - backgroundColor: colors.primaryContainer, - child: Icon( - Icons.person, - size: 44, - color: colors.onPrimaryContainer, - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Alex Rivera', - textAlign: TextAlign.center, - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 4), - Text( - 'alex@example.com', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: _ProfileStat( - label: 'Items', - value: '${items.count}', - ), - ), - const SizedBox(width: 12), - Expanded( - child: _ProfileStat( - label: 'Theme', - value: labelForThemeMode(themeController.themeMode), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _ProfileStat( - label: 'Source', - value: items.useRemoteData ? 'Remote' : 'Local', - ), - ), - ], - ), - const SizedBox(height: 24), - Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.add_box_outlined), - title: const Text('Add a catalog item'), - subtitle: const Text('Validated form with category picker'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const AddItemScreen(), - ), - ); - }, - ), - const Divider(height: 1), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text('About'), - subtitle: Text('$kAppName · $kPackageName'), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _ProfileStat extends StatelessWidget { - const _ProfileStat({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues(alpha: 0.06), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - Text(value, style: theme.textTheme.titleLarge), - const SizedBox(height: 4), - Text(label, style: theme.textTheme.labelMedium), - ], - ), - ); - } -} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart deleted file mode 100644 index fcabda3..0000000 --- a/lib/screens/search_screen.dart +++ /dev/null @@ -1,104 +0,0 @@ -/// Search tab: query field plus a [ListView.builder] of matching items. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../models/item.dart'; -import '../providers/item_provider.dart'; -import '../widgets/empty_state.dart'; -import '../widgets/item_card.dart'; -import '../widgets/responsive_body.dart'; - -class SearchScreen extends StatefulWidget { - const SearchScreen({super.key}); - - @override - State createState() => _SearchScreenState(); -} - -class _SearchScreenState extends State { - final _query = TextEditingController(); - - @override - void dispose() { - _query.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final results = context.watch().search(_query.text); - - return Scaffold( - appBar: AppBar(title: const Text('Search')), - body: ResponsiveBody( - child: Column( - children: [ - const SizedBox(height: 8), - TextField( - controller: _query, - textInputAction: TextInputAction.search, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - hintText: 'Title, summary, or category', - prefixIcon: const Icon(Icons.search), - suffixIcon: _query.text.isEmpty - ? null - : IconButton( - tooltip: 'Clear', - onPressed: () { - _query.clear(); - setState(() {}); - }, - icon: const Icon(Icons.close), - ), - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: Text( - '${results.length} result${results.length == 1 ? '' : 's'}', - style: Theme.of(context).textTheme.labelMedium, - ), - ), - const SizedBox(height: 8), - Expanded(child: _ResultsList(results: results, query: _query.text)), - ], - ), - ), - ); - } -} - -class _ResultsList extends StatelessWidget { - const _ResultsList({required this.results, required this.query}); - - final List results; - final String query; - - @override - Widget build(BuildContext context) { - if (results.isEmpty) { - return EmptyState( - icon: Icons.search_off, - title: query.trim().isEmpty ? 'Start typing' : 'No matches', - message: query.trim().isEmpty - ? 'Search the catalog by title, summary, or category.' - : 'Try a different keyword.', - ); - } - - return ListView.builder( - padding: const EdgeInsets.only(bottom: 24), - itemCount: results.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: ItemCard(item: results[index], heroScope: 'search'), - ); - }, - ); - } -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart deleted file mode 100644 index 35b0c9b..0000000 --- a/lib/screens/settings_screen.dart +++ /dev/null @@ -1,104 +0,0 @@ -/// Settings tab: theme mode, data source, and about information. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../providers/item_provider.dart'; -import '../providers/theme_provider.dart'; -import '../utils/constants.dart'; -import '../utils/helpers.dart'; -import '../widgets/responsive_body.dart'; - -class SettingsScreen extends StatelessWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context) { - final themeController = context.watch(); - final items = context.watch(); - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: ResponsiveBody( - child: ListView( - children: [ - const SizedBox(height: 8), - Text('Appearance', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Theme is saved with shared_preferences and restored on launch.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 16), - SegmentedButton( - segments: const [ - ButtonSegment( - value: ThemeMode.system, - label: Text('System'), - icon: Icon(Icons.brightness_auto), - ), - ButtonSegment( - value: ThemeMode.light, - label: Text('Light'), - icon: Icon(Icons.light_mode_outlined), - ), - ButtonSegment( - value: ThemeMode.dark, - label: Text('Dark'), - icon: Icon(Icons.dark_mode_outlined), - ), - ], - selected: {themeController.themeMode}, - onSelectionChanged: (selection) { - themeController.setThemeMode(selection.first); - }, - ), - const SizedBox(height: 28), - Text('Data', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Card( - child: SwitchListTile( - title: const Text('Use remote sample API'), - subtitle: const Text( - 'When on, items are fetched with the http package from ' - 'JSONPlaceholder. Failures fall back to local mock data.', - ), - value: items.useRemoteData, - onChanged: (value) => items.setUseRemoteData(value), - ), - ), - const SizedBox(height: 28), - Text('About', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Card( - child: Column( - children: [ - const ListTile( - leading: Icon(Icons.apps), - title: Text(kAppName), - subtitle: Text('Generic Flutter Android starter'), - ), - const Divider(height: 1), - const ListTile( - leading: Icon(Icons.fingerprint), - title: Text('Package'), - subtitle: Text(kPackageName), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.contrast), - title: const Text('Active theme'), - subtitle: Text(labelForThemeMode(themeController.themeMode)), - ), - ], - ), - ), - const SizedBox(height: 24), - ], - ), - ), - ); - } -} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart deleted file mode 100644 index 3e7df2d..0000000 --- a/lib/screens/splash_screen.dart +++ /dev/null @@ -1,130 +0,0 @@ -/// First route shown after launch. Hands off to [MainShell] after a short delay. -library; - -import 'package:flutter/material.dart'; - -import '../utils/constants.dart'; -import 'main_shell.dart'; - -/// Branded splash with a fade-in logo. Replaced by the bottom-nav shell. -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _fade; - late final Animation _scale; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 700), - ); - _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOut); - _scale = Tween(begin: 0.92, end: 1).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), - ); - _controller.forward(); - _goToShell(); - } - - Future _goToShell() async { - await Future.delayed(kSplashDuration); - if (!mounted) return; - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (_, animation, _) => FadeTransition( - opacity: animation, - child: const MainShell(), - ), - transitionDuration: const Duration(milliseconds: 400), - ), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Scaffold( - body: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colors.primary, - colors.primaryContainer, - colors.tertiary, - ], - ), - ), - child: SafeArea( - child: FadeTransition( - opacity: _fade, - child: ScaleTransition( - scale: _scale, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 88, - height: 88, - decoration: BoxDecoration( - color: colors.onPrimary.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - Icons.layers_rounded, - size: 44, - color: colors.onPrimary, - ), - ), - const SizedBox(height: 24), - Text( - kAppName, - style: textTheme.displaySmall?.copyWith( - color: colors.onPrimary, - ), - ), - const SizedBox(height: 8), - Text( - 'A Material 3 starter template', - style: textTheme.bodyLarge?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.85), - ), - ), - const SizedBox(height: 40), - SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 3, - color: colors.onPrimary, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/services/fake_item_data.dart b/lib/services/fake_item_data.dart deleted file mode 100644 index 9dbcdf9..0000000 --- a/lib/services/fake_item_data.dart +++ /dev/null @@ -1,97 +0,0 @@ -/// In-memory sample catalog used when the app is offline or in mock mode. -library; - -import '../models/item.dart'; - -/// Static starter data so the UI is useful without a network. -abstract final class FakeItemData { - static final List items = [ - Item( - id: 'local-1', - title: 'Material 3 surfaces', - subtitle: 'Use tonal layers instead of heavy drop shadows.', - description: - 'Material 3 prefers surface tints and short, soft shadows. Keep cards ' - 'on surfaceContainerLow, bump the hero to surfaceContainerHighest, and ' - 'reserve elevation for sheets and dialogs.', - category: ItemCategory.design, - createdAt: DateTime(2026, 4, 12), - ), - Item( - id: 'local-2', - title: 'Provider at the root', - subtitle: 'Lift shared state above the widget that consumes it.', - description: - 'Wrap MaterialApp with MultiProvider so ThemeController and ' - 'ItemController survive route changes. Keep UI widgets dumb: they ' - 'read context.watch and call controller methods.', - category: ItemCategory.development, - createdAt: DateTime(2026, 5, 3), - ), - Item( - id: 'local-3', - title: 'Focus on one task', - subtitle: 'A short list beats an overflowing dashboard.', - description: - 'The home screen shows a greeting, a compact stat strip, and a ' - 'ListView.builder of items. Extra tools live one tap away on Search, ' - 'Profile, and Settings.', - category: ItemCategory.productivity, - createdAt: DateTime(2026, 6, 18), - ), - Item( - id: 'local-4', - title: 'Breathable spacing', - subtitle: '16–24px padding and 12px gaps keep cards from crowding.', - description: - 'On compact phones use 16px page padding. From 600px up, step to 24px ' - 'and cap the content width so lines stay readable on tablets.', - category: ItemCategory.lifestyle, - createdAt: DateTime(2026, 7, 1), - ), - Item( - id: 'local-5', - title: 'Hero transitions', - subtitle: 'Match a tag on the list tile and the detail header.', - description: - 'Wrap the leading glyph in a Hero with a stable tag derived from the ' - 'item id. The detail screen reuses the same widget so Flutter can ' - 'animate size and position.', - category: ItemCategory.design, - createdAt: DateTime(2026, 7, 22), - ), - Item( - id: 'local-6', - title: 'Mock first, HTTP later', - subtitle: 'A repository hides whether data is fake or remote.', - description: - 'ItemRepository serves FakeItemData by default and can switch to ' - 'ItemApi (http + JSONPlaceholder) from Settings. Failures fall back ' - 'to the mock list so the UI never goes empty on a demo device.', - category: ItemCategory.development, - createdAt: DateTime(2026, 8, 4), - ), - Item( - id: 'local-7', - title: 'Capture ideas quickly', - subtitle: 'The add-item form validates before it writes.', - description: - 'Title, summary, and description each have a length rule. Category is ' - 'a required dropdown. On success the new item is inserted at the top ' - 'of ItemController so Home and Search update immediately.', - category: ItemCategory.productivity, - createdAt: DateTime(2026, 8, 15), - ), - Item( - id: 'local-8', - title: 'Light and dark equally', - subtitle: 'Seeded ColorSchemes keep contrast in both modes.', - description: - 'ThemeController persists ThemeMode in shared_preferences. The ' - 'settings screen offers System, Light, and Dark. Both schemes come ' - 'from the same teal seed color.', - category: ItemCategory.lifestyle, - createdAt: DateTime(2026, 8, 20), - ), - ]; -} diff --git a/lib/services/item_api.dart b/lib/services/item_api.dart deleted file mode 100644 index 56a417c..0000000 --- a/lib/services/item_api.dart +++ /dev/null @@ -1,61 +0,0 @@ -/// HTTP client placeholder that talks to a public sample API. -library; - -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../models/item.dart'; - -/// Fetches catalog items from JSONPlaceholder. -/// -/// This is intentionally a thin, replaceable client. Swap [baseUrl] and -/// [Item.fromJson] when you point the app at a real backend. -class ItemApi { - ItemApi({http.Client? client, this.baseUrl = defaultBaseUrl}) - : _client = client ?? http.Client(); - - static const defaultBaseUrl = 'https://jsonplaceholder.typicode.com'; - - final http.Client _client; - final String baseUrl; - - /// GET `/posts` and map each record to an [Item]. - Future> fetchItems({int limit = 12}) async { - final uri = Uri.parse('$baseUrl/posts'); - final response = await _client.get( - uri, - headers: const {'Accept': 'application/json'}, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw ItemApiException( - 'Request failed (${response.statusCode})', - statusCode: response.statusCode, - ); - } - - final decoded = jsonDecode(response.body); - if (decoded is! List) { - throw const ItemApiException('Unexpected response shape'); - } - - return decoded - .whereType>() - .take(limit) - .map(Item.fromJson) - .toList(growable: false); - } - - void close() => _client.close(); -} - -class ItemApiException implements Exception { - const ItemApiException(this.message, {this.statusCode}); - - final String message; - final int? statusCode; - - @override - String toString() => message; -} diff --git a/lib/services/item_repository.dart b/lib/services/item_repository.dart deleted file mode 100644 index 4ae8b48..0000000 --- a/lib/services/item_repository.dart +++ /dev/null @@ -1,50 +0,0 @@ -/// Chooses between mock data and the HTTP client, then returns [Item] lists. -library; - -import '../models/item.dart'; -import 'fake_item_data.dart'; -import 'item_api.dart'; - -/// Single entry point for catalog data. UI code should depend on this type, -/// not on [ItemApi] or [FakeItemData] directly. -class ItemRepository { - ItemRepository({ - ItemApi? api, - this.useRemoteData = false, - }) : _api = api ?? ItemApi(); - - final ItemApi _api; - - /// When true, [fetchItems] hits the network; otherwise it returns local fakes. - bool useRemoteData; - - Future> fetchItems() async { - if (!useRemoteData) { - return List.from(FakeItemData.items); - } - - try { - return await _api.fetchItems(); - } catch (_) { - // Keep the demo usable if the device is offline or the API fails. - return List.from(FakeItemData.items); - } - } - - /// Builds a locally-created item (no network round-trip). - Item createLocal({ - required String title, - required String subtitle, - required String description, - required ItemCategory category, - }) { - return Item( - id: 'local-${DateTime.now().microsecondsSinceEpoch}', - title: title.trim(), - subtitle: subtitle.trim(), - description: description.trim(), - category: category, - createdAt: DateTime.now(), - ); - } -} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart deleted file mode 100644 index ea525a5..0000000 --- a/lib/services/storage_service.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// Thin wrapper around [SharedPreferences] for local key-value persistence. -library; - -import 'package:shared_preferences/shared_preferences.dart'; - -import '../utils/constants.dart'; - -/// Loads and stores user preferences. Call [init] once at startup. -class StorageService { - StorageService({this._preferences}); - - SharedPreferences? _preferences; - - Future init() async { - _preferences ??= await SharedPreferences.getInstance(); - } - - SharedPreferences get _prefs { - final prefs = _preferences; - if (prefs == null) { - throw StateError('StorageService.init() must be called before use.'); - } - return prefs; - } - - String get themeModeName => - _prefs.getString(StorageKeys.themeMode) ?? 'system'; - - Future setThemeModeName(String value) { - return _prefs.setString(StorageKeys.themeMode, value); - } - - bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; - - Future setUseRemoteData(bool value) { - return _prefs.setBool(StorageKeys.useRemoteData, value); - } -} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index dcacb69..3b76a91 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,34 +1,7 @@ -/// Shared string keys, timings, and layout breakpoints used across the app. +/// Shared strings used across the app. library; -/// Human-readable name shown in the splash screen, app bars, and About tile. -const String kAppName = 'MyApp'; +const String kAppName = 'Receipity'; -/// Package / Android application id. -const String kPackageName = 'com.example.myapp'; - -/// How long the splash screen stays visible before navigating to the shell. -const Duration kSplashDuration = Duration(seconds: 2); - -/// SharedPreferences keys. -abstract final class StorageKeys { - static const themeMode = 'theme_mode'; - static const useRemoteData = 'use_remote_data'; -} - -/// Named routes registered on [MaterialApp]. -abstract final class AppRoutes { - static const splash = '/'; - static const shell = '/shell'; - static const itemDetail = '/item'; - static const addItem = '/add-item'; -} - -/// Width thresholds used to adapt padding and card density. -abstract final class Breakpoints { - static const compact = 600.0; - static const medium = 840.0; -} - -/// Maximum content width on tablets / large phones so lists stay readable. -const double kMaxContentWidth = 720.0; +/// Bundled Jumbo receipt used to test OCR without the camera. +const String kSampleReceiptAsset = 'assets/receipt.jpeg'; diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart deleted file mode 100644 index 4d6e0fb..0000000 --- a/lib/utils/helpers.dart +++ /dev/null @@ -1,59 +0,0 @@ -/// Small pure helpers for layout, formatting, and user-facing strings. -library; - -import 'package:flutter/material.dart'; - -import 'constants.dart'; - -/// Horizontal padding that grows a little on wider screens. -double pagePadding(double width) { - if (width >= Breakpoints.medium) return 32; - if (width >= Breakpoints.compact) return 24; - return 16; -} - -/// Formats [date] as `MMM d, y` without pulling in `intl`. -String formatDate(DateTime date) { - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; -} - -/// Converts a stored theme key into a [ThemeMode]. -ThemeMode themeModeFromName(String name) { - return switch (name) { - 'light' => ThemeMode.light, - 'dark' => ThemeMode.dark, - _ => ThemeMode.system, - }; -} - -/// Inverse of [themeModeFromName] for persistence. -String nameFromThemeMode(ThemeMode mode) { - return switch (mode) { - ThemeMode.light => 'light', - ThemeMode.dark => 'dark', - ThemeMode.system => 'system', - }; -} - -/// Title-cases a [ThemeMode] for settings labels. -String labelForThemeMode(ThemeMode mode) { - return switch (mode) { - ThemeMode.light => 'Light', - ThemeMode.dark => 'Dark', - ThemeMode.system => 'System', - }; -} diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart deleted file mode 100644 index 4cdeb3e..0000000 --- a/lib/widgets/app_text_field.dart +++ /dev/null @@ -1,44 +0,0 @@ -/// Labeled text field with a shared decoration used by the add-item form. -library; - -import 'package:flutter/material.dart'; - -class AppTextField extends StatelessWidget { - const AppTextField({ - super.key, - required this.controller, - required this.label, - this.hint, - this.maxLines = 1, - this.textInputAction, - this.keyboardType, - this.validator, - this.autofocus = false, - }); - - final TextEditingController controller; - final String label; - final String? hint; - final int maxLines; - final TextInputAction? textInputAction; - final TextInputType? keyboardType; - final String? Function(String?)? validator; - final bool autofocus; - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - maxLines: maxLines, - autofocus: autofocus, - textInputAction: textInputAction, - keyboardType: keyboardType, - validator: validator, - decoration: InputDecoration( - labelText: label, - hintText: hint, - alignLabelWithHint: maxLines > 1, - ), - ); - } -} diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart deleted file mode 100644 index 088c92e..0000000 --- a/lib/widgets/empty_state.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// Placeholder shown when a list or search has nothing to display. -library; - -import 'package:flutter/material.dart'; - -class EmptyState extends StatelessWidget { - const EmptyState({ - super.key, - required this.icon, - required this.title, - required this.message, - this.actionLabel, - this.onAction, - }); - - final IconData icon; - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: colors.outline), - const SizedBox(height: 16), - Text(title, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(height: 20), - FilledButton(onPressed: onAction, child: Text(actionLabel!)), - ], - ], - ), - ), - ); - } -} diff --git a/lib/widgets/item_card.dart b/lib/widgets/item_card.dart deleted file mode 100644 index 71ed062..0000000 --- a/lib/widgets/item_card.dart +++ /dev/null @@ -1,127 +0,0 @@ -/// Tappable card used inside [ListView.builder] on Home and Search. -library; - -import 'package:flutter/material.dart'; - -import '../models/item.dart'; -import '../screens/item_detail_screen.dart'; -import '../utils/helpers.dart'; -import 'item_hero_badge.dart'; - -/// Displays one [Item] as a rounded, shadowed card that opens the detail route. -class ItemCard extends StatelessWidget { - const ItemCard({ - super.key, - required this.item, - this.heroScope = 'home', - }); - - final Item item; - - /// Prefix so Home and Search can show the same item without colliding Heroes. - final String heroScope; - - String get heroTag => '$heroScope-${item.heroTag}'; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: theme.shadowColor.withValues(alpha: 0.08), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], - ), - child: Material( - color: colors.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ItemDetailScreen( - item: item, - heroTag: heroTag, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ItemHeroBadge(item: item, heroTag: heroTag), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.title, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - item.subtitle, - style: theme.textTheme.bodyMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _MetaChip( - label: item.category.label, - color: item.category.accent, - ), - _MetaChip( - label: formatDate(item.createdAt), - color: colors.outline, - ), - ], - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: colors.outline, - ), - ], - ), - ), - ), - ), - ); - } -} - -class _MetaChip extends StatelessWidget { - const _MetaChip({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color), - ), - ); - } -} diff --git a/lib/widgets/item_hero_badge.dart b/lib/widgets/item_hero_badge.dart deleted file mode 100644 index fbbb30b..0000000 --- a/lib/widgets/item_hero_badge.dart +++ /dev/null @@ -1,54 +0,0 @@ -/// Colored glyph used as the [Hero] flight target for an [Item]. -library; - -import 'package:flutter/material.dart'; - -import '../models/item.dart'; - -/// Rounded square that carries the category icon. Size is animated by [Hero]. -class ItemHeroBadge extends StatelessWidget { - const ItemHeroBadge({ - super.key, - required this.item, - required this.heroTag, - this.size = 48, - }); - - final Item item; - final double size; - - /// Unique among on-screen Heroes. Home and Search both list the same items, - /// so callers pass a scoped tag (for example `home-item-hero-1`). - final String heroTag; - - @override - Widget build(BuildContext context) { - final radius = size * 0.28; - return Hero( - tag: heroTag, - child: Material( - color: Colors.transparent, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - color: item.category.accent, - borderRadius: BorderRadius.circular(radius), - boxShadow: [ - BoxShadow( - color: item.category.accent.withValues(alpha: 0.35), - blurRadius: size * 0.18, - offset: Offset(0, size * 0.08), - ), - ], - ), - child: Icon( - item.category.icon, - color: Colors.white, - size: size * 0.5, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/loading_view.dart b/lib/widgets/loading_view.dart deleted file mode 100644 index 1ad7652..0000000 --- a/lib/widgets/loading_view.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Centered spinner used while [ItemController] is fetching data. -library; - -import 'package:flutter/material.dart'; - -class LoadingView extends StatelessWidget { - const LoadingView({super.key, this.message = 'Loading…'}); - - final String message; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text(message, style: Theme.of(context).textTheme.bodyMedium), - ], - ), - ); - } -} diff --git a/lib/widgets/responsive_body.dart b/lib/widgets/responsive_body.dart deleted file mode 100644 index 089fea2..0000000 --- a/lib/widgets/responsive_body.dart +++ /dev/null @@ -1,34 +0,0 @@ -/// Shared layout helper that centers content and caps width on large screens. -library; - -import 'package:flutter/material.dart'; - -import '../utils/constants.dart'; -import '../utils/helpers.dart'; - -/// Wraps [child] with responsive horizontal padding and an optional max width. -class ResponsiveBody extends StatelessWidget { - const ResponsiveBody({ - super.key, - required this.child, - this.maxWidth = kMaxContentWidth, - }); - - final Widget child; - final double maxWidth; - - @override - Widget build(BuildContext context) { - final width = MediaQuery.sizeOf(context).width; - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: pagePadding(width)), - child: child, - ), - ), - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 5600031..e16b52a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" cupertino_icons: dependency: "direct main" description: @@ -57,22 +65,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" - ffi: + file_selector_linux: dependency: transitive description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "2.2.0" - file: + version: "0.9.4+1" + file_selector_macos: dependency: transitive description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "0.9.5+1" + 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: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -86,6 +110,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: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -96,8 +128,24 @@ packages: description: flutter source: sdk version: "0.0.0" - http: + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "621e94de22fd0f1e902e593b892746d80ab7542b39c2552aa091d904eb82fc6f" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + google_mlkit_text_recognition: dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: e13685af5093c8be009a3555909b72a80e10cfe8f97752bcbdf99fa0ccbda810 + url: "https://pub.dev" + source: hosted + version: "0.17.1" + http: + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -112,6 +160,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: f71f4f3a9c5dbbe39800b31839cb3b305aac403a7cfbddf8331cf179d813b72a + url: "https://pub.dev" + source: hosted + version: "0.8.13+21" + 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: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + 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" leak_tracker: dependency: transitive description: @@ -168,14 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" - nested: + mime: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.0.0" path: dependency: transitive description: @@ -184,38 +296,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -224,70 +304,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" - url: "https://pub.dev" - source: hosted - version: "2.4.28" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" - url: "https://pub.dev" - source: hosted - version: "2.5.7" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -373,14 +389,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" sdks: dart: ">=3.13.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 20fabd9..f91cdfb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,92 +1,25 @@ -name: myapp -description: Generic Flutter Android starter with Material 3, Provider, and local persistence. -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. +name: receipity +description: Capture a shopping receipt and show Google ML Kit text recognition output. +publish_to: 'none' version: 1.0.0+1 environment: sdk: ^3.13.2 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog - http: ^1.6.0 # Placeholder network client (JSONPlaceholder) - shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle + image_picker: ^1.2.3 + google_mlkit_text_recognition: ^0.17.1 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + assets: + - assets/receipt.jpeg diff --git a/test/widget_test.dart b/test/widget_test.dart index 9793445..82ac2e3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,66 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:myapp/main.dart'; -import 'package:myapp/models/item.dart'; -import 'package:myapp/services/item_repository.dart'; -import 'package:myapp/services/storage_service.dart'; -import 'package:myapp/utils/constants.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:receipity/main.dart'; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('scan screen shows capture actions', (tester) async { + await tester.pumpWidget(const ReceipityApp()); - Future pumpApp(WidgetTester tester) async { - SharedPreferences.setMockInitialValues({}); - final storage = StorageService(); - await storage.init(); - await tester.pumpWidget( - MyApp( - storage: storage, - repository: ItemRepository(), - ), + expect(find.text('Receipity'), findsOneWidget); + expect(find.text('Take photo'), findsOneWidget); + expect(find.text('Pick from gallery'), findsOneWidget); + expect(find.text('Use sample receipt'), findsOneWidget); + expect( + find.textContaining('Take a photo of a shopping receipt'), + findsOneWidget, ); - } - - testWidgets('splash shows the app name then opens Home', (tester) async { - await pumpApp(tester); - - expect(find.text(kAppName), findsWidgets); - - await tester.pump(kSplashDuration); - await tester.pump(const Duration(milliseconds: 450)); - await tester.pump(const Duration(milliseconds: 350)); - - expect(find.text('Home'), findsWidgets); - expect(find.text('Featured items'), findsOneWidget); - expect(find.text('Search'), findsOneWidget); - expect(find.text('Profile'), findsOneWidget); - expect(find.text('Settings'), findsOneWidget); - }); - - testWidgets('tapping a list card opens the detail screen', (tester) async { - await pumpApp(tester); - await tester.pump(kSplashDuration); - await tester.pump(const Duration(milliseconds: 450)); - await tester.pump(const Duration(milliseconds: 350)); - - await tester.tap(find.text('Material 3 surfaces')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - - expect(find.text('Details'), findsOneWidget); - expect(find.text('Back to list'), findsOneWidget); - }); - - test('Item.fromJson maps a JSONPlaceholder post', () { - final item = Item.fromJson({ - 'id': 3, - 'title': 'hello world', - 'body': 'First sentence. Second sentence.', - }); - - expect(item.id, 'remote-3'); - expect(item.title, 'Hello world'); - expect(item.subtitle, 'First sentence.'); - expect(item.category, ItemCategory.lifestyle); }); }