feat(mobile): Stage 6A — Android feedback and action polish
- Convert MobileProductDetailPage to StatefulWidget with local controller listener for SnackBar feedback in detail page context - Add confirmation dialogs for publish/move-to-draft status changes - Add haptic feedback (mediumImpact for status, lightImpact for field edits) on successful actions - Guard MobilePublishingPage SnackBars with _detailPageActive flag to prevent invisible behind-route feedback when detail page is pushed - Add 4 new Stage 6A widget tests (14 total kell_mobile tests passing) - Update build_execution_tracker.md and master_development_brief.md
This commit is contained in:
parent
591de0c5c4
commit
e23d41b098
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- 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
|
||||
- main baseline updated through: android-feedback-polish (Stage 6A complete)
|
||||
- main baseline commit: merge of `feat/android-feedback-polish` (2026-05-29)
|
||||
- next branch: feat/android-mobile-ux-hardening (Stage 6B)
|
||||
- current stage: Stage 6A complete — Stage 6B next
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -198,3 +198,17 @@
|
|||
- 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
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
|||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/android-feedback-polish`** — Stage 6A: Android feedback and action polish.
|
||||
Branch from latest `main`. Stage 5 (Android application foundation) is complete.
|
||||
**`feat/android-mobile-ux-hardening`** — Stage 6B: Android mobile workflow hardening.
|
||||
Branch from latest `main`. Stage 6A (Android feedback and action polish) is complete.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -303,17 +303,10 @@ 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
|
||||
#### ~~Stage 6A — Android feedback and action polish~~ ✅ COMPLETE
|
||||
|
||||
##### 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
|
||||
> 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).
|
||||
|
||||
#### Stage 6B — Android mobile workflow hardening
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Full-screen product detail page for mobile.
|
||||
///
|
||||
|
|
@ -9,17 +10,140 @@ import 'package:flutter/material.dart';
|
|||
///
|
||||
/// Wraps the shared [ProductPreviewPanel] from [feature_wordpress]
|
||||
/// inside a [Scaffold] with an [AppBar] showing the product name.
|
||||
class MobileProductDetailPage extends StatelessWidget {
|
||||
///
|
||||
/// 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 {
|
||||
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) {
|
||||
|
|
@ -35,13 +159,13 @@ class MobileProductDetailPage extends StatelessWidget {
|
|||
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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ 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();
|
||||
|
|
@ -54,7 +62,12 @@ 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();
|
||||
|
|
@ -263,10 +276,18 @@ 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);
|
||||
Navigator.of(context).push(
|
||||
setState(() => _detailPageActive = true);
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
MaterialPageRoute<void>(builder: (_) => MobileProductDetailPage(controller: _controller)),
|
||||
);
|
||||
)
|
||||
.then((_) {
|
||||
if (mounted) setState(() => _detailPageActive = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,4 +126,142 @@ 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue