diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 41afac0..8f12664 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: feat/orders-domain (Stage 5C complete) -- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-page-ui (Stage 5D) -- current stage: Stage 5C complete — orders domain expansion with write operations (updateOrderStatus, addOrderNote, createOrder), OrderActionResult, copyWith, and 90 tests +- main baseline updated through: feat/orders-page-ui (Stage 5D complete) +- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-mobile-surface (Stage 6B) +- current stage: Stage 5D complete — orders page UI with search/filter toolbar, OrderDetailPanel, AddNoteDialog, OrderCard, OrderStatusChip, OrdersActionSnackBar, and 115 tests ## Slice tracker @@ -310,6 +310,25 @@ - analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders and kell_web) - brief updated: yes +### feat/orders-page-ui + +- status: merged to main +- date: 2026-07-10 +- inspection: complete +- implementation: complete +- files changed: + - `feature_orders/lib/src/presentation/orders_page.dart` — full orders page with search bar, status filter chips (All / Pending / Processing / Shipped / Delivered / Cancelled), order count display, `OrderCard` list, master-detail wide layout, push-navigation narrow layout; `initialFilter`, `initialQuery`, `initialSelectedId` constructor params; `onViewProduct` and `onViewInventory` cross-feature callbacks + - `feature_orders/lib/src/presentation/widgets/order_card.dart` — `OrderCard` with order ID, customer name, date, status chip, item count, and total + - `feature_orders/lib/src/presentation/widgets/order_detail_panel.dart` — `OrderDetailPanel` with customer info, shipping address, line items table, order total, order notes, status update dropdown, Add Note button, and `isUpdating` loading indicator; optional `onViewProduct`/`onViewInventory` cross-feature links per line item + - `feature_orders/lib/src/presentation/widgets/order_status_chip.dart` — `OrderStatusChip` mapping `OrderStatus` to label and color + - `feature_orders/lib/src/presentation/widgets/add_note_dialog.dart` — `AddNoteDialog` with text field, empty-note validation, and `AddNoteDialogResult` value object + - `feature_orders/lib/src/presentation/orders_action_snack_bar.dart` — `showOrderActionSnackBar()` helper for success/failure feedback + - `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new presentation types and `OrdersController` + - `feature_orders/test/widgets/orders_page_test.dart` — 25 new widget tests covering `OrderDetailPanel` (14), `AddNoteDialog` (3), `OrdersPage` (6), and `_EmptyOrdersRepository` test double +- tests: passed (115/115 feature_orders, 24/24 kell_web — 139 total for affected packages) +- analyze: passed (dart analyze — no issues found) +- brief updated: yes + ### chore/analyze-cleanup-and-tracker-sync - status: merged to main diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 4c3dad5..fccf3f5 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -111,6 +111,8 @@ Rules: - ✅ Flutter CI/CD pipeline landed (Stage 4C complete — merged `feat/flutter-cicd` → `main`, 2026-05-22). - ✅ Test coverage visibility landed (Stage 4D complete — merged `feat/test-coverage-visibility` → `main`, 2026-05-22). - ✅ Bulk move-to-draft action landed (Stage 7A complete — merged `feat/bulk-status-actions` → `main`, 2026-05-30). +- ✅ Orders domain expansion landed (Stage 5C complete — merged `feat/orders-domain` → `main`, 2026-07-10). +- ✅ Orders page UI hardening landed (Stage 5D complete — merged `feat/orders-page-ui` → `main`, 2026-07-10). ### Current narrow edit capabilities on `main` @@ -132,11 +134,12 @@ Rules: - latest reported count for `core`: `20/20 passed` - latest reported count for `design_system`: `41/41 passed` - latest reported count for `feature_inventory`: `75/75 passed` +- latest reported count for `feature_orders`: `115/115 passed` - latest reported count for `feature_wordpress`: `311/311 passed` - latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_mobile`: `14/14 passed` -- total: `485/485 passed` -- baseline commit: merge of `feat/inventory-page-ui` (Stage 5B complete, 2026-07-10) +- total: `600/600 passed` +- baseline commit: merge of `feat/orders-page-ui` (Stage 5D complete, 2026-07-10) #### Baseline test coverage (established 2026-05-22) @@ -152,8 +155,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/orders-page-ui`** — Stage 5D: Orders page UI hardening (status update action from detail panel, add note UI, snack bar feedback — matching the pattern established by `feat/inventory-page-ui`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review). -Branch from latest `main`. Stage 5C (orders domain expansion) is complete. Stage 5D adds write-action UI to the orders page. Stage 7B adds incremental bulk actions to the publishing workflow. +**`feat/orders-mobile-surface`** — Stage 6B (orders): Mobile orders surface for Android (matching the pattern established by `feat/android-publishing-surface`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review). +Branch from latest `main`. Stage 5D (orders page UI) is complete. The orders feature now has full write-capable UI on web. Next logical step is either surfacing orders on mobile or adding more bulk publishing actions. --- @@ -1251,7 +1254,7 @@ Working rules: | ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- | | `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** | | `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** | -| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ⚠️ Read-only UI | 90 | **Fake write-ready** | +| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ✅ Write-capable UI | 115 | **Fake write-ready** | | `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** | | `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | | `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index 0a301e7..328945b 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -3,6 +3,7 @@ library; export 'src/application/add_order_note.dart'; export 'src/application/create_order.dart'; export 'src/application/get_orders.dart'; +export 'src/application/orders_controller.dart'; export 'src/application/update_order_status.dart'; export 'src/data/fake_orders_repository.dart'; export 'src/domain/order.dart'; @@ -10,4 +11,9 @@ export 'src/domain/order_action_result.dart'; export 'src/domain/order_item.dart'; export 'src/domain/order_status.dart'; export 'src/domain/orders_repository.dart'; +export 'src/presentation/orders_action_snack_bar.dart'; export 'src/presentation/orders_page.dart'; +export 'src/presentation/widgets/add_note_dialog.dart'; +export 'src/presentation/widgets/order_card.dart'; +export 'src/presentation/widgets/order_detail_panel.dart'; +export 'src/presentation/widgets/order_status_chip.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_action_snack_bar.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_action_snack_bar.dart new file mode 100644 index 0000000..467c104 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_action_snack_bar.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import '../domain/order_action_result.dart'; + +/// Shows a [SnackBar] reflecting the outcome of an order write action. +/// +/// Displays a success message when [result.success] is true, or an error +/// message (with optional detail) when it is false. +void showOrderActionSnackBar( + BuildContext context, + OrderActionResult result, { + String successMessage = 'Order 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), + ), + ); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart index 98979db..59fe9cf 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart @@ -1,16 +1,22 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import '../application/add_order_note.dart'; import '../application/get_orders.dart'; import '../application/orders_controller.dart'; +import '../application/update_order_status.dart'; +import '../domain/order_status.dart'; import '../domain/orders_repository.dart'; +import 'orders_action_snack_bar.dart'; +import 'widgets/add_note_dialog.dart'; import 'widgets/order_card.dart'; import 'widgets/order_detail_panel.dart'; /// The main Orders page. /// -/// Displays a list of orders on the left and a detail panel on the right. -/// Users can select an order to view its full details. +/// Provides a search bar, status filter chips, a scrollable order list, and a +/// detail panel that appears when an order is selected. Status updates and note +/// additions are performed via the detail panel and confirmed with a SnackBar. class OrdersPage extends StatefulWidget { final OrdersRepository repository; @@ -44,61 +50,111 @@ class OrdersPage extends StatefulWidget { } class _OrdersPageState extends State { - late final OrdersController controller; + late final OrdersController _controller; + late final TextEditingController _searchController; @override void initState() { super.initState(); - controller = OrdersController(GetOrders(widget.repository)); + _controller = OrdersController( + GetOrders(widget.repository), + updateOrderStatus: UpdateOrderStatus(widget.repository), + addOrderNote: AddOrderNote(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 ID if requested. + _controller.addListener(_onControllerChanged); + + _controller.load().then((_) { if (widget.initialSelectedId != null) { - controller.selectById(widget.initialSelectedId!); + _controller.selectById(widget.initialSelectedId!); } }); } @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) { + final message = result.success ? 'Order updated.' : 'Update failed.'; + showOrderActionSnackBar(context, result, successMessage: message); + // Consume the result so it is not shown again on the next rebuild. + _controller.lastActionResult = null; + } + } + + Future _handleAddNote() async { + final selected = _controller.selectedOrder; + if (selected == null) return; + final dialogResult = await showAddNoteDialog(context); + if (dialogResult == null) return; + await _controller.addOrderNote(selected.id, dialogResult.note); + } + + Future _handleUpdateStatus(OrderStatus newStatus) async { + final selected = _controller.selectedOrder; + if (selected == null) return; + await _controller.updateOrderStatus(selected.id, newStatus); + } + @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 orders…'); } - if (controller.error != null) { - return const Center(child: Text('Failed to load orders.')); + if (_controller.error != null) { + return KcErrorState(message: 'Failed to load orders.', onRetry: _controller.load); } return LayoutBuilder( builder: (context, constraints) { - // On narrow screens show only the list; on wide screens show - // a master-detail layout. - if (constraints.maxWidth < 800) { - return _buildOrderList(); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, + final isWide = constraints.maxWidth >= KcBreakpoints.medium; + + return Column( children: [ - SizedBox(width: 380, child: _buildOrderList()), - const SizedBox(width: KcSpacing.md), - Expanded(child: _buildDetail()), + _OrdersToolbar( + searchController: _searchController, + activeFilter: _controller.activeFilter, + orderCount: _controller.orders.length, + onSearchChanged: _controller.setSearchQuery, + onFilterChanged: _controller.setFilter, + ), + Expanded( + child: isWide + ? _WideLayout( + controller: _controller, + onUpdateStatus: _handleUpdateStatus, + onAddNote: _handleAddNote, + onViewProduct: widget.onViewProduct, + onViewInventory: widget.onViewInventory, + ) + : _NarrowLayout( + controller: _controller, + onUpdateStatus: _handleUpdateStatus, + onAddNote: _handleAddNote, + onViewProduct: widget.onViewProduct, + onViewInventory: widget.onViewInventory, + ), + ), ], ); }, @@ -106,9 +162,203 @@ class _OrdersPageState extends State { }, ); } +} + +// ── Toolbar ──────────────────────────────────────────────────────────────────── + +class _OrdersToolbar extends StatelessWidget { + final TextEditingController searchController; + final String? activeFilter; + final int orderCount; + final ValueChanged onSearchChanged; + final ValueChanged onFilterChanged; + + const _OrdersToolbar({ + required this.searchController, + required this.activeFilter, + required this.orderCount, + required this.onSearchChanged, + required this.onFilterChanged, + }); + + static const _filters = [ + (label: 'All', value: null), + (label: 'Pending', value: 'pending'), + (label: 'Processing', value: 'processing'), + (label: 'Shipped', value: 'shipped'), + (label: 'Delivered', value: 'delivered'), + (label: 'Cancelled', value: 'cancelled'), + ]; + + @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 customer name or order ID…', + 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 + order 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( + '$orderCount order${orderCount == 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 OrdersController controller; + final Future Function(OrderStatus) onUpdateStatus; + final Future Function() onAddNote; + final void Function(String sku)? onViewProduct; + final void Function(String sku)? onViewInventory; + + const _WideLayout({ + required this.controller, + required this.onUpdateStatus, + required this.onAddNote, + required this.onViewProduct, + required this.onViewInventory, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Master list — fixed width + SizedBox(width: 340, child: _OrderList(controller: controller)), + const VerticalDivider(width: 1), + // Detail panel — remaining width + Expanded( + child: controller.selectedOrder == null + ? const KcEmptyState( + icon: Icons.receipt_long_outlined, + message: 'Select an order from the list to view details.', + ) + : OrderDetailPanel( + order: controller.selectedOrder!, + isUpdating: controller.isUpdating, + onUpdateStatus: onUpdateStatus, + onAddNote: onAddNote, + onViewProduct: onViewProduct, + onViewInventory: onViewInventory, + ), + ), + ], + ); + } +} + +// ── Narrow layout (list only; tap navigates to detail) ───────────────────────── + +class _NarrowLayout extends StatelessWidget { + final OrdersController controller; + final Future Function(OrderStatus) onUpdateStatus; + final Future Function() onAddNote; + final void Function(String sku)? onViewProduct; + final void Function(String sku)? onViewInventory; + + const _NarrowLayout({ + required this.controller, + required this.onUpdateStatus, + required this.onAddNote, + required this.onViewProduct, + required this.onViewInventory, + }); + + @override + Widget build(BuildContext context) { + return _OrderList( + controller: controller, + onTap: (order) { + controller.selectOrder(order); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _OrderDetailPage( + controller: controller, + onUpdateStatus: onUpdateStatus, + onAddNote: onAddNote, + onViewProduct: onViewProduct, + onViewInventory: onViewInventory, + ), + ), + ); + }, + ); + } +} + +// ── Shared order list ────────────────────────────────────────────────────────── + +class _OrderList extends StatelessWidget { + final OrdersController controller; + final void Function(dynamic order)? onTap; + + const _OrderList({required this.controller, this.onTap}); + + @override + Widget build(BuildContext context) { + if (controller.orders.isEmpty) { + return const KcEmptyState( + icon: Icons.receipt_long_outlined, + message: 'No orders found. Try adjusting your search or filter.', + ); + } - Widget _buildOrderList() { return ListView.separated( + padding: const EdgeInsets.all(KcSpacing.md), itemCount: controller.orders.length, separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm), itemBuilder: (context, index) { @@ -118,22 +368,53 @@ class _OrdersPageState extends State { child: OrderCard( order: order, isSelected: order.id == controller.selectedOrder?.id, - onTap: () => controller.selectOrder(order), + onTap: () { + controller.selectOrder(order); + onTap?.call(order); + }, ), ); }, ); } +} - Widget _buildDetail() { - final selected = controller.selectedOrder; - if (selected == null) { - return const Center(child: Text('Select an order to view details')); - } - return OrderDetailPanel( - order: selected, - onViewProduct: widget.onViewProduct, - onViewInventory: widget.onViewInventory, +// ── Narrow detail page (pushed on mobile) ───────────────────────────────────── + +class _OrderDetailPage extends StatelessWidget { + final OrdersController controller; + final Future Function(OrderStatus) onUpdateStatus; + final Future Function() onAddNote; + final void Function(String sku)? onViewProduct; + final void Function(String sku)? onViewInventory; + + const _OrderDetailPage({ + required this.controller, + required this.onUpdateStatus, + required this.onAddNote, + required this.onViewProduct, + required this.onViewInventory, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(controller.selectedOrder?.id ?? 'Order Details')), + body: AnimatedBuilder( + animation: controller, + builder: (context, _) { + final order = controller.selectedOrder; + if (order == null) return const SizedBox.shrink(); + return OrderDetailPanel( + order: order, + isUpdating: controller.isUpdating, + onUpdateStatus: onUpdateStatus, + onAddNote: onAddNote, + onViewProduct: onViewProduct, + onViewInventory: onViewInventory, + ); + }, + ), ); } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/add_note_dialog.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/add_note_dialog.dart new file mode 100644 index 0000000..3482c0c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/add_note_dialog.dart @@ -0,0 +1,80 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// The result returned by [showAddNoteDialog]. +class AddNoteDialogResult { + /// The trimmed note text entered by the operator. + final String note; + + const AddNoteDialogResult({required this.note}); +} + +/// Shows a modal dialog that lets the operator type a note for an order. +/// +/// Returns an [AddNoteDialogResult] when the operator confirms, or `null` +/// if they cancel. +Future showAddNoteDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const _AddNoteDialog(), + ); +} + +class _AddNoteDialog extends StatefulWidget { + const _AddNoteDialog(); + + @override + State<_AddNoteDialog> createState() => _AddNoteDialogState(); +} + +class _AddNoteDialogState extends State<_AddNoteDialog> { + final _controller = TextEditingController(); + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final text = _controller.text.trim(); + if (text.isEmpty) { + setState(() => _error = 'Note cannot be empty.'); + return; + } + Navigator.of(context).pop(AddNoteDialogResult(note: text)); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Note'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter a note to attach to this order.'), + const SizedBox(height: KcSpacing.md), + TextField( + controller: _controller, + autofocus: true, + maxLines: 3, + decoration: InputDecoration( + hintText: 'e.g. Gift wrap requested, fragile item…', + border: const OutlineInputBorder(), + errorText: _error, + ), + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + FilledButton(onPressed: _submit, child: const Text('Add Note')), + ], + ); + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart index 45505d8..8f85bb2 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_detail_panel.dart @@ -2,14 +2,25 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import '../../domain/order.dart'; +import '../../domain/order_status.dart'; import 'order_status_chip.dart'; /// A detail panel that shows the full information for the selected [Order]. /// -/// Includes customer info, shipping address, line items table, and order total. +/// Includes customer info, shipping address, line items table, order total, +/// order notes, and action buttons for status update and note addition. class OrderDetailPanel extends StatelessWidget { final Order order; + /// Whether a write operation is currently in progress. + final bool isUpdating; + + /// Called when the operator selects a new status from the dropdown. + final void Function(OrderStatus newStatus)? onUpdateStatus; + + /// Called when the operator taps "Add Note". + final VoidCallback? onAddNote; + /// Optional callback to navigate to the Products page for a given SKU. final void Function(String sku)? onViewProduct; @@ -19,6 +30,9 @@ class OrderDetailPanel extends StatelessWidget { const OrderDetailPanel({ super.key, required this.order, + this.isUpdating = false, + this.onUpdateStatus, + this.onAddNote, this.onViewProduct, this.onViewInventory, }); @@ -27,122 +41,176 @@ class OrderDetailPanel extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return KcCard( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header ───────────────────────────────────────────────── - Row( - children: [ - Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineMedium)), - const SizedBox(width: KcSpacing.sm), - OrderStatusChip(status: order.status), - ], - ), - const SizedBox(height: KcSpacing.md), + return SingleChildScrollView( + padding: const EdgeInsets.all(KcSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ───────────────────────────────────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineSmall)), + const SizedBox(width: KcSpacing.sm), + OrderStatusChip(status: order.status), + ], + ), + const SizedBox(height: KcSpacing.md), - // ── Customer info ────────────────────────────────────────── - Text('Customer', style: theme.textTheme.titleLarge), - const SizedBox(height: KcSpacing.sm), - _MetadataRow(label: 'Name', value: order.customerName), - _MetadataRow(label: 'Email', value: order.customerEmail), - _MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)), - const SizedBox(height: KcSpacing.md), + // ── Customer info ────────────────────────────────────────── + KcSectionHeader(title: 'Customer'), + const SizedBox(height: KcSpacing.sm), + _MetadataRow(label: 'Name', value: order.customerName), + _MetadataRow(label: 'Email', value: order.customerEmail), + if (order.customerPhone != null) + _MetadataRow(label: 'Phone', value: order.customerPhone!), + _MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)), + const SizedBox(height: KcSpacing.md), - // ── Shipping address ─────────────────────────────────────── - Text('Shipping Address', style: theme.textTheme.titleLarge), - const SizedBox(height: KcSpacing.sm), - Text(order.shippingAddress, style: theme.textTheme.bodyLarge), - const SizedBox(height: KcSpacing.md), + // ── Shipping address ─────────────────────────────────────── + KcSectionHeader(title: 'Shipping Address'), + const SizedBox(height: KcSpacing.sm), + Text(order.shippingAddress, style: theme.textTheme.bodyMedium), + const SizedBox(height: KcSpacing.md), - // ── Line items ───────────────────────────────────────────── - Text('Items', style: theme.textTheme.titleLarge), - const SizedBox(height: KcSpacing.sm), - ...order.items.map( - (item) => Padding( - padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), - child: Row( - children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.productName, style: theme.textTheme.bodyMedium), - Text( - 'SKU: ${item.sku}', - style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral), - ), - if (onViewProduct != null || onViewInventory != null) - Padding( - padding: const EdgeInsets.only(top: KcSpacing.xs), - child: Wrap( - spacing: KcSpacing.sm, - children: [ - if (onViewProduct != null) - GestureDetector( - onTap: () => onViewProduct!(item.sku), - child: Text( - 'View Product', - style: theme.textTheme.bodySmall?.copyWith( - color: KcColors.denimBlue, - fontWeight: FontWeight.w600, - ), + const Divider(), + const SizedBox(height: KcSpacing.md), + + // ── Line items ───────────────────────────────────────────── + KcSectionHeader(title: 'Items'), + const SizedBox(height: KcSpacing.sm), + ...order.items.map( + (item) => Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.productName, style: theme.textTheme.bodyMedium), + Text( + 'SKU: ${item.sku}', + style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + if (onViewProduct != null || onViewInventory != null) + Padding( + padding: const EdgeInsets.only(top: KcSpacing.xs), + child: Wrap( + spacing: KcSpacing.sm, + children: [ + if (onViewProduct != null) + GestureDetector( + onTap: () => onViewProduct!(item.sku), + child: Text( + 'View Product', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, ), ), - if (onViewInventory != null) - GestureDetector( - onTap: () => onViewInventory!(item.sku), - child: Text( - 'View Inventory', - style: theme.textTheme.bodySmall?.copyWith( - color: KcColors.denimBlue, - fontWeight: FontWeight.w600, - ), + ), + if (onViewInventory != null) + GestureDetector( + onTap: () => onViewInventory!(item.sku), + child: Text( + 'View Inventory', + style: theme.textTheme.bodySmall?.copyWith( + color: KcColors.denimBlue, + fontWeight: FontWeight.w600, ), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), - SizedBox( - width: 40, - child: Text( - 'x${item.quantity}', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + ), + SizedBox( + width: 40, + child: Text( + 'x${item.quantity}', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, ), - SizedBox( - width: 80, - child: Text( - '\$${item.lineTotal.toStringAsFixed(2)}', - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - textAlign: TextAlign.end, - ), + ), + SizedBox( + width: 80, + child: Text( + '\$${item.lineTotal.toStringAsFixed(2)}', + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.end, ), + ), + ], + ), + ), + ), + const Divider(height: KcSpacing.lg), + + // ── Total ────────────────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('Total: ', style: theme.textTheme.titleLarge), + Text( + '\$${order.total.toStringAsFixed(2)}', + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + + // ── Order notes ──────────────────────────────────────────── + if (order.orderNotes.isNotEmpty) ...[ + const SizedBox(height: KcSpacing.md), + const Divider(), + const SizedBox(height: KcSpacing.md), + KcSectionHeader(title: 'Notes'), + const SizedBox(height: KcSpacing.sm), + ...order.orderNotes.map( + (note) => Padding( + padding: const EdgeInsets.only(bottom: KcSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.notes, size: 16, color: KcColors.neutral), + const SizedBox(width: KcSpacing.xs), + Expanded(child: Text(note, style: theme.textTheme.bodyMedium)), ], ), ), ), - const Divider(height: KcSpacing.lg), + ], - // ── Total ────────────────────────────────────────────────── - Row( - mainAxisAlignment: MainAxisAlignment.end, + const SizedBox(height: KcSpacing.xl), + + // ── Actions ──────────────────────────────────────────────── + if (isUpdating) + const Center(child: CircularProgressIndicator()) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Total: ', style: theme.textTheme.titleLarge), - Text( - '\$${order.total.toStringAsFixed(2)}', - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), - ), + // Status update dropdown + if (onUpdateStatus != null) ...[ + Text('Update Status', style: theme.textTheme.labelLarge), + const SizedBox(height: KcSpacing.xs), + _StatusDropdown(currentStatus: order.status, onChanged: onUpdateStatus!), + const SizedBox(height: KcSpacing.sm), + ], + + // Add note button + if (onAddNote != null) + OutlinedButton.icon( + onPressed: onAddNote, + icon: const Icon(Icons.note_add_outlined, size: 18), + label: const Text('Add Note'), + ), ], ), - ], - ), + ], ), ); } @@ -152,6 +220,43 @@ class OrderDetailPanel extends StatelessWidget { } } +// ── Status dropdown ──────────────────────────────────────────────────────────── + +class _StatusDropdown extends StatelessWidget { + final OrderStatus currentStatus; + final void Function(OrderStatus) onChanged; + + const _StatusDropdown({required this.currentStatus, required this.onChanged}); + + static const _statuses = [ + (label: 'Pending', value: OrderStatus.pending), + (label: 'Processing', value: OrderStatus.processing), + (label: 'Shipped', value: OrderStatus.shipped), + (label: 'Delivered', value: OrderStatus.delivered), + (label: 'Cancelled', value: OrderStatus.cancelled), + ]; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + initialValue: currentStatus, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.sm), + ), + items: _statuses.map((s) => DropdownMenuItem(value: s.value, child: Text(s.label))).toList(), + onChanged: (value) { + if (value != null && value != currentStatus) { + onChanged(value); + } + }, + ); + } +} + +// ── Private helpers ──────────────────────────────────────────────────────────── + class _MetadataRow extends StatelessWidget { final String label; final String value; diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart new file mode 100644 index 0000000..c18fbf3 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart @@ -0,0 +1,301 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_orders/feature_orders.dart'; +import 'package:feature_orders/src/presentation/widgets/add_note_dialog.dart'; +import 'package:feature_orders/src/presentation/widgets/order_detail_panel.dart'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +final _sampleOrder = Order( + id: 'KC-9999', + customerName: 'Test Customer', + customerEmail: 'test@example.com', + customerPhone: '555-0100', + orderDate: DateTime(2026, 4, 1), + status: OrderStatus.pending, + shippingAddress: '123 Test St, Test City, TS 00000', + items: const [ + OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 2, unitPrice: 10.00), + ], +); + +final _orderWithNotes = Order( + id: 'KC-8888', + customerName: 'Noted Customer', + customerEmail: 'noted@example.com', + orderDate: DateTime(2026, 4, 2), + status: OrderStatus.processing, + shippingAddress: '456 Note Ave, Test City, TS 00001', + items: const [OrderItem(productName: 'Widget', sku: 'WG-001', quantity: 1, unitPrice: 5.00)], + orderNotes: ['Please gift wrap.', 'Fragile item.'], +); + +Widget _buildPanel( + Order order, { + bool isUpdating = false, + void Function(OrderStatus)? onUpdateStatus, + VoidCallback? onAddNote, +}) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: SingleChildScrollView( + child: OrderDetailPanel( + order: order, + isUpdating: isUpdating, + onUpdateStatus: onUpdateStatus, + onAddNote: onAddNote, + ), + ), + ), + ); +} + +Widget _buildPage(OrdersRepository repository) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold(body: OrdersPage(repository: repository)), + ); +} + +// ── Test doubles ─────────────────────────────────────────────────────────────── + +class _EmptyOrdersRepository implements OrdersRepository { + @override + Future> getOrders() async => []; + + @override + Future updateOrderStatus(String id, OrderStatus newStatus) async => + const OrderActionResult.failure('Not implemented.'); + + @override + Future addOrderNote(String id, String note) async => + const OrderActionResult.failure('Not implemented.'); + + @override + Future createOrder(Order order) async => + const OrderActionResult.failure('Not implemented.'); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +void main() { + // ── OrderDetailPanel ─────────────────────────────────────────────────────── + + group('OrderDetailPanel', () { + testWidgets('displays order ID in header', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Order KC-9999'), findsOneWidget); + }); + + testWidgets('displays status chip', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Pending'), findsOneWidget); + }); + + testWidgets('displays customer name', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Test Customer'), findsOneWidget); + }); + + testWidgets('displays customer email', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('test@example.com'), findsOneWidget); + }); + + testWidgets('displays customer phone when present', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('555-0100'), findsOneWidget); + }); + + testWidgets('displays shipping address', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('123 Test St, Test City, TS 00000'), findsOneWidget); + }); + + testWidgets('displays line item product name', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Test Product'), findsOneWidget); + }); + + testWidgets('displays order total', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + // 2 * 10.00 = 20.00 — appears in line item and in total row + expect(find.text('\$20.00'), findsWidgets); + }); + + testWidgets('shows status dropdown when onUpdateStatus is provided', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder, onUpdateStatus: (_) {})); + expect(find.text('Update Status'), findsOneWidget); + expect(find.byType(DropdownButtonFormField), findsOneWidget); + }); + + testWidgets('hides status dropdown when onUpdateStatus is null', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Update Status'), findsNothing); + expect(find.byType(DropdownButtonFormField), findsNothing); + }); + + testWidgets('shows Add Note button when onAddNote is provided', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder, onAddNote: () {})); + expect(find.text('Add Note'), findsOneWidget); + }); + + testWidgets('hides Add Note button when onAddNote is null', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Add Note'), findsNothing); + }); + + testWidgets('shows loading indicator when isUpdating is true', (tester) async { + await tester.pumpWidget( + _buildPanel(_sampleOrder, isUpdating: true, onUpdateStatus: (_) {}, onAddNote: () {}), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Actions hidden during update + expect(find.text('Update Status'), findsNothing); + expect(find.text('Add Note'), findsNothing); + }); + + testWidgets('displays order notes when present', (tester) async { + await tester.pumpWidget(_buildPanel(_orderWithNotes)); + expect(find.text('Please gift wrap.'), findsOneWidget); + expect(find.text('Fragile item.'), findsOneWidget); + }); + + testWidgets('hides notes section when orderNotes is empty', (tester) async { + await tester.pumpWidget(_buildPanel(_sampleOrder)); + expect(find.text('Notes'), findsNothing); + }); + + testWidgets('calls onAddNote when Add Note button is tapped', (tester) async { + var called = false; + await tester.pumpWidget(_buildPanel(_sampleOrder, onAddNote: () => called = true)); + await tester.tap(find.text('Add Note')); + await tester.pump(); + expect(called, true); + }); + }); + + // ── AddNoteDialog ────────────────────────────────────────────────────────── + + group('AddNoteDialog', () { + testWidgets('shows dialog title and text field', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildKcTheme(), + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showAddNoteDialog(context), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Dialog title "Add Note" and button "Add Note" both appear — use findsWidgets + expect(find.text('Add Note'), findsWidgets); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('shows validation error when submitting empty note', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildKcTheme(), + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showAddNoteDialog(context), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Tap Add Note without entering text + await tester.tap(find.widgetWithText(FilledButton, 'Add Note')); + await tester.pump(); + + expect(find.text('Note cannot be empty.'), findsOneWidget); + }); + + testWidgets('dismisses dialog on Cancel', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildKcTheme(), + home: Builder( + builder: (context) => Scaffold( + body: ElevatedButton( + onPressed: () => showAddNoteDialog(context), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Add Note'), findsNothing); + }); + }); + + // ── OrdersPage ───────────────────────────────────────────────────────────── + + group('OrdersPage', () { + testWidgets('shows loading state initially', (tester) async { + await tester.pumpWidget(_buildPage(FakeOrdersRepository())); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Drain pending timers so the widget tree can be disposed cleanly. + await tester.pumpAndSettle(); + }); + + testWidgets('shows order list after load', (tester) async { + await tester.pumpWidget(_buildPage(FakeOrdersRepository())); + await tester.pumpAndSettle(); + // FakeOrdersRepository has 6 orders + expect(find.byType(OrderCard), findsWidgets); + }); + + testWidgets('shows search bar', (tester) async { + await tester.pumpWidget(_buildPage(FakeOrdersRepository())); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('shows filter chips for all statuses', (tester) async { + await tester.pumpWidget(_buildPage(FakeOrdersRepository())); + await tester.pumpAndSettle(); + expect(find.text('All'), findsOneWidget); + // Status labels appear in both filter chips and order cards — use findsWidgets + expect(find.text('Pending'), findsWidgets); + expect(find.text('Processing'), findsWidgets); + expect(find.text('Shipped'), findsWidgets); + expect(find.text('Delivered'), findsWidgets); + expect(find.text('Cancelled'), findsWidgets); + }); + + testWidgets('shows order count', (tester) async { + await tester.pumpWidget(_buildPage(FakeOrdersRepository())); + await tester.pumpAndSettle(); + expect(find.text('6 orders'), findsOneWidget); + }); + + testWidgets('shows empty state when repository returns no orders', (tester) async { + await tester.pumpWidget(_buildPage(_EmptyOrdersRepository())); + await tester.pumpAndSettle(); + expect(find.text('No orders found. Try adjusting your search or filter.'), findsOneWidget); + }); + }); +}