Compare commits
No commits in common. "6f839d24cd0e9034c3baf455da8e11d0f37079eb" and "9a66a8a2b02dc8117f5d2bd7be62c16f5918ff42" have entirely different histories.
6f839d24cd
...
9a66a8a2b0
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/orders-page-ui (Stage 5D complete)
|
- main baseline updated through: feat/orders-domain (Stage 5C complete)
|
||||||
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-mobile-surface (Stage 6B)
|
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-page-ui (Stage 5D)
|
||||||
- current stage: Stage 5D complete — orders page UI with search/filter toolbar, OrderDetailPanel, AddNoteDialog, OrderCard, OrderStatusChip, OrdersActionSnackBar, and 115 tests
|
- current stage: Stage 5C complete — orders domain expansion with write operations (updateOrderStatus, addOrderNote, createOrder), OrderActionResult, copyWith, and 90 tests
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -310,25 +310,6 @@
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders and kell_web)
|
- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders and kell_web)
|
||||||
- brief updated: yes
|
- 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
|
### chore/analyze-cleanup-and-tracker-sync
|
||||||
|
|
||||||
- status: merged to main
|
- status: merged to main
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,6 @@ Rules:
|
||||||
- ✅ Flutter CI/CD pipeline landed (Stage 4C complete — merged `feat/flutter-cicd` → `main`, 2026-05-22).
|
- ✅ 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).
|
- ✅ 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).
|
- ✅ 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`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -134,12 +132,11 @@ Rules:
|
||||||
- latest reported count for `core`: `20/20 passed`
|
- latest reported count for `core`: `20/20 passed`
|
||||||
- latest reported count for `design_system`: `41/41 passed`
|
- latest reported count for `design_system`: `41/41 passed`
|
||||||
- latest reported count for `feature_inventory`: `75/75 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 `feature_wordpress`: `311/311 passed`
|
||||||
- latest reported count for `kell_web`: `24/24 passed`
|
- latest reported count for `kell_web`: `24/24 passed`
|
||||||
- latest reported count for `kell_mobile`: `14/14 passed`
|
- latest reported count for `kell_mobile`: `14/14 passed`
|
||||||
- total: `600/600 passed`
|
- total: `485/485 passed`
|
||||||
- baseline commit: merge of `feat/orders-page-ui` (Stage 5D complete, 2026-07-10)
|
- baseline commit: merge of `feat/inventory-page-ui` (Stage 5B complete, 2026-07-10)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -155,8 +152,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`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).
|
**`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 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1254,7 +1251,7 @@ Working rules:
|
||||||
| ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
| ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
|
||||||
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
|
| `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_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** |
|
||||||
| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ✅ Write-capable UI | 115 | **Fake write-ready** |
|
| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ⚠️ Read-only UI | 90 | **Fake write-ready** |
|
||||||
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
|
||||||
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ library;
|
||||||
export 'src/application/add_order_note.dart';
|
export 'src/application/add_order_note.dart';
|
||||||
export 'src/application/create_order.dart';
|
export 'src/application/create_order.dart';
|
||||||
export 'src/application/get_orders.dart';
|
export 'src/application/get_orders.dart';
|
||||||
export 'src/application/orders_controller.dart';
|
|
||||||
export 'src/application/update_order_status.dart';
|
export 'src/application/update_order_status.dart';
|
||||||
export 'src/data/fake_orders_repository.dart';
|
export 'src/data/fake_orders_repository.dart';
|
||||||
export 'src/domain/order.dart';
|
export 'src/domain/order.dart';
|
||||||
|
|
@ -11,9 +10,4 @@ export 'src/domain/order_action_result.dart';
|
||||||
export 'src/domain/order_item.dart';
|
export 'src/domain/order_item.dart';
|
||||||
export 'src/domain/order_status.dart';
|
export 'src/domain/order_status.dart';
|
||||||
export 'src/domain/orders_repository.dart';
|
export 'src/domain/orders_repository.dart';
|
||||||
export 'src/presentation/orders_action_snack_bar.dart';
|
|
||||||
export 'src/presentation/orders_page.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';
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../application/add_order_note.dart';
|
|
||||||
import '../application/get_orders.dart';
|
import '../application/get_orders.dart';
|
||||||
import '../application/orders_controller.dart';
|
import '../application/orders_controller.dart';
|
||||||
import '../application/update_order_status.dart';
|
|
||||||
import '../domain/order_status.dart';
|
|
||||||
import '../domain/orders_repository.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_card.dart';
|
||||||
import 'widgets/order_detail_panel.dart';
|
import 'widgets/order_detail_panel.dart';
|
||||||
|
|
||||||
/// The main Orders page.
|
/// The main Orders page.
|
||||||
///
|
///
|
||||||
/// Provides a search bar, status filter chips, a scrollable order list, and a
|
/// Displays a list of orders on the left and a detail panel on the right.
|
||||||
/// detail panel that appears when an order is selected. Status updates and note
|
/// Users can select an order to view its full details.
|
||||||
/// additions are performed via the detail panel and confirmed with a SnackBar.
|
|
||||||
class OrdersPage extends StatefulWidget {
|
class OrdersPage extends StatefulWidget {
|
||||||
final OrdersRepository repository;
|
final OrdersRepository repository;
|
||||||
|
|
||||||
|
|
@ -50,315 +44,71 @@ class OrdersPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OrdersPageState extends State<OrdersPage> {
|
class _OrdersPageState extends State<OrdersPage> {
|
||||||
late final OrdersController _controller;
|
late final OrdersController controller;
|
||||||
late final TextEditingController _searchController;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = OrdersController(
|
controller = OrdersController(GetOrders(widget.repository));
|
||||||
GetOrders(widget.repository),
|
|
||||||
updateOrderStatus: UpdateOrderStatus(widget.repository),
|
|
||||||
addOrderNote: AddOrderNote(widget.repository),
|
|
||||||
);
|
|
||||||
_searchController = TextEditingController(text: widget.initialQuery ?? '');
|
|
||||||
|
|
||||||
// Apply any initial filter / query before loading.
|
// Apply any initial filter / query before loading.
|
||||||
if (widget.initialFilter != null) {
|
if (widget.initialFilter != null) {
|
||||||
_controller.activeFilter = widget.initialFilter;
|
controller.activeFilter = widget.initialFilter;
|
||||||
}
|
}
|
||||||
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
|
||||||
_controller.searchQuery = widget.initialQuery!;
|
controller.searchQuery = widget.initialQuery!;
|
||||||
}
|
}
|
||||||
|
|
||||||
_controller.addListener(_onControllerChanged);
|
controller.load().then((_) {
|
||||||
|
// After data is loaded, try to pre-select by ID if requested.
|
||||||
_controller.load().then((_) {
|
|
||||||
if (widget.initialSelectedId != null) {
|
if (widget.initialSelectedId != null) {
|
||||||
_controller.selectById(widget.initialSelectedId!);
|
controller.selectById(widget.initialSelectedId!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.removeListener(_onControllerChanged);
|
controller.dispose();
|
||||||
_controller.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.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<void> _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<void> _handleUpdateStatus(OrderStatus newStatus) async {
|
|
||||||
final selected = _controller.selectedOrder;
|
|
||||||
if (selected == null) return;
|
|
||||||
await _controller.updateOrderStatus(selected.id, newStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (_controller.isLoading) {
|
if (controller.isLoading) {
|
||||||
return const KcLoadingState(message: 'Loading orders…');
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_controller.error != null) {
|
if (controller.error != null) {
|
||||||
return KcErrorState(message: 'Failed to load orders.', onRetry: _controller.load);
|
return const Center(child: Text('Failed to load orders.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth >= KcBreakpoints.medium;
|
// On narrow screens show only the list; on wide screens show
|
||||||
|
// a master-detail layout.
|
||||||
return Column(
|
if (constraints.maxWidth < 800) {
|
||||||
children: [
|
return _buildOrderList();
|
||||||
_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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toolbar ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _OrdersToolbar extends StatelessWidget {
|
|
||||||
final TextEditingController searchController;
|
|
||||||
final String? activeFilter;
|
|
||||||
final int orderCount;
|
|
||||||
final ValueChanged<String> onSearchChanged;
|
|
||||||
final ValueChanged<String?> 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<void> Function(OrderStatus) onUpdateStatus;
|
|
||||||
final Future<void> 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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Master list — fixed width
|
SizedBox(width: 380, child: _buildOrderList()),
|
||||||
SizedBox(width: 340, child: _OrderList(controller: controller)),
|
const SizedBox(width: KcSpacing.md),
|
||||||
const VerticalDivider(width: 1),
|
Expanded(child: _buildDetail()),
|
||||||
// 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<void> Function(OrderStatus) onUpdateStatus;
|
|
||||||
final Future<void> 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<void>(
|
|
||||||
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(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
|
||||||
itemCount: controller.orders.length,
|
itemCount: controller.orders.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
@ -368,53 +118,22 @@ class _OrderList extends StatelessWidget {
|
||||||
child: OrderCard(
|
child: OrderCard(
|
||||||
order: order,
|
order: order,
|
||||||
isSelected: order.id == controller.selectedOrder?.id,
|
isSelected: order.id == controller.selectedOrder?.id,
|
||||||
onTap: () {
|
onTap: () => controller.selectOrder(order),
|
||||||
controller.selectOrder(order);
|
|
||||||
onTap?.call(order);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Narrow detail page (pushed on mobile) ─────────────────────────────────────
|
Widget _buildDetail() {
|
||||||
|
final selected = controller.selectedOrder;
|
||||||
class _OrderDetailPage extends StatelessWidget {
|
if (selected == null) {
|
||||||
final OrdersController controller;
|
return const Center(child: Text('Select an order to view details'));
|
||||||
final Future<void> Function(OrderStatus) onUpdateStatus;
|
}
|
||||||
final Future<void> 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(
|
return OrderDetailPanel(
|
||||||
order: order,
|
order: selected,
|
||||||
isUpdating: controller.isUpdating,
|
onViewProduct: widget.onViewProduct,
|
||||||
onUpdateStatus: onUpdateStatus,
|
onViewInventory: widget.onViewInventory,
|
||||||
onAddNote: onAddNote,
|
|
||||||
onViewProduct: onViewProduct,
|
|
||||||
onViewInventory: onViewInventory,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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<AddNoteDialogResult?> showAddNoteDialog(BuildContext context) {
|
|
||||||
return showDialog<AddNoteDialogResult>(
|
|
||||||
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')),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,25 +2,14 @@ import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../domain/order.dart';
|
import '../../domain/order.dart';
|
||||||
import '../../domain/order_status.dart';
|
|
||||||
import 'order_status_chip.dart';
|
import 'order_status_chip.dart';
|
||||||
|
|
||||||
/// A detail panel that shows the full information for the selected [Order].
|
/// A detail panel that shows the full information for the selected [Order].
|
||||||
///
|
///
|
||||||
/// Includes customer info, shipping address, line items table, order total,
|
/// Includes customer info, shipping address, line items table, and order total.
|
||||||
/// order notes, and action buttons for status update and note addition.
|
|
||||||
class OrderDetailPanel extends StatelessWidget {
|
class OrderDetailPanel extends StatelessWidget {
|
||||||
final Order order;
|
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.
|
/// Optional callback to navigate to the Products page for a given SKU.
|
||||||
final void Function(String sku)? onViewProduct;
|
final void Function(String sku)? onViewProduct;
|
||||||
|
|
||||||
|
|
@ -30,9 +19,6 @@ class OrderDetailPanel extends StatelessWidget {
|
||||||
const OrderDetailPanel({
|
const OrderDetailPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.order,
|
required this.order,
|
||||||
this.isUpdating = false,
|
|
||||||
this.onUpdateStatus,
|
|
||||||
this.onAddNote,
|
|
||||||
this.onViewProduct,
|
this.onViewProduct,
|
||||||
this.onViewInventory,
|
this.onViewInventory,
|
||||||
});
|
});
|
||||||
|
|
@ -41,16 +27,15 @@ class OrderDetailPanel extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return KcCard(
|
||||||
padding: const EdgeInsets.all(KcSpacing.lg),
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ── Header ─────────────────────────────────────────────────
|
// ── Header ─────────────────────────────────────────────────
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineSmall)),
|
Expanded(child: Text('Order ${order.id}', style: theme.textTheme.headlineMedium)),
|
||||||
const SizedBox(width: KcSpacing.sm),
|
const SizedBox(width: KcSpacing.sm),
|
||||||
OrderStatusChip(status: order.status),
|
OrderStatusChip(status: order.status),
|
||||||
],
|
],
|
||||||
|
|
@ -58,26 +43,21 @@ class OrderDetailPanel extends StatelessWidget {
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
// ── Customer info ──────────────────────────────────────────
|
// ── Customer info ──────────────────────────────────────────
|
||||||
KcSectionHeader(title: 'Customer'),
|
Text('Customer', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
_MetadataRow(label: 'Name', value: order.customerName),
|
_MetadataRow(label: 'Name', value: order.customerName),
|
||||||
_MetadataRow(label: 'Email', value: order.customerEmail),
|
_MetadataRow(label: 'Email', value: order.customerEmail),
|
||||||
if (order.customerPhone != null)
|
|
||||||
_MetadataRow(label: 'Phone', value: order.customerPhone!),
|
|
||||||
_MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)),
|
_MetadataRow(label: 'Order Date', value: _formatDate(order.orderDate)),
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
// ── Shipping address ───────────────────────────────────────
|
// ── Shipping address ───────────────────────────────────────
|
||||||
KcSectionHeader(title: 'Shipping Address'),
|
Text('Shipping Address', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
Text(order.shippingAddress, style: theme.textTheme.bodyMedium),
|
Text(order.shippingAddress, style: theme.textTheme.bodyLarge),
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
// ── Line items ─────────────────────────────────────────────
|
// ── Line items ─────────────────────────────────────────────
|
||||||
KcSectionHeader(title: 'Items'),
|
Text('Items', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
...order.items.map(
|
...order.items.map(
|
||||||
(item) => Padding(
|
(item) => Padding(
|
||||||
|
|
@ -161,57 +141,9 @@ class OrderDetailPanel extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── 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 SizedBox(height: KcSpacing.xl),
|
|
||||||
|
|
||||||
// ── Actions ────────────────────────────────────────────────
|
|
||||||
if (isUpdating)
|
|
||||||
const Center(child: CircularProgressIndicator())
|
|
||||||
else
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// 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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,43 +152,6 @@ 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<OrderStatus>(
|
|
||||||
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 {
|
class _MetadataRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
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<List<Order>> getOrders() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async =>
|
|
||||||
const OrderActionResult.failure('Not implemented.');
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<OrderActionResult> addOrderNote(String id, String note) async =>
|
|
||||||
const OrderActionResult.failure('Not implemented.');
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<OrderActionResult> 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<OrderStatus>), 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<OrderStatus>), 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue