full app done
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user