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