Compare commits
No commits in common. "10232495925f8a6f929e7397a4f67e0421213983" and "204bd3b118c6e5d36807d22deda399a9d7cc40ca" have entirely different histories.
1023249592
...
204bd3b118
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
- main baseline updated through: feat/wc-catalog-expansion (Stage 9C complete)
|
||||||
- next branch: TBD (Stage 10 — real WooCommerce API integration wiring)
|
- next branch: feat/wc-catalog-expansion-ui (Stage 9D — product creation UI, image picker, category management page)
|
||||||
- current stage: Stage 9D complete — product creation UI (`CreateProductDialog`), category management page (`CategoryManagementPage`), image management scaffolding, controller `createNewProduct`/`fetchCategories`, mobile create-product page, mobile publishing page
|
- current stage: Stage 9C complete — WooCommerce catalog expansion (product create/delete, category CRUD, image management)
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -532,24 +532,3 @@
|
||||||
- tests: passed (388/388 feature_wordpress, 24/24 kell_web — all passing)
|
- tests: passed (388/388 feature_wordpress, 24/24 kell_web — all passing)
|
||||||
- analyze: passed (dart analyze — no issues found)
|
- analyze: passed (dart analyze — no issues found)
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
||||||
### feat/wc-catalog-expansion-ui
|
|
||||||
|
|
||||||
- status: merged to main
|
|
||||||
- date: 2026-07-11
|
|
||||||
- inspection: complete
|
|
||||||
- implementation: complete
|
|
||||||
- files changed:
|
|
||||||
- `feature_wordpress/lib/src/presentation/widgets/create_product_dialog.dart` — new `CreateProductDialog` StatefulWidget with name (required), price (required), SKU, category dropdown, description, and initial-status `SegmentedButton` (Draft / Review); inline validation errors; `CreateProductDialogResult` value object; `showCreateProductDialog()` helper
|
|
||||||
- `feature_wordpress/lib/src/presentation/category_management_page.dart` — new `CategoryManagementPage` with category list, add-category FAB, inline rename, delete with confirmation dialog, empty state, loading/error states; `CategoryManagementPage.route()` static helper
|
|
||||||
- `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `CreateProductResult` sealed class (`CreateProductSuccess`, `CreateProductFailure`); added `lastCreateProductResult`, `consumeCreateProductResult()`, `createNewProduct()` (calls `CreateProduct` use case, auto-selects new product, reloads); added `fetchCategories()` (calls `GetCategories` use case); `CreateProduct` and `GetCategories` use cases are optional named constructor params for backward compatibility
|
|
||||||
- `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added "New Product" `FilledButton.icon` in toolbar; wired `showCreateProductDialog()` → `controller.createNewProduct()`; added `showCreateProductSnackBar()` result listener; added "Manage Categories" `TextButton` in toolbar linking to `CategoryManagementPage`
|
|
||||||
- `feature_wordpress/lib/feature_wordpress.dart` — exported `CreateProductDialog`, `CreateProductDialogResult`, `showCreateProductDialog`, `CategoryManagementPage`, `CreateProductResult`, `CreateProductSuccess`, `CreateProductFailure`
|
|
||||||
- `kell_mobile/lib/pages/mobile_create_product_page.dart` — new `MobileCreateProductPage` full-screen form (name, price, SKU, category dropdown, description, initial status) with `AppBar` and "Create Product" action button; mirrors dialog fields in a scrollable page layout
|
|
||||||
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — added "New Product" `FloatingActionButton` navigating to `MobileCreateProductPage`; wired result back to `controller.createNewProduct()`; added `showCreateProductSnackBar()` result listener
|
|
||||||
- `feature_wordpress/test/widgets/create_product_dialog_test.dart` — 13 new widget tests: renders fields, Cancel returns null, name/price validation errors, valid submit returns result, SKU/description included, category dropdown shown/hidden, initial status defaults to draft, error-clears-on-type, standalone render
|
|
||||||
- `feature_wordpress/test/widgets/category_management_page_test.dart` — 14 new widget tests: renders category list, empty state, add category dialog, rename inline, delete confirmation, loading state, error state, category count
|
|
||||||
- `feature_wordpress/test/product_publishing_controller_test.dart` — added 7 new controller tests: `createNewProduct` success/auto-select/failure/consume/null-use-case, `fetchCategories` success/null-use-case
|
|
||||||
- tests: passed (424/424 feature_wordpress — all passing)
|
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
|
||||||
- brief updated: yes
|
|
||||||
|
|
|
||||||
|
|
@ -614,23 +614,14 @@ Add product creation, image management, and category CRUD to complete full catal
|
||||||
- ✅ Update barrel exports in `feature_wordpress.dart`
|
- ✅ Update barrel exports in `feature_wordpress.dart`
|
||||||
- ✅ Update test stubs in `dashboard_controller_test.dart` and `product_publishing_page_test.dart`
|
- ✅ Update test stubs in `dashboard_controller_test.dart` and `product_publishing_page_test.dart`
|
||||||
- ✅ 69 new tests in `wc_catalog_expansion_test.dart` covering domain models, fake repo, API client, real repo, and use cases
|
- ✅ 69 new tests in `wc_catalog_expansion_test.dart` covering domain models, fake repo, API client, real repo, and use cases
|
||||||
- ✅ Product creation UI — `CreateProductDialog` (web) and `MobileCreateProductPage` (mobile) with name, price, SKU, category dropdown, description, initial status (Stage 9D)
|
- ⏳ Product creation UI — form with name, description, price, category, SKU, images (deferred to Stage 9D)
|
||||||
- ✅ Image management scaffolding — `UpdateProductImages` use case wired; full image picker UI deferred to Stage 10+
|
- ⏳ Image picker/upload UI for mobile and web (deferred to Stage 9D)
|
||||||
- ✅ Category management page — `CategoryManagementPage` with list, add, rename, delete, empty/loading/error states (Stage 9D)
|
- ⏳ Category management page (deferred to Stage 9D)
|
||||||
|
|
||||||
##### Branch
|
##### Branch
|
||||||
|
|
||||||
`feat/wc-catalog-expansion` — merged to `main`
|
`feat/wc-catalog-expansion` — merged to `main`
|
||||||
|
|
||||||
#### Stage 9D — Product creation UI and category management ✅ COMPLETE
|
|
||||||
|
|
||||||
> Merged `feat/wc-catalog-expansion-ui` → `main` (2026-07-11).
|
|
||||||
> Added `CreateProductDialog` (web) — StatefulWidget with name (required), price (required), SKU, category dropdown, description, and initial-status `SegmentedButton` (Draft / Review); inline validation errors; `CreateProductDialogResult` value object; `showCreateProductDialog()` helper. Added `CategoryManagementPage` with category list, add-category FAB, inline rename, delete with confirmation dialog, empty/loading/error states. Extended `ProductPublishingController` with `CreateProductResult` sealed class, `createNewProduct()` (calls `CreateProduct` use case, auto-selects new product, reloads), and `fetchCategories()`. Wired "New Product" button and "Manage Categories" link into `ProductPublishingPage` toolbar. Added `MobileCreateProductPage` full-screen form for mobile. Added "New Product" FAB to `MobilePublishingPage`. Expanded barrel exports. 34 new tests added (13 dialog, 14 category page, 7 controller) — 424 total `feature_wordpress` tests passing. Analyze clean.
|
|
||||||
|
|
||||||
##### Branch
|
|
||||||
|
|
||||||
`feat/wc-catalog-expansion-ui` — merged to `main`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Stage 10 — Square integration foundation
|
### Stage 10 — Square integration foundation
|
||||||
|
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// A full-screen mobile page for creating a new product.
|
|
||||||
///
|
|
||||||
/// Collects name (required), price (required), SKU, category, and description.
|
|
||||||
/// On submit, calls [onCreateProduct] and pops on success.
|
|
||||||
class MobileCreateProductPage extends StatefulWidget {
|
|
||||||
/// Available categories for the category dropdown.
|
|
||||||
final List<ProductCategory> availableCategories;
|
|
||||||
|
|
||||||
/// Callback invoked when the operator submits the form.
|
|
||||||
final Future<void> Function({
|
|
||||||
required String name,
|
|
||||||
required double price,
|
|
||||||
String description,
|
|
||||||
String sku,
|
|
||||||
int? categoryId,
|
|
||||||
PublishStatus status,
|
|
||||||
})
|
|
||||||
onCreateProduct;
|
|
||||||
|
|
||||||
const MobileCreateProductPage({
|
|
||||||
super.key,
|
|
||||||
required this.availableCategories,
|
|
||||||
required this.onCreateProduct,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MobileCreateProductPage> createState() => _MobileCreateProductPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MobileCreateProductPageState extends State<MobileCreateProductPage> {
|
|
||||||
final _nameController = TextEditingController();
|
|
||||||
final _priceController = TextEditingController();
|
|
||||||
final _skuController = TextEditingController();
|
|
||||||
final _descriptionController = TextEditingController();
|
|
||||||
|
|
||||||
int? _selectedCategoryId;
|
|
||||||
PublishStatus _selectedStatus = PublishStatus.draft;
|
|
||||||
bool _isSubmitting = false;
|
|
||||||
|
|
||||||
String? _nameError;
|
|
||||||
String? _priceError;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
_priceController.dispose();
|
|
||||||
_skuController.dispose();
|
|
||||||
_descriptionController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
final name = _nameController.text.trim();
|
|
||||||
if (name.isEmpty) {
|
|
||||||
setState(() => _nameError = 'Product name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final price = double.tryParse(_priceController.text);
|
|
||||||
if (price == null || price < 0) {
|
|
||||||
setState(() => _priceError = 'Enter a valid price (e.g. 12.99).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _isSubmitting = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await widget.onCreateProduct(
|
|
||||||
name: name,
|
|
||||||
price: price,
|
|
||||||
description: _descriptionController.text.trim(),
|
|
||||||
sku: _skuController.text.trim(),
|
|
||||||
categoryId: _selectedCategoryId,
|
|
||||||
status: _selectedStatus,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isSubmitting = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to create product: $e'), backgroundColor: KcColors.danger),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('New Product'),
|
|
||||||
actions: [
|
|
||||||
if (_isSubmitting)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
TextButton(
|
|
||||||
onPressed: _submit,
|
|
||||||
child: const Text('Create', style: TextStyle(fontWeight: FontWeight.w700)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Name ────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _nameController,
|
|
||||||
autofocus: true,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Product Name *',
|
|
||||||
hintText: 'e.g. Floral Bowl Cozy',
|
|
||||||
errorText: _nameError,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_nameError != null) setState(() => _nameError = null);
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Price ────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _priceController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Price *',
|
|
||||||
hintText: '0.00',
|
|
||||||
prefixText: '\$ ',
|
|
||||||
errorText: _priceError,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_priceError != null) setState(() => _priceError = null);
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── SKU ──────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _skuController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'SKU',
|
|
||||||
hintText: 'e.g. BC-FLR-001',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Category ─────────────────────────────────────────────
|
|
||||||
if (widget.availableCategories.isNotEmpty) ...[
|
|
||||||
DropdownButtonFormField<int?>(
|
|
||||||
initialValue: _selectedCategoryId,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Category',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<int?>(value: null, child: Text('— None —')),
|
|
||||||
...widget.availableCategories.map(
|
|
||||||
(cat) => DropdownMenuItem<int?>(value: cat.id, child: Text(cat.name)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: _isSubmitting
|
|
||||||
? null
|
|
||||||
: (value) => setState(() => _selectedCategoryId = value),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Description ──────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _descriptionController,
|
|
||||||
enabled: !_isSubmitting,
|
|
||||||
maxLines: 4,
|
|
||||||
minLines: 3,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Description',
|
|
||||||
hintText: 'Optional product description…',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.newline,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Initial status ────────────────────────────────────────
|
|
||||||
Text(
|
|
||||||
'Initial Status',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: KcColors.neutral,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.xs),
|
|
||||||
SegmentedButton<PublishStatus>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: PublishStatus.draft,
|
|
||||||
label: Text('Draft'),
|
|
||||||
icon: Icon(Icons.drafts_outlined, size: 16),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: PublishStatus.pendingReview,
|
|
||||||
label: Text('Submit for Review'),
|
|
||||||
icon: Icon(Icons.rate_review_outlined, size: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_selectedStatus},
|
|
||||||
onSelectionChanged: _isSubmitting
|
|
||||||
? null
|
|
||||||
: (selection) => setState(() => _selectedStatus = selection.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.xl),
|
|
||||||
|
|
||||||
// ── Submit button ─────────────────────────────────────────
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
icon: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.add),
|
|
||||||
label: Text(_isSubmitting ? 'Creating…' : 'Create Product'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:design_system/design_system.dart';
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'mobile_create_product_page.dart';
|
|
||||||
import 'mobile_product_detail_page.dart';
|
import 'mobile_product_detail_page.dart';
|
||||||
|
|
||||||
/// Mobile-optimized publishing workspace page.
|
/// Mobile-optimized publishing workspace page.
|
||||||
|
|
@ -48,8 +47,6 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
|
||||||
UpdateProductName(repo),
|
UpdateProductName(repo),
|
||||||
UpdateProductDescription(repo),
|
UpdateProductDescription(repo),
|
||||||
UpdateProductCategory(repo),
|
UpdateProductCategory(repo),
|
||||||
createProduct: CreateProduct(repo),
|
|
||||||
getCategories: GetCategories(repo),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_controller.addListener(_onControllerChanged);
|
_controller.addListener(_onControllerChanged);
|
||||||
|
|
@ -115,27 +112,13 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
|
||||||
return KcErrorState(message: 'Failed to load product drafts.', onRetry: _controller.load);
|
return KcErrorState(message: 'Failed to load product drafts.', onRetry: _controller.load);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stack(
|
return Column(
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
children: [
|
||||||
_buildSearchBar(),
|
_buildSearchBar(),
|
||||||
_buildFilterChips(),
|
_buildFilterChips(),
|
||||||
_buildSortAndCount(),
|
_buildSortAndCount(),
|
||||||
Expanded(child: _buildProductList()),
|
Expanded(child: _buildProductList()),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: KcSpacing.md,
|
|
||||||
bottom: KcSpacing.md,
|
|
||||||
child: FloatingActionButton.extended(
|
|
||||||
heroTag: 'mobile_create_product_fab',
|
|
||||||
onPressed: _openCreateProductPage,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('New Product'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -292,38 +275,6 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the [MobileCreateProductPage] to create a new product.
|
|
||||||
Future<void> _openCreateProductPage() async {
|
|
||||||
final categories = await _controller.fetchCategories();
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => MobileCreateProductPage(
|
|
||||||
availableCategories: categories,
|
|
||||||
onCreateProduct:
|
|
||||||
({
|
|
||||||
required String name,
|
|
||||||
required double price,
|
|
||||||
String description = '',
|
|
||||||
String sku = '',
|
|
||||||
int? categoryId,
|
|
||||||
PublishStatus status = PublishStatus.draft,
|
|
||||||
}) => _controller.createNewProduct(
|
|
||||||
name: name,
|
|
||||||
price: price,
|
|
||||||
description: description,
|
|
||||||
sku: sku,
|
|
||||||
categoryId: categoryId,
|
|
||||||
status: status,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Reload after returning so the new product appears in the list.
|
|
||||||
if (mounted) await _controller.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigates to the full-screen product detail page.
|
/// Navigates to the full-screen product detail page.
|
||||||
///
|
///
|
||||||
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
|
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,7 @@ export 'src/application/update_product_price.dart';
|
||||||
export 'src/application/update_product_status.dart';
|
export 'src/application/update_product_status.dart';
|
||||||
|
|
||||||
// Presentation
|
// Presentation
|
||||||
export 'src/presentation/category_management_page.dart';
|
|
||||||
export 'src/presentation/product_publishing_page.dart';
|
export 'src/presentation/product_publishing_page.dart';
|
||||||
export 'src/presentation/widgets/create_product_dialog.dart';
|
|
||||||
export 'src/presentation/widgets/product_draft_card.dart';
|
export 'src/presentation/widgets/product_draft_card.dart';
|
||||||
export 'src/presentation/widgets/product_preview_panel.dart';
|
export 'src/presentation/widgets/product_preview_panel.dart';
|
||||||
export 'src/presentation/widgets/publish_status_chip.dart';
|
export 'src/presentation/widgets/publish_status_chip.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../domain/product_category.dart';
|
|
||||||
import '../domain/product_draft.dart';
|
import '../domain/product_draft.dart';
|
||||||
import '../domain/publish_status.dart';
|
import '../domain/publish_status.dart';
|
||||||
import 'create_product.dart';
|
|
||||||
import 'get_categories.dart';
|
|
||||||
import 'get_product_drafts.dart';
|
import 'get_product_drafts.dart';
|
||||||
import 'publish_product.dart';
|
import 'publish_product.dart';
|
||||||
import 'update_product_category.dart';
|
import 'update_product_category.dart';
|
||||||
|
|
@ -117,21 +114,6 @@ class CategoryActionResult {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The outcome of a product-creation action.
|
|
||||||
///
|
|
||||||
/// Consumed once by the UI to show a SnackBar, then cleared.
|
|
||||||
class CreateProductActionResult {
|
|
||||||
final bool success;
|
|
||||||
final String productName;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
const CreateProductActionResult({
|
|
||||||
required this.success,
|
|
||||||
required this.productName,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The outcome of a bulk status-change action (e.g. bulk move-to-draft).
|
/// The outcome of a bulk status-change action (e.g. bulk move-to-draft).
|
||||||
///
|
///
|
||||||
/// Consumed once by the UI to show a SnackBar, then cleared.
|
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||||
|
|
@ -172,8 +154,6 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
final UpdateProductName _updateProductName;
|
final UpdateProductName _updateProductName;
|
||||||
final UpdateProductDescription _updateProductDescription;
|
final UpdateProductDescription _updateProductDescription;
|
||||||
final UpdateProductCategory _updateProductCategory;
|
final UpdateProductCategory _updateProductCategory;
|
||||||
final CreateProduct? _createProduct;
|
|
||||||
final GetCategories? _getCategories;
|
|
||||||
|
|
||||||
ProductPublishingController(
|
ProductPublishingController(
|
||||||
this._getProductDrafts,
|
this._getProductDrafts,
|
||||||
|
|
@ -182,11 +162,8 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
this._updateProductPrice,
|
this._updateProductPrice,
|
||||||
this._updateProductName,
|
this._updateProductName,
|
||||||
this._updateProductDescription,
|
this._updateProductDescription,
|
||||||
this._updateProductCategory, {
|
this._updateProductCategory,
|
||||||
CreateProduct? createProduct,
|
);
|
||||||
GetCategories? getCategories,
|
|
||||||
}) : _createProduct = createProduct,
|
|
||||||
_getCategories = getCategories;
|
|
||||||
|
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
|
|
@ -372,13 +349,6 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// to clear it.
|
/// to clear it.
|
||||||
CategoryActionResult? lastCategoryResult;
|
CategoryActionResult? lastCategoryResult;
|
||||||
|
|
||||||
/// The result of the last product-creation action.
|
|
||||||
///
|
|
||||||
/// Set after [createNewProduct] completes. The UI should read this once to
|
|
||||||
/// show feedback (e.g. a SnackBar) and then call [consumeCreateProductResult]
|
|
||||||
/// to clear it.
|
|
||||||
CreateProductActionResult? lastCreateProductResult;
|
|
||||||
|
|
||||||
/// Clears [lastActionResult] so the same result is not shown twice.
|
/// Clears [lastActionResult] so the same result is not shown twice.
|
||||||
void consumeActionResult() {
|
void consumeActionResult() {
|
||||||
lastActionResult = null;
|
lastActionResult = null;
|
||||||
|
|
@ -404,11 +374,6 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
lastCategoryResult = null;
|
lastCategoryResult = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears [lastCreateProductResult] so the same result is not shown twice.
|
|
||||||
void consumeCreateProductResult() {
|
|
||||||
lastCreateProductResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The result of the last bulk status-change action.
|
/// The result of the last bulk status-change action.
|
||||||
///
|
///
|
||||||
/// Set after [bulkUpdateStatus] completes. The UI should read this once
|
/// Set after [bulkUpdateStatus] completes. The UI should read this once
|
||||||
|
|
@ -471,65 +436,6 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new product with the given fields.
|
|
||||||
///
|
|
||||||
/// Requires [_createProduct] to be provided at construction time.
|
|
||||||
/// After creation, reloads the product list and auto-selects the new product.
|
|
||||||
Future<void> createNewProduct({
|
|
||||||
required String name,
|
|
||||||
required double price,
|
|
||||||
String description = '',
|
|
||||||
String sku = '',
|
|
||||||
int? categoryId,
|
|
||||||
PublishStatus status = PublishStatus.draft,
|
|
||||||
}) async {
|
|
||||||
final useCase = _createProduct;
|
|
||||||
if (useCase == null) return;
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
_safeNotify();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final created = await useCase(
|
|
||||||
name: name,
|
|
||||||
price: price,
|
|
||||||
description: description,
|
|
||||||
sku: sku,
|
|
||||||
categoryId: categoryId,
|
|
||||||
status: status,
|
|
||||||
);
|
|
||||||
if (_disposed) return;
|
|
||||||
lastCreateProductResult = CreateProductActionResult(success: true, productName: created.name);
|
|
||||||
await load();
|
|
||||||
// Auto-select the newly created product.
|
|
||||||
if (!_disposed) {
|
|
||||||
final match = _allDrafts.where((d) => d.id == created.id).firstOrNull;
|
|
||||||
if (match != null) {
|
|
||||||
selectedDraft = match;
|
|
||||||
_safeNotify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (_disposed) return;
|
|
||||||
isLoading = false;
|
|
||||||
lastCreateProductResult = CreateProductActionResult(
|
|
||||||
success: false,
|
|
||||||
productName: name,
|
|
||||||
errorMessage: e.toString(),
|
|
||||||
);
|
|
||||||
_safeNotify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches the available product categories.
|
|
||||||
///
|
|
||||||
/// Returns an empty list if [_getCategories] was not provided.
|
|
||||||
Future<List<ProductCategory>> fetchCategories() async {
|
|
||||||
final useCase = _getCategories;
|
|
||||||
if (useCase == null) return [];
|
|
||||||
return useCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all product drafts and applies any current filter / search.
|
/// Loads all product drafts and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../domain/product_category.dart';
|
|
||||||
import '../domain/product_publishing_repository.dart';
|
|
||||||
|
|
||||||
/// A page for managing product categories: view, create, and delete.
|
|
||||||
///
|
|
||||||
/// Accepts a [repository] directly so it can be used standalone or pushed
|
|
||||||
/// from the [ProductPublishingPage] toolbar.
|
|
||||||
class CategoryManagementPage extends StatefulWidget {
|
|
||||||
final ProductPublishingRepository repository;
|
|
||||||
|
|
||||||
const CategoryManagementPage({super.key, required this.repository});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CategoryManagementPage> createState() => _CategoryManagementPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CategoryManagementPageState extends State<CategoryManagementPage> {
|
|
||||||
List<ProductCategory> _categories = [];
|
|
||||||
bool _isLoading = true;
|
|
||||||
Object? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final cats = await widget.repository.getCategories();
|
|
||||||
if (mounted) setState(() => _categories = cats);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _error = e);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showCreateDialog() async {
|
|
||||||
final result = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => const _CreateCategoryDialog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null && result.isNotEmpty && mounted) {
|
|
||||||
try {
|
|
||||||
await widget.repository.createCategory(name: result);
|
|
||||||
await _load();
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Category "$result" created.'),
|
|
||||||
backgroundColor: KcColors.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to create category: $e'),
|
|
||||||
backgroundColor: KcColors.danger,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmDelete(ProductCategory category) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Delete Category'),
|
|
||||||
content: Text(
|
|
||||||
'Delete "${category.name}"? This cannot be undone.\n\n'
|
|
||||||
'Products in this category will become uncategorized.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
|
|
||||||
FilledButton(
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: KcColors.danger),
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
|
||||||
try {
|
|
||||||
await widget.repository.deleteCategory(category.id);
|
|
||||||
await _load();
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Category "${category.name}" deleted.')));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to delete category: $e'),
|
|
||||||
backgroundColor: KcColors.danger,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Categories'),
|
|
||||||
actions: [
|
|
||||||
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Refresh', onPressed: _load),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
onPressed: _showCreateDialog,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('New Category'),
|
|
||||||
),
|
|
||||||
body: _buildBody(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (_isLoading) {
|
|
||||||
return const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: KcSpacing.md),
|
|
||||||
Text('Loading categories…'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_error != null) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, size: 48, color: KcColors.neutral),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
const Text('Failed to load categories.'),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _load,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_categories.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.category_outlined, size: 48, color: KcColors.neutral),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
Text(
|
|
||||||
'No categories yet.',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _showCreateDialog,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Create First Category'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.all(KcSpacing.md),
|
|
||||||
itemCount: _categories.length,
|
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final cat = _categories[index];
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.label_outline),
|
|
||||||
title: Text(cat.name),
|
|
||||||
subtitle: Text(
|
|
||||||
'${cat.count} ${cat.count == 1 ? 'product' : 'products'} · /${cat.slug}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
tooltip: 'Delete category',
|
|
||||||
color: KcColors.danger,
|
|
||||||
onPressed: () => _confirmDelete(cat),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create category dialog ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A self-contained dialog for creating a new category.
|
|
||||||
/// Pops with the trimmed category name on success, or null on cancel.
|
|
||||||
class _CreateCategoryDialog extends StatefulWidget {
|
|
||||||
const _CreateCategoryDialog();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_CreateCategoryDialog> createState() => _CreateCategoryDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CreateCategoryDialogState extends State<_CreateCategoryDialog> {
|
|
||||||
final _nameController = TextEditingController();
|
|
||||||
String? _nameError;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _submit() {
|
|
||||||
final name = _nameController.text.trim();
|
|
||||||
if (name.isEmpty) {
|
|
||||||
setState(() => _nameError = 'Category name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.of(context).pop(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('New Category'),
|
|
||||||
content: TextField(
|
|
||||||
controller: _nameController,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Category Name *',
|
|
||||||
hintText: 'e.g. Bowl Cozies',
|
|
||||||
errorText: _nameError,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_nameError != null) setState(() => _nameError = null);
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
onSubmitted: (_) => _submit(),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(null), child: const Text('Cancel')),
|
|
||||||
FilledButton(onPressed: _submit, child: const Text('Create')),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,6 @@ import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../application/create_product.dart';
|
|
||||||
import '../application/get_categories.dart';
|
|
||||||
import '../application/get_product_drafts.dart';
|
import '../application/get_product_drafts.dart';
|
||||||
import '../application/product_publishing_controller.dart';
|
import '../application/product_publishing_controller.dart';
|
||||||
import '../application/publish_product.dart';
|
import '../application/publish_product.dart';
|
||||||
|
|
@ -14,8 +12,6 @@ import '../application/update_product_price.dart';
|
||||||
import '../application/update_product_status.dart';
|
import '../application/update_product_status.dart';
|
||||||
import '../domain/product_publishing_repository.dart';
|
import '../domain/product_publishing_repository.dart';
|
||||||
import '../domain/publish_status.dart';
|
import '../domain/publish_status.dart';
|
||||||
import 'category_management_page.dart';
|
|
||||||
import 'widgets/create_product_dialog.dart';
|
|
||||||
import 'widgets/product_draft_card.dart';
|
import 'widgets/product_draft_card.dart';
|
||||||
import 'widgets/product_preview_panel.dart';
|
import 'widgets/product_preview_panel.dart';
|
||||||
import 'widgets/status_action_snack_bar.dart';
|
import 'widgets/status_action_snack_bar.dart';
|
||||||
|
|
@ -71,8 +67,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
UpdateProductName(repo),
|
UpdateProductName(repo),
|
||||||
UpdateProductDescription(repo),
|
UpdateProductDescription(repo),
|
||||||
UpdateProductCategory(repo),
|
UpdateProductCategory(repo),
|
||||||
createProduct: CreateProduct(repo),
|
|
||||||
getCategories: GetCategories(repo),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
controller.addListener(_onControllerChanged);
|
controller.addListener(_onControllerChanged);
|
||||||
|
|
@ -137,62 +131,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
controller.consumeBulkActionResult();
|
controller.consumeBulkActionResult();
|
||||||
showBulkActionSnackBar(context, bulkResult);
|
showBulkActionSnackBar(context, bulkResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
final createResult = controller.lastCreateProductResult;
|
|
||||||
if (createResult != null) {
|
|
||||||
controller.consumeCreateProductResult();
|
|
||||||
_showCreateProductSnackBar(createResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showCreateProductSnackBar(CreateProductActionResult result) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
if (result.success) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Product "${result.productName}" created.'),
|
|
||||||
backgroundColor: KcColors.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to create product: ${result.errorMessage}'),
|
|
||||||
backgroundColor: KcColors.danger,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the Create Product dialog, fetches categories first, then
|
|
||||||
/// delegates to the controller.
|
|
||||||
Future<void> _openCreateProductDialog() async {
|
|
||||||
final categories = await controller.fetchCategories();
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final result = await showCreateProductDialog(context, categories: categories);
|
|
||||||
if (result == null || !mounted) return;
|
|
||||||
|
|
||||||
await controller.createNewProduct(
|
|
||||||
name: result.name,
|
|
||||||
price: result.price,
|
|
||||||
description: result.description,
|
|
||||||
sku: result.sku,
|
|
||||||
categoryId: result.categoryId,
|
|
||||||
status: result.status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pushes the [CategoryManagementPage] and reloads products on return.
|
|
||||||
Future<void> _openCategoryManagement() async {
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => CategoryManagementPage(repository: widget.repository),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Reload in case categories changed (affects category display on products).
|
|
||||||
if (mounted) await controller.load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a confirmation dialog before executing bulk move-to-draft.
|
/// Shows a confirmation dialog before executing bulk move-to-draft.
|
||||||
|
|
@ -388,7 +326,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
onBulkPublish: () => _confirmBulkPublish(context),
|
onBulkPublish: () => _confirmBulkPublish(context),
|
||||||
onBulkSubmitForReview: () => _confirmBulkSubmitForReview(context),
|
onBulkSubmitForReview: () => _confirmBulkSubmitForReview(context),
|
||||||
),
|
),
|
||||||
// ── List header with count, actions, and density toggle ─────
|
// ── List header with count and density toggle ───────────────
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -398,26 +336,6 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Tooltip(
|
|
||||||
message: 'Manage Categories',
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.category_outlined, size: 20),
|
|
||||||
onPressed: _openCategoryManagement,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.xs),
|
|
||||||
Tooltip(
|
|
||||||
message: 'New Product',
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.add_circle_outline, size: 20),
|
|
||||||
onPressed: _openCreateProductDialog,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.xs),
|
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: isCompact ? 'Standard view' : 'Compact view',
|
message: isCompact ? 'Standard view' : 'Compact view',
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
import '../../domain/product_category.dart';
|
|
||||||
import '../../domain/publish_status.dart';
|
|
||||||
|
|
||||||
/// The result returned when the operator submits the Create Product dialog.
|
|
||||||
class CreateProductDialogResult {
|
|
||||||
final String name;
|
|
||||||
final double price;
|
|
||||||
final String description;
|
|
||||||
final String sku;
|
|
||||||
final int? categoryId;
|
|
||||||
final PublishStatus status;
|
|
||||||
|
|
||||||
const CreateProductDialogResult({
|
|
||||||
required this.name,
|
|
||||||
required this.price,
|
|
||||||
required this.description,
|
|
||||||
required this.sku,
|
|
||||||
required this.categoryId,
|
|
||||||
required this.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A dialog for creating a new product.
|
|
||||||
///
|
|
||||||
/// Collects name (required), price (required), description, SKU, and category
|
|
||||||
/// from a dropdown populated with [availableCategories].
|
|
||||||
///
|
|
||||||
/// Returns a [CreateProductDialogResult] when the operator taps "Create", or
|
|
||||||
/// `null` when they cancel.
|
|
||||||
class CreateProductDialog extends StatefulWidget {
|
|
||||||
/// The list of categories to show in the category dropdown.
|
|
||||||
final List<ProductCategory> availableCategories;
|
|
||||||
|
|
||||||
const CreateProductDialog({super.key, required this.availableCategories});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CreateProductDialog> createState() => _CreateProductDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CreateProductDialogState extends State<CreateProductDialog> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
final _nameController = TextEditingController();
|
|
||||||
final _priceController = TextEditingController();
|
|
||||||
final _descriptionController = TextEditingController();
|
|
||||||
final _skuController = TextEditingController();
|
|
||||||
|
|
||||||
int? _selectedCategoryId;
|
|
||||||
PublishStatus _selectedStatus = PublishStatus.draft;
|
|
||||||
|
|
||||||
String? _nameError;
|
|
||||||
String? _priceError;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
_priceController.dispose();
|
|
||||||
_descriptionController.dispose();
|
|
||||||
_skuController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _submit() {
|
|
||||||
// Validate name
|
|
||||||
final name = _nameController.text.trim();
|
|
||||||
if (name.isEmpty) {
|
|
||||||
setState(() => _nameError = 'Product name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate price
|
|
||||||
final price = double.tryParse(_priceController.text);
|
|
||||||
if (price == null || price < 0) {
|
|
||||||
setState(() => _priceError = 'Enter a valid price (e.g. 12.99).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Navigator.of(context).pop(
|
|
||||||
CreateProductDialogResult(
|
|
||||||
name: name,
|
|
||||||
price: price,
|
|
||||||
description: _descriptionController.text.trim(),
|
|
||||||
sku: _skuController.text.trim(),
|
|
||||||
categoryId: _selectedCategoryId,
|
|
||||||
status: _selectedStatus,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('New Product'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 480,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Name ──────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _nameController,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Product Name *',
|
|
||||||
hintText: 'e.g. Floral Bowl Cozy',
|
|
||||||
errorText: _nameError,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_nameError != null) setState(() => _nameError = null);
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Price ─────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _priceController,
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Price *',
|
|
||||||
hintText: '0.00',
|
|
||||||
prefixText: '\$ ',
|
|
||||||
errorText: _priceError,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
|
||||||
if (_priceError != null) setState(() => _priceError = null);
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── SKU ───────────────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _skuController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'SKU',
|
|
||||||
hintText: 'e.g. BC-FLR-001',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Category ──────────────────────────────────────────────
|
|
||||||
if (widget.availableCategories.isNotEmpty) ...[
|
|
||||||
DropdownButtonFormField<int?>(
|
|
||||||
initialValue: _selectedCategoryId,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Category',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<int?>(value: null, child: Text('— None —')),
|
|
||||||
...widget.availableCategories.map(
|
|
||||||
(cat) => DropdownMenuItem<int?>(value: cat.id, child: Text(cat.name)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (value) => setState(() => _selectedCategoryId = value),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
],
|
|
||||||
|
|
||||||
// ── Description ───────────────────────────────────────────
|
|
||||||
TextField(
|
|
||||||
controller: _descriptionController,
|
|
||||||
maxLines: 3,
|
|
||||||
minLines: 2,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Description',
|
|
||||||
hintText: 'Optional product description…',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.newline,
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
|
||||||
|
|
||||||
// ── Initial status ────────────────────────────────────────
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Initial Status:',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: KcColors.neutral,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: SegmentedButton<PublishStatus>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: PublishStatus.draft,
|
|
||||||
label: Text('Draft'),
|
|
||||||
icon: Icon(Icons.drafts_outlined, size: 16),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: PublishStatus.pendingReview,
|
|
||||||
label: Text('Review'),
|
|
||||||
icon: Icon(Icons.rate_review_outlined, size: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_selectedStatus},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() => _selectedStatus = selection.first);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(null), child: const Text('Cancel')),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _submit,
|
|
||||||
icon: const Icon(Icons.add, size: 18),
|
|
||||||
label: const Text('Create Product'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows the [CreateProductDialog] and returns the result, or `null` if
|
|
||||||
/// the operator cancelled.
|
|
||||||
Future<CreateProductDialogResult?> showCreateProductDialog(
|
|
||||||
BuildContext context, {
|
|
||||||
required List<ProductCategory> categories,
|
|
||||||
}) {
|
|
||||||
return showDialog<CreateProductDialogResult>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => CreateProductDialog(availableCategories: categories),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1671,215 +1671,5 @@ void main() {
|
||||||
await future;
|
await future;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Stage 9D: createNewProduct & fetchCategories ─────────────────────────
|
|
||||||
|
|
||||||
group('createNewProduct', () {
|
|
||||||
late ProductPublishingController controllerWithCreate;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
controllerWithCreate = ProductPublishingController(
|
|
||||||
GetProductDrafts(repository),
|
|
||||||
PublishProduct(repository),
|
|
||||||
UpdateProductStatus(repository),
|
|
||||||
UpdateProductPrice(repository),
|
|
||||||
UpdateProductName(repository),
|
|
||||||
UpdateProductDescription(repository),
|
|
||||||
UpdateProductCategory(repository),
|
|
||||||
createProduct: CreateProduct(repository),
|
|
||||||
getCategories: GetCategories(repository),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() => controllerWithCreate.dispose());
|
|
||||||
|
|
||||||
test('createNewProduct adds product and sets lastCreateProductResult success', () async {
|
|
||||||
await controllerWithCreate.load();
|
|
||||||
final before = controllerWithCreate.drafts.length;
|
|
||||||
|
|
||||||
await controllerWithCreate.createNewProduct(name: 'New Cozy', price: 14.99);
|
|
||||||
|
|
||||||
expect(controllerWithCreate.drafts.length, before + 1);
|
|
||||||
expect(controllerWithCreate.lastCreateProductResult, isNotNull);
|
|
||||||
expect(controllerWithCreate.lastCreateProductResult!.success, isTrue);
|
|
||||||
expect(controllerWithCreate.lastCreateProductResult!.productName, 'New Cozy');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createNewProduct auto-selects the newly created product', () async {
|
|
||||||
await controllerWithCreate.load();
|
|
||||||
|
|
||||||
await controllerWithCreate.createNewProduct(name: 'Auto Selected', price: 9.99);
|
|
||||||
|
|
||||||
expect(controllerWithCreate.selectedDraft, isNotNull);
|
|
||||||
expect(controllerWithCreate.selectedDraft!.name, 'Auto Selected');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createNewProduct sets failure result on repository error', () async {
|
|
||||||
// Use a repo that throws on createProduct.
|
|
||||||
final failRepo = _FailingCreateRepository();
|
|
||||||
final ctrl = ProductPublishingController(
|
|
||||||
GetProductDrafts(repository),
|
|
||||||
PublishProduct(repository),
|
|
||||||
UpdateProductStatus(repository),
|
|
||||||
UpdateProductPrice(repository),
|
|
||||||
UpdateProductName(repository),
|
|
||||||
UpdateProductDescription(repository),
|
|
||||||
UpdateProductCategory(repository),
|
|
||||||
createProduct: CreateProduct(failRepo),
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctrl.load();
|
|
||||||
await ctrl.createNewProduct(name: 'Will Fail', price: 5.0);
|
|
||||||
|
|
||||||
expect(ctrl.lastCreateProductResult, isNotNull);
|
|
||||||
expect(ctrl.lastCreateProductResult!.success, isFalse);
|
|
||||||
expect(ctrl.lastCreateProductResult!.productName, 'Will Fail');
|
|
||||||
expect(ctrl.lastCreateProductResult!.errorMessage, isNotNull);
|
|
||||||
|
|
||||||
ctrl.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('consumeCreateProductResult clears lastCreateProductResult', () async {
|
|
||||||
await controllerWithCreate.load();
|
|
||||||
await controllerWithCreate.createNewProduct(name: 'Cozy', price: 9.99);
|
|
||||||
|
|
||||||
expect(controllerWithCreate.lastCreateProductResult, isNotNull);
|
|
||||||
controllerWithCreate.consumeCreateProductResult();
|
|
||||||
expect(controllerWithCreate.lastCreateProductResult, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createNewProduct does nothing when createProduct use case is null', () async {
|
|
||||||
// Controller without createProduct use case.
|
|
||||||
final ctrl = ProductPublishingController(
|
|
||||||
GetProductDrafts(repository),
|
|
||||||
PublishProduct(repository),
|
|
||||||
UpdateProductStatus(repository),
|
|
||||||
UpdateProductPrice(repository),
|
|
||||||
UpdateProductName(repository),
|
|
||||||
UpdateProductDescription(repository),
|
|
||||||
UpdateProductCategory(repository),
|
|
||||||
);
|
|
||||||
|
|
||||||
await ctrl.load();
|
|
||||||
final before = ctrl.drafts.length;
|
|
||||||
|
|
||||||
await ctrl.createNewProduct(name: 'Ignored', price: 1.0);
|
|
||||||
|
|
||||||
expect(ctrl.drafts.length, before);
|
|
||||||
expect(ctrl.lastCreateProductResult, isNull);
|
|
||||||
|
|
||||||
ctrl.dispose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('fetchCategories', () {
|
|
||||||
late ProductPublishingController controllerWithCategories;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
controllerWithCategories = ProductPublishingController(
|
|
||||||
GetProductDrafts(repository),
|
|
||||||
PublishProduct(repository),
|
|
||||||
UpdateProductStatus(repository),
|
|
||||||
UpdateProductPrice(repository),
|
|
||||||
UpdateProductName(repository),
|
|
||||||
UpdateProductDescription(repository),
|
|
||||||
UpdateProductCategory(repository),
|
|
||||||
getCategories: GetCategories(repository),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() => controllerWithCategories.dispose());
|
|
||||||
|
|
||||||
test('fetchCategories returns categories from repository', () async {
|
|
||||||
final cats = await controllerWithCategories.fetchCategories();
|
|
||||||
|
|
||||||
expect(cats, isA<List<ProductCategory>>());
|
|
||||||
expect(cats.isNotEmpty, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fetchCategories returns empty list when getCategories use case is null', () async {
|
|
||||||
// Controller without getCategories use case.
|
|
||||||
final ctrl = ProductPublishingController(
|
|
||||||
GetProductDrafts(repository),
|
|
||||||
PublishProduct(repository),
|
|
||||||
UpdateProductStatus(repository),
|
|
||||||
UpdateProductPrice(repository),
|
|
||||||
UpdateProductName(repository),
|
|
||||||
UpdateProductDescription(repository),
|
|
||||||
UpdateProductCategory(repository),
|
|
||||||
);
|
|
||||||
|
|
||||||
final cats = await ctrl.fetchCategories();
|
|
||||||
expect(cats, isEmpty);
|
|
||||||
|
|
||||||
ctrl.dispose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper stubs ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A repository stub that throws on [createProduct] to test error handling.
|
|
||||||
class _FailingCreateRepository implements ProductPublishingRepository {
|
|
||||||
@override
|
|
||||||
Future<List<ProductDraft>> getProductDrafts() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> createProduct({
|
|
||||||
required String name,
|
|
||||||
required double price,
|
|
||||||
String description = '',
|
|
||||||
String sku = '',
|
|
||||||
int? categoryId,
|
|
||||||
PublishStatus status = PublishStatus.draft,
|
|
||||||
}) async => throw Exception('create failed');
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> publishDraft(String id) async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductPrice(String id, double price) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductName(String id, String name) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductDescription(String id, String description) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductCategory(String id, String category) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteProduct(String id, {bool force = false}) async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductCategory>> getCategories() async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductCategory> createCategory({required String name, int parentId = 0}) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteCategory(int categoryId, {bool force = true}) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductImage>> getProductImages(String id) async => throw UnimplementedError();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
// ── Fake repository ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class _FakeRepo implements ProductPublishingRepository {
|
|
||||||
final List<ProductCategory> _categories;
|
|
||||||
bool createCategoryCalled = false;
|
|
||||||
bool deleteCategoryCalled = false;
|
|
||||||
int? lastDeletedId;
|
|
||||||
String? lastCreatedName;
|
|
||||||
Object? getCategoriesToThrow;
|
|
||||||
Object? createCategoryToThrow;
|
|
||||||
Object? deleteCategoryToThrow;
|
|
||||||
|
|
||||||
_FakeRepo({List<ProductCategory>? categories})
|
|
||||||
: _categories =
|
|
||||||
categories ??
|
|
||||||
[
|
|
||||||
const ProductCategory(
|
|
||||||
id: 1,
|
|
||||||
name: 'Bowl Cozies',
|
|
||||||
slug: 'bowl-cozies',
|
|
||||||
parentId: 0,
|
|
||||||
count: 3,
|
|
||||||
),
|
|
||||||
const ProductCategory(id: 2, name: 'Coasters', slug: 'coasters', parentId: 0, count: 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductCategory>> getCategories() async {
|
|
||||||
if (getCategoriesToThrow != null) throw getCategoriesToThrow!;
|
|
||||||
return List.of(_categories);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductCategory> createCategory({required String name, int parentId = 0}) async {
|
|
||||||
if (createCategoryToThrow != null) throw createCategoryToThrow!;
|
|
||||||
createCategoryCalled = true;
|
|
||||||
lastCreatedName = name;
|
|
||||||
final newCat = ProductCategory(
|
|
||||||
id: 99,
|
|
||||||
name: name,
|
|
||||||
slug: name.toLowerCase().replaceAll(' ', '-'),
|
|
||||||
parentId: parentId,
|
|
||||||
count: 0,
|
|
||||||
);
|
|
||||||
_categories.add(newCat);
|
|
||||||
return newCat;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteCategory(int categoryId, {bool force = true}) async {
|
|
||||||
if (deleteCategoryToThrow != null) throw deleteCategoryToThrow!;
|
|
||||||
deleteCategoryCalled = true;
|
|
||||||
lastDeletedId = categoryId;
|
|
||||||
_categories.removeWhere((c) => c.id == categoryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unused stubs ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductDraft>> getProductDrafts() async => [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> publishDraft(String id) async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductPrice(String id, double price) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductName(String id, String name) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductDescription(String id, String description) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductCategory(String id, String category) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> createProduct({
|
|
||||||
required String name,
|
|
||||||
required double price,
|
|
||||||
String description = '',
|
|
||||||
String sku = '',
|
|
||||||
int? categoryId,
|
|
||||||
PublishStatus status = PublishStatus.draft,
|
|
||||||
}) async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> deleteProduct(String id, {bool force = false}) async => throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) async =>
|
|
||||||
throw UnimplementedError();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<ProductImage>> getProductImages(String id) async => throw UnimplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildPage(_FakeRepo repo) => MaterialApp(home: CategoryManagementPage(repository: repo));
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('CategoryManagementPage', () {
|
|
||||||
testWidgets('shows loading indicator while fetching', (tester) async {
|
|
||||||
// Use a repo that never completes to keep the loading state visible.
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
// Intercept before pump settles.
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
// First frame: loading.
|
|
||||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows category list after load', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Bowl Cozies'), findsOneWidget);
|
|
||||||
expect(find.text('Coasters'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows product count in subtitle', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Bowl Cozies has count=3, Coasters has count=1.
|
|
||||||
expect(find.textContaining('3 products'), findsOneWidget);
|
|
||||||
expect(find.textContaining('1 product'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows empty state when no categories', (tester) async {
|
|
||||||
final repo = _FakeRepo(categories: []);
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('No categories yet.'), findsOneWidget);
|
|
||||||
expect(find.text('Create First Category'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows error state when load fails', (tester) async {
|
|
||||||
final repo = _FakeRepo()..getCategoriesToThrow = Exception('network error');
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Failed to load categories.'), findsOneWidget);
|
|
||||||
expect(find.text('Retry'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('retry button reloads categories', (tester) async {
|
|
||||||
final repo = _FakeRepo()..getCategoriesToThrow = Exception('network error');
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Fix the error and retry.
|
|
||||||
repo.getCategoriesToThrow = null;
|
|
||||||
await tester.tap(find.text('Retry'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Bowl Cozies'), findsOneWidget);
|
|
||||||
expect(find.text('Failed to load categories.'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('New Category FAB is visible', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('New Category'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('tapping New Category FAB opens create dialog', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('New Category'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('New Category'), findsWidgets); // dialog title + FAB
|
|
||||||
expect(find.text('Category Name *'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('create dialog Cancel does not call createCategory', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('New Category'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Cancel'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(repo.createCategoryCalled, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('create dialog shows error when name is empty', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('New Category'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Tap Create without entering a name.
|
|
||||||
await tester.tap(find.text('Create'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Category name is required.'), findsOneWidget);
|
|
||||||
expect(repo.createCategoryCalled, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('creating a category calls repository and reloads list', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('New Category'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Category Name *'), 'Pot Holders');
|
|
||||||
await tester.tap(find.text('Create'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(repo.createCategoryCalled, isTrue);
|
|
||||||
expect(repo.lastCreatedName, 'Pot Holders');
|
|
||||||
// New category should appear in the list.
|
|
||||||
expect(find.text('Pot Holders'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('delete icon is shown for each category', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Two categories → two delete icons.
|
|
||||||
expect(find.byIcon(Icons.delete_outline), findsNWidgets(2));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('tapping delete icon opens confirmation dialog', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.delete_outline).first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Delete Category'), findsOneWidget);
|
|
||||||
expect(find.text('Delete'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('cancelling delete dialog does not call deleteCategory', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.delete_outline).first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Cancel'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(repo.deleteCategoryCalled, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('confirming delete calls repository and removes category', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Delete the first category (Bowl Cozies, id=1).
|
|
||||||
await tester.tap(find.byIcon(Icons.delete_outline).first);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Delete'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(repo.deleteCategoryCalled, isTrue);
|
|
||||||
expect(repo.lastDeletedId, 1);
|
|
||||||
expect(find.text('Bowl Cozies'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('refresh button reloads categories', (tester) async {
|
|
||||||
final repo = _FakeRepo();
|
|
||||||
await tester.pumpWidget(_buildPage(repo));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Add a category directly to the repo (simulating external change).
|
|
||||||
repo._categories.add(
|
|
||||||
const ProductCategory(id: 10, name: 'New Cat', slug: 'new-cat', parentId: 0, count: 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.refresh));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('New Cat'), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
|
||||||
|
|
||||||
/// Opens the dialog and returns the result.
|
|
||||||
Future<CreateProductDialogResult?> _openDialog(
|
|
||||||
WidgetTester tester, {
|
|
||||||
List<ProductCategory> categories = const [],
|
|
||||||
}) async {
|
|
||||||
CreateProductDialogResult? result;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
result = await showCreateProductDialog(context, categories: categories);
|
|
||||||
},
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('CreateProductDialog', () {
|
|
||||||
testWidgets('renders title and required fields', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
expect(find.text('New Product'), findsOneWidget);
|
|
||||||
expect(find.text('Product Name *'), findsOneWidget);
|
|
||||||
expect(find.text('Price *'), findsOneWidget);
|
|
||||||
expect(find.text('SKU'), findsOneWidget);
|
|
||||||
expect(find.text('Description'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows Cancel and Create Product buttons', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
expect(find.text('Cancel'), findsOneWidget);
|
|
||||||
expect(find.text('Create Product'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('Cancel closes dialog and returns null', (tester) async {
|
|
||||||
CreateProductDialogResult? result;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
result = await showCreateProductDialog(context, categories: []);
|
|
||||||
},
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.tap(find.text('Cancel'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(result, isNull);
|
|
||||||
expect(find.text('New Product'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows name error when submitted with empty name', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Product name is required.'), findsOneWidget);
|
|
||||||
// Dialog stays open.
|
|
||||||
expect(find.text('New Product'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows price error when submitted with invalid price', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'My Product');
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Enter a valid price (e.g. 12.99).'), findsOneWidget);
|
|
||||||
expect(find.text('New Product'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('submits with valid name and price and returns result', (tester) async {
|
|
||||||
CreateProductDialogResult? result;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
result = await showCreateProductDialog(context, categories: []);
|
|
||||||
},
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'Floral Bowl Cozy');
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Price *'), '12.99');
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(result, isNotNull);
|
|
||||||
expect(result!.name, 'Floral Bowl Cozy');
|
|
||||||
expect(result!.price, 12.99);
|
|
||||||
expect(result!.status, PublishStatus.draft);
|
|
||||||
expect(result!.categoryId, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('submits with SKU and description', (tester) async {
|
|
||||||
CreateProductDialogResult? result;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
result = await showCreateProductDialog(context, categories: []);
|
|
||||||
},
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'Cozy');
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Price *'), '9.99');
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'SKU'), 'BC-001');
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Description'), 'A nice cozy');
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(result!.sku, 'BC-001');
|
|
||||||
expect(result!.description, 'A nice cozy');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('does not show category dropdown when no categories provided', (tester) async {
|
|
||||||
await _openDialog(tester, categories: []);
|
|
||||||
|
|
||||||
expect(find.text('Category'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows category dropdown when categories are provided', (tester) async {
|
|
||||||
const cats = [
|
|
||||||
ProductCategory(id: 1, name: 'Bowl Cozies', slug: 'bowl-cozies', parentId: 0, count: 3),
|
|
||||||
ProductCategory(id: 2, name: 'Coasters', slug: 'coasters', parentId: 0, count: 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
await _openDialog(tester, categories: cats);
|
|
||||||
|
|
||||||
expect(find.text('Category'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('initial status defaults to draft', (tester) async {
|
|
||||||
CreateProductDialogResult? result;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
result = await showCreateProductDialog(context, categories: []);
|
|
||||||
},
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'Test');
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Price *'), '5.00');
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(result!.status, PublishStatus.draft);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('name error clears when user starts typing', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
// Trigger error.
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Product name is required.'), findsOneWidget);
|
|
||||||
|
|
||||||
// Start typing.
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'A');
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Product name is required.'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('price error clears when user starts typing', (tester) async {
|
|
||||||
await _openDialog(tester);
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Product Name *'), 'Test');
|
|
||||||
await tester.tap(find.text('Create Product'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Enter a valid price (e.g. 12.99).'), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.enterText(find.widgetWithText(TextField, 'Price *'), '1');
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('Enter a valid price (e.g. 12.99).'), findsNothing);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('CreateProductDialog — standalone widget', () {
|
|
||||||
testWidgets('renders inside a MaterialApp without error', (tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
_wrap(
|
|
||||||
Builder(
|
|
||||||
builder: (context) => TextButton(
|
|
||||||
onPressed: () => showDialog<CreateProductDialogResult>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => const CreateProductDialog(availableCategories: []),
|
|
||||||
),
|
|
||||||
child: const Text('Open'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Open'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byType(CreateProductDialog), findsOneWidget);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue