feat(products): add product publishing workspace vertical slice
Validate Docs / validate-docs (push) Successful in 50s
Details
Validate Docs / validate-docs (push) Successful in 50s
Details
This commit is contained in:
parent
6b0e16dec6
commit
226b21d22d
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:feature_inventory/feature_inventory.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../pages/dashboard_page.dart';
|
||||
|
|
@ -6,7 +7,6 @@ import '../pages/finance_placeholder_page.dart';
|
|||
import '../pages/integrations_placeholder_page.dart';
|
||||
import '../pages/orders_placeholder_page.dart';
|
||||
import '../pages/policy_placeholder_page.dart';
|
||||
import '../pages/products_placeholder_page.dart';
|
||||
import '../shell/app_shell.dart';
|
||||
|
||||
abstract final class AppRoutes {
|
||||
|
|
@ -36,7 +36,7 @@ abstract final class AppRoutes {
|
|||
const AppShell(
|
||||
selectedRoute: products,
|
||||
title: 'Products',
|
||||
child: ProductsPlaceholderPage(),
|
||||
child: ProductPublishingPage(),
|
||||
),
|
||||
);
|
||||
case orders:
|
||||
|
|
|
|||
|
|
@ -78,6 +78,13 @@ packages:
|
|||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
feature_wordpress:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/feature_wordpress"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ dependencies:
|
|||
path: ../../packages/design_system
|
||||
feature_inventory:
|
||||
path: ../../packages/feature_inventory
|
||||
feature_wordpress:
|
||||
path: ../../packages/feature_wordpress
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
library;
|
||||
|
||||
export 'src/domain/product_draft.dart';
|
||||
export 'src/domain/publish_status.dart';
|
||||
export 'src/presentation/product_publishing_page.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import '../domain/product_draft.dart';
|
||||
import '../domain/product_publishing_repository.dart';
|
||||
|
||||
/// Use case: retrieve all product drafts from the repository.
|
||||
class GetProductDrafts {
|
||||
final ProductPublishingRepository repository;
|
||||
|
||||
GetProductDrafts(this.repository);
|
||||
|
||||
Future<List<ProductDraft>> call() => repository.getProductDrafts();
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../domain/product_draft.dart';
|
||||
import 'get_product_drafts.dart';
|
||||
import 'publish_product.dart';
|
||||
|
||||
/// Controller that manages the product publishing workspace state.
|
||||
class ProductPublishingController extends ChangeNotifier {
|
||||
final GetProductDrafts _getProductDrafts;
|
||||
final PublishProduct _publishProduct;
|
||||
|
||||
ProductPublishingController(this._getProductDrafts, this._publishProduct);
|
||||
|
||||
bool isLoading = false;
|
||||
List<ProductDraft> drafts = [];
|
||||
ProductDraft? selectedDraft;
|
||||
Object? error;
|
||||
|
||||
/// Loads all product drafts.
|
||||
Future<void> load() async {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
drafts = await _getProductDrafts();
|
||||
// Auto-select the first draft if nothing is selected.
|
||||
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects a draft for preview.
|
||||
void selectDraft(ProductDraft draft) {
|
||||
selectedDraft = draft;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Publishes the draft with the given [id] and reloads the list.
|
||||
Future<void> publish(String id) async {
|
||||
try {
|
||||
await _publishProduct(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import '../domain/product_draft.dart';
|
||||
import '../domain/product_publishing_repository.dart';
|
||||
|
||||
/// Use case: publish a single product draft by its [id].
|
||||
class PublishProduct {
|
||||
final ProductPublishingRepository repository;
|
||||
|
||||
PublishProduct(this.repository);
|
||||
|
||||
Future<ProductDraft> call(String id) => repository.publishDraft(id);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import '../domain/product_draft.dart';
|
||||
import '../domain/product_publishing_repository.dart';
|
||||
import '../domain/publish_status.dart';
|
||||
|
||||
/// Stubbed implementation of [ProductPublishingRepository] with sample
|
||||
/// Kell Creations products. No real WordPress/WooCommerce API calls are made.
|
||||
class FakeProductPublishingRepository implements ProductPublishingRepository {
|
||||
final List<ProductDraft> _drafts = [
|
||||
ProductDraft(
|
||||
id: '1',
|
||||
name: 'Floral Bowl Cozy',
|
||||
description:
|
||||
'A beautifully crafted fabric bowl cozy with a vibrant floral pattern. '
|
||||
'Microwave-safe and perfect for keeping dishes warm at the table.',
|
||||
price: 12.99,
|
||||
sku: 'BC-FLR-001',
|
||||
category: 'Bowl Cozies',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 3, 28),
|
||||
),
|
||||
ProductDraft(
|
||||
id: '2',
|
||||
name: 'Citrus Coaster Set',
|
||||
description:
|
||||
'Set of four sublimated coasters featuring bright citrus designs. '
|
||||
'Heat-resistant cork backing protects surfaces.',
|
||||
price: 16.50,
|
||||
sku: 'CS-CIT-002',
|
||||
category: 'Coasters',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 3, 25),
|
||||
),
|
||||
ProductDraft(
|
||||
id: '3',
|
||||
name: 'Ocean Nightlight',
|
||||
description:
|
||||
'Sublimated ceramic nightlight with a calming ocean wave design. '
|
||||
'Includes LED bulb and plugs into any standard outlet.',
|
||||
price: 19.99,
|
||||
sku: 'NL-OCN-003',
|
||||
category: 'Nightlights',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.pendingReview,
|
||||
lastModified: DateTime(2026, 4, 1),
|
||||
),
|
||||
ProductDraft(
|
||||
id: '4',
|
||||
name: 'Fabric Jar Gripper',
|
||||
description:
|
||||
'Non-slip fabric jar gripper with a fun patterned design. '
|
||||
'Opens even the tightest lids with ease.',
|
||||
price: 8.50,
|
||||
sku: 'JG-BLU-004',
|
||||
category: 'Kitchen Accessories',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 4, 2),
|
||||
),
|
||||
ProductDraft(
|
||||
id: '5',
|
||||
name: 'Skillet Handle Sleeve',
|
||||
description:
|
||||
'Quilted fabric sleeve that slips over hot skillet handles. '
|
||||
'Available in multiple patterns to match your kitchen décor.',
|
||||
price: 10.99,
|
||||
sku: 'SH-SUN-005',
|
||||
category: 'Kitchen Accessories',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 4, 3),
|
||||
),
|
||||
ProductDraft(
|
||||
id: '6',
|
||||
name: 'Sublimated Slate Coaster',
|
||||
description:
|
||||
'Natural slate coaster with a full-color sublimated image. '
|
||||
'Felt feet protect furniture. Makes a great personalized gift.',
|
||||
price: 14.99,
|
||||
sku: 'SC-SUB-006',
|
||||
category: 'Coasters',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.unpublished,
|
||||
lastModified: DateTime(2026, 3, 20),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<ProductDraft>> getProductDrafts() async {
|
||||
// Simulate network latency.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
return List.unmodifiable(_drafts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductDraft> publishDraft(String id) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
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 = ProductDraft(
|
||||
id: original.id,
|
||||
name: original.name,
|
||||
description: original.description,
|
||||
price: original.price,
|
||||
sku: original.sku,
|
||||
category: original.category,
|
||||
imageUrl: original.imageUrl,
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
_drafts[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import 'publish_status.dart';
|
||||
|
||||
/// A product draft that may be published to the WordPress/WooCommerce store.
|
||||
class ProductDraft {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final double price;
|
||||
final String sku;
|
||||
final String category;
|
||||
final String imageUrl;
|
||||
final PublishStatus status;
|
||||
final DateTime lastModified;
|
||||
|
||||
const ProductDraft({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.price,
|
||||
required this.sku,
|
||||
required this.category,
|
||||
required this.imageUrl,
|
||||
required this.status,
|
||||
required this.lastModified,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import 'product_draft.dart';
|
||||
|
||||
/// Contract for fetching and managing product drafts.
|
||||
abstract class ProductPublishingRepository {
|
||||
/// Returns all product drafts.
|
||||
Future<List<ProductDraft>> getProductDrafts();
|
||||
|
||||
/// Publishes a draft by [id]. Returns the updated draft.
|
||||
Future<ProductDraft> publishDraft(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/// The publishing status of a product draft on the WordPress/WooCommerce store.
|
||||
enum PublishStatus {
|
||||
/// Product is still being drafted; not yet sent to the store.
|
||||
draft,
|
||||
|
||||
/// Product has been submitted and is awaiting review.
|
||||
pendingReview,
|
||||
|
||||
/// Product is live on the store.
|
||||
published,
|
||||
|
||||
/// Product was previously published but has been taken down.
|
||||
unpublished,
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/get_product_drafts.dart';
|
||||
import '../application/product_publishing_controller.dart';
|
||||
import '../application/publish_product.dart';
|
||||
import '../data/fake_product_publishing_repository.dart';
|
||||
import 'widgets/product_draft_card.dart';
|
||||
import 'widgets/product_preview_panel.dart';
|
||||
|
||||
/// The main Product Publishing Workspace page.
|
||||
///
|
||||
/// Displays a list of product drafts on the left and a preview panel on the
|
||||
/// right. Users can select a draft to preview and publish it to the store.
|
||||
class ProductPublishingPage extends StatefulWidget {
|
||||
const ProductPublishingPage({super.key});
|
||||
|
||||
@override
|
||||
State<ProductPublishingPage> createState() => _ProductPublishingPageState();
|
||||
}
|
||||
|
||||
class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||
late final ProductPublishingController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final repo = FakeProductPublishingRepository();
|
||||
controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo));
|
||||
controller.load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
if (controller.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (controller.error != null) {
|
||||
return const Center(child: Text('Failed to load product drafts.'));
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// On narrow screens show only the list; on wide screens show
|
||||
// a master-detail layout.
|
||||
if (constraints.maxWidth < 800) {
|
||||
return _buildDraftList();
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 380, child: _buildDraftList()),
|
||||
const SizedBox(width: KcSpacing.md),
|
||||
Expanded(child: _buildPreview()),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDraftList() {
|
||||
return ListView.separated(
|
||||
itemCount: controller.drafts.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.sm),
|
||||
itemBuilder: (context, index) {
|
||||
final draft = controller.drafts[index];
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: ProductDraftCard(
|
||||
draft: draft,
|
||||
isSelected: draft.id == controller.selectedDraft?.id,
|
||||
onTap: () => controller.selectDraft(draft),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview() {
|
||||
final selected = controller.selectedDraft;
|
||||
if (selected == null) {
|
||||
return const Center(child: Text('Select a product draft to preview'));
|
||||
}
|
||||
return ProductPreviewPanel(draft: selected, onPublish: () => controller.publish(selected.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/product_draft.dart';
|
||||
import 'publish_status_chip.dart';
|
||||
|
||||
/// A card displaying a summary of a [ProductDraft].
|
||||
///
|
||||
/// Shows the product name, SKU, price, category, and publish status.
|
||||
/// Highlights when [isSelected] is true.
|
||||
class ProductDraftCard extends StatelessWidget {
|
||||
final ProductDraft draft;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(KcSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: KcColors.surface,
|
||||
border: Border.all(
|
||||
color: isSelected ? KcColors.denimBlue : KcColors.border,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
draft.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: KcSpacing.xs),
|
||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'\$${draft.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
Text(
|
||||
draft.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
PublishStatusChip(status: draft.status),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/product_draft.dart';
|
||||
import '../../domain/publish_status.dart';
|
||||
import 'publish_status_chip.dart';
|
||||
|
||||
/// A detail panel that shows a full preview of the selected [ProductDraft].
|
||||
///
|
||||
/// Includes product image placeholder, description, metadata, and a
|
||||
/// publish action button when the draft is not yet published.
|
||||
class ProductPreviewPanel extends StatelessWidget {
|
||||
final ProductDraft draft;
|
||||
final VoidCallback? onPublish;
|
||||
|
||||
const ProductPreviewPanel({super.key, required this.draft, this.onPublish});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return KcCard(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Image placeholder ──────────────────────────────────────
|
||||
Container(
|
||||
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),
|
||||
|
||||
// ── Title & status ─────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
PublishStatusChip(status: draft.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
|
||||
// ── Metadata ───────────────────────────────────────────────
|
||||
_MetadataRow(label: 'SKU', value: draft.sku),
|
||||
_MetadataRow(label: 'Price', value: '\$${draft.price.toStringAsFixed(2)}'),
|
||||
_MetadataRow(label: 'Category', value: draft.category),
|
||||
_MetadataRow(
|
||||
label: 'Last Modified',
|
||||
value:
|
||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
const SizedBox(height: KcSpacing.md),
|
||||
|
||||
// ── Description ────────────────────────────────────────────
|
||||
Text('Description', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
Text(draft.description, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: KcSpacing.xl),
|
||||
|
||||
// ── Publish button ─────────────────────────────────────────
|
||||
if (draft.status != PublishStatus.published)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onPublish,
|
||||
icon: const Icon(Icons.publish),
|
||||
label: const Text('Publish to Store'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _MetadataRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: KcColors.neutral,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../domain/publish_status.dart';
|
||||
|
||||
/// A chip that displays the [PublishStatus] of a product draft using the
|
||||
/// design-system [KcStatusChip].
|
||||
class PublishStatusChip extends StatelessWidget {
|
||||
final PublishStatus status;
|
||||
|
||||
const PublishStatusChip({super.key, required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, bg, fg) = _style(status);
|
||||
return KcStatusChip(label: label, background: bg, foreground: fg);
|
||||
}
|
||||
|
||||
static (String, Color, Color) _style(PublishStatus status) {
|
||||
switch (status) {
|
||||
case PublishStatus.draft:
|
||||
return ('Draft', const Color(0xFFECEFF1), KcColors.neutral);
|
||||
case PublishStatus.pendingReview:
|
||||
return ('Pending Review', const Color(0xFFFFF8E1), KcColors.warning);
|
||||
case PublishStatus.published:
|
||||
return ('Published', const Color(0xFFE8F5E9), KcColors.success);
|
||||
case PublishStatus.unpublished:
|
||||
return ('Unpublished', const Color(0xFFFFEBEE), KcColors.danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
name: feature_wordpress
|
||||
description: "A new Flutter package project."
|
||||
description: "Product publishing workspace for WordPress/WooCommerce integration."
|
||||
version: 0.0.1
|
||||
publish_to: "none"
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
|
|
@ -10,45 +11,12 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
design_system:
|
||||
path: ../design_system
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/data/fake_product_publishing_repository.dart';
|
||||
|
||||
void main() {
|
||||
late FakeProductPublishingRepository repository;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeProductPublishingRepository();
|
||||
});
|
||||
|
||||
group('FakeProductPublishingRepository', () {
|
||||
test('getProductDrafts returns six sample products', () async {
|
||||
final drafts = await repository.getProductDrafts();
|
||||
expect(drafts.length, 6);
|
||||
});
|
||||
|
||||
test('getProductDrafts returns products with expected names', () async {
|
||||
final drafts = await repository.getProductDrafts();
|
||||
final names = drafts.map((d) => d.name).toList();
|
||||
expect(names, contains('Floral Bowl Cozy'));
|
||||
expect(names, contains('Citrus Coaster Set'));
|
||||
expect(names, contains('Ocean Nightlight'));
|
||||
expect(names, contains('Fabric Jar Gripper'));
|
||||
expect(names, contains('Skillet Handle Sleeve'));
|
||||
expect(names, contains('Sublimated Slate Coaster'));
|
||||
});
|
||||
|
||||
test('getProductDrafts returns products with various statuses', () async {
|
||||
final drafts = await repository.getProductDrafts();
|
||||
final statuses = drafts.map((d) => d.status).toSet();
|
||||
expect(statuses, contains(PublishStatus.published));
|
||||
expect(statuses, contains(PublishStatus.pendingReview));
|
||||
expect(statuses, contains(PublishStatus.draft));
|
||||
expect(statuses, contains(PublishStatus.unpublished));
|
||||
});
|
||||
|
||||
test('publishDraft changes status to published', () async {
|
||||
// Product 4 starts as draft.
|
||||
final updated = await repository.publishDraft('4');
|
||||
expect(updated.status, PublishStatus.published);
|
||||
expect(updated.name, 'Fabric Jar Gripper');
|
||||
});
|
||||
|
||||
test('publishDraft throws for unknown id', () async {
|
||||
expect(() => repository.publishDraft('unknown'), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -3,10 +3,42 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
group('PublishStatus', () {
|
||||
test('has four values', () {
|
||||
expect(PublishStatus.values.length, 4);
|
||||
});
|
||||
|
||||
test('contains expected statuses', () {
|
||||
expect(PublishStatus.values, contains(PublishStatus.draft));
|
||||
expect(PublishStatus.values, contains(PublishStatus.pendingReview));
|
||||
expect(PublishStatus.values, contains(PublishStatus.published));
|
||||
expect(PublishStatus.values, contains(PublishStatus.unpublished));
|
||||
});
|
||||
});
|
||||
|
||||
group('ProductDraft', () {
|
||||
test('can be constructed with required fields', () {
|
||||
final draft = ProductDraft(
|
||||
id: '1',
|
||||
name: 'Test Product',
|
||||
description: 'A test product',
|
||||
price: 9.99,
|
||||
sku: 'TP-001',
|
||||
category: 'Test',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 4, 1),
|
||||
);
|
||||
|
||||
expect(draft.id, '1');
|
||||
expect(draft.name, 'Test Product');
|
||||
expect(draft.description, 'A test product');
|
||||
expect(draft.price, 9.99);
|
||||
expect(draft.sku, 'TP-001');
|
||||
expect(draft.category, 'Test');
|
||||
expect(draft.imageUrl, '');
|
||||
expect(draft.status, PublishStatus.draft);
|
||||
expect(draft.lastModified, DateTime(2026, 4, 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:feature_wordpress/src/application/get_product_drafts.dart';
|
||||
import 'package:feature_wordpress/src/application/product_publishing_controller.dart';
|
||||
import 'package:feature_wordpress/src/application/publish_product.dart';
|
||||
import 'package:feature_wordpress/src/data/fake_product_publishing_repository.dart';
|
||||
|
||||
void main() {
|
||||
late FakeProductPublishingRepository repository;
|
||||
late ProductPublishingController controller;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeProductPublishingRepository();
|
||||
controller = ProductPublishingController(
|
||||
GetProductDrafts(repository),
|
||||
PublishProduct(repository),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
group('ProductPublishingController', () {
|
||||
test('starts with empty state', () {
|
||||
expect(controller.isLoading, false);
|
||||
expect(controller.drafts, isEmpty);
|
||||
expect(controller.selectedDraft, isNull);
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('load populates drafts and auto-selects first', () async {
|
||||
await controller.load();
|
||||
|
||||
expect(controller.isLoading, false);
|
||||
expect(controller.drafts.length, 6);
|
||||
expect(controller.selectedDraft, isNotNull);
|
||||
expect(controller.selectedDraft!.id, '1');
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('selectDraft updates selectedDraft', () async {
|
||||
await controller.load();
|
||||
|
||||
final third = controller.drafts[2];
|
||||
controller.selectDraft(third);
|
||||
|
||||
expect(controller.selectedDraft!.id, third.id);
|
||||
});
|
||||
|
||||
test('publish changes draft status and reloads', () async {
|
||||
await controller.load();
|
||||
|
||||
// Draft id 4 starts as PublishStatus.draft.
|
||||
await controller.publish('4');
|
||||
|
||||
final updated = controller.drafts.firstWhere((d) => d.id == '4');
|
||||
expect(updated.status, PublishStatus.published);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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/presentation/widgets/product_draft_card.dart';
|
||||
|
||||
void main() {
|
||||
final sampleDraft = ProductDraft(
|
||||
id: '1',
|
||||
name: 'Test Bowl Cozy',
|
||||
description: 'A test product',
|
||||
price: 12.99,
|
||||
sku: 'BC-TST-001',
|
||||
category: 'Bowl Cozies',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 4, 1),
|
||||
);
|
||||
|
||||
Widget buildTestWidget({bool isSelected = false, VoidCallback? onTap}) {
|
||||
return MaterialApp(
|
||||
theme: buildKcTheme(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 200,
|
||||
width: 400,
|
||||
child: ProductDraftCard(draft: sampleDraft, isSelected: isSelected, onTap: onTap),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('ProductDraftCard', () {
|
||||
testWidgets('displays product name', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget());
|
||||
expect(find.text('Test Bowl Cozy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays SKU', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget());
|
||||
expect(find.text('SKU: BC-TST-001'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays price', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget());
|
||||
expect(find.text('\$12.99'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays category', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget());
|
||||
expect(find.text('Bowl Cozies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays status chip', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget());
|
||||
expect(find.text('Draft'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calls onTap when tapped', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(buildTestWidget(onTap: () => tapped = true));
|
||||
await tester.tap(find.text('Test Bowl Cozy'));
|
||||
expect(tapped, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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/presentation/widgets/product_preview_panel.dart';
|
||||
|
||||
void main() {
|
||||
final sampleDraft = ProductDraft(
|
||||
id: '1',
|
||||
name: 'Test Bowl Cozy',
|
||||
description: 'A beautifully crafted test product.',
|
||||
price: 12.99,
|
||||
sku: 'BC-TST-001',
|
||||
category: 'Bowl Cozies',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.draft,
|
||||
lastModified: DateTime(2026, 4, 1),
|
||||
);
|
||||
|
||||
final publishedDraft = ProductDraft(
|
||||
id: '2',
|
||||
name: 'Published Product',
|
||||
description: 'Already published.',
|
||||
price: 19.99,
|
||||
sku: 'PP-001',
|
||||
category: 'Coasters',
|
||||
imageUrl: '',
|
||||
status: PublishStatus.published,
|
||||
lastModified: DateTime(2026, 3, 28),
|
||||
);
|
||||
|
||||
Widget buildTestWidget(ProductDraft draft, {VoidCallback? onPublish}) {
|
||||
return MaterialApp(
|
||||
theme: buildKcTheme(),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ProductPreviewPanel(draft: draft, onPublish: onPublish),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('ProductPreviewPanel', () {
|
||||
testWidgets('displays product name', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('Test Bowl Cozy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays description', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('A beautifully crafted test product.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays SKU metadata', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('BC-TST-001'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays price metadata', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('\$12.99'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays category metadata', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('Bowl Cozies'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows publish button for non-published drafts', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft));
|
||||
expect(find.text('Publish to Store'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides publish button for published drafts', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(publishedDraft));
|
||||
expect(find.text('Publish to Store'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('calls onPublish when button is tapped', (tester) async {
|
||||
var published = false;
|
||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
|
||||
await tester.tap(find.text('Publish to Store'));
|
||||
expect(published, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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/presentation/widgets/publish_status_chip.dart';
|
||||
|
||||
void main() {
|
||||
Widget buildTestWidget(PublishStatus status) {
|
||||
return MaterialApp(
|
||||
theme: buildKcTheme(),
|
||||
home: Scaffold(body: PublishStatusChip(status: status)),
|
||||
);
|
||||
}
|
||||
|
||||
group('PublishStatusChip', () {
|
||||
testWidgets('shows Draft label for draft status', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(PublishStatus.draft));
|
||||
expect(find.text('Draft'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Pending Review label for pendingReview status', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(PublishStatus.pendingReview));
|
||||
expect(find.text('Pending Review'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Published label for published status', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(PublishStatus.published));
|
||||
expect(find.text('Published'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Unpublished label for unpublished status', (tester) async {
|
||||
await tester.pumpWidget(buildTestWidget(PublishStatus.unpublished));
|
||||
expect(find.text('Unpublished'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue