This commit is contained in:
davmar
2026-08-28 18:23:56 +02:00
commit 0311b8955a
50 changed files with 3120 additions and 0 deletions
+44
View File
@@ -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,
),
);
}
}
+51
View File
@@ -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!)),
],
],
),
),
);
}
}
+127
View File
@@ -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),
),
);
}
}
+54
View File
@@ -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,
),
),
),
);
}
}
+24
View File
@@ -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),
],
),
);
}
}
+34
View File
@@ -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,
),
),
);
}
}