feat(mobile): add Android publishing surface (Stage 5B)
Validate Docs / validate-docs (push) Successful in 2m12s Details
Publish Docs / publish-docs (push) Successful in 59s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details

- Add MobilePublishingPage with search, filter chips, sort, product count,
  compact card list, pull-to-refresh, and push navigation to detail
- Add MobileProductDetailPage wrapping shared ProductPreviewPanel with
  all narrow edit callbacks (status, price, name, description, category)
- Switch Products tab in MobileShell from ProductPublishingPage to
  MobilePublishingPage
- Expand feature_wordpress barrel exports for mobile consumption
- Add 4 new widget tests (10 total kell_mobile tests passing)
- Zero business logic forked — all shared layers reused
- dart analyze clean, all tests passing

Stage 5 (Android application foundation) complete.
This commit is contained in:
Mike Kell 2026-05-29 02:32:28 -04:00
parent 65466ba513
commit 591de0c5c4
7 changed files with 409 additions and 31 deletions

View File

@ -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

View File

@ -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<MobileAppServices>` 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.
---

View File

@ -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),
),
),
);
},
);
}
}

View File

@ -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<MobilePublishingPage> createState() => _MobilePublishingPageState();
}
class _MobilePublishingPageState extends State<MobilePublishingPage> {
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<ProductSortField>(
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<ProductSortField> _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<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
);
}
}

View File

@ -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<MobileShell> {
},
);
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,

View File

@ -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);
});
}

View File

@ -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';