diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 291d57d..404d479 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,10 +2,10 @@ ## Current status -- main baseline updated through: android-app-shell (Stage 5A complete) -- main baseline commit: merge of `feat/android-app-shell` (2026-05-28) -- next branch: feat/android-publishing-surface (Stage 5B) -- current stage: Stage 5 — Android application foundation +- main baseline updated through: android-publishing-surface (Stage 5B complete) +- main baseline commit: merge of `feat/android-publishing-surface` (2026-05-29) +- next branch: feat/android-feedback-polish (Stage 6A) +- current stage: Stage 5 complete — Stage 6 next ## Slice tracker @@ -182,3 +182,19 @@ - tests: passed (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing) - analyze: not yet run (SDK constraint fix was prerequisite) - brief updated: yes + +### feat/android-publishing-surface + +- status: merged to main +- date: 2026-05-29 +- inspection: complete +- implementation: complete +- files changed: + - `feature_wordpress/lib/feature_wordpress.dart` — expanded barrel exports with `ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, and all 5 use cases + snack bar helpers for mobile consumption + - `kell_mobile/lib/pages/mobile_publishing_page.dart` — new mobile-optimized publishing workspace with search bar, horizontal filter chips, sort dropdown, product count, compact card list, pull-to-refresh, and push navigation to detail page + - `kell_mobile/lib/pages/mobile_product_detail_page.dart` — new full-screen product detail page wrapping shared `ProductPreviewPanel` with all narrow edit callbacks + - `kell_mobile/lib/shell/mobile_shell.dart` — Products tab (case 2) switched from `ProductPublishingPage` to `MobilePublishingPage`; removed unused `feature_wordpress` import + - `kell_mobile/test/widget_test.dart` — added 4 new mobile publishing surface tests (search bar, filter chips, product count, sort button) — 10 total kell_mobile tests +- tests: passed (10/10 kell_mobile) +- analyze: passed (dart analyze — no issues found) +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 0f971b0..5df702c 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -132,8 +132,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/android-publishing-surface`** — Stage 5B: Android publishing surface. -Branch from latest `main`. Stage 5A (Android app shell and bootstrap) is complete. +**`feat/android-feedback-polish`** — Stage 6A: Android feedback and action polish. +Branch from latest `main`. Stage 5 (Android application foundation) is complete. --- @@ -285,23 +285,10 @@ Business logic, domain logic, repositories, and feature application logic should > Merged `feat/android-app-shell` → `main` (2026-05-28). > Replaced the default Flutter counter template with a fully integrated mobile app shell. Created `MobileAppServices` extending `KcAppServices` with shared composition pattern, `KellMobileApp` with `KcAppScope` and `KcBootstrap`, `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More). Dashboard reuses shared `DashboardSummary`/`DashboardController` with mobile-optimized `GridView` layout and design system widgets. Placeholder pages for Finance, Integrations, and feature tab content. `pubspec.yaml` references all shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`). Environment badge shows runtime mode. SDK constraint corrected to `^3.11.0` across all 14 pubspec files. 6 new `kell_mobile` widget tests added (6/6 kell_mobile, 24/24 kell_web, 294/294 feature_wordpress — all passing). -#### Stage 5B — Android publishing surface +#### ~~Stage 5B — Android publishing surface~~ ✅ COMPLETE -##### Goal - -Adapt the publishing workflow for mobile form factor. - -##### Requirements - -- reuse shared controller/use case/repository layers -- optimize preview/edit interactions for smaller screens -- keep feature parity for current publishing workflow where feasible -- do not fork business rules for Android - -##### Definition of done - -- Android supports browsing, filtering, status changes, and existing narrow edits -- mobile presentation is usable and tested where practical +> Merged `feat/android-publishing-surface` → `main` (2026-05-29). +> Created mobile-optimized publishing workspace: `MobilePublishingPage` with search bar, horizontal filter chips, sort dropdown with ascending/descending toggle, product count display, compact card list with pull-to-refresh, and push navigation to detail page. `MobileProductDetailPage` wraps the shared `ProductPreviewPanel` with all narrow edit callbacks (status, price, name, description, category). Products tab in `MobileShell` switched from web `ProductPublishingPage` to `MobilePublishingPage`. Expanded `feature_wordpress` barrel exports for mobile consumption (`ProductPublishingController`, `ProductSortField`, `ProductDraftCard`, `ProductPreviewPanel`, all 5 use cases, snack bar helpers). Zero business logic forked — all shared layers reused. 4 new mobile publishing surface tests added (10 total `kell_mobile` tests passing). Analyze clean. Stage 5 complete. --- diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_product_detail_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_product_detail_page.dart new file mode 100644 index 0000000..dd8d1ad --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_product_detail_page.dart @@ -0,0 +1,51 @@ +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter/material.dart'; + +/// Full-screen product detail page for mobile. +/// +/// Receives the shared [ProductPublishingController] so that edits +/// (name, price, description, category, status) are immediately +/// reflected in the list when the user pops back. +/// +/// Wraps the shared [ProductPreviewPanel] from [feature_wordpress] +/// inside a [Scaffold] with an [AppBar] showing the product name. +class MobileProductDetailPage extends StatelessWidget { + final ProductPublishingController controller; + + const MobileProductDetailPage({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final draft = controller.selectedDraft; + + // If the product was removed or deselected, pop back. + if (draft == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.of(context).pop(); + }); + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + return Scaffold( + appBar: AppBar(title: Text(draft.name)), + body: Padding( + padding: const EdgeInsets.all(16), + child: ProductPreviewPanel( + draft: draft, + isUpdating: controller.isUpdating(draft.id), + onPublish: () => controller.updateStatus(draft.id, PublishStatus.published), + onMoveToDraft: () => controller.updateStatus(draft.id, PublishStatus.draft), + onPriceChanged: (price) => controller.updatePrice(draft.id, price), + onNameChanged: (name) => controller.updateName(draft.id, name), + onDescriptionChanged: (desc) => controller.updateDescription(draft.id, desc), + onCategoryChanged: (cat) => controller.updateCategory(draft.id, cat), + ), + ), + ); + }, + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_publishing_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_publishing_page.dart new file mode 100644 index 0000000..cd4c1ca --- /dev/null +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_publishing_page.dart @@ -0,0 +1,272 @@ +import 'package:design_system/design_system.dart'; +import 'package:feature_wordpress/feature_wordpress.dart'; +import 'package:flutter/material.dart'; + +import 'mobile_product_detail_page.dart'; + +/// Mobile-optimized publishing workspace page. +/// +/// Provides browsing, filtering, searching, sorting, and status changes +/// for the product publishing workflow on smaller screens. Tapping a +/// product pushes a full-screen detail page for viewing and editing. +/// +/// Reuses the shared [ProductPublishingController] and all use cases +/// from [feature_wordpress] — no business logic is forked for mobile. +class MobilePublishingPage extends StatefulWidget { + final ProductPublishingRepository repository; + + const MobilePublishingPage({super.key, required this.repository}); + + @override + State createState() => _MobilePublishingPageState(); +} + +class _MobilePublishingPageState extends State { + late final ProductPublishingController _controller; + late final TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + + final repo = widget.repository; + _controller = ProductPublishingController( + GetProductDrafts(repo), + PublishProduct(repo), + UpdateProductStatus(repo), + UpdateProductPrice(repo), + UpdateProductName(repo), + UpdateProductDescription(repo), + UpdateProductCategory(repo), + ); + + _controller.addListener(_onControllerChanged); + _controller.load(); + } + + @override + void dispose() { + _controller.removeListener(_onControllerChanged); + _controller.dispose(); + _searchController.dispose(); + super.dispose(); + } + + /// Handles action result feedback via SnackBars. + 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); + } + + final descriptionResult = _controller.lastDescriptionResult; + if (descriptionResult != null) { + _controller.consumeDescriptionResult(); + showDescriptionActionSnackBar(context, descriptionResult); + } + + final categoryResult = _controller.lastCategoryResult; + if (categoryResult != null) { + _controller.consumeCategoryResult(); + showCategoryActionSnackBar(context, categoryResult); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + if (_controller.isLoading) { + return const KcLoadingState(message: 'Loading products…'); + } + + if (_controller.error != null) { + return KcErrorState(message: 'Failed to load product drafts.', onRetry: _controller.load); + } + + return Column( + children: [ + _buildSearchBar(), + _buildFilterChips(), + _buildSortAndCount(), + Expanded(child: _buildProductList()), + ], + ); + }, + ); + } + + /// Search bar with real-time filtering. + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(KcSpacing.md, KcSpacing.sm, KcSpacing.md, KcSpacing.xs), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search products…', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + _searchController.clear(); + _controller.setSearchQuery(''); + }, + ) + : null, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: KcSpacing.sm, + vertical: KcSpacing.sm, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onChanged: (query) { + _controller.setSearchQuery(query); + setState(() {}); // refresh clear button visibility + }, + ), + ); + } + + /// Horizontal scrollable filter chips for status filtering. + Widget _buildFilterChips() { + const filters = [ + (null, 'All'), + ('draft', 'Draft'), + ('pendingReview', 'Pending'), + ('published', 'Published'), + ('unpublished', 'Unpublished'), + ]; + + return SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md), + itemCount: filters.length, + separatorBuilder: (_, _) => const SizedBox(width: KcSpacing.xs), + itemBuilder: (context, index) { + final (value, label) = filters[index]; + final isActive = _controller.activeFilter == value; + return FilterChip( + label: Text(label), + selected: isActive, + onSelected: (_) => _controller.setFilter(isActive ? null : value), + ); + }, + ), + ); + } + + /// Sort dropdown and product count row. + Widget _buildSortAndCount() { + final count = _controller.drafts.length; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md, vertical: KcSpacing.xs), + child: Row( + children: [ + Text( + '$count ${count == 1 ? 'product' : 'products'}', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral), + ), + const Spacer(), + PopupMenuButton( + icon: const Icon(Icons.sort, size: 20), + tooltip: 'Sort', + onSelected: (field) { + if (field == _controller.activeSortField) { + _controller.setSort(field, ascending: !_controller.sortAscending); + } else { + _controller.setSort(field, ascending: true); + } + }, + itemBuilder: (_) => [ + _sortMenuItem(ProductSortField.name, 'Name'), + _sortMenuItem(ProductSortField.lastModified, 'Last Modified'), + _sortMenuItem(ProductSortField.status, 'Status'), + ], + ), + ], + ), + ); + } + + PopupMenuEntry _sortMenuItem(ProductSortField field, String label) { + final isActive = _controller.activeSortField == field; + return PopupMenuItem( + value: field, + child: Row( + children: [ + Expanded( + child: Text( + label, + style: isActive ? const TextStyle(fontWeight: FontWeight.bold) : null, + ), + ), + if (isActive) + Icon(_controller.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 16), + ], + ), + ); + } + + /// Scrollable product list using compact cards. + Widget _buildProductList() { + final drafts = _controller.drafts; + + if (drafts.isEmpty) { + return KcEmptyState( + icon: Icons.search_off, + message: _controller.searchQuery.isNotEmpty || _controller.activeFilter != null + ? 'No products match your criteria.' + : 'No product drafts available.', + ); + } + + return RefreshIndicator( + onRefresh: _controller.load, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: KcSpacing.md), + itemCount: drafts.length, + separatorBuilder: (_, _) => const SizedBox(height: KcSpacing.xs), + itemBuilder: (context, index) { + final draft = drafts[index]; + return SizedBox( + height: 72, + child: ProductDraftCard( + draft: draft, + isSelected: draft.id == _controller.selectedDraft?.id, + isCompact: true, + isStale: _controller.isStale(draft), + onTap: () => _navigateToDetail(draft), + ), + ); + }, + ), + ); + } + + /// Navigates to the full-screen product detail page. + void _navigateToDetail(ProductDraft draft) { + _controller.selectDraft(draft); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => MobileProductDetailPage(controller: _controller)), + ); + } +} diff --git a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart index 794c932..9c1d88c 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/shell/mobile_shell.dart @@ -2,7 +2,6 @@ import 'package:core/core.dart'; import 'package:feature_inventory/feature_inventory.dart'; import 'package:feature_orders/feature_orders.dart'; import 'package:feature_policy/feature_policy.dart'; -import 'package:feature_wordpress/feature_wordpress.dart'; import 'package:flutter/material.dart'; import '../composition/mobile_app_services.dart'; @@ -11,6 +10,7 @@ import '../dashboard/application/get_dashboard_summary.dart'; import '../pages/dashboard_page.dart'; import '../pages/finance_placeholder_page.dart'; import '../pages/integrations_placeholder_page.dart'; +import '../pages/mobile_publishing_page.dart'; /// The main shell for the mobile app. /// @@ -104,13 +104,7 @@ class _MobileShellState extends State { }, ); case 2: - return ProductPublishingPage( - repository: services.productPublishingRepository, - onViewPolicy: () { - // Cross-feature nav: not directly reachable from bottom nav, - // but we can show it as a placeholder for now. - }, - ); + return MobilePublishingPage(repository: services.productPublishingRepository); case 3: return OrdersPage( repository: services.ordersRepository, diff --git a/kell_creations_apps/apps/kell_mobile/test/widget_test.dart b/kell_creations_apps/apps/kell_mobile/test/widget_test.dart index 9f2bf25..76ef431 100644 --- a/kell_creations_apps/apps/kell_mobile/test/widget_test.dart +++ b/kell_creations_apps/apps/kell_mobile/test/widget_test.dart @@ -74,4 +74,56 @@ void main() { expect(find.text('Policy'), findsOneWidget); expect(find.text('Integrations'), findsOneWidget); }); + + // ── Mobile Publishing Surface tests ────────────────────────────── + + testWidgets('Products tab shows mobile publishing page with search bar', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + // Tap the Products destination + await tester.tap(find.text('Products').last); + await tester.pumpAndSettle(); + + // Should show the search bar + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Search products…'), findsOneWidget); + }); + + testWidgets('Products tab shows filter chips', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Products').last); + await tester.pumpAndSettle(); + + // Should show filter chips for status categories + expect(find.widgetWithText(FilterChip, 'All'), findsOneWidget); + expect(find.widgetWithText(FilterChip, 'Draft'), findsOneWidget); + expect(find.widgetWithText(FilterChip, 'Published'), findsOneWidget); + }); + + testWidgets('Products tab shows product count', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Products').last); + await tester.pumpAndSettle(); + + // Fake data should produce a product count label + expect(find.textContaining('products'), findsWidgets); + }); + + testWidgets('Products tab shows sort button', (WidgetTester tester) async { + await tester.pumpWidget(_buildTestApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Products').last); + await tester.pumpAndSettle(); + + // Should have a sort icon/button + expect(find.byIcon(Icons.sort), findsOneWidget); + }); } 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 d733ba3..0d68ce4 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -12,7 +12,9 @@ export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; // Application -export 'src/application/product_publishing_controller.dart' show ListDensity, ProductSortField; +export 'src/application/get_product_drafts.dart'; +export 'src/application/product_publishing_controller.dart'; +export 'src/application/publish_product.dart'; export 'src/application/update_product_category.dart'; export 'src/application/update_product_description.dart'; export 'src/application/update_product_name.dart'; @@ -21,3 +23,7 @@ export 'src/application/update_product_status.dart'; // Presentation export 'src/presentation/product_publishing_page.dart'; +export 'src/presentation/widgets/product_draft_card.dart'; +export 'src/presentation/widgets/product_preview_panel.dart'; +export 'src/presentation/widgets/publish_status_chip.dart'; +export 'src/presentation/widgets/status_action_snack_bar.dart';