Compare commits

..

No commits in common. "4b5c96c5ec79291a09543acc331cf74a2411c8ed" and "591de0c5c4cd676eb04a4f958e45927a176f24a1" have entirely different histories.

5 changed files with 31 additions and 323 deletions

View File

@ -2,10 +2,10 @@
## Current status
- main baseline updated through: android-feedback-polish (Stage 6A complete)
- main baseline commit: merge of `feat/android-feedback-polish` (2026-05-30)
- next branch: feat/android-mobile-ux-hardening (Stage 6B)
- current stage: Stage 6A complete — Stage 6B next
- 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
@ -198,17 +198,3 @@
- tests: passed (10/10 kell_mobile)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes
### feat/android-feedback-polish
- status: merged to main
- date: 2026-05-29
- inspection: complete
- implementation: complete
- files changed:
- `kell_mobile/lib/pages/mobile_product_detail_page.dart` — converted from stateless to StatefulWidget; added local controller listener for SnackBar feedback in detail page context; added confirmation dialogs for publish/move-to-draft actions; added haptic feedback (mediumImpact for status, lightImpact for field edits) on successful actions
- `kell_mobile/lib/pages/mobile_publishing_page.dart` — added `_detailPageActive` guard to suppress SnackBars when detail page is pushed (prevents invisible behind-route feedback); updated `_navigateToDetail` to set/clear the guard flag using Navigator.push().then()
- `kell_mobile/test/widget_test.dart` — added 4 new Stage 6A tests: detail page navigation, confirmation dialog for status changes, product name in app bar, and back navigation returning to product list
- tests: passed (14/14 kell_mobile)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes

View File

@ -112,13 +112,11 @@ Rules:
- `design_system` tests passing
- `feature_wordpress` tests passing
- `kell_web` tests passing
- `kell_mobile` tests passing
- latest reported count for `core`: `20/20 passed`
- latest reported count for `design_system`: `41/41 passed`
- latest reported count for `feature_wordpress`: `294/294 passed`
- latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `kell_mobile`: `14/14 passed`
- baseline commit: merge of `feat/android-feedback-polish` (2026-05-30)
- baseline commit: merge of `feat/test-coverage-visibility` (2026-05-22)
#### Baseline test coverage (established 2026-05-22)
@ -134,8 +132,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch
**`feat/android-mobile-ux-hardening`** — Stage 6B: Android mobile workflow hardening.
Branch from latest `main`. Stage 6A (Android feedback and action polish) is complete.
**`feat/android-feedback-polish`** — Stage 6A: Android feedback and action polish.
Branch from latest `main`. Stage 5 (Android application foundation) is complete.
---
@ -305,10 +303,17 @@ Harden Android UX after the core feature surface works.
- `feat/android-feedback-polish`
- `feat/android-mobile-ux-hardening`
#### ~~Stage 6A — Android feedback and action polish~~ ✅ COMPLETE
#### Stage 6A — Android feedback and action polish
> Merged `feat/android-feedback-polish``main` (2026-05-29).
> Converted `MobileProductDetailPage` to StatefulWidget with its own controller listener for local SnackBar feedback. Added confirmation dialogs for publish/move-to-draft status changes. Added haptic feedback (mediumImpact for status, lightImpact for field edits) on successful actions. Guarded list page SnackBars with `_detailPageActive` flag to prevent invisible behind-route feedback. 4 new tests added (14 total kell_mobile tests passing).
##### Goal
Ensure action feedback patterns translate cleanly to Android.
##### Requirements
- reuse shared action result model where possible
- adapt SnackBar/feedback timing and presentation appropriately
- validate status/edit workflows on mobile
#### Stage 6B — Android mobile workflow hardening
@ -407,7 +412,7 @@ The following packages exist as scaffolded stubs with no implementation. Each ne
### ~~6. Build execution tracker synchronization~~ ✅ RESOLVED
> The `build_execution_tracker.md` has been fully updated through Stage 6A. All 13 merged branches are tracked with status, dates, files changed, test counts, and analysis results. Current status correctly reflects Stage 6A complete and next branch as `feat/android-mobile-ux-hardening` (Stage 6B). Last synchronized 2026-05-30.
> The `build_execution_tracker.md` has been fully updated through Stage 4D. All 10 merged branches are tracked with status, dates, files changed, test counts, and analysis results. Current status correctly reflects Stage 4 complete and next branch as `feat/android-app-shell` (Stage 5A). Updated as part of the Stage 4C/4D merge cycle (2026-05-22).
### 7. Image and media management

View File

@ -1,6 +1,5 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Full-screen product detail page for mobile.
///
@ -10,140 +9,17 @@ import 'package:flutter/services.dart';
///
/// Wraps the shared [ProductPreviewPanel] from [feature_wordpress]
/// inside a [Scaffold] with an [AppBar] showing the product name.
///
/// Unlike the web layout where feedback SnackBars are handled by the
/// publishing page wrapper, this detail page is pushed via [Navigator]
/// and owns its own [Scaffold]. It therefore attaches its own listener
/// to the controller and shows action-result SnackBars using its local
/// [BuildContext], ensuring they are visible on the active screen.
///
/// Status-change actions (publish / move-to-draft) present a
/// confirmation dialog before executing, reducing accidental taps on
/// touch screens.
class MobileProductDetailPage extends StatefulWidget {
class MobileProductDetailPage extends StatelessWidget {
final ProductPublishingController controller;
const MobileProductDetailPage({super.key, required this.controller});
@override
State<MobileProductDetailPage> createState() => _MobileProductDetailPageState();
}
class _MobileProductDetailPageState extends State<MobileProductDetailPage> {
ProductPublishingController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
}
@override
void dispose() {
_controller.removeListener(_onControllerChanged);
super.dispose();
}
/// Handles action result feedback via SnackBars on this detail page.
///
/// Because this page is pushed on top of the list page via [Navigator],
/// the list page's listener cannot reliably display SnackBars (they
/// would appear behind this route). This listener ensures feedback is
/// always visible to the operator.
void _onControllerChanged() {
if (!mounted) return;
final result = _controller.lastActionResult;
if (result != null) {
_controller.consumeActionResult();
showStatusActionSnackBar(context, result);
if (result.success) HapticFeedback.mediumImpact();
}
final priceResult = _controller.lastPriceResult;
if (priceResult != null) {
_controller.consumePriceResult();
showPriceActionSnackBar(context, priceResult);
if (priceResult.success) HapticFeedback.lightImpact();
}
final nameResult = _controller.lastNameResult;
if (nameResult != null) {
_controller.consumeNameResult();
showNameActionSnackBar(context, nameResult);
if (nameResult.success) HapticFeedback.lightImpact();
}
final descriptionResult = _controller.lastDescriptionResult;
if (descriptionResult != null) {
_controller.consumeDescriptionResult();
showDescriptionActionSnackBar(context, descriptionResult);
if (descriptionResult.success) HapticFeedback.lightImpact();
}
final categoryResult = _controller.lastCategoryResult;
if (categoryResult != null) {
_controller.consumeCategoryResult();
showCategoryActionSnackBar(context, categoryResult);
if (categoryResult.success) HapticFeedback.lightImpact();
}
}
/// Shows a confirmation dialog before executing a status change.
///
/// Returns `true` if the user confirmed, `false` otherwise.
Future<bool> _confirmStatusChange({
required String productName,
required String actionLabel,
required String description,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('$actionLabel?'),
content: Text(description),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(actionLabel)),
],
),
);
return confirmed ?? false;
}
/// Publishes the product after confirmation.
Future<void> _handlePublish(String id, String name) async {
final confirmed = await _confirmStatusChange(
productName: name,
actionLabel: 'Publish',
description: 'Publish "$name" to the store? This will make it visible to customers.',
);
if (confirmed && mounted) {
_controller.updateStatus(id, PublishStatus.published);
}
}
/// Moves the product to draft after confirmation.
Future<void> _handleMoveToDraft(String id, String name) async {
final confirmed = await _confirmStatusChange(
productName: name,
actionLabel: 'Move to Draft',
description: 'Move "$name" back to draft? This will remove it from the store.',
);
if (confirmed && mounted) {
_controller.updateStatus(id, PublishStatus.draft);
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
animation: controller,
builder: (context, _) {
final draft = _controller.selectedDraft;
final draft = controller.selectedDraft;
// If the product was removed or deselected, pop back.
if (draft == null) {
@ -159,13 +35,13 @@ class _MobileProductDetailPageState extends State<MobileProductDetailPage> {
padding: const EdgeInsets.all(16),
child: ProductPreviewPanel(
draft: draft,
isUpdating: _controller.isUpdating(draft.id),
onPublish: () => _handlePublish(draft.id, draft.name),
onMoveToDraft: () => _handleMoveToDraft(draft.id, draft.name),
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),
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

@ -25,14 +25,6 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
late final ProductPublishingController _controller;
late final TextEditingController _searchController;
/// Whether the detail page is currently pushed on top.
///
/// When `true`, action-result SnackBars are suppressed here because the
/// detail page owns its own listener and shows feedback in its own
/// [Scaffold]. Without this guard, SnackBars would be rendered behind
/// the detail route and be invisible to the operator.
bool _detailPageActive = false;
@override
void initState() {
super.initState();
@ -62,12 +54,7 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
}
/// Handles action result feedback via SnackBars.
///
/// Suppressed while the detail page is active the detail page has its
/// own listener that shows SnackBars using its local context.
void _onControllerChanged() {
if (_detailPageActive) return;
final result = _controller.lastActionResult;
if (result != null) {
_controller.consumeActionResult();
@ -276,18 +263,10 @@ class _MobilePublishingPageState extends State<MobilePublishingPage> {
}
/// Navigates to the full-screen product detail page.
///
/// Sets [_detailPageActive] to suppress SnackBars on this page while the
/// detail page is visible. Cleared when the detail page pops back.
void _navigateToDetail(ProductDraft draft) {
_controller.selectDraft(draft);
setState(() => _detailPageActive = true);
Navigator.of(context)
.push(
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
)
.then((_) {
if (mounted) setState(() => _detailPageActive = false);
});
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
);
}
}

View File

@ -126,142 +126,4 @@ void main() {
// Should have a sort icon/button
expect(find.byIcon(Icons.sort), findsOneWidget);
});
// Stage 6A: Android feedback and action polish tests
testWidgets('tapping product card navigates to detail page', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card in the list
final cards = find.byType(GestureDetector);
expect(cards, findsWidgets);
// Find any product card and tap it
final productCards = find.byType(SizedBox);
// Tap on the first product in the list view
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Should now be on the detail page has an AppBar with a back button
expect(find.byType(AppBar), findsOneWidget);
}
});
testWidgets('detail page shows confirmation dialog for publish action', (
WidgetTester tester,
) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first 72-height card to navigate to detail
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Scroll down to reveal the status action button
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
// Look for Publish or Move to Draft button
final publishButton = find.text('Publish to Store');
final moveToDraftButton = find.text('Move to Draft');
if (publishButton.evaluate().isNotEmpty) {
await tester.tap(publishButton);
await tester.pumpAndSettle();
// Confirmation dialog should appear
expect(find.text('Publish?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Publish'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// Dialog should be gone
expect(find.text('Publish?'), findsNothing);
} else if (moveToDraftButton.evaluate().isNotEmpty) {
await tester.tap(moveToDraftButton);
await tester.pumpAndSettle();
// Confirmation dialog should appear
expect(find.text('Move to Draft?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Cancel should dismiss the dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
// Dialog should be gone
expect(find.text('Move to Draft?'), findsNothing);
}
}
});
testWidgets('detail page shows product name in app bar', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Detail page AppBar should show the product name
// The AppBar should exist and have a title
expect(find.byType(AppBar), findsOneWidget);
}
});
testWidgets('detail page back navigation returns to product list', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Navigate to Products tab
await tester.tap(find.text('Products').last);
await tester.pumpAndSettle();
// Tap the first product card
final firstProduct = find.byWidgetPredicate(
(widget) => widget is SizedBox && widget.height == 72 && widget.child != null,
);
if (firstProduct.evaluate().isNotEmpty) {
await tester.tap(firstProduct.first);
await tester.pumpAndSettle();
// Press back (the leading back button in AppBar)
final backButton = find.byType(BackButton);
if (backButton.evaluate().isNotEmpty) {
await tester.tap(backButton);
await tester.pumpAndSettle();
// Should be back on the product list with search bar
expect(find.text('Search products…'), findsOneWidget);
}
}
});
}