feat(feature_wordpress): add name-only product update
Publish Docs / publish-docs (push) Successful in 57s
Details
Publish Docs / publish-docs (push) Successful in 57s
Details
This commit is contained in:
parent
f8f373b018
commit
ae9c1dd90c
|
|
@ -29,6 +29,12 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
@override
|
@override
|
||||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
|
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductPrice(String id, double price) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StubOrdersRepository implements OrdersRepository {
|
class _StubOrdersRepository implements OrdersRepository {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ export 'src/domain/product_publishing_repository.dart';
|
||||||
export 'src/domain/publish_status.dart';
|
export 'src/domain/publish_status.dart';
|
||||||
|
|
||||||
// Application
|
// Application
|
||||||
|
export 'src/application/product_publishing_controller.dart' show ProductSortField;
|
||||||
|
export 'src/application/update_product_name.dart';
|
||||||
|
export 'src/application/update_product_price.dart';
|
||||||
export 'src/application/update_product_status.dart';
|
export 'src/application/update_product_status.dart';
|
||||||
|
|
||||||
// Presentation
|
// Presentation
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,88 @@ import '../domain/product_draft.dart';
|
||||||
import '../domain/publish_status.dart';
|
import '../domain/publish_status.dart';
|
||||||
import 'get_product_drafts.dart';
|
import 'get_product_drafts.dart';
|
||||||
import 'publish_product.dart';
|
import 'publish_product.dart';
|
||||||
|
import 'update_product_name.dart';
|
||||||
|
import 'update_product_price.dart';
|
||||||
import 'update_product_status.dart';
|
import 'update_product_status.dart';
|
||||||
|
|
||||||
|
/// The field used to sort the visible product list.
|
||||||
|
enum ProductSortField {
|
||||||
|
/// Sort alphabetically by product name.
|
||||||
|
name,
|
||||||
|
|
||||||
|
/// Sort by last-modified date.
|
||||||
|
lastModified,
|
||||||
|
|
||||||
|
/// Sort by publishing status (draft → pendingReview → published → unpublished).
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a single status-change action (publish / move-to-draft).
|
||||||
|
///
|
||||||
|
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||||
|
class StatusActionResult {
|
||||||
|
final bool success;
|
||||||
|
final String productName;
|
||||||
|
final PublishStatus targetStatus;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const StatusActionResult({
|
||||||
|
required this.success,
|
||||||
|
required this.productName,
|
||||||
|
required this.targetStatus,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a price-update action.
|
||||||
|
///
|
||||||
|
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||||
|
class PriceActionResult {
|
||||||
|
final bool success;
|
||||||
|
final String productName;
|
||||||
|
final double newPrice;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const PriceActionResult({
|
||||||
|
required this.success,
|
||||||
|
required this.productName,
|
||||||
|
required this.newPrice,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of a name-update action.
|
||||||
|
///
|
||||||
|
/// Consumed once by the UI to show a SnackBar, then cleared.
|
||||||
|
class NameActionResult {
|
||||||
|
final bool success;
|
||||||
|
final String productName;
|
||||||
|
final String newName;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const NameActionResult({
|
||||||
|
required this.success,
|
||||||
|
required this.productName,
|
||||||
|
required this.newName,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Controller that manages the product publishing workspace state, including
|
/// Controller that manages the product publishing workspace state, including
|
||||||
/// filtering by publish status, free-text search, and draft selection.
|
/// filtering by publish status, free-text search, and draft selection.
|
||||||
class ProductPublishingController extends ChangeNotifier {
|
class ProductPublishingController extends ChangeNotifier {
|
||||||
final GetProductDrafts _getProductDrafts;
|
final GetProductDrafts _getProductDrafts;
|
||||||
final PublishProduct _publishProduct;
|
final PublishProduct _publishProduct;
|
||||||
final UpdateProductStatus _updateProductStatus;
|
final UpdateProductStatus _updateProductStatus;
|
||||||
|
final UpdateProductPrice _updateProductPrice;
|
||||||
|
final UpdateProductName _updateProductName;
|
||||||
|
|
||||||
ProductPublishingController(
|
ProductPublishingController(
|
||||||
this._getProductDrafts,
|
this._getProductDrafts,
|
||||||
this._publishProduct,
|
this._publishProduct,
|
||||||
this._updateProductStatus,
|
this._updateProductStatus,
|
||||||
|
this._updateProductPrice,
|
||||||
|
this._updateProductName,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
@ -38,9 +107,18 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`.
|
/// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`.
|
||||||
String? activeFilter;
|
String? activeFilter;
|
||||||
|
|
||||||
/// The current free-text search query applied to name / SKU.
|
/// The current free-text search query applied to name, SKU, and category.
|
||||||
|
///
|
||||||
|
/// Multi-word queries use AND semantics: every whitespace-separated token
|
||||||
|
/// must appear in at least one of the searchable fields.
|
||||||
String searchQuery = '';
|
String searchQuery = '';
|
||||||
|
|
||||||
|
/// The field used to sort the visible product list.
|
||||||
|
ProductSortField activeSortField = ProductSortField.name;
|
||||||
|
|
||||||
|
/// Whether the current sort is ascending (`true`) or descending (`false`).
|
||||||
|
bool sortAscending = true;
|
||||||
|
|
||||||
/// Product IDs that currently have an in-flight status update.
|
/// Product IDs that currently have an in-flight status update.
|
||||||
///
|
///
|
||||||
/// Used to prevent duplicate clicks and to let the UI show per-row
|
/// Used to prevent duplicate clicks and to let the UI show per-row
|
||||||
|
|
@ -50,6 +128,42 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// Whether the product with [id] is currently being updated.
|
/// Whether the product with [id] is currently being updated.
|
||||||
bool isUpdating(String id) => updatingIds.contains(id);
|
bool isUpdating(String id) => updatingIds.contains(id);
|
||||||
|
|
||||||
|
/// The result of the last status-change action.
|
||||||
|
///
|
||||||
|
/// Set after [publish] or [updateStatus] completes. The UI should read
|
||||||
|
/// this once to show feedback (e.g. a SnackBar) and then call
|
||||||
|
/// [consumeActionResult] to clear it.
|
||||||
|
StatusActionResult? lastActionResult;
|
||||||
|
|
||||||
|
/// The result of the last price-update action.
|
||||||
|
///
|
||||||
|
/// Set after [updatePrice] completes. The UI should read this once to
|
||||||
|
/// show feedback (e.g. a SnackBar) and then call [consumePriceResult]
|
||||||
|
/// to clear it.
|
||||||
|
PriceActionResult? lastPriceResult;
|
||||||
|
|
||||||
|
/// The result of the last name-update action.
|
||||||
|
///
|
||||||
|
/// Set after [updateName] completes. The UI should read this once to
|
||||||
|
/// show feedback (e.g. a SnackBar) and then call [consumeNameResult]
|
||||||
|
/// to clear it.
|
||||||
|
NameActionResult? lastNameResult;
|
||||||
|
|
||||||
|
/// Clears [lastActionResult] so the same result is not shown twice.
|
||||||
|
void consumeActionResult() {
|
||||||
|
lastActionResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears [lastPriceResult] so the same result is not shown twice.
|
||||||
|
void consumePriceResult() {
|
||||||
|
lastPriceResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears [lastNameResult] so the same result is not shown twice.
|
||||||
|
void consumeNameResult() {
|
||||||
|
lastNameResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
|
|
@ -85,6 +199,16 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the sort field and direction, then recomputes the visible list.
|
||||||
|
void setSort(ProductSortField field, {bool? ascending}) {
|
||||||
|
activeSortField = field;
|
||||||
|
if (ascending != null) {
|
||||||
|
sortAscending = ascending;
|
||||||
|
}
|
||||||
|
_applyFilters();
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects a draft for preview.
|
/// Selects a draft for preview.
|
||||||
void selectDraft(ProductDraft draft) {
|
void selectDraft(ProductDraft draft) {
|
||||||
selectedDraft = draft;
|
selectedDraft = draft;
|
||||||
|
|
@ -104,13 +228,33 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
|
|
||||||
/// Publishes the draft with the given [id] and reloads the list.
|
/// Publishes the draft with the given [id] and reloads the list.
|
||||||
Future<void> publish(String id) async {
|
Future<void> publish(String id) async {
|
||||||
|
// Prevent duplicate clicks while this row is already updating.
|
||||||
|
if (updatingIds.contains(id)) return;
|
||||||
|
|
||||||
|
final productName = _productNameById(id);
|
||||||
|
|
||||||
|
updatingIds.add(id);
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _publishProduct(id);
|
await _publishProduct(id);
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastActionResult = StatusActionResult(
|
||||||
|
success: true,
|
||||||
|
productName: productName,
|
||||||
|
targetStatus: PublishStatus.published,
|
||||||
|
);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
error = e;
|
updatingIds.remove(id);
|
||||||
|
lastActionResult = StatusActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: productName,
|
||||||
|
targetStatus: PublishStatus.published,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +268,8 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
// Prevent duplicate clicks while this row is already updating.
|
// Prevent duplicate clicks while this row is already updating.
|
||||||
if (updatingIds.contains(id)) return;
|
if (updatingIds.contains(id)) return;
|
||||||
|
|
||||||
|
final productName = _productNameById(id);
|
||||||
|
|
||||||
updatingIds.add(id);
|
updatingIds.add(id);
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
|
|
||||||
|
|
@ -131,11 +277,81 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
await _updateProductStatus(id, status);
|
await _updateProductStatus(id, status);
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
updatingIds.remove(id);
|
updatingIds.remove(id);
|
||||||
|
lastActionResult = StatusActionResult(
|
||||||
|
success: true,
|
||||||
|
productName: productName,
|
||||||
|
targetStatus: status,
|
||||||
|
);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
updatingIds.remove(id);
|
updatingIds.remove(id);
|
||||||
error = e;
|
lastActionResult = StatusActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: productName,
|
||||||
|
targetStatus: status,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates only the price of the product with [id].
|
||||||
|
///
|
||||||
|
/// Follows the same per-row updating pattern as [updateStatus].
|
||||||
|
Future<void> updatePrice(String id, double price) async {
|
||||||
|
if (updatingIds.contains(id)) return;
|
||||||
|
|
||||||
|
final productName = _productNameById(id);
|
||||||
|
|
||||||
|
updatingIds.add(id);
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _updateProductPrice(id, price);
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastPriceResult = PriceActionResult(success: true, productName: productName, newPrice: price);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastPriceResult = PriceActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: productName,
|
||||||
|
newPrice: price,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates only the name of the product with [id].
|
||||||
|
///
|
||||||
|
/// Follows the same per-row updating pattern as [updateStatus].
|
||||||
|
Future<void> updateName(String id, String name) async {
|
||||||
|
if (updatingIds.contains(id)) return;
|
||||||
|
|
||||||
|
final productName = _productNameById(id);
|
||||||
|
|
||||||
|
updatingIds.add(id);
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _updateProductName(id, name);
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastNameResult = NameActionResult(success: true, productName: productName, newName: name);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
updatingIds.remove(id);
|
||||||
|
lastNameResult = NameActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: productName,
|
||||||
|
newName: name,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +372,11 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the product name for [id], or a fallback if not found.
|
||||||
|
String _productNameById(String id) {
|
||||||
|
return _allDrafts.where((d) => d.id == id).map((d) => d.name).firstOrNull ?? 'Product $id';
|
||||||
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
void _applyFilters() {
|
||||||
var result = _allDrafts;
|
var result = _allDrafts;
|
||||||
|
|
||||||
|
|
@ -165,13 +386,26 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
result = result.where((d) => d.status == status).toList();
|
result = result.where((d) => d.status == status).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free-text search on name and SKU
|
// Free-text search on name, SKU, and category.
|
||||||
|
// Multi-word queries use AND semantics: every whitespace-separated
|
||||||
|
// token must appear in at least one of the searchable fields.
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final q = searchQuery.toLowerCase();
|
final tokens = searchQuery
|
||||||
|
.toLowerCase()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((t) => t.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (tokens.isNotEmpty) {
|
||||||
result = result.where((d) {
|
result = result.where((d) {
|
||||||
return d.name.toLowerCase().contains(q) || d.sku.toLowerCase().contains(q);
|
final haystack =
|
||||||
|
'${d.name.toLowerCase()} ${d.sku.toLowerCase()} ${d.category.toLowerCase()}';
|
||||||
|
return tokens.every((token) => haystack.contains(token));
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
result = _sortDrafts(result);
|
||||||
|
|
||||||
drafts = result;
|
drafts = result;
|
||||||
|
|
||||||
|
|
@ -181,6 +415,35 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sorts [items] according to [activeSortField] and [sortAscending].
|
||||||
|
///
|
||||||
|
/// Uses name as a stable secondary sort when the primary field has ties.
|
||||||
|
List<ProductDraft> _sortDrafts(List<ProductDraft> items) {
|
||||||
|
if (items.length <= 1) return items;
|
||||||
|
|
||||||
|
final sorted = List<ProductDraft>.of(items);
|
||||||
|
final dir = sortAscending ? 1 : -1;
|
||||||
|
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
int cmp;
|
||||||
|
switch (activeSortField) {
|
||||||
|
case ProductSortField.name:
|
||||||
|
cmp = a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
case ProductSortField.lastModified:
|
||||||
|
cmp = a.lastModified.compareTo(b.lastModified);
|
||||||
|
case ProductSortField.status:
|
||||||
|
cmp = a.status.index.compareTo(b.status.index);
|
||||||
|
}
|
||||||
|
// Stable secondary sort by name when primary field ties.
|
||||||
|
if (cmp == 0 && activeSortField != ProductSortField.name) {
|
||||||
|
cmp = a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||||
|
}
|
||||||
|
return cmp * dir;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
static PublishStatus? _parseStatus(String? filter) {
|
static PublishStatus? _parseStatus(String? filter) {
|
||||||
if (filter == null) return null;
|
if (filter == null) return null;
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import '../domain/product_draft.dart';
|
||||||
|
import '../domain/product_publishing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case: update only the name of a single product by its [id].
|
||||||
|
///
|
||||||
|
/// This is a narrow name mutation — not a generic product edit.
|
||||||
|
class UpdateProductName {
|
||||||
|
final ProductPublishingRepository repository;
|
||||||
|
|
||||||
|
UpdateProductName(this.repository);
|
||||||
|
|
||||||
|
Future<ProductDraft> call(String id, String name) => repository.updateProductName(id, name);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import '../domain/product_draft.dart';
|
||||||
|
import '../domain/product_publishing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case: update only the price of a single product by its [id].
|
||||||
|
///
|
||||||
|
/// This is a narrow price mutation — not a generic product edit.
|
||||||
|
class UpdateProductPrice {
|
||||||
|
final ProductPublishingRepository repository;
|
||||||
|
|
||||||
|
UpdateProductPrice(this.repository);
|
||||||
|
|
||||||
|
Future<ProductDraft> call(String id, double price) => repository.updateProductPrice(id, price);
|
||||||
|
}
|
||||||
|
|
@ -121,10 +121,37 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
final original = _drafts[index];
|
final original = _drafts[index];
|
||||||
final updated = original.copyWith(
|
final updated = original.copyWith(status: status, lastModified: DateTime.now());
|
||||||
status: status,
|
_drafts[index] = updated;
|
||||||
lastModified: DateTime.now(),
|
return updated;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductPrice(String id, double price) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final index = _drafts.indexWhere((d) => d.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
throw StateError('Draft with id $id not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final original = _drafts[index];
|
||||||
|
final updated = original.copyWith(price: price, lastModified: DateTime.now());
|
||||||
|
_drafts[index] = updated;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductName(String id, String name) async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
final index = _drafts.indexWhere((d) => d.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
throw StateError('Draft with id $id not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final original = _drafts[index];
|
||||||
|
final updated = original.copyWith(name: name, lastModified: DateTime.now());
|
||||||
_drafts[index] = updated;
|
_drafts[index] = updated;
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,16 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
|
||||||
final json = await _apiClient.updateProduct(id, {'status': wooStatus});
|
final json = await _apiClient.updateProduct(id, {'status': wooStatus});
|
||||||
return _mapper.fromJson(json);
|
return _mapper.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductPrice(String id, double price) async {
|
||||||
|
final json = await _apiClient.updateProduct(id, {'regular_price': price.toStringAsFixed(2)});
|
||||||
|
return _mapper.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductName(String id, String name) async {
|
||||||
|
final json = await _apiClient.updateProduct(id, {'name': name});
|
||||||
|
return _mapper.fromJson(json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,14 @@ abstract class ProductPublishingRepository {
|
||||||
///
|
///
|
||||||
/// Returns the updated [ProductDraft] reflecting the new status.
|
/// Returns the updated [ProductDraft] reflecting the new status.
|
||||||
Future<ProductDraft> updateProductStatus(String id, PublishStatus status);
|
Future<ProductDraft> updateProductStatus(String id, PublishStatus status);
|
||||||
|
|
||||||
|
/// Updates only the price of the product identified by [id].
|
||||||
|
///
|
||||||
|
/// Returns the updated [ProductDraft] reflecting the new price.
|
||||||
|
Future<ProductDraft> updateProductPrice(String id, double price);
|
||||||
|
|
||||||
|
/// Updates only the name of the product identified by [id].
|
||||||
|
///
|
||||||
|
/// Returns the updated [ProductDraft] reflecting the new name.
|
||||||
|
Future<ProductDraft> updateProductName(String id, String name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import 'package:flutter/material.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';
|
||||||
|
import '../application/update_product_name.dart';
|
||||||
|
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 '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';
|
||||||
|
|
||||||
/// The main Product Publishing Workspace page.
|
/// The main Product Publishing Workspace page.
|
||||||
///
|
///
|
||||||
|
|
@ -54,8 +57,12 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
GetProductDrafts(repo),
|
GetProductDrafts(repo),
|
||||||
PublishProduct(repo),
|
PublishProduct(repo),
|
||||||
UpdateProductStatus(repo),
|
UpdateProductStatus(repo),
|
||||||
|
UpdateProductPrice(repo),
|
||||||
|
UpdateProductName(repo),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
controller.addListener(_onControllerChanged);
|
||||||
|
|
||||||
// Apply any initial filter / query before loading.
|
// Apply any initial filter / query before loading.
|
||||||
if (widget.initialFilter != null) {
|
if (widget.initialFilter != null) {
|
||||||
controller.activeFilter = widget.initialFilter;
|
controller.activeFilter = widget.initialFilter;
|
||||||
|
|
@ -74,21 +81,66 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
controller.removeListener(_onControllerChanged);
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onControllerChanged() {
|
||||||
|
final result = controller.lastActionResult;
|
||||||
|
if (result != null) {
|
||||||
|
controller.consumeActionResult();
|
||||||
|
showStatusActionSnackBar(context, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
final priceResult = controller.lastPriceResult;
|
||||||
|
if (priceResult != null) {
|
||||||
|
controller.consumePriceResult();
|
||||||
|
showPriceActionSnackBar(context, priceResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameResult = controller.lastNameResult;
|
||||||
|
if (nameResult != null) {
|
||||||
|
controller.consumeNameResult();
|
||||||
|
showNameActionSnackBar(context, nameResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: controller,
|
animation: controller,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (controller.isLoading) {
|
if (controller.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: KcSpacing.md),
|
||||||
|
Text('Loading products…'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller.error != null) {
|
if (controller.error != null) {
|
||||||
return const Center(child: Text('Failed to load product drafts.'));
|
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 product drafts.'),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: controller.load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
|
|
@ -113,8 +165,39 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDraftList() {
|
Widget _buildDraftList() {
|
||||||
return ListView.separated(
|
final count = controller.drafts.length;
|
||||||
itemCount: controller.drafts.length,
|
|
||||||
|
if (count == 0) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search_off, size: 48, color: KcColors.neutral),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
Text(
|
||||||
|
controller.searchQuery.isNotEmpty || controller.activeFilter != null
|
||||||
|
? 'No products match your criteria.'
|
||||||
|
: 'No product drafts available.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||||
|
child: Text(
|
||||||
|
'$count ${count == 1 ? 'product' : 'products'}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: count,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final draft = controller.drafts[index];
|
final draft = controller.drafts[index];
|
||||||
|
|
@ -127,19 +210,36 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPreview() {
|
Widget _buildPreview() {
|
||||||
final selected = controller.selectedDraft;
|
final selected = controller.selectedDraft;
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return const Center(child: Text('Select a product draft to preview'));
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.touch_app_outlined, size: 48, color: KcColors.neutral),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
Text(
|
||||||
|
'Select a product to preview',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ProductPreviewPanel(
|
return ProductPreviewPanel(
|
||||||
draft: selected,
|
draft: selected,
|
||||||
isUpdating: controller.isUpdating(selected.id),
|
isUpdating: controller.isUpdating(selected.id),
|
||||||
onPublish: () => controller.updateStatus(selected.id, PublishStatus.published),
|
onPublish: () => controller.updateStatus(selected.id, PublishStatus.published),
|
||||||
onMoveToDraft: () => controller.updateStatus(selected.id, PublishStatus.draft),
|
onMoveToDraft: () => controller.updateStatus(selected.id, PublishStatus.draft),
|
||||||
|
onPriceChanged: (price) => controller.updatePrice(selected.id, price),
|
||||||
|
onNameChanged: (name) => controller.updateName(selected.id, name),
|
||||||
onViewPolicy: widget.onViewPolicy,
|
onViewPolicy: widget.onViewPolicy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,16 @@ class ProductDraftCard extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
PublishStatusChip(status: draft.status),
|
PublishStatusChip(status: draft.status),
|
||||||
|
Text(
|
||||||
|
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../../domain/product_draft.dart';
|
import '../../domain/product_draft.dart';
|
||||||
import '../../domain/publish_status.dart';
|
import '../../domain/publish_status.dart';
|
||||||
|
|
@ -12,13 +13,19 @@ import 'publish_status_chip.dart';
|
||||||
/// - **Publish to Store** when the draft status is [PublishStatus.draft].
|
/// - **Publish to Store** when the draft status is [PublishStatus.draft].
|
||||||
/// - **Move to Draft** when the draft status is [PublishStatus.published],
|
/// - **Move to Draft** when the draft status is [PublishStatus.published],
|
||||||
/// [PublishStatus.unpublished], or [PublishStatus.pendingReview].
|
/// [PublishStatus.unpublished], or [PublishStatus.pendingReview].
|
||||||
class ProductPreviewPanel extends StatelessWidget {
|
class ProductPreviewPanel extends StatefulWidget {
|
||||||
final ProductDraft draft;
|
final ProductDraft draft;
|
||||||
final VoidCallback? onPublish;
|
final VoidCallback? onPublish;
|
||||||
|
|
||||||
/// Callback to revert a published product back to draft status.
|
/// Callback to revert a published product back to draft status.
|
||||||
final VoidCallback? onMoveToDraft;
|
final VoidCallback? onMoveToDraft;
|
||||||
|
|
||||||
|
/// Callback to update the product price. Receives the new price value.
|
||||||
|
final ValueChanged<double>? onPriceChanged;
|
||||||
|
|
||||||
|
/// Callback to update the product name. Receives the new name value.
|
||||||
|
final ValueChanged<String>? onNameChanged;
|
||||||
|
|
||||||
/// Whether this product currently has an in-flight status update.
|
/// Whether this product currently has an in-flight status update.
|
||||||
/// When true, the action button is disabled and a progress indicator is shown.
|
/// When true, the action button is disabled and a progress indicator is shown.
|
||||||
final bool isUpdating;
|
final bool isUpdating;
|
||||||
|
|
@ -31,60 +38,104 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
required this.draft,
|
required this.draft,
|
||||||
this.onPublish,
|
this.onPublish,
|
||||||
this.onMoveToDraft,
|
this.onMoveToDraft,
|
||||||
|
this.onPriceChanged,
|
||||||
|
this.onNameChanged,
|
||||||
this.isUpdating = false,
|
this.isUpdating = false,
|
||||||
this.onViewPolicy,
|
this.onViewPolicy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProductPreviewPanel> createState() => _ProductPreviewPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
|
bool _editingPrice = false;
|
||||||
|
late TextEditingController _priceController;
|
||||||
|
|
||||||
|
bool _editingName = false;
|
||||||
|
late TextEditingController _nameController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2));
|
||||||
|
_nameController = TextEditingController(text: widget.draft.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ProductPreviewPanel oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.price != widget.draft.price) {
|
||||||
|
_editingPrice = false;
|
||||||
|
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.name != widget.draft.name) {
|
||||||
|
_editingName = false;
|
||||||
|
_nameController.text = widget.draft.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_priceController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitPrice() {
|
||||||
|
final parsed = double.tryParse(_priceController.text);
|
||||||
|
if (parsed != null && parsed >= 0) {
|
||||||
|
widget.onPriceChanged?.call(parsed);
|
||||||
|
setState(() => _editingPrice = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelEdit() {
|
||||||
|
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
||||||
|
setState(() => _editingPrice = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submitName() {
|
||||||
|
final trimmed = _nameController.text.trim();
|
||||||
|
if (trimmed.isNotEmpty) {
|
||||||
|
widget.onNameChanged?.call(trimmed);
|
||||||
|
setState(() => _editingName = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelNameEdit() {
|
||||||
|
_nameController.text = widget.draft.name;
|
||||||
|
setState(() => _editingName = false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final draft = widget.draft;
|
||||||
|
|
||||||
return KcCard(
|
return KcCard(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ── Image placeholder ──────────────────────────────────────
|
// ── Product image ──────────────────────────────────────────
|
||||||
Container(
|
_ProductImage(imageUrl: draft.imageUrl),
|
||||||
height: 180,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: KcColors.background,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: KcColors.border),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(Icons.image_outlined, size: 48, color: KcColors.neutral),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
// ── Title & status ─────────────────────────────────────────
|
// ── Title & status ─────────────────────────────────────────
|
||||||
Row(
|
_buildNameRow(context),
|
||||||
children: [
|
|
||||||
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
|
|
||||||
const SizedBox(width: KcSpacing.sm),
|
|
||||||
if (isUpdating)
|
|
||||||
const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
if (isUpdating) const SizedBox(width: KcSpacing.sm),
|
|
||||||
PublishStatusChip(status: draft.status),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
|
||||||
// ── Metadata ───────────────────────────────────────────────
|
// ── Metadata ───────────────────────────────────────────────
|
||||||
_MetadataRow(label: 'SKU', value: draft.sku),
|
_MetadataRow(label: 'SKU', value: draft.sku),
|
||||||
_MetadataRow(label: 'Price', value: '\$${draft.price.toStringAsFixed(2)}'),
|
_buildPriceRow(context),
|
||||||
_MetadataRow(label: 'Category', value: draft.category),
|
_MetadataRow(label: 'Category', value: draft.category),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Last Modified',
|
label: 'Last Modified',
|
||||||
value:
|
value:
|
||||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||||
),
|
),
|
||||||
|
if (draft.imageUrl.isNotEmpty) _MetadataRow(label: 'Image URL', value: draft.imageUrl),
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
|
||||||
// ── Description ────────────────────────────────────────────
|
// ── Description ────────────────────────────────────────────
|
||||||
|
|
@ -94,9 +145,9 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
const SizedBox(height: KcSpacing.xl),
|
const SizedBox(height: KcSpacing.xl),
|
||||||
|
|
||||||
// ── Policy link ────────────────────────────────────────────
|
// ── Policy link ────────────────────────────────────────────
|
||||||
if (onViewPolicy != null) ...[
|
if (widget.onViewPolicy != null) ...[
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onViewPolicy,
|
onTap: widget.onViewPolicy,
|
||||||
child: Text(
|
child: Text(
|
||||||
'View Compliance Policy →',
|
'View Compliance Policy →',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
|
@ -113,7 +164,7 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: isUpdating ? null : onPublish,
|
onPressed: widget.isUpdating ? null : widget.onPublish,
|
||||||
icon: const Icon(Icons.publish),
|
icon: const Icon(Icons.publish),
|
||||||
label: const Text('Publish to Store'),
|
label: const Text('Publish to Store'),
|
||||||
),
|
),
|
||||||
|
|
@ -124,7 +175,7 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: isUpdating ? null : onMoveToDraft,
|
onPressed: widget.isUpdating ? null : widget.onMoveToDraft,
|
||||||
icon: const Icon(Icons.unpublished_outlined),
|
icon: const Icon(Icons.unpublished_outlined),
|
||||||
label: const Text('Move to Draft'),
|
label: const Text('Move to Draft'),
|
||||||
),
|
),
|
||||||
|
|
@ -134,6 +185,156 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the Name title row — either a static display with an edit
|
||||||
|
/// icon, or an inline text field with save/cancel actions.
|
||||||
|
Widget _buildNameRow(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final draft = widget.draft;
|
||||||
|
|
||||||
|
if (_editingName) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submitName(),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.check, size: 20),
|
||||||
|
onPressed: _submitName,
|
||||||
|
tooltip: 'Save name',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: _cancelNameEdit,
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
|
||||||
|
if (widget.onNameChanged != null && !widget.isUpdating) ...[
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
onPressed: () => setState(() => _editingName = true),
|
||||||
|
tooltip: 'Edit name',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: KcSpacing.sm),
|
||||||
|
if (widget.isUpdating)
|
||||||
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
if (widget.isUpdating) const SizedBox(width: KcSpacing.sm),
|
||||||
|
PublishStatusChip(status: draft.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Price metadata row — either a static display with an edit
|
||||||
|
/// icon, or an inline text field with save/cancel actions.
|
||||||
|
Widget _buildPriceRow(BuildContext context) {
|
||||||
|
if (_editingPrice) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'Price',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.neutral,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 100,
|
||||||
|
child: TextField(
|
||||||
|
controller: _priceController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixText: '\$ ',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submitPrice(),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.check, size: 20),
|
||||||
|
onPressed: _submitPrice,
|
||||||
|
tooltip: 'Save price',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: KcSpacing.xs),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: _cancelEdit,
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: Text(
|
||||||
|
'Price',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: KcColors.neutral,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'\$${widget.draft.price.toStringAsFixed(2)}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.onPriceChanged != null && !widget.isUpdating)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
onPressed: () => setState(() => _editingPrice = true),
|
||||||
|
tooltip: 'Edit price',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MetadataRow extends StatelessWidget {
|
class _MetadataRow extends StatelessWidget {
|
||||||
|
|
@ -164,3 +365,34 @@ class _MetadataRow extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Displays the product image when [imageUrl] is non-empty, falling back to a
|
||||||
|
/// placeholder icon when no URL is available.
|
||||||
|
class _ProductImage extends StatelessWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
|
||||||
|
const _ProductImage({required this.imageUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: KcColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: KcColors.border),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: imageUrl.isEmpty
|
||||||
|
? const Center(child: Icon(Icons.image_outlined, size: 48, color: KcColors.neutral))
|
||||||
|
: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => const Center(
|
||||||
|
child: Icon(Icons.broken_image_outlined, size: 48, color: KcColors.neutral),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../application/product_publishing_controller.dart';
|
||||||
|
import '../../domain/publish_status.dart';
|
||||||
|
|
||||||
|
/// Shows a [SnackBar] for the given [StatusActionResult].
|
||||||
|
///
|
||||||
|
/// Uses [KcColors.success] / [KcColors.danger] to match the design system.
|
||||||
|
/// This is a plain function — no broad notification infrastructure required.
|
||||||
|
void showStatusActionSnackBar(BuildContext context, StatusActionResult result) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
final verb = _pastVerbForStatus(result.targetStatus);
|
||||||
|
message = '${result.productName} $verb successfully.';
|
||||||
|
backgroundColor = KcColors.success;
|
||||||
|
icon = Icons.check_circle_outline;
|
||||||
|
} else {
|
||||||
|
final verb = _infinitiveVerbForStatus(result.targetStatus);
|
||||||
|
message = 'Failed to $verb ${result.productName}.';
|
||||||
|
backgroundColor = KcColors.danger;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: result.success ? const Duration(seconds: 3) : const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Past-tense verb for success messages (e.g. "X published successfully").
|
||||||
|
String _pastVerbForStatus(PublishStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PublishStatus.published:
|
||||||
|
return 'published';
|
||||||
|
case PublishStatus.draft:
|
||||||
|
return 'moved to draft';
|
||||||
|
case PublishStatus.pendingReview:
|
||||||
|
return 'submitted for review';
|
||||||
|
case PublishStatus.unpublished:
|
||||||
|
return 'unpublished';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infinitive verb for failure messages (e.g. "Failed to publish X").
|
||||||
|
String _infinitiveVerbForStatus(PublishStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case PublishStatus.published:
|
||||||
|
return 'publish';
|
||||||
|
case PublishStatus.draft:
|
||||||
|
return 'move to draft';
|
||||||
|
case PublishStatus.pendingReview:
|
||||||
|
return 'submit for review';
|
||||||
|
case PublishStatus.unpublished:
|
||||||
|
return 'unpublish';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a [SnackBar] for the given [PriceActionResult].
|
||||||
|
void showPriceActionSnackBar(BuildContext context, PriceActionResult result) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message = '${result.productName} price updated to \$${result.newPrice.toStringAsFixed(2)}.';
|
||||||
|
backgroundColor = KcColors.success;
|
||||||
|
icon = Icons.check_circle_outline;
|
||||||
|
} else {
|
||||||
|
message = 'Failed to update price for ${result.productName}.';
|
||||||
|
backgroundColor = KcColors.danger;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: result.success ? const Duration(seconds: 3) : const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a [SnackBar] for the given [NameActionResult].
|
||||||
|
void showNameActionSnackBar(BuildContext context, NameActionResult result) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message = 'Product renamed to ${result.newName}.';
|
||||||
|
backgroundColor = KcColors.success;
|
||||||
|
icon = Icons.check_circle_outline;
|
||||||
|
} else {
|
||||||
|
message = 'Failed to rename ${result.productName}.';
|
||||||
|
backgroundColor = KcColors.danger;
|
||||||
|
icon = Icons.error_outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(message)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
duration: result.success ? const Duration(seconds: 3) : const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -135,5 +135,113 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('updateProductPrice', () {
|
||||||
|
test('updates price of the target product', () async {
|
||||||
|
final updated = await repository.updateProductPrice('4', 11.99);
|
||||||
|
|
||||||
|
expect(updated.id, '4');
|
||||||
|
expect(updated.price, 11.99);
|
||||||
|
expect(updated.name, 'Fabric Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the price change in the list', () async {
|
||||||
|
await repository.updateProductPrice('4', 11.99);
|
||||||
|
|
||||||
|
final drafts = await repository.getProductDrafts();
|
||||||
|
final product4 = drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(product4.price, 11.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves all fields except price and lastModified', () async {
|
||||||
|
final draftsBefore = await repository.getProductDrafts();
|
||||||
|
final before = draftsBefore.firstWhere((d) => d.id == '4');
|
||||||
|
|
||||||
|
final updated = await repository.updateProductPrice('4', 11.99);
|
||||||
|
|
||||||
|
expect(updated.name, before.name);
|
||||||
|
expect(updated.sku, before.sku);
|
||||||
|
expect(updated.category, before.category);
|
||||||
|
expect(updated.description, before.description);
|
||||||
|
expect(updated.imageUrl, before.imageUrl);
|
||||||
|
expect(updated.status, before.status);
|
||||||
|
expect(updated.price, 11.99);
|
||||||
|
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other products unchanged', () async {
|
||||||
|
final draftsBefore = await repository.getProductDrafts();
|
||||||
|
|
||||||
|
await repository.updateProductPrice('4', 11.99);
|
||||||
|
|
||||||
|
final draftsAfter = await repository.getProductDrafts();
|
||||||
|
for (final before in draftsBefore) {
|
||||||
|
if (before.id == '4') continue;
|
||||||
|
final after = draftsAfter.firstWhere((d) => d.id == before.id);
|
||||||
|
expect(after.price, before.price);
|
||||||
|
expect(after.name, before.name);
|
||||||
|
expect(after.status, before.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws StateError for unknown id', () async {
|
||||||
|
expect(() => repository.updateProductPrice('unknown', 9.99), throwsA(isA<StateError>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateProductName', () {
|
||||||
|
test('updates name of the target product', () async {
|
||||||
|
final updated = await repository.updateProductName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
expect(updated.id, '4');
|
||||||
|
expect(updated.name, 'Deluxe Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the name change in the list', () async {
|
||||||
|
await repository.updateProductName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
final drafts = await repository.getProductDrafts();
|
||||||
|
final product4 = drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(product4.name, 'Deluxe Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves all fields except name and lastModified', () async {
|
||||||
|
final draftsBefore = await repository.getProductDrafts();
|
||||||
|
final before = draftsBefore.firstWhere((d) => d.id == '4');
|
||||||
|
|
||||||
|
final updated = await repository.updateProductName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
expect(updated.price, before.price);
|
||||||
|
expect(updated.sku, before.sku);
|
||||||
|
expect(updated.category, before.category);
|
||||||
|
expect(updated.description, before.description);
|
||||||
|
expect(updated.imageUrl, before.imageUrl);
|
||||||
|
expect(updated.status, before.status);
|
||||||
|
expect(updated.name, 'Deluxe Jar Gripper');
|
||||||
|
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other products unchanged', () async {
|
||||||
|
final draftsBefore = await repository.getProductDrafts();
|
||||||
|
|
||||||
|
await repository.updateProductName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
final draftsAfter = await repository.getProductDrafts();
|
||||||
|
for (final before in draftsBefore) {
|
||||||
|
if (before.id == '4') continue;
|
||||||
|
final after = draftsAfter.firstWhere((d) => d.id == before.id);
|
||||||
|
expect(after.name, before.name);
|
||||||
|
expect(after.price, before.price);
|
||||||
|
expect(after.status, before.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws StateError for unknown id', () async {
|
||||||
|
expect(
|
||||||
|
() => repository.updateProductName('unknown', 'New Name'),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ void main() {
|
||||||
GetProductDrafts(repository),
|
GetProductDrafts(repository),
|
||||||
PublishProduct(repository),
|
PublishProduct(repository),
|
||||||
UpdateProductStatus(repository),
|
UpdateProductStatus(repository),
|
||||||
|
UpdateProductPrice(repository),
|
||||||
|
UpdateProductName(repository),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -39,7 +41,8 @@ void main() {
|
||||||
expect(controller.isLoading, false);
|
expect(controller.isLoading, false);
|
||||||
expect(controller.drafts.length, 6);
|
expect(controller.drafts.length, 6);
|
||||||
expect(controller.selectedDraft, isNotNull);
|
expect(controller.selectedDraft, isNotNull);
|
||||||
expect(controller.selectedDraft!.id, '1');
|
// Default sort is name ascending; 'Citrus Coaster Set' (id 2) comes first.
|
||||||
|
expect(controller.selectedDraft!.id, '2');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -181,14 +184,19 @@ void main() {
|
||||||
expect(controller.updatingIds, isEmpty);
|
expect(controller.updatingIds, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sets error on failed update', () async {
|
test('sets lastActionResult on failed update', () async {
|
||||||
await controller.load();
|
await controller.load();
|
||||||
|
|
||||||
// Unknown id triggers StateError in the fake repository.
|
// Unknown id triggers StateError in the fake repository.
|
||||||
await controller.updateStatus('unknown', PublishStatus.draft);
|
await controller.updateStatus('unknown', PublishStatus.draft);
|
||||||
|
|
||||||
expect(controller.error, isA<StateError>());
|
expect(controller.lastActionResult, isNotNull);
|
||||||
|
expect(controller.lastActionResult!.success, isFalse);
|
||||||
|
expect(controller.lastActionResult!.targetStatus, PublishStatus.draft);
|
||||||
|
expect(controller.lastActionResult!.errorMessage, isNotNull);
|
||||||
expect(controller.updatingIds, isEmpty);
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
// Page-level error should NOT be set for action failures.
|
||||||
|
expect(controller.error, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prevents duplicate calls while row is already updating', () async {
|
test('prevents duplicate calls while row is already updating', () async {
|
||||||
|
|
@ -233,6 +241,465 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('lastActionResult', () {
|
||||||
|
test('starts as null', () {
|
||||||
|
expect(controller.lastActionResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set to success after updateStatus completes', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateStatus('4', PublishStatus.published);
|
||||||
|
|
||||||
|
expect(controller.lastActionResult, isNotNull);
|
||||||
|
expect(controller.lastActionResult!.success, isTrue);
|
||||||
|
expect(controller.lastActionResult!.productName, 'Fabric Jar Gripper');
|
||||||
|
expect(controller.lastActionResult!.targetStatus, PublishStatus.published);
|
||||||
|
expect(controller.lastActionResult!.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set to success after publish completes', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.publish('4');
|
||||||
|
|
||||||
|
expect(controller.lastActionResult, isNotNull);
|
||||||
|
expect(controller.lastActionResult!.success, isTrue);
|
||||||
|
expect(controller.lastActionResult!.productName, 'Fabric Jar Gripper');
|
||||||
|
expect(controller.lastActionResult!.targetStatus, PublishStatus.published);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set to failure with error message on failed publish', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.publish('unknown');
|
||||||
|
|
||||||
|
expect(controller.lastActionResult, isNotNull);
|
||||||
|
expect(controller.lastActionResult!.success, isFalse);
|
||||||
|
expect(controller.lastActionResult!.errorMessage, isNotNull);
|
||||||
|
// Page-level error should NOT be set for action failures.
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consumeActionResult clears the result', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateStatus('4', PublishStatus.published);
|
||||||
|
expect(controller.lastActionResult, isNotNull);
|
||||||
|
|
||||||
|
controller.consumeActionResult();
|
||||||
|
expect(controller.lastActionResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses fallback name for unknown product id', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateStatus('unknown', PublishStatus.draft);
|
||||||
|
|
||||||
|
expect(controller.lastActionResult!.productName, 'Product unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updatePrice', () {
|
||||||
|
test('updates price and reloads', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 4 starts at 8.50.
|
||||||
|
await controller.updatePrice('4', 11.99);
|
||||||
|
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.price, 11.99);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastPriceResult on success', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updatePrice('4', 11.99);
|
||||||
|
|
||||||
|
expect(controller.lastPriceResult, isNotNull);
|
||||||
|
expect(controller.lastPriceResult!.success, isTrue);
|
||||||
|
expect(controller.lastPriceResult!.productName, 'Fabric Jar Gripper');
|
||||||
|
expect(controller.lastPriceResult!.newPrice, 11.99);
|
||||||
|
expect(controller.lastPriceResult!.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastPriceResult on failure', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updatePrice('unknown', 9.99);
|
||||||
|
|
||||||
|
expect(controller.lastPriceResult, isNotNull);
|
||||||
|
expect(controller.lastPriceResult!.success, isFalse);
|
||||||
|
expect(controller.lastPriceResult!.errorMessage, isNotNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consumePriceResult clears the result', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updatePrice('4', 11.99);
|
||||||
|
expect(controller.lastPriceResult, isNotNull);
|
||||||
|
|
||||||
|
controller.consumePriceResult();
|
||||||
|
expect(controller.lastPriceResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents duplicate calls while row is already updating', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final first = controller.updatePrice('4', 11.99);
|
||||||
|
expect(controller.isUpdating('4'), isTrue);
|
||||||
|
|
||||||
|
final second = controller.updatePrice('4', 15.00);
|
||||||
|
await first;
|
||||||
|
await second;
|
||||||
|
|
||||||
|
// Only the first price should have been applied.
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.price, 11.99);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateName', () {
|
||||||
|
test('updates name and reloads', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// Product 4 starts as 'Fabric Jar Gripper'.
|
||||||
|
await controller.updateName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.name, 'Deluxe Jar Gripper');
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastNameResult on success', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
expect(controller.lastNameResult, isNotNull);
|
||||||
|
expect(controller.lastNameResult!.success, isTrue);
|
||||||
|
expect(controller.lastNameResult!.productName, 'Fabric Jar Gripper');
|
||||||
|
expect(controller.lastNameResult!.newName, 'Deluxe Jar Gripper');
|
||||||
|
expect(controller.lastNameResult!.errorMessage, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastNameResult on failure', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateName('unknown', 'New Name');
|
||||||
|
|
||||||
|
expect(controller.lastNameResult, isNotNull);
|
||||||
|
expect(controller.lastNameResult!.success, isFalse);
|
||||||
|
expect(controller.lastNameResult!.errorMessage, isNotNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
expect(controller.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consumeNameResult clears the result', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
await controller.updateName('4', 'Deluxe Jar Gripper');
|
||||||
|
expect(controller.lastNameResult, isNotNull);
|
||||||
|
|
||||||
|
controller.consumeNameResult();
|
||||||
|
expect(controller.lastNameResult, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prevents duplicate calls while row is already updating', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
final first = controller.updateName('4', 'First Name');
|
||||||
|
expect(controller.isUpdating('4'), isTrue);
|
||||||
|
|
||||||
|
final second = controller.updateName('4', 'Second Name');
|
||||||
|
await first;
|
||||||
|
await second;
|
||||||
|
|
||||||
|
// Only the first name should have been applied.
|
||||||
|
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||||
|
expect(updated.name, 'First Name');
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search refinements ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('search: category matching', () {
|
||||||
|
test('matches by category name', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('coasters');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
expect(controller.drafts.every((d) => d.category == 'Coasters'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches by category "Kitchen Accessories"', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('kitchen');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
expect(controller.drafts.every((d) => d.category == 'Kitchen Accessories'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category search is case-insensitive', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('NIGHTLIGHTS');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.category, 'Nightlights');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('search: multi-word AND tokenization', () {
|
||||||
|
test('two-word query matches across fields', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// "bowl cozy" — both tokens appear in "Floral Bowl Cozy"
|
||||||
|
controller.setSearchQuery('bowl cozy');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Floral Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tokens can span name and category', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// "fabric kitchen" — "fabric" in name, "kitchen" in category
|
||||||
|
controller.setSearchQuery('fabric kitchen');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Fabric Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tokens can span name and SKU', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// "ocean NL" — "ocean" in name, "NL" in SKU (NL-OCN-003)
|
||||||
|
controller.setSearchQuery('ocean NL');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.sku, 'NL-OCN-003');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all tokens must match (AND semantics)', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
// "bowl nightlight" — no single product has both
|
||||||
|
controller.setSearchQuery('bowl nightlight');
|
||||||
|
expect(controller.drafts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extra whitespace is ignored', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery(' bowl cozy ');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Floral Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace-only query shows all products', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery(' ');
|
||||||
|
expect(controller.drafts.length, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sort refinements ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('sort', () {
|
||||||
|
test('default sort is name ascending', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.activeSortField, ProductSortField.name);
|
||||||
|
expect(controller.sortAscending, true);
|
||||||
|
|
||||||
|
final names = controller.drafts.map((d) => d.name).toList();
|
||||||
|
expect(names, [
|
||||||
|
'Citrus Coaster Set',
|
||||||
|
'Fabric Jar Gripper',
|
||||||
|
'Floral Bowl Cozy',
|
||||||
|
'Ocean Nightlight',
|
||||||
|
'Skillet Handle Sleeve',
|
||||||
|
'Sublimated Slate Coaster',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort by name descending', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.name, ascending: false);
|
||||||
|
|
||||||
|
final names = controller.drafts.map((d) => d.name).toList();
|
||||||
|
expect(names, [
|
||||||
|
'Sublimated Slate Coaster',
|
||||||
|
'Skillet Handle Sleeve',
|
||||||
|
'Ocean Nightlight',
|
||||||
|
'Floral Bowl Cozy',
|
||||||
|
'Fabric Jar Gripper',
|
||||||
|
'Citrus Coaster Set',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort by lastModified ascending', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.lastModified, ascending: true);
|
||||||
|
|
||||||
|
final ids = controller.drafts.map((d) => d.id).toList();
|
||||||
|
// Dates: id6=Mar20, id2=Mar25, id1=Mar28, id3=Apr1, id4=Apr2, id5=Apr3
|
||||||
|
expect(ids, ['6', '2', '1', '3', '4', '5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort by lastModified descending', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.lastModified, ascending: false);
|
||||||
|
|
||||||
|
final ids = controller.drafts.map((d) => d.id).toList();
|
||||||
|
expect(ids, ['5', '4', '3', '1', '2', '6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort by status ascending groups by status order', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.status, ascending: true);
|
||||||
|
|
||||||
|
final statuses = controller.drafts.map((d) => d.status).toList();
|
||||||
|
// draft(0), draft(0), pendingReview(1), published(2), published(2), unpublished(3)
|
||||||
|
expect(statuses, [
|
||||||
|
PublishStatus.draft,
|
||||||
|
PublishStatus.draft,
|
||||||
|
PublishStatus.pendingReview,
|
||||||
|
PublishStatus.published,
|
||||||
|
PublishStatus.published,
|
||||||
|
PublishStatus.unpublished,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Within same status, secondary sort by name ascending.
|
||||||
|
final draftNames = controller.drafts
|
||||||
|
.where((d) => d.status == PublishStatus.draft)
|
||||||
|
.map((d) => d.name)
|
||||||
|
.toList();
|
||||||
|
expect(draftNames, ['Fabric Jar Gripper', 'Skillet Handle Sleeve']);
|
||||||
|
|
||||||
|
final publishedNames = controller.drafts
|
||||||
|
.where((d) => d.status == PublishStatus.published)
|
||||||
|
.map((d) => d.name)
|
||||||
|
.toList();
|
||||||
|
expect(publishedNames, ['Citrus Coaster Set', 'Floral Bowl Cozy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort by status descending reverses status order', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.status, ascending: false);
|
||||||
|
|
||||||
|
final statuses = controller.drafts.map((d) => d.status).toList();
|
||||||
|
expect(statuses, [
|
||||||
|
PublishStatus.unpublished,
|
||||||
|
PublishStatus.published,
|
||||||
|
PublishStatus.published,
|
||||||
|
PublishStatus.pendingReview,
|
||||||
|
PublishStatus.draft,
|
||||||
|
PublishStatus.draft,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSort preserves current ascending when not specified', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.name, ascending: false);
|
||||||
|
expect(controller.sortAscending, false);
|
||||||
|
|
||||||
|
// Change field without specifying ascending — should keep false.
|
||||||
|
controller.setSort(ProductSortField.lastModified);
|
||||||
|
expect(controller.sortAscending, false);
|
||||||
|
expect(controller.activeSortField, ProductSortField.lastModified);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort persists across filter changes', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.name, ascending: false);
|
||||||
|
controller.setFilter('draft');
|
||||||
|
|
||||||
|
final names = controller.drafts.map((d) => d.name).toList();
|
||||||
|
// Two drafts, sorted Z→A
|
||||||
|
expect(names, ['Skillet Handle Sleeve', 'Fabric Jar Gripper']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort persists across search changes', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.name, ascending: false);
|
||||||
|
controller.setSearchQuery('coaster');
|
||||||
|
|
||||||
|
final names = controller.drafts.map((d) => d.name).toList();
|
||||||
|
// Two coaster products, sorted Z→A
|
||||||
|
expect(names, ['Sublimated Slate Coaster', 'Citrus Coaster Set']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort persists across load cycles', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSort(ProductSortField.lastModified, ascending: false);
|
||||||
|
|
||||||
|
// Reload — sort settings should be preserved.
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.activeSortField, ProductSortField.lastModified);
|
||||||
|
expect(controller.sortAscending, false);
|
||||||
|
final ids = controller.drafts.map((d) => d.id).toList();
|
||||||
|
expect(ids, ['5', '4', '3', '1', '2', '6']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Persistence ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('persistence across load', () {
|
||||||
|
test('filter persists across load cycles', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setFilter('draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.activeFilter, 'draft');
|
||||||
|
expect(controller.drafts.length, 2);
|
||||||
|
expect(controller.drafts.every((d) => d.status == PublishStatus.draft), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search persists across load cycles', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.setSearchQuery('nightlight');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.searchQuery, 'nightlight');
|
||||||
|
expect(controller.drafts.length, 1);
|
||||||
|
expect(controller.drafts.first.name, 'Ocean Nightlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection persists across load when still visible', () async {
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
controller.selectBySku('NL-OCN-003');
|
||||||
|
expect(controller.selectedDraft!.sku, 'NL-OCN-003');
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
expect(controller.selectedDraft, isNotNull);
|
||||||
|
expect(controller.selectedDraft!.sku, 'NL-OCN-003');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('disposed guard', () {
|
group('disposed guard', () {
|
||||||
test('load does not notify after disposal', () async {
|
test('load does not notify after disposal', () async {
|
||||||
// Start load, then immediately dispose.
|
// Start load, then immediately dispose.
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,18 @@ void main() {
|
||||||
lastModified: DateTime(2026, 4, 1),
|
lastModified: DateTime(2026, 4, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget buildTestWidget({bool isSelected = false, VoidCallback? onTap}) {
|
Widget buildTestWidget({ProductDraft? draft, bool isSelected = false, VoidCallback? onTap}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
theme: buildKcTheme(),
|
theme: buildKcTheme(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: SizedBox(
|
body: SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
width: 400,
|
width: 400,
|
||||||
child: ProductDraftCard(draft: sampleDraft, isSelected: isSelected, onTap: onTap),
|
child: ProductDraftCard(
|
||||||
|
draft: draft ?? sampleDraft,
|
||||||
|
isSelected: isSelected,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -57,6 +61,17 @@ void main() {
|
||||||
expect(find.text('Draft'), findsOneWidget);
|
expect(find.text('Draft'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('displays last modified date', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget());
|
||||||
|
expect(find.text('2026-04-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('formats single-digit month and day with leading zeros', (tester) async {
|
||||||
|
final janDraft = sampleDraft.copyWith(lastModified: DateTime(2026, 1, 5));
|
||||||
|
await tester.pumpWidget(buildTestWidget(draft: janDraft));
|
||||||
|
expect(find.text('2026-01-05'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('calls onTap when tapped', (tester) async {
|
testWidgets('calls onTap when tapped', (tester) async {
|
||||||
var tapped = false;
|
var tapped = false;
|
||||||
await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true));
|
await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true));
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,18 @@ void main() {
|
||||||
lastModified: DateTime(2026, 4, 1),
|
lastModified: DateTime(2026, 4, 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final draftWithImageUrl = ProductDraft(
|
||||||
|
id: '7',
|
||||||
|
name: 'Product With Image',
|
||||||
|
description: 'Has an image URL.',
|
||||||
|
price: 25.00,
|
||||||
|
sku: 'IMG-001',
|
||||||
|
category: 'Bowl Cozies',
|
||||||
|
imageUrl: 'https://example.com/product.jpg',
|
||||||
|
status: PublishStatus.draft,
|
||||||
|
lastModified: DateTime(2026, 4, 5),
|
||||||
|
);
|
||||||
|
|
||||||
final publishedDraft = ProductDraft(
|
final publishedDraft = ProductDraft(
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Published Product',
|
name: 'Published Product',
|
||||||
|
|
@ -58,6 +70,8 @@ void main() {
|
||||||
ProductDraft draft, {
|
ProductDraft draft, {
|
||||||
VoidCallback? onPublish,
|
VoidCallback? onPublish,
|
||||||
VoidCallback? onMoveToDraft,
|
VoidCallback? onMoveToDraft,
|
||||||
|
ValueChanged<double>? onPriceChanged,
|
||||||
|
ValueChanged<String>? onNameChanged,
|
||||||
bool isUpdating = false,
|
bool isUpdating = false,
|
||||||
}) {
|
}) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
|
@ -68,6 +82,8 @@ void main() {
|
||||||
draft: draft,
|
draft: draft,
|
||||||
onPublish: onPublish,
|
onPublish: onPublish,
|
||||||
onMoveToDraft: onMoveToDraft,
|
onMoveToDraft: onMoveToDraft,
|
||||||
|
onPriceChanged: onPriceChanged,
|
||||||
|
onNameChanged: onNameChanged,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -101,6 +117,11 @@ void main() {
|
||||||
expect(find.text('Bowl Cozies'), findsOneWidget);
|
expect(find.text('Bowl Cozies'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('displays last modified date', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.text('2026-04-01'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('calls onPublish when button is tapped', (tester) async {
|
testWidgets('calls onPublish when button is tapped', (tester) async {
|
||||||
var published = false;
|
var published = false;
|
||||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
|
||||||
|
|
@ -109,6 +130,34 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('image display', () {
|
||||||
|
testWidgets('shows placeholder icon when imageUrl is empty', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides Image URL metadata row when imageUrl is empty', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.text('Image URL'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows Image URL metadata row when imageUrl is present', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(draftWithImageUrl));
|
||||||
|
expect(find.text('Image URL'), findsOneWidget);
|
||||||
|
expect(find.text('https://example.com/product.jpg'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders Image.network when imageUrl is present', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(draftWithImageUrl));
|
||||||
|
expect(find.byType(Image), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not render Image.network when imageUrl is empty', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('status-aware action buttons', () {
|
group('status-aware action buttons', () {
|
||||||
testWidgets('draft row shows Publish to Store button', (tester) async {
|
testWidgets('draft row shows Publish to Store button', (tester) async {
|
||||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
|
@ -223,4 +272,193 @@ void main() {
|
||||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('price editing', () {
|
||||||
|
testWidgets('shows edit icon when onPriceChanged is provided', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||||
|
expect(find.byIcon(Icons.edit), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides edit icon when onPriceChanged is null', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.byIcon(Icons.edit), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides edit icon while updating', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onPriceChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
expect(find.byIcon(Icons.edit), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping edit icon shows text field with current price', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
// The text field should be pre-filled with the current price.
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.controller!.text, '12.99');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping check icon submits the new price', (tester) async {
|
||||||
|
double? receivedPrice;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onPriceChanged: (p) => receivedPrice = p),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter edit mode.
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Clear and type a new price.
|
||||||
|
await tester.enterText(find.byType(TextField), '15.50');
|
||||||
|
await tester.tap(find.byIcon(Icons.check));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedPrice, 15.50);
|
||||||
|
// Should exit edit mode — no more text field.
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping close icon cancels editing', (tester) async {
|
||||||
|
double? receivedPrice;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onPriceChanged: (p) => receivedPrice = p),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter edit mode.
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Type a new price but cancel.
|
||||||
|
await tester.enterText(find.byType(TextField), '99.99');
|
||||||
|
await tester.tap(find.byIcon(Icons.close));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedPrice, isNull);
|
||||||
|
// Should exit edit mode and show original price.
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
expect(find.text('\$12.99'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submitting via keyboard calls onPriceChanged', (tester) async {
|
||||||
|
double? receivedPrice;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onPriceChanged: (p) => receivedPrice = p),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.edit));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '20.00');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedPrice, 20.00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('name editing', () {
|
||||||
|
testWidgets('shows edit name icon when onNameChanged is provided', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||||
|
// Find the edit icon with 'Edit name' tooltip.
|
||||||
|
expect(find.byTooltip('Edit name'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides edit name icon when onNameChanged is null', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||||
|
expect(find.byTooltip('Edit name'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides edit name icon while updating', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildTestWidget(sampleDraft, onNameChanged: (_) {}, isUpdating: true),
|
||||||
|
);
|
||||||
|
expect(find.byTooltip('Edit name'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping edit name icon shows text field with current name', (tester) async {
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
final textField = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
expect(textField.controller!.text, 'Test Bowl Cozy');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping check icon submits the new name', (tester) async {
|
||||||
|
String? receivedName;
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
|
||||||
|
|
||||||
|
// Enter edit mode.
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Clear and type a new name.
|
||||||
|
await tester.enterText(find.byType(TextField), 'Updated Bowl Cozy');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedName, 'Updated Bowl Cozy');
|
||||||
|
// Should exit edit mode — no more text field.
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping close icon cancels name editing', (tester) async {
|
||||||
|
String? receivedName;
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
|
||||||
|
|
||||||
|
// Enter edit mode.
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Type a new name but cancel.
|
||||||
|
await tester.enterText(find.byType(TextField), 'Cancelled Name');
|
||||||
|
// Find the Cancel button (close icon) — use tooltip to disambiguate.
|
||||||
|
await tester.tap(find.byTooltip('Cancel'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedName, isNull);
|
||||||
|
// Should exit edit mode and show original name.
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
expect(find.text('Test Bowl Cozy'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('submitting via keyboard calls onNameChanged', (tester) async {
|
||||||
|
String? receivedName;
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'Keyboard Name');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(receivedName, 'Keyboard Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not submit empty name', (tester) async {
|
||||||
|
String? receivedName;
|
||||||
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Edit name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Clear the field and try to submit.
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.tap(find.byTooltip('Save name'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Should not have called the callback.
|
||||||
|
expect(receivedName, isNull);
|
||||||
|
// Should still be in edit mode.
|
||||||
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
|
||||||
|
/// A repository that always throws on [getProductDrafts] to exercise the
|
||||||
|
/// error state. Kept test-local to avoid broadening the public API.
|
||||||
|
class _FailingRepository implements ProductPublishingRepository {
|
||||||
|
int loadAttempts = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ProductDraft>> getProductDrafts() async {
|
||||||
|
loadAttempts++;
|
||||||
|
throw Exception('network error');
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ProductPublishingPage polish states', () {
|
||||||
|
// ── Loading state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows loading indicator with contextual text', (tester) async {
|
||||||
|
// Use a narrow viewport to keep the layout simple.
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: ProductPublishingPage(repository: repository)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Before pumpAndSettle — the controller is still loading.
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
expect(find.text('Loading products…'), findsOneWidget);
|
||||||
|
|
||||||
|
// Let the load complete so the test tears down cleanly.
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Error state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows error state with retry button', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = _FailingRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: ProductPublishingPage(repository: repository)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Failed to load product drafts.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
|
expect(repository.loadAttempts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('retry button triggers a new load attempt', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = _FailingRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: ProductPublishingPage(repository: repository)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.loadAttempts, 1);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Retry'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.loadAttempts, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Empty list state (filtered to zero results) ────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows empty state when filter yields no results', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
// Use a search query that matches nothing.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: ProductPublishingPage(repository: repository, initialQuery: 'zzz_no_match_zzz'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No products match your criteria.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.search_off), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Product count header ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows product count header on narrow layout', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: ProductPublishingPage(repository: repository)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The fake repository has 6 products.
|
||||||
|
expect(find.text('6 products'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows singular product count when filtered to one', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
// "nightlight" matches exactly one product in the fake data.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: ProductPublishingPage(repository: repository, initialQuery: 'nightlight'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('1 product'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Empty selection placeholder (wide layout) ──────────────────────────
|
||||||
|
|
||||||
|
testWidgets('shows selection placeholder with icon on wide layout', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
// A query that matches nothing yields empty drafts + null selection.
|
||||||
|
// On wide layout the Row renders both panes, so we can verify both
|
||||||
|
// the empty-list and empty-selection placeholders.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: ProductPublishingPage(repository: repository, initialQuery: 'zzz_no_match_zzz'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The empty-list state shows in the left pane.
|
||||||
|
expect(find.text('No products match your criteria.'), findsOneWidget);
|
||||||
|
|
||||||
|
// The empty-selection state shows in the right pane.
|
||||||
|
expect(find.text('Select a product to preview'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.touch_app_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||||
|
import 'package:feature_wordpress/src/application/product_publishing_controller.dart';
|
||||||
|
import 'package:feature_wordpress/src/presentation/widgets/status_action_snack_bar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('showStatusActionSnackBar', () {
|
||||||
|
testWidgets('shows success SnackBar for publish action', (tester) async {
|
||||||
|
late BuildContext capturedContext;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
capturedContext = context;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
showStatusActionSnackBar(
|
||||||
|
capturedContext,
|
||||||
|
const StatusActionResult(
|
||||||
|
success: true,
|
||||||
|
productName: 'Floral Bowl Cozy',
|
||||||
|
targetStatus: PublishStatus.published,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Floral Bowl Cozy published successfully.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows success SnackBar for move-to-draft action', (tester) async {
|
||||||
|
late BuildContext capturedContext;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
capturedContext = context;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
showStatusActionSnackBar(
|
||||||
|
capturedContext,
|
||||||
|
const StatusActionResult(
|
||||||
|
success: true,
|
||||||
|
productName: 'Ocean Nightlight',
|
||||||
|
targetStatus: PublishStatus.draft,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Ocean Nightlight moved to draft successfully.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.check_circle_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows failure SnackBar with error icon', (tester) async {
|
||||||
|
late BuildContext capturedContext;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
capturedContext = context;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
showStatusActionSnackBar(
|
||||||
|
capturedContext,
|
||||||
|
const StatusActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: 'Floral Bowl Cozy',
|
||||||
|
targetStatus: PublishStatus.published,
|
||||||
|
errorMessage: 'Network error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Failed to publish Floral Bowl Cozy.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows failure SnackBar for move-to-draft', (tester) async {
|
||||||
|
late BuildContext capturedContext;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
capturedContext = context;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
showStatusActionSnackBar(
|
||||||
|
capturedContext,
|
||||||
|
const StatusActionResult(
|
||||||
|
success: false,
|
||||||
|
productName: 'Ocean Nightlight',
|
||||||
|
targetStatus: PublishStatus.draft,
|
||||||
|
errorMessage: 'Server error',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Failed to move to draft Ocean Nightlight.'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ProductPublishingPage SnackBar integration', () {
|
||||||
|
testWidgets('shows success SnackBar after moving to draft on narrow layout', (tester) async {
|
||||||
|
// Use a narrow viewport so the page renders only the list (no preview
|
||||||
|
// panel), avoiding the multi-scrollable layout issue. We verify the
|
||||||
|
// wiring by checking that the page's listener fires the SnackBar when
|
||||||
|
// the controller completes an action triggered externally.
|
||||||
|
tester.view.physicalSize = const Size(600, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final repository = FakeProductPublishingRepository();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildKcTheme(),
|
||||||
|
home: Scaffold(body: ProductPublishingPage(repository: repository)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for initial load — narrow layout shows only the list.
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Floral Bowl Cozy'), findsOneWidget);
|
||||||
|
|
||||||
|
// The page creates its own controller internally. We can't access it
|
||||||
|
// directly, but the SnackBar helper + controller tests already cover
|
||||||
|
// the full behaviour. This test verifies the page renders without
|
||||||
|
// errors and the list loads correctly in the narrow layout.
|
||||||
|
// Full SnackBar integration is covered by the unit-level tests above.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -338,6 +338,28 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns ProductDraft, not raw WooCommerce JSON (package boundary)', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
final json = buildSingleProductJson(id: 5, status: 'publish');
|
||||||
|
json['permalink'] = 'https://store.example.com/product/5';
|
||||||
|
return http.Response(jsonEncode(json), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: 'https://store.example.com',
|
||||||
|
consumerKey: 'ck_test',
|
||||||
|
consumerSecret: 'cs_test',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
|
||||||
|
final result = await repository.updateProductStatus('5', PublishStatus.published);
|
||||||
|
|
||||||
|
expect(result, isA<ProductDraft>());
|
||||||
|
expect(result.id, '5');
|
||||||
|
expect(result.status, PublishStatus.published);
|
||||||
|
});
|
||||||
|
|
||||||
test('throws ArgumentError for unsupported status values', () {
|
test('throws ArgumentError for unsupported status values', () {
|
||||||
final mockClient = MockClient((request) async {
|
final mockClient = MockClient((request) async {
|
||||||
return http.Response('{}', 200);
|
return http.Response('{}', 200);
|
||||||
|
|
@ -390,5 +412,153 @@ void main() {
|
||||||
expect(result.status, PublishStatus.published);
|
expect(result.status, PublishStatus.published);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('updateProductPrice', () {
|
||||||
|
Map<String, dynamic> buildPriceProductJson({required int id, required String price}) {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': 'Product $id',
|
||||||
|
'description': '<p>Description</p>',
|
||||||
|
'price': price,
|
||||||
|
'regular_price': price,
|
||||||
|
'sku': 'SKU-$id',
|
||||||
|
'status': 'publish',
|
||||||
|
'date_modified': '2026-04-01T10:00:00',
|
||||||
|
'date_created': '2026-03-01T08:00:00',
|
||||||
|
'categories': [
|
||||||
|
{'id': 1, 'name': 'Test Category', 'slug': 'test-category'},
|
||||||
|
],
|
||||||
|
'images': [
|
||||||
|
{'id': 1, 'src': 'https://example.com/img$id.jpg'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sends PUT with regular_price payload and returns mapped ProductDraft', () async {
|
||||||
|
Map<String, dynamic>? capturedBody;
|
||||||
|
Uri? capturedUri;
|
||||||
|
String? capturedMethod;
|
||||||
|
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
capturedMethod = request.method;
|
||||||
|
capturedUri = request.url;
|
||||||
|
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode(buildPriceProductJson(id: 4, price: '11.99')), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: 'https://store.example.com',
|
||||||
|
consumerKey: 'ck_test',
|
||||||
|
consumerSecret: 'cs_test',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
|
||||||
|
final result = await repository.updateProductPrice('4', 11.99);
|
||||||
|
|
||||||
|
expect(capturedMethod, 'PUT');
|
||||||
|
expect(capturedUri!.path, '/wp-json/wc/v3/products/4');
|
||||||
|
expect(capturedBody, {'regular_price': '11.99'});
|
||||||
|
|
||||||
|
expect(result, isA<ProductDraft>());
|
||||||
|
expect(result.id, '4');
|
||||||
|
expect(result.price, 11.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws WooCommerceApiException on non-200 response', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('{"code":"rest_cannot_edit"}', 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: 'https://store.example.com',
|
||||||
|
consumerKey: 'ck_test',
|
||||||
|
consumerSecret: 'cs_test',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => repository.updateProductPrice('4', 11.99),
|
||||||
|
throwsA(isA<WooCommerceApiException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateProductName', () {
|
||||||
|
Map<String, dynamic> buildNameProductJson({required int id, required String name}) {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': '<p>Description</p>',
|
||||||
|
'price': '10.99',
|
||||||
|
'sku': 'SKU-$id',
|
||||||
|
'status': 'publish',
|
||||||
|
'date_modified': '2026-04-01T10:00:00',
|
||||||
|
'date_created': '2026-03-01T08:00:00',
|
||||||
|
'categories': [
|
||||||
|
{'id': 1, 'name': 'Test Category', 'slug': 'test-category'},
|
||||||
|
],
|
||||||
|
'images': [
|
||||||
|
{'id': 1, 'src': 'https://example.com/img$id.jpg'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sends PUT with name payload and returns mapped ProductDraft', () async {
|
||||||
|
Map<String, dynamic>? capturedBody;
|
||||||
|
Uri? capturedUri;
|
||||||
|
String? capturedMethod;
|
||||||
|
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
capturedMethod = request.method;
|
||||||
|
capturedUri = request.url;
|
||||||
|
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode(buildNameProductJson(id: 4, name: 'Deluxe Jar Gripper')),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: 'https://store.example.com',
|
||||||
|
consumerKey: 'ck_test',
|
||||||
|
consumerSecret: 'cs_test',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
|
||||||
|
final result = await repository.updateProductName('4', 'Deluxe Jar Gripper');
|
||||||
|
|
||||||
|
expect(capturedMethod, 'PUT');
|
||||||
|
expect(capturedUri!.path, '/wp-json/wc/v3/products/4');
|
||||||
|
expect(capturedBody, {'name': 'Deluxe Jar Gripper'});
|
||||||
|
|
||||||
|
expect(result, isA<ProductDraft>());
|
||||||
|
expect(result.id, '4');
|
||||||
|
expect(result.name, 'Deluxe Jar Gripper');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws WooCommerceApiException on non-200 response', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('{"code":"rest_cannot_edit"}', 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
final apiClient = WooCommerceApiClient(
|
||||||
|
siteUrl: 'https://store.example.com',
|
||||||
|
consumerKey: 'ck_test',
|
||||||
|
consumerSecret: 'cs_test',
|
||||||
|
httpClient: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => repository.updateProductName('4', 'New Name'),
|
||||||
|
throwsA(isA<WooCommerceApiException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue