Compare commits
No commits in common. "cef112872ab199f402daf67a4e0631a40cc3276f" and "427bb38381f004a96166d563e39aff5f10a1cb18" have entirely different histories.
cef112872a
...
427bb38381
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/inventory-mobile-surface (Stage 6C complete)
|
- main baseline updated through: feat/orders-mobile-surface (Stage 6B complete)
|
||||||
- next branch: feat/bulk-operator-workflows (Stage 7B)
|
- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/inventory-mobile-surface (Stage 6C)
|
||||||
- current stage: Stage 6C complete — mobile inventory surface with MobileInventoryPage (search, filter chips, item count, pull-to-refresh, compact 120px cards), MobileInventoryDetailPage (haptic feedback, adjust quantity dialog, local SnackBar listener, SafeArea), shell updated from shared InventoryPage to MobileInventoryPage, and 6 new kell_mobile tests (26/26 total). Stage 6 complete.
|
- current stage: Stage 6B complete — mobile orders surface with MobileOrdersPage (search, filter chips, order count, pull-to-refresh), MobileOrderDetailPage (haptic feedback, confirmation dialogs, local SnackBar listener), and 6 new kell_mobile tests (20/20 total)
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -344,21 +344,6 @@
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
||||||
### feat/inventory-mobile-surface
|
|
||||||
|
|
||||||
- status: merged to main
|
|
||||||
- date: 2026-07-10
|
|
||||||
- inspection: complete
|
|
||||||
- implementation: complete
|
|
||||||
- files changed:
|
|
||||||
- `kell_mobile/lib/pages/mobile_inventory_page.dart` — new `MobileInventoryPage` with search bar, horizontal status filter chips (All / In Stock / Low Stock / Out of Stock / Draft), item count display, compact `_MobileInventoryCard` list at 120px height showing name, SKU, status chip, and quantity-on-hand, pull-to-refresh, `_detailPageActive` guard to suppress behind-route SnackBars
|
|
||||||
- `kell_mobile/lib/pages/mobile_inventory_detail_page.dart` — new `MobileInventoryDetailPage` wrapping shared `InventoryDetailPanel`; StatefulWidget with local controller listener for SnackBar feedback; shows `showAdjustQuantityDialog` on Adjust Quantity tap; haptic feedback (mediumImpact on result, lightImpact on successful adjust); `SafeArea` wrapper
|
|
||||||
- `kell_mobile/lib/shell/mobile_shell.dart` — Inventory tab (case 1) switched from shared `InventoryPage` to `MobileInventoryPage`; removed unused `feature_inventory` direct import
|
|
||||||
- `kell_mobile/test/widget_test.dart` — added 6 new Stage 6C tests: Inventory tab search bar, status filter chips, item count, inventory card navigation to detail, adjust quantity dialog, back navigation returning to inventory list — 26 total kell_mobile tests
|
|
||||||
- tests: passed (26/26 kell_mobile, 75/75 feature_inventory — all passing)
|
|
||||||
- analyze: passed (dart analyze --fatal-infos — 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
|
||||||
|
|
|
||||||
|
|
@ -137,9 +137,9 @@ Rules:
|
||||||
- latest reported count for `feature_orders`: `115/115 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`: `26/26 passed`
|
- latest reported count for `kell_mobile`: `20/20 passed`
|
||||||
- total: `612/612 passed`
|
- total: `606/606 passed`
|
||||||
- baseline commit: merge of `feat/inventory-mobile-surface` (Stage 6C complete, 2026-07-10)
|
- baseline commit: merge of `feat/orders-mobile-surface` (Stage 6B complete, 2026-07-10)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -155,8 +155,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review).
|
**`feat/inventory-mobile-surface`** — Stage 6C (inventory): Mobile inventory surface for Android (matching the pattern established by `feat/android-publishing-surface` and `feat/orders-mobile-surface`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review).
|
||||||
Branch from latest `main`. Stage 6C (inventory mobile surface) is complete. All three primary features (Products, Orders, Inventory) now have mobile-optimized surfaces on Android. Stage 6 is complete. Next logical step is adding more bulk publishing actions.
|
Branch from latest `main`. Stage 6B (orders mobile surface) is complete. The orders feature now has full write-capable UI on both web and mobile. Next logical step is either surfacing inventory on mobile or adding more bulk publishing actions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// Full-screen inventory detail page for mobile.
|
|
||||||
///
|
|
||||||
/// Receives the shared [InventoryController] so that quantity adjustments are
|
|
||||||
/// immediately reflected in the list when the user pops back.
|
|
||||||
///
|
|
||||||
/// Wraps the shared [InventoryDetailPanel] from [feature_inventory] inside a
|
|
||||||
/// [Scaffold] with an [AppBar] showing the item name.
|
|
||||||
///
|
|
||||||
/// Unlike the web layout where feedback SnackBars are handled by the inventory
|
|
||||||
/// page wrapper, this detail page is pushed via [Navigator] and owns its own
|
|
||||||
/// [Scaffold]. It therefore attaches its own listener to the controller and
|
|
||||||
/// shows action-result SnackBars using its local [BuildContext], ensuring
|
|
||||||
/// they are visible on the active screen.
|
|
||||||
///
|
|
||||||
/// Quantity-adjust actions present a confirmation dialog before executing,
|
|
||||||
/// reducing accidental taps on touch screens.
|
|
||||||
class MobileInventoryDetailPage extends StatefulWidget {
|
|
||||||
final InventoryController controller;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewProduct;
|
|
||||||
|
|
||||||
const MobileInventoryDetailPage({super.key, required this.controller, this.onViewProduct});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MobileInventoryDetailPage> createState() => _MobileInventoryDetailPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MobileInventoryDetailPageState extends State<MobileInventoryDetailPage> {
|
|
||||||
InventoryController get _controller => widget.controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller.addListener(_onControllerChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.removeListener(_onControllerChanged);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles action result feedback via SnackBars on this detail page.
|
|
||||||
///
|
|
||||||
/// Because this page is pushed on top of the list page via [Navigator],
|
|
||||||
/// the list page's listener cannot reliably display SnackBars (they
|
|
||||||
/// would appear behind this route). This listener ensures feedback is
|
|
||||||
/// always visible to the operator.
|
|
||||||
void _onControllerChanged() {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final result = _controller.lastActionResult;
|
|
||||||
if (result != null && !_controller.isUpdating) {
|
|
||||||
_controller.lastActionResult = null;
|
|
||||||
showInventoryActionSnackBar(context, result);
|
|
||||||
if (result.success) HapticFeedback.mediumImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles the quantity adjustment flow.
|
|
||||||
///
|
|
||||||
/// Shows the shared [showAdjustQuantityDialog] and, if confirmed, calls
|
|
||||||
/// [InventoryController.adjustQuantity] with haptic feedback on success.
|
|
||||||
Future<void> _handleAdjustQuantity() async {
|
|
||||||
final item = _controller.selectedItem;
|
|
||||||
if (item == null) return;
|
|
||||||
|
|
||||||
final dialogResult = await showAdjustQuantityDialog(context, item);
|
|
||||||
if (dialogResult == null) return;
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
await _controller.adjustQuantity(item.id, dialogResult.delta, dialogResult.reason);
|
|
||||||
if (_controller.lastActionResult?.success == true) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
final item = _controller.selectedItem;
|
|
||||||
|
|
||||||
// If the item was removed or deselected, pop back.
|
|
||||||
if (item == null) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
});
|
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: Text(item.name)),
|
|
||||||
body: SafeArea(
|
|
||||||
child: InventoryDetailPanel(
|
|
||||||
item: item,
|
|
||||||
isUpdating: _controller.isUpdating,
|
|
||||||
onAdjustQuantity: _handleAdjustQuantity,
|
|
||||||
onViewProduct: widget.onViewProduct != null
|
|
||||||
? () => widget.onViewProduct!(item.sku)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:feature_inventory/feature_inventory.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'mobile_inventory_detail_page.dart';
|
|
||||||
|
|
||||||
/// Mobile-optimized inventory workspace page.
|
|
||||||
///
|
|
||||||
/// Provides browsing, filtering, and searching for inventory items on smaller
|
|
||||||
/// screens. Tapping an item pushes a full-screen detail page for viewing and
|
|
||||||
/// quantity adjustment.
|
|
||||||
///
|
|
||||||
/// Reuses the shared [InventoryController] and all use cases from
|
|
||||||
/// [feature_inventory] — no business logic is forked for mobile.
|
|
||||||
class MobileInventoryPage extends StatefulWidget {
|
|
||||||
final InventoryRepository repository;
|
|
||||||
|
|
||||||
/// Optional callback to navigate to the Products page for a given SKU.
|
|
||||||
final void Function(String sku)? onViewProduct;
|
|
||||||
|
|
||||||
const MobileInventoryPage({super.key, required this.repository, this.onViewProduct});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MobileInventoryPage> createState() => _MobileInventoryPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MobileInventoryPageState extends State<MobileInventoryPage> {
|
|
||||||
late final InventoryController _controller;
|
|
||||||
late final TextEditingController _searchController;
|
|
||||||
|
|
||||||
/// Whether the detail page is currently pushed on top.
|
|
||||||
///
|
|
||||||
/// When `true`, action-result SnackBars are suppressed here because the
|
|
||||||
/// detail page owns its own listener and shows feedback in its own
|
|
||||||
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
|
|
||||||
/// the detail route and be invisible to the operator.
|
|
||||||
bool _detailPageActive = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_searchController = TextEditingController();
|
|
||||||
|
|
||||||
final repo = widget.repository;
|
|
||||||
_controller = InventoryController(
|
|
||||||
GetInventoryItems(repo),
|
|
||||||
AdjustInventoryQuantity(repo),
|
|
||||||
CreateInventoryItem(repo),
|
|
||||||
UpdateInventoryItem(repo),
|
|
||||||
);
|
|
||||||
|
|
||||||
_controller.addListener(_onControllerChanged);
|
|
||||||
_controller.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.removeListener(_onControllerChanged);
|
|
||||||
_controller.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles action result feedback via SnackBars.
|
|
||||||
///
|
|
||||||
/// Suppressed while the detail page is active — the detail page has its
|
|
||||||
/// own listener that shows SnackBars using its local context.
|
|
||||||
void _onControllerChanged() {
|
|
||||||
if (_detailPageActive) return;
|
|
||||||
|
|
||||||
final result = _controller.lastActionResult;
|
|
||||||
if (result != null && !_controller.isUpdating) {
|
|
||||||
_controller.lastActionResult = null;
|
|
||||||
showInventoryActionSnackBar(context, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
if (_controller.isLoading) {
|
|
||||||
return const KcLoadingState(message: 'Loading inventory…');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_controller.error != null) {
|
|
||||||
return KcErrorState(message: 'Failed to load inventory.', onRetry: _controller.load);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_buildSearchBar(),
|
|
||||||
_buildFilterChips(),
|
|
||||||
_buildItemCount(),
|
|
||||||
Expanded(child: _buildItemList()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search bar with real-time filtering.
|
|
||||||
Widget _buildSearchBar() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.sm, KcSpacing.md, KcSpacing.xs),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Search by name or SKU…',
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 20),
|
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.clear, size: 20),
|
|
||||||
onPressed: () {
|
|
||||||
_searchController.clear();
|
|
||||||
_controller.setSearchQuery('');
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: KcSpacing.sm,
|
|
||||||
vertical: KcSpacing.sm,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
),
|
|
||||||
onChanged: (query) {
|
|
||||||
_controller.setSearchQuery(query);
|
|
||||||
setState(() {}); // refresh clear button visibility
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Horizontal scrollable filter chips for status filtering.
|
|
||||||
Widget _buildFilterChips() {
|
|
||||||
const filters = [
|
|
||||||
(null, 'All'),
|
|
||||||
('inStock', 'In Stock'),
|
|
||||||
('lowStock', 'Low Stock'),
|
|
||||||
('outOfStock', 'Out of Stock'),
|
|
||||||
('draft', 'Draft'),
|
|
||||||
];
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
height: 48,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
|
||||||
itemCount: filters.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: KcSpacing.xs),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final (value, label) = filters[index];
|
|
||||||
final isActive = _controller.activeFilter == value;
|
|
||||||
return FilterChip(
|
|
||||||
label: Text(label),
|
|
||||||
selected: isActive,
|
|
||||||
onSelected: (_) => _controller.setFilter(isActive ? null : value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Item count row.
|
|
||||||
Widget _buildItemCount() {
|
|
||||||
final count = _controller.items.length;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
'$count ${count == 1 ? 'item' : 'items'}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scrollable inventory list using compact cards.
|
|
||||||
Widget _buildItemList() {
|
|
||||||
final items = _controller.items;
|
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return KcEmptyState(
|
|
||||||
icon: Icons.inventory_2_outlined,
|
|
||||||
message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null
|
|
||||||
? 'No items match your criteria.'
|
|
||||||
: 'No inventory items available.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _controller.load,
|
|
||||||
child: ListView.separated(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md),
|
|
||||||
itemCount: items.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
return SizedBox(
|
|
||||||
height: 120,
|
|
||||||
child: _MobileInventoryCard(
|
|
||||||
item: item,
|
|
||||||
isSelected: item.id == _controller.selectedItem?.id,
|
|
||||||
onTap: () => _navigateToDetail(item),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigates to the full-screen inventory detail page.
|
|
||||||
///
|
|
||||||
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
|
|
||||||
/// detail page is visible. Cleared when the detail page pops back.
|
|
||||||
void _navigateToDetail(InventoryItem item) {
|
|
||||||
_controller.selectItem(item);
|
|
||||||
setState(() => _detailPageActive = true);
|
|
||||||
Navigator.of(context)
|
|
||||||
.push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => MobileInventoryDetailPage(
|
|
||||||
controller: _controller,
|
|
||||||
onViewProduct: widget.onViewProduct,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((_) {
|
|
||||||
if (mounted) setState(() => _detailPageActive = false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Compact mobile inventory card ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A compact card for displaying an [InventoryItem] in the mobile list.
|
|
||||||
///
|
|
||||||
/// Shows name, SKU, status chip, and quantity at a glance.
|
|
||||||
class _MobileInventoryCard extends StatelessWidget {
|
|
||||||
final InventoryItem item;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _MobileInventoryCard({required this.item, required this.isSelected, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
elevation: isSelected ? 3 : 1,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
side: isSelected ? BorderSide(color: theme.colorScheme.primary, width: 2) : BorderSide.none,
|
|
||||||
),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(KcSpacing.sm),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: theme.textTheme.titleSmall,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'SKU: ${item.sku}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.xs),
|
|
||||||
InventoryStatusChip(status: item.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${item.quantityOnHand}',
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'on hand',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.xs),
|
|
||||||
const Icon(Icons.chevron_right, color: KcColors.neutral),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:core/core.dart';
|
import 'package:core/core.dart';
|
||||||
|
import 'package:feature_inventory/feature_inventory.dart';
|
||||||
import 'package:feature_policy/feature_policy.dart';
|
import 'package:feature_policy/feature_policy.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
@ -8,7 +9,6 @@ import '../dashboard/application/get_dashboard_summary.dart';
|
||||||
import '../pages/dashboard_page.dart';
|
import '../pages/dashboard_page.dart';
|
||||||
import '../pages/finance_placeholder_page.dart';
|
import '../pages/finance_placeholder_page.dart';
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
import '../pages/integrations_placeholder_page.dart';
|
||||||
import '../pages/mobile_inventory_page.dart';
|
|
||||||
import '../pages/mobile_orders_page.dart';
|
import '../pages/mobile_orders_page.dart';
|
||||||
import '../pages/mobile_publishing_page.dart';
|
import '../pages/mobile_publishing_page.dart';
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ class _MobileShellState extends State<MobileShell> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return MobileInventoryPage(
|
return InventoryPage(
|
||||||
repository: services.inventoryRepository,
|
repository: services.inventoryRepository,
|
||||||
onViewProduct: (_) {
|
onViewProduct: (_) {
|
||||||
// Cross-feature nav: switch to Products tab.
|
// Cross-feature nav: switch to Products tab.
|
||||||
|
|
|
||||||
|
|
@ -402,141 +402,4 @@ void main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Stage 6C: Mobile inventory surface tests ─────────────────────
|
|
||||||
|
|
||||||
testWidgets('Inventory tab shows mobile inventory page with search bar', (
|
|
||||||
WidgetTester tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap the Inventory destination
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Should show the search bar
|
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
|
||||||
expect(find.text('Search by name or SKU…'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Inventory tab shows status filter chips', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Should show filter chips for inventory status categories
|
|
||||||
expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget);
|
|
||||||
expect(find.widgetWithText(FilterChip, 'In Stock'), findsOneWidget);
|
|
||||||
expect(find.widgetWithText(FilterChip, 'Low Stock'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Inventory tab shows item count', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Fake data should produce an item count label
|
|
||||||
expect(find.textContaining('item'), findsWidgets);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('tapping inventory card navigates to inventory detail page', (
|
|
||||||
WidgetTester tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Navigate to Inventory tab
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap the first 120-height inventory card
|
|
||||||
final firstItem = find.byWidgetPredicate(
|
|
||||||
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
|
|
||||||
);
|
|
||||||
if (firstItem.evaluate().isNotEmpty) {
|
|
||||||
await tester.tap(firstItem.first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Should now be on the detail page — AppBar shows the item name
|
|
||||||
expect(find.byType(AppBar), findsOneWidget);
|
|
||||||
// Detail page shows Adjust Quantity button
|
|
||||||
expect(find.text('Adjust Quantity'), findsOneWidget);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('inventory detail page shows adjust quantity dialog', (WidgetTester tester) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Navigate to Inventory tab
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap the first inventory card to navigate to detail
|
|
||||||
final firstItem = find.byWidgetPredicate(
|
|
||||||
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
|
|
||||||
);
|
|
||||||
if (firstItem.evaluate().isNotEmpty) {
|
|
||||||
await tester.tap(firstItem.first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Scroll down to reveal the Adjust Quantity button
|
|
||||||
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap Adjust Quantity
|
|
||||||
final adjustButton = find.text('Adjust Quantity');
|
|
||||||
if (adjustButton.evaluate().isNotEmpty) {
|
|
||||||
await tester.tap(adjustButton);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Adjust Quantity dialog should appear
|
|
||||||
expect(find.byType(AlertDialog), findsOneWidget);
|
|
||||||
expect(find.text('Adjust Quantity'), findsWidgets);
|
|
||||||
expect(find.text('Cancel'), findsOneWidget);
|
|
||||||
expect(find.text('Confirm'), findsOneWidget);
|
|
||||||
|
|
||||||
// Cancel should dismiss the dialog
|
|
||||||
await tester.tap(find.text('Cancel'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byType(AlertDialog), findsNothing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('inventory detail page back navigation returns to inventory list', (
|
|
||||||
WidgetTester tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_buildTestApp());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Navigate to Inventory tab
|
|
||||||
await tester.tap(find.text('Inventory').last);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap the first inventory card
|
|
||||||
final firstItem = find.byWidgetPredicate(
|
|
||||||
(widget) => widget is SizedBox && widget.height == 120 && widget.child != null,
|
|
||||||
);
|
|
||||||
if (firstItem.evaluate().isNotEmpty) {
|
|
||||||
await tester.tap(firstItem.first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Press back
|
|
||||||
final backButton = find.byType(BackButton);
|
|
||||||
if (backButton.evaluate().isNotEmpty) {
|
|
||||||
await tester.tap(backButton);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Should be back on the inventory list with search bar
|
|
||||||
expect(find.text('Search by name or SKU…'), findsOneWidget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue