full app done

This commit is contained in:
davmar
2026-09-18 00:01:42 +02:00
parent 8475abeddb
commit 78fd23f69f
74 changed files with 8104 additions and 1963 deletions
-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),
],
),
);
}
}
+168
View File
@@ -0,0 +1,168 @@
/// Shared layout chrome for Values Sort screens.
library;
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import 'theme_toggle_button.dart';
/// Centers content and applies mobile margins from the design system.
class PracticeScaffold extends StatelessWidget {
const PracticeScaffold({
super.key,
required this.child,
this.header,
this.footer,
this.padding,
this.showThemeToggle = true,
});
final Widget child;
final Widget? header;
final Widget? footer;
final EdgeInsetsGeometry? padding;
final bool showThemeToggle;
@override
Widget build(BuildContext context) {
final pad = padding ??
const EdgeInsets.fromLTRB(20, 8, 20, 16);
return Scaffold(
body: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kMaxContentWidth),
child: Column(
children: [
if (showThemeToggle)
const Padding(
padding: EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Align(
alignment: Alignment.centerRight,
child: ThemeToggleButton(),
),
),
if (header != null) header!,
Expanded(
child: Padding(
padding: pad,
child: child,
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: footer!,
),
],
),
),
),
),
);
}
}
/// Compact brand row used on the welcome screen.
class BrandMark extends StatelessWidget {
const BrandMark({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final labels = Theme.of(context).textTheme;
return Row(
children: [
ClipOval(
child: Image.asset(
'assets/images/emblem.png',
width: 32,
height: 32,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 32,
height: 32,
color: colors.primaryContainer,
child: Icon(Icons.spa, size: 18, color: colors.onPrimary),
),
),
),
const SizedBox(width: 8),
Text(
'VALUES SORT',
style: labels.labelSmall?.copyWith(
letterSpacing: 2.2,
color: colors.onSurfaceVariant,
),
),
],
);
}
}
/// Full-width primary pill CTA.
class PrimaryPillButton extends StatelessWidget {
const PrimaryPillButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, size: 18),
],
],
),
);
}
}
/// Quiet secondary text action.
class GhostPillButton extends StatelessWidget {
const GhostPillButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
if (icon != null) ...[
const SizedBox(width: 6),
Icon(icon, size: 18),
],
],
),
);
}
}
-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,
),
),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
/// Moon / sun control for switching light and dark themes.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key});
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeController>();
final colors = Theme.of(context).colorScheme;
final dark = theme.isDark;
return IconButton(
tooltip: dark ? 'Switch to light mode' : 'Switch to dark mode',
onPressed: theme.toggleLightDark,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
style: IconButton.styleFrom(
backgroundColor: colors.surfaceContainer,
foregroundColor: colors.onSurfaceVariant,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
size: 16,
),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
/// Visual tiles for a [ValueCard] in hero, sort, and list contexts.
library;
import 'package:flutter/material.dart';
import '../models/value_card.dart';
/// Elevated paper card used during sorting and as list detail rows.
class ValueCardTile extends StatelessWidget {
const ValueCardTile({
super.key,
required this.card,
this.compact = false,
this.trailing,
this.onTap,
this.elevation = 0,
});
final ValueCard card;
final bool compact;
final Widget? trailing;
final VoidCallback? onTap;
final double elevation;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surfaceContainerLow,
elevation: elevation,
shadowColor: colors.shadow.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(color: colors.outlineVariant),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: EdgeInsets.all(compact ? 14 : 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
card.title,
style: (compact ? text.titleLarge : text.headlineMedium)
?.copyWith(color: colors.onSurface),
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
],
),
if (card.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
card.description,
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: compact ? 3 : 5,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
);
}
}
/// Small chip used in overview grids.
class ValueChip extends StatelessWidget {
const ValueChip({
super.key,
required this.title,
this.onTap,
});
final String title;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ActionChip(
label: Text(title),
onPressed: onTap,
visualDensity: VisualDensity.compact,
);
}
}