chore(inventory): stage uncommitted presentation layer files from Stage 5B
Publish Docs / publish-docs (push) Successful in 42s Details

This commit is contained in:
Mike Kell 2026-07-10 18:52:29 -04:00
parent 44689b5cdb
commit 73fdcf99e7
6 changed files with 681 additions and 38 deletions

View File

@ -10,4 +10,9 @@ export 'src/domain/inventory_adjustment_result.dart';
export 'src/domain/inventory_item.dart';
export 'src/domain/inventory_repository.dart';
export 'src/domain/inventory_status.dart';
export 'src/presentation/inventory_action_snack_bar.dart';
export 'src/presentation/inventory_page.dart';
export 'src/presentation/widgets/adjust_quantity_dialog.dart';
export 'src/presentation/widgets/inventory_detail_panel.dart';
export 'src/presentation/widgets/inventory_item_card.dart';
export 'src/presentation/widgets/inventory_status_chip.dart';

View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../domain/inventory_adjustment_result.dart';
/// Shows a [SnackBar] reflecting the outcome of an inventory write action.
///
/// Displays a success message when [result.success] is true, or an error
/// message (with optional detail) when it is false.
void showInventoryActionSnackBar(
BuildContext context,
InventoryAdjustmentResult result, {
String successMessage = 'Quantity updated.',
}) {
if (!context.mounted) return;
final isSuccess = result.success;
final message = isSuccess
? successMessage
: 'Update failed.${result.errorMessage != null ? ' ${result.errorMessage}' : ''}';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isSuccess ? Colors.green.shade700 : Colors.red.shade700,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}

View File

@ -6,9 +6,18 @@ import '../application/create_inventory_item.dart';
import '../application/get_inventory_items.dart';
import '../application/inventory_controller.dart';
import '../application/update_inventory_item.dart';
import '../domain/inventory_item.dart';
import '../domain/inventory_repository.dart';
import 'inventory_action_snack_bar.dart';
import 'widgets/adjust_quantity_dialog.dart';
import 'widgets/inventory_detail_panel.dart';
import 'widgets/inventory_item_card.dart';
/// The main inventory workspace page.
///
/// Provides a search bar, status filter chips, a scrollable item list, and a
/// detail panel that appears when an item is selected. Quantity adjustments are
/// performed via a modal dialog and confirmed with a SnackBar.
class InventoryPage extends StatefulWidget {
final InventoryRepository repository;
@ -39,81 +48,106 @@ class InventoryPage extends StatefulWidget {
}
class _InventoryPageState extends State<InventoryPage> {
late final InventoryController controller;
late final InventoryController _controller;
late final TextEditingController _searchController;
@override
void initState() {
super.initState();
controller = InventoryController(
_controller = InventoryController(
GetInventoryItems(widget.repository),
AdjustInventoryQuantity(widget.repository),
CreateInventoryItem(widget.repository),
UpdateInventoryItem(widget.repository),
);
_searchController = TextEditingController(text: widget.initialQuery ?? '');
// Apply any initial filter / query before loading.
if (widget.initialFilter != null) {
controller.activeFilter = widget.initialFilter;
_controller.activeFilter = widget.initialFilter;
}
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
controller.searchQuery = widget.initialQuery!;
_controller.searchQuery = widget.initialQuery!;
}
controller.load().then((_) {
// After data is loaded, try to pre-select by SKU if requested.
_controller.addListener(_onControllerChanged);
_controller.load().then((_) {
if (widget.initialSelectedSku != null) {
controller.selectBySku(widget.initialSelectedSku!);
_controller.selectBySku(widget.initialSelectedSku!);
}
});
}
@override
void dispose() {
controller.dispose();
_controller.removeListener(_onControllerChanged);
_controller.dispose();
_searchController.dispose();
super.dispose();
}
void _onControllerChanged() {
final result = _controller.lastActionResult;
if (result != null && !_controller.isUpdating) {
showInventoryActionSnackBar(
context,
result,
successMessage: result.success ? 'Quantity updated.' : 'Update failed.',
);
// Consume the result so it is not shown again on the next rebuild.
_controller.lastActionResult = null;
}
}
Future<void> _handleAdjustQuantity(InventoryItem item) async {
final dialogResult = await showAdjustQuantityDialog(context, item);
if (dialogResult == null) return;
await _controller.adjustQuantity(item.id, dialogResult.delta, dialogResult.reason);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
animation: _controller,
builder: (context, _) {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
if (_controller.isLoading) {
return const KcLoadingState(message: 'Loading inventory…');
}
if (controller.error != null) {
return const Center(child: Text('Failed to load inventory data.'));
if (_controller.error != null) {
return KcErrorState(message: 'Failed to load inventory data.', onRetry: _controller.load);
}
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final isWide = constraints.maxWidth >= KcBreakpoints.medium;
int crossAxisCount = 1;
if (width >= 1200) {
crossAxisCount = 3;
} else if (width >= 700) {
crossAxisCount = 2;
}
return GridView.builder(
itemCount: controller.items.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: KcSpacing.md,
mainAxisSpacing: KcSpacing.md,
childAspectRatio: 1.5,
),
itemBuilder: (context, index) {
final item = controller.items[index];
return InventoryItemCard(
item: item,
onViewProduct: widget.onViewProduct != null
? () => widget.onViewProduct!(item.sku)
: null,
);
return Column(
children: [
_InventoryToolbar(
searchController: _searchController,
activeFilter: _controller.activeFilter,
itemCount: _controller.items.length,
onSearchChanged: (q) {
_controller.setSearchQuery(q);
},
onFilterChanged: _controller.setFilter,
),
Expanded(
child: isWide
? _WideLayout(
controller: _controller,
onAdjustQuantity: _handleAdjustQuantity,
onViewProduct: widget.onViewProduct,
)
: _NarrowLayout(
controller: _controller,
onAdjustQuantity: _handleAdjustQuantity,
onViewProduct: widget.onViewProduct,
),
),
],
);
},
);
@ -121,3 +155,261 @@ class _InventoryPageState extends State<InventoryPage> {
);
}
}
// Toolbar
class _InventoryToolbar extends StatelessWidget {
final TextEditingController searchController;
final String? activeFilter;
final int itemCount;
final ValueChanged<String> onSearchChanged;
final ValueChanged<String?> onFilterChanged;
const _InventoryToolbar({
required this.searchController,
required this.activeFilter,
required this.itemCount,
required this.onSearchChanged,
required this.onFilterChanged,
});
static const _filters = [
(label: 'All', value: null),
(label: 'In Stock', value: 'inStock'),
(label: 'Low Stock', value: 'lowStock'),
(label: 'Out of Stock', value: 'outOfStock'),
(label: 'Draft', value: 'draft'),
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.md, KcSpacing.md, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search bar
TextField(
controller: searchController,
onChanged: onSearchChanged,
decoration: InputDecoration(
hintText: 'Search by name or SKU…',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear search',
onPressed: () {
searchController.clear();
onSearchChanged('');
},
)
: null,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: KcSpacing.sm),
// Filter chips + item count
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _filters.map((f) {
final isSelected = activeFilter == f.value;
return Padding(
padding: const EdgeInsets.only(right: KcSpacing.xs),
child: FilterChip(
label: Text(f.label),
selected: isSelected,
onSelected: (_) => onFilterChanged(f.value),
),
);
}).toList(),
),
),
),
const SizedBox(width: KcSpacing.sm),
Text(
'$itemCount item${itemCount == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
],
),
const SizedBox(height: KcSpacing.sm),
],
),
);
}
}
// Wide layout (master + detail side-by-side)
class _WideLayout extends StatelessWidget {
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _WideLayout({
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Master list 40% width
SizedBox(
width: 340,
child: _ItemList(controller: controller, onViewProduct: onViewProduct),
),
const VerticalDivider(width: 1),
// Detail panel remaining width
Expanded(
child: controller.selectedItem == null
? const KcEmptyState(
icon: Icons.inventory_2_outlined,
message: 'Select an item from the list to view details.',
)
: InventoryDetailPanel(
item: controller.selectedItem!,
isUpdating: controller.isUpdating,
onAdjustQuantity: () => onAdjustQuantity(controller.selectedItem!),
onViewProduct: onViewProduct != null
? () => onViewProduct!(controller.selectedItem!.sku)
: null,
),
),
],
);
}
}
// Narrow layout (list only; tap navigates to detail)
class _NarrowLayout extends StatelessWidget {
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _NarrowLayout({
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return _ItemList(
controller: controller,
onViewProduct: onViewProduct,
onTap: (item) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _InventoryDetailPage(
item: item,
controller: controller,
onAdjustQuantity: onAdjustQuantity,
onViewProduct: onViewProduct,
),
),
);
},
);
}
}
// Shared item list
class _ItemList extends StatelessWidget {
final InventoryController controller;
final void Function(String sku)? onViewProduct;
/// Optional tap handler for narrow layout navigation.
final void Function(InventoryItem)? onTap;
const _ItemList({required this.controller, required this.onViewProduct, this.onTap});
@override
Widget build(BuildContext context) {
if (controller.items.isEmpty) {
return const KcEmptyState(
icon: Icons.inventory_2_outlined,
message: 'No items found. Try adjusting your search or filter.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(KcSpacing.md),
itemCount: controller.items.length,
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
itemBuilder: (context, index) {
final item = controller.items[index];
final isSelected = controller.selectedItem?.id == item.id;
return GestureDetector(
onTap: () {
controller.selectItem(item);
onTap?.call(item);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? Theme.of(context).colorScheme.primary : Colors.transparent,
width: 2,
),
),
child: InventoryItemCard(
item: item,
onViewProduct: onViewProduct != null ? () => onViewProduct!(item.sku) : null,
),
),
);
},
);
}
}
// Narrow detail page (pushed on mobile)
class _InventoryDetailPage extends StatelessWidget {
final InventoryItem item;
final InventoryController controller;
final Future<void> Function(InventoryItem) onAdjustQuantity;
final void Function(String sku)? onViewProduct;
const _InventoryDetailPage({
required this.item,
required this.controller,
required this.onAdjustQuantity,
required this.onViewProduct,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(item.name)),
body: AnimatedBuilder(
animation: controller,
builder: (context, _) {
// Use the refreshed item from the controller if available.
final current = controller.selectedItem?.id == item.id ? controller.selectedItem! : item;
return InventoryDetailPanel(
item: current,
isUpdating: controller.isUpdating,
onAdjustQuantity: () => onAdjustQuantity(current),
onViewProduct: onViewProduct != null ? () => onViewProduct!(current.sku) : null,
);
},
),
);
}
}

View File

@ -0,0 +1,142 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../domain/inventory_item.dart';
/// Result returned by [showAdjustQuantityDialog].
class AdjustQuantityDialogResult {
final int delta;
final String reason;
const AdjustQuantityDialogResult({required this.delta, required this.reason});
}
/// Shows a modal dialog for adjusting the quantity of [item].
///
/// Returns an [AdjustQuantityDialogResult] if the operator confirms, or `null`
/// if they cancel.
Future<AdjustQuantityDialogResult?> showAdjustQuantityDialog(
BuildContext context,
InventoryItem item,
) {
return showDialog<AdjustQuantityDialogResult>(
context: context,
builder: (_) => _AdjustQuantityDialog(item: item),
);
}
// Private dialog widget
class _AdjustQuantityDialog extends StatefulWidget {
final InventoryItem item;
const _AdjustQuantityDialog({required this.item});
@override
State<_AdjustQuantityDialog> createState() => _AdjustQuantityDialogState();
}
class _AdjustQuantityDialogState extends State<_AdjustQuantityDialog> {
final _deltaController = TextEditingController();
final _reasonController = TextEditingController();
final _formKey = GlobalKey<FormState>();
/// Whether the adjustment is an addition (+) or removal ().
bool _isAddition = true;
@override
void dispose() {
_deltaController.dispose();
_reasonController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final currentQty = widget.item.quantityOnHand;
return AlertDialog(
title: const Text('Adjust Quantity'),
content: SizedBox(
width: 360,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current quantity display
Text(
'Current quantity: $currentQty',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.md),
// Add / Remove toggle
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('Add'), icon: Icon(Icons.add)),
ButtonSegment(value: false, label: Text('Remove'), icon: Icon(Icons.remove)),
],
selected: {_isAddition},
onSelectionChanged: (s) => setState(() => _isAddition = s.first),
),
const SizedBox(height: KcSpacing.md),
// Amount field
TextFormField(
controller: _deltaController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Amount',
hintText: 'e.g. 5',
border: OutlineInputBorder(),
),
autofocus: true,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Enter an amount';
final n = int.tryParse(v.trim());
if (n == null || n <= 0) return 'Must be a positive number';
if (!_isAddition && n > currentQty) {
return 'Cannot remove more than $currentQty';
}
return null;
},
),
const SizedBox(height: KcSpacing.md),
// Reason field
TextFormField(
controller: _reasonController,
decoration: const InputDecoration(
labelText: 'Reason',
hintText: 'e.g. restock, sold, damaged',
border: OutlineInputBorder(),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Enter a reason';
return null;
},
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(onPressed: _submit, child: const Text('Confirm')),
],
);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final amount = int.parse(_deltaController.text.trim());
final delta = _isAddition ? amount : -amount;
Navigator.of(
context,
).pop(AdjustQuantityDialogResult(delta: delta, reason: _reasonController.text.trim()));
}
}

View File

@ -0,0 +1,147 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/inventory_item.dart';
import 'inventory_status_chip.dart';
/// A detail panel that displays full information for a selected [InventoryItem]
/// and exposes callbacks for quantity adjustment and item editing.
class InventoryDetailPanel extends StatelessWidget {
final InventoryItem item;
final bool isUpdating;
/// Called when the operator taps "Adjust Quantity".
final VoidCallback? onAdjustQuantity;
/// Called when the operator taps "View Product" (cross-feature handoff).
final VoidCallback? onViewProduct;
const InventoryDetailPanel({
super.key,
required this.item,
required this.isUpdating,
this.onAdjustQuantity,
this.onViewProduct,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(KcSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(item.name, style: theme.textTheme.headlineSmall)),
InventoryStatusChip(status: item.status),
],
),
const SizedBox(height: KcSpacing.xs),
Text(
'SKU: ${item.sku}',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.md),
// Description
if (item.description.isNotEmpty) ...[
Text(item.description, style: theme.textTheme.bodyMedium),
const SizedBox(height: KcSpacing.md),
],
const Divider(),
const SizedBox(height: KcSpacing.md),
// Stock info
KcSectionHeader(title: 'Stock'),
const SizedBox(height: KcSpacing.sm),
_InfoRow(label: 'Quantity on hand', value: '${item.quantityOnHand}'),
_InfoRow(label: 'Reorder point', value: '${item.reorderPoint}'),
_InfoRow(label: 'Unit price', value: '\$${item.unitPrice.toStringAsFixed(2)}'),
const SizedBox(height: KcSpacing.md),
// Location / supplier
if (item.location != null || item.supplier != null) ...[
KcSectionHeader(title: 'Storage'),
const SizedBox(height: KcSpacing.sm),
if (item.location != null) _InfoRow(label: 'Location', value: item.location!),
if (item.supplier != null) _InfoRow(label: 'Supplier', value: item.supplier!),
const SizedBox(height: KcSpacing.md),
],
// Category
if (item.category.isNotEmpty) ...[
KcSectionHeader(title: 'Category'),
const SizedBox(height: KcSpacing.sm),
Text(item.category, style: theme.textTheme.bodyMedium),
const SizedBox(height: KcSpacing.md),
],
// Last updated
Text(
'Last updated: ${_formatDate(item.lastUpdated)}',
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
),
const SizedBox(height: KcSpacing.xl),
// Actions
if (isUpdating)
const Center(child: CircularProgressIndicator())
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: onAdjustQuantity,
icon: const Icon(Icons.tune),
label: const Text('Adjust Quantity'),
),
if (onViewProduct != null) ...[
const SizedBox(height: KcSpacing.sm),
OutlinedButton.icon(
onPressed: onViewProduct,
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('View Product'),
),
],
],
),
],
),
);
}
static String _formatDate(DateTime dt) =>
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
// Private helpers
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: KcSpacing.xs),
child: Row(
children: [
SizedBox(
width: 140,
child: Text(label, style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral)),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/inventory_status.dart';
/// A status chip for an [InventoryStatus] value, using the shared [KcStatusChip].
class InventoryStatusChip extends StatelessWidget {
final InventoryStatus status;
const InventoryStatusChip({super.key, required this.status});
@override
Widget build(BuildContext context) {
final (label, bg, fg) = _style(status);
return KcStatusChip(label: label, background: bg, foreground: fg);
}
static (String, Color, Color) _style(InventoryStatus status) {
switch (status) {
case InventoryStatus.inStock:
return ('In Stock', const Color(0xFFE8F5E9), KcColors.success);
case InventoryStatus.lowStock:
return ('Low Stock', const Color(0xFFFFF8E1), KcColors.warning);
case InventoryStatus.outOfStock:
return ('Out of Stock', const Color(0xFFFFEBEE), KcColors.danger);
case InventoryStatus.draft:
return ('Draft', const Color(0xFFECEFF1), KcColors.neutral);
}
}
}