first
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// 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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/// 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user