reading test receipt text detected OK

This commit is contained in:
davmar
2026-08-28 19:14:38 +02:00
parent 0311b8955a
commit 2d8fc46a30
31 changed files with 356 additions and 2289 deletions
+7 -53
View File
@@ -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<void> 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<ThemeController>().themeMode;
return MaterialApp(
title: kAppName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: themeMode,
home: const SplashScreen(),
home: const ScanScreen(),
);
}
}
-130
View File
@@ -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<String, dynamic> 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<String, dynamic> 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);
}
}
-93
View File
@@ -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<Item> _items = const [];
bool _isLoading = false;
String? _error;
List<Item> get items => List.unmodifiable(_items);
bool get isLoading => _isLoading;
String? get error => _error;
bool get useRemoteData => _repository.useRemoteData;
int get count => _items.length;
Future<void> loadItems() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
// Small delay so the loading indicator is visible with local data.
await Future<void>.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<void> 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<Item> 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;
}
}
-37
View File
@@ -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<void> setThemeMode(ThemeMode mode) async {
if (mode == _themeMode) return;
_themeMode = mode;
notifyListeners();
await _storage.setThemeModeName(nameFromThemeMode(mode));
}
Future<void> cycleThemeMode() {
final next = switch (_themeMode) {
ThemeMode.system => ThemeMode.light,
ThemeMode.light => ThemeMode.dark,
ThemeMode.dark => ThemeMode.system,
};
return setThemeMode(next);
}
}
+192
View File
@@ -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<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
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<void> _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<void> _capture(ImageSource source) async {
final file = await _picker.pickImage(
source: source,
imageQuality: 95,
);
if (file == null || !mounted) return;
await _runOcr(file.path);
}
Future<void> _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<void> _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,
),
),
);
}
}
-142
View File
@@ -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<AddItemScreen> createState() => _AddItemScreenState();
}
class _AddItemScreenState extends State<AddItemScreen> {
final _formKey = GlobalKey<FormState>();
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<ItemController>().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<ItemCategory>(
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),
],
),
),
),
);
}
}
-212
View File
@@ -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<ItemController>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Home')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(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,
),
),
],
),
);
}
}
-82
View File
@@ -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),
],
),
),
);
}
}
-64
View File
@@ -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<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
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',
),
],
),
);
}
}
-142
View File
@@ -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<ItemController>();
final themeController = context.watch<ThemeController>();
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(
'[email protected]',
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<void>(
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),
],
),
);
}
}
-104
View File
@@ -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<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final results = context.watch<ItemController>().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<Item> 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'),
);
},
);
}
}
-104
View File
@@ -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<ThemeController>();
final items = context.watch<ItemController>();
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<ThemeMode>(
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),
],
),
),
);
}
}
-130
View File
@@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fade;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
_fade = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
_scale = Tween<double>(begin: 0.92, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
);
_controller.forward();
_goToShell();
}
Future<void> _goToShell() async {
await Future<void>.delayed(kSplashDuration);
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder<void>(
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,
),
),
],
),
),
),
),
),
),
);
}
}
-97
View File
@@ -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<Item> 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: '1624px 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),
),
];
}
-61
View File
@@ -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<List<Item>> 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<Map<String, dynamic>>()
.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;
}
-50
View File
@@ -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<List<Item>> fetchItems() async {
if (!useRemoteData) {
return List<Item>.from(FakeItemData.items);
}
try {
return await _api.fetchItems();
} catch (_) {
// Keep the demo usable if the device is offline or the API fails.
return List<Item>.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(),
);
}
}
-38
View File
@@ -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<void> 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<void> setThemeModeName(String value) {
return _prefs.setString(StorageKeys.themeMode, value);
}
bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false;
Future<void> setUseRemoteData(bool value) {
return _prefs.setBool(StorageKeys.useRemoteData, value);
}
}
+4 -31
View File
@@ -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';
-59
View File
@@ -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',
};
}
-44
View File
@@ -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,
),
);
}
}
-51
View File
@@ -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!)),
],
],
),
),
);
}
}
-127
View File
@@ -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<void>(
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),
),
);
}
}
-54
View File
@@ -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,
),
),
),
);
}
}
-24
View File
@@ -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),
],
),
);
}
}
-34
View File
@@ -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,
),
),
);
}
}