From 226b21d22df089c296d206a4840805c00ad62e38 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 4 Apr 2026 13:13:26 -0400 Subject: [PATCH] feat(products): add product publishing workspace vertical slice --- .../apps/kell_web/lib/routing/app_routes.dart | 4 +- .../apps/kell_web/pubspec.lock | 7 + .../apps/kell_web/pubspec.yaml | 2 + .../lib/feature_wordpress.dart | 10 +- .../src/application/get_product_drafts.dart | 11 ++ .../product_publishing_controller.dart | 53 ++++++++ .../lib/src/application/publish_product.dart | 11 ++ .../fake_product_publishing_repository.dart | 120 ++++++++++++++++++ .../lib/src/domain/product_draft.dart | 26 ++++ .../domain/product_publishing_repository.dart | 10 ++ .../lib/src/domain/publish_status.dart | 14 ++ .../presentation/product_publishing_page.dart | 98 ++++++++++++++ .../widgets/product_draft_card.dart | 70 ++++++++++ .../widgets/product_preview_panel.dart | 113 +++++++++++++++++ .../widgets/publish_status_chip.dart | 31 +++++ .../packages/feature_wordpress/pubspec.yaml | 40 +----- ...ke_product_publishing_repository_test.dart | 50 ++++++++ .../test/feature_wordpress_test.dart | 42 +++++- .../product_publishing_controller_test.dart | 62 +++++++++ .../test/widgets/product_draft_card_test.dart | 67 ++++++++++ .../widgets/product_preview_panel_test.dart | 87 +++++++++++++ .../widgets/publish_status_chip_test.dart | 37 ++++++ 22 files changed, 917 insertions(+), 48 deletions(-) create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index 5286f36..dcc3323 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -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: diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index b16dcc8..6d4447d 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -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 diff --git a/kell_creations_apps/apps/kell_web/pubspec.yaml b/kell_creations_apps/apps/kell_web/pubspec.yaml index 3e7837f..77eea1a 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.yaml +++ b/kell_creations_apps/apps/kell_web/pubspec.yaml @@ -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: diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 298576d..18717c6 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -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'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.dart new file mode 100644 index 0000000..895281e --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_product_drafts.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> call() => repository.getProductDrafts(); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart new file mode 100644 index 0000000..6c3546d --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart @@ -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 drafts = []; + ProductDraft? selectedDraft; + Object? error; + + /// Loads all product drafts. + Future 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 publish(String id) async { + try { + await _publishProduct(id); + await load(); + } catch (e) { + error = e; + notifyListeners(); + } + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart new file mode 100644 index 0000000..6e2672f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/publish_product.dart @@ -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 call(String id) => repository.publishDraft(id); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart new file mode 100644 index 0000000..7f27e2f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart @@ -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 _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> getProductDrafts() async { + // Simulate network latency. + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_drafts); + } + + @override + Future publishDraft(String id) async { + await Future.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; + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart new file mode 100644 index 0000000..15917c3 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_draft.dart @@ -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, + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart new file mode 100644 index 0000000..0106749 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart @@ -0,0 +1,10 @@ +import 'product_draft.dart'; + +/// Contract for fetching and managing product drafts. +abstract class ProductPublishingRepository { + /// Returns all product drafts. + Future> getProductDrafts(); + + /// Publishes a draft by [id]. Returns the updated draft. + Future publishDraft(String id); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart new file mode 100644 index 0000000..cdc1042 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/publish_status.dart @@ -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, +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart new file mode 100644 index 0000000..793485c --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart @@ -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 createState() => _ProductPublishingPageState(); +} + +class _ProductPublishingPageState extends State { + 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)); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart new file mode 100644 index 0000000..d764d64 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_draft_card.dart @@ -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), + ], + ), + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart new file mode 100644 index 0000000..b99af44 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart @@ -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)), + ], + ), + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart new file mode 100644 index 0000000..af6890c --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/publish_status_chip.dart @@ -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); + } + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml index 3d04c67..35a031a 100644 --- a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml +++ b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml @@ -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 diff --git a/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart new file mode 100644 index 0000000..e505406 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart @@ -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())); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart b/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart index 4181d7c..c27f267 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/feature_wordpress_test.dart @@ -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)); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart new file mode 100644 index 0000000..346dbf4 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart new file mode 100644 index 0000000..7c7ec25 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_draft_card_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart new file mode 100644 index 0000000..c2ac569 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart @@ -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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart new file mode 100644 index 0000000..794f5ec --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/publish_status_chip_test.dart @@ -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); + }); + }); +}