feat(mobile): Stage 5A — Android app shell and bootstrap
Validate Docs / validate-docs (push) Successful in 3m31s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details

Replace default Flutter counter template in kell_mobile with a fully
integrated mobile operations platform shell reusing shared packages.

Mobile app shell:
- MobileAppServices extending KcAppServices with fake()/wp() factories
- KellMobileApp with KcAppScope<MobileAppServices>, KcTheme, env badge
- MobileShell with 5-tab NavigationBar (Dashboard, Inventory, Orders,
  Publishing, More) using IndexedStack for state preservation
- KcBootstrap entry point with --dart-define environment variables

Dashboard:
- DashboardSummary value object with fromData()/empty() constructors
- GetDashboardSummary use case aggregating inventory, orders, publishing
- DashboardController (ChangeNotifier) with loading/error/summary state
- MobileDashboardPage with GridView summary cards using design system
  widgets (KcSectionHeader, KcSummaryCard, KcEmptyState)

Placeholder pages:
- FinancePlaceholderPage, IntegrationsPlaceholderPage for More tab
- Feature tab pages delegate to shared feature presentation layers

Infrastructure:
- pubspec.yaml references all shared packages (core, design_system,
  feature_inventory, feature_orders, feature_policy, feature_wordpress)
- SDK constraint corrected from ^3.11.4 to ^3.11.0 across all 14
  pubspec.yaml files to match installed Dart SDK 3.11.3

Tests:
- 6 new kell_mobile widget tests: shell loading, summary cards,
  environment badge, navigation bar destinations, tab switching, More menu
- All existing tests remain passing (24/24 kell_web, 294/294
  feature_wordpress)

Documentation:
- master_development_brief.md: Stage 5A marked complete, next branch
  updated to feat/android-publishing-surface (Stage 5B), kell_mobile
  platform description updated
- build_execution_tracker.md: Stage 5A entry added with full file list
This commit is contained in:
Mike Kell 2026-05-28 19:10:14 -04:00
parent f056d5f0b5
commit 65466ba513
29 changed files with 926 additions and 251 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: test-coverage-visibility (Stage 4D complete — Stage 4 complete) - main baseline updated through: android-app-shell (Stage 5A complete)
- main baseline commit: merge of `feat/test-coverage-visibility` (2026-05-22) - main baseline commit: merge of `feat/android-app-shell` (2026-05-28)
- next branch: feat/android-app-shell (Stage 5A) - next branch: feat/android-publishing-surface (Stage 5B)
- current stage: Stage 5 — Android application foundation - current stage: Stage 5 — Android application foundation
## Slice tracker ## Slice tracker
@ -158,3 +158,27 @@
- analyze: passed - analyze: passed
- coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4% - coverage baseline: core 85.7%, design_system 100.0%, feature_wordpress 84.7%, kell_web 54.1%, overall 78.4%
- brief updated: yes - brief updated: yes
### feat/android-app-shell
- status: merged to main
- date: 2026-05-28
- inspection: complete
- implementation: complete
- files changed:
- `kell_mobile/pubspec.yaml` — replaced default template dependencies with shared packages (`core`, `design_system`, `feature_inventory`, `feature_orders`, `feature_policy`, `feature_wordpress`); SDK constraint corrected to `^3.11.0`
- `kell_mobile/lib/main.dart` — replaced counter template with `KcBootstrap` entry point using `--dart-define` environment variables
- `kell_mobile/lib/app.dart` — new `KellMobileApp` widget with `KcAppScope<MobileAppServices>`, `KcTheme`, and environment badge
- `kell_mobile/lib/composition/mobile_app_services.dart` — new `MobileAppServices` extending `KcAppServices` with `fake()` and `wp()` factory constructors
- `kell_mobile/lib/shell/mobile_shell.dart` — new `MobileShell` with 5-tab `NavigationBar` (Dashboard, Inventory, Orders, Publishing, More) and `IndexedStack` body
- `kell_mobile/lib/dashboard/domain/dashboard_summary.dart` — shared `DashboardSummary` value object with `fromData()` and `empty()` constructors
- `kell_mobile/lib/dashboard/application/get_dashboard_summary.dart` — use case aggregating inventory, orders, and publishing repositories
- `kell_mobile/lib/dashboard/application/dashboard_controller.dart``ChangeNotifier` controller with loading/error/summary state
- `kell_mobile/lib/pages/dashboard_page.dart` — mobile-optimized dashboard with `GridView` summary cards using design system widgets
- `kell_mobile/lib/pages/finance_placeholder_page.dart` — placeholder page for Finance tab
- `kell_mobile/lib/pages/integrations_placeholder_page.dart` — placeholder page for Integrations tab
- `kell_mobile/test/widget_test.dart` — 6 widget tests covering shell loading, summary cards, environment badge, navigation bar, tab switching, and More menu
- 13 other `pubspec.yaml` files — SDK constraint corrected from `^3.11.4` to `^3.11.0` across all packages
- 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

View File

@ -46,7 +46,7 @@ Rules:
### Platform structure ### Platform structure
- `apps/kell_web` exists — active, wired to shared packages. - `apps/kell_web` exists — active, wired to shared packages.
- `apps/kell_mobile` exists — scaffolded as default Flutter template, **not yet integrated** with shared packages. - `apps/kell_mobile` exists — integrated with shared packages, mobile-optimized shell with bottom navigation, dashboard, and placeholder pages for all features.
- Shared packages include: - Shared packages include:
- `core` — shared domain/application abstractions and cross-platform composition pattern (`KcAppConfig`, `KcAppServices`, `KcBootstrap`, `KcAppScope`) - `core` — shared domain/application abstractions and cross-platform composition pattern (`KcAppConfig`, `KcAppServices`, `KcBootstrap`, `KcAppScope`)
- `design_system` — theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`) - `design_system` — theme (`KcColors`, `KcSpacing`, `KcTheme`), typography (`KcTypography`), layout (`KcBreakpoints`), and 7 shared widgets (`KcCard`, `KcStatusChip`, `KcEmptyState`, `KcSectionHeader`, `KcSummaryCard`, `KcLoadingState`, `KcErrorState`)
@ -132,8 +132,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch ### Next recommended branch
**`feat/android-app-shell`** — Stage 5A: Android app shell and bootstrap. **`feat/android-publishing-surface`** — Stage 5B: Android publishing surface.
Branch from latest `main`. Stage 4 (Platform foundations and cross-platform readiness) is complete. Branch from latest `main`. Stage 5A (Android app shell and bootstrap) is complete.
--- ---
@ -280,30 +280,10 @@ Business logic, domain logic, repositories, and feature application logic should
- `feat/android-app-shell` - `feat/android-app-shell`
- `feat/android-publishing-surface` - `feat/android-publishing-surface`
#### Stage 5A — Android app shell and bootstrap #### ~~Stage 5A — Android app shell and bootstrap~~ ✅ COMPLETE
##### Goal > 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).
Create the Android app entry and shell for the existing platform.
##### Requirements
- add or adapt app target for Android
- reuse shared packages and feature modules
- preserve runtime environment selection model
- ensure FAKE mode works cleanly on Android first
- mobile shell/navigation should stay simple and consistent with shared app structure
##### Current state note
> `kell_mobile` exists as a default Flutter counter template. It does **not** yet reference any shared packages (`core`, `design_system`, `feature_*`). This stage must replace the template with a proper app shell that mirrors the `kell_web` composition pattern (`AppServices`, `AppScope`, routing, shell).
##### Definition of done
- app runs on Android emulator/device in FAKE mode
- shell, navigation, and core screens render
- analyze/tests remain clean
- `kell_mobile/pubspec.yaml` references shared packages
#### Stage 5B — Android publishing surface #### Stage 5B — Android publishing surface

View File

@ -0,0 +1,22 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'shell/mobile_shell.dart';
/// Root widget for the Kell Creations mobile application.
///
/// Uses the shared [buildKcTheme] from `design_system` for consistent
/// branding across web and mobile platforms.
class KellMobileApp extends StatelessWidget {
const KellMobileApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Kell Creations',
debugShowCheckedModeBanner: false,
theme: buildKcTheme(),
home: const MobileShell(),
);
}
}

View File

@ -0,0 +1,71 @@
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';
/// Holds the concrete service implementations used by `kell_mobile`.
///
/// Extends [KcAppServices] from the shared `core` package so that the
/// generic [KcBootstrap] and [KcAppScope] infrastructure can work with
/// this app's specific service set.
///
/// Mirrors the same service composition as `kell_web`'s `AppServices`,
/// ensuring both platforms share identical business/domain logic.
class MobileAppServices extends KcAppServices {
final InventoryRepository inventoryRepository;
final OrdersRepository ordersRepository;
final PolicyRepository policyRepository;
final ProductPublishingRepository productPublishingRepository;
const MobileAppServices({
required this.inventoryRepository,
required this.ordersRepository,
required this.policyRepository,
required this.productPublishingRepository,
});
/// Creates a [MobileAppServices] backed by fake, in-memory repositories.
factory MobileAppServices.fake() {
return MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: FakeProductPublishingRepository(),
);
}
/// Creates a [MobileAppServices] with a real WooCommerce-backed product
/// repository. Other repositories remain fake until their backends are
/// ready.
factory MobileAppServices.wordpress({
required String siteUrl,
required String consumerKey,
required String consumerSecret,
}) {
final apiClient = WooCommerceApiClient(
siteUrl: siteUrl,
consumerKey: consumerKey,
consumerSecret: consumerSecret,
);
return MobileAppServices(
inventoryRepository: FakeInventoryRepository(),
ordersRepository: FakeOrdersRepository(),
policyRepository: FakePolicyRepository(),
productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient),
);
}
/// Returns a [KcServiceFactory] for use with [KcBootstrap.run].
static KcServiceFactory<MobileAppServices> get serviceFactory {
return KcServiceFactory<MobileAppServices>(
createFake: () => MobileAppServices.fake(),
createWordPress: (config) => MobileAppServices.wordpress(
siteUrl: config.wcSiteUrl,
consumerKey: config.wcConsumerKey,
consumerSecret: config.wcConsumerSecret,
),
);
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import '../domain/dashboard_summary.dart';
import 'get_dashboard_summary.dart';
/// Controller that manages the dashboard summary state.
///
/// Follows the same [ChangeNotifier] pattern used by other feature
/// controllers. Mirrors `kell_web`'s equivalent controller.
class DashboardController extends ChangeNotifier {
final GetDashboardSummary _getDashboardSummary;
DashboardController(this._getDashboardSummary);
bool isLoading = false;
DashboardSummary summary = DashboardSummary.empty;
Object? error;
/// Loads the aggregated dashboard summary from all repositories.
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
try {
summary = await _getDashboardSummary();
} catch (e) {
error = e;
} finally {
isLoading = false;
notifyListeners();
}
}
}

View File

@ -0,0 +1,36 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import '../domain/dashboard_summary.dart';
/// Use case: fetches data from all three repositories and returns an
/// aggregated [DashboardSummary].
///
/// This lives in the app layer (not in a feature package) because it
/// crosses feature boundaries. Mirrors `kell_web`'s equivalent use case.
class GetDashboardSummary {
final InventoryRepository inventoryRepository;
final ProductPublishingRepository productPublishingRepository;
final OrdersRepository ordersRepository;
GetDashboardSummary({
required this.inventoryRepository,
required this.productPublishingRepository,
required this.ordersRepository,
});
Future<DashboardSummary> call() async {
final results = await Future.wait([
inventoryRepository.getInventoryItems(),
productPublishingRepository.getProductDrafts(),
ordersRepository.getOrders(),
]);
return DashboardSummary.fromData(
inventoryItems: results[0] as List<InventoryItem>,
productDrafts: results[1] as List<ProductDraft>,
orders: results[2] as List<Order>,
);
}
}

View File

@ -0,0 +1,150 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
/// Aggregated summary data displayed on the mobile dashboard.
///
/// This is an app-level value object that composes data from multiple
/// feature-package repositories without leaking domain logic back into
/// those packages.
///
/// Mirrors the same domain model as `kell_web`'s `DashboardSummary`.
class DashboardSummary {
/// Total number of inventory items.
final int totalProducts;
/// Items with [InventoryStatus.inStock].
final int inStock;
/// Items with [InventoryStatus.lowStock].
final int lowStock;
/// Items with [InventoryStatus.outOfStock].
final int outOfStock;
/// Product drafts with [PublishStatus.draft].
final int draftProducts;
/// Total number of orders.
final int totalOrders;
/// Orders with [OrderStatus.pending].
final int pendingOrders;
/// Orders with [OrderStatus.processing] or [OrderStatus.shipped].
final int activeOrders;
/// Revenue from delivered orders.
final double deliveredRevenue;
const DashboardSummary({
required this.totalProducts,
required this.inStock,
required this.lowStock,
required this.outOfStock,
required this.draftProducts,
required this.totalOrders,
required this.pendingOrders,
required this.activeOrders,
required this.deliveredRevenue,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DashboardSummary &&
totalProducts == other.totalProducts &&
inStock == other.inStock &&
lowStock == other.lowStock &&
outOfStock == other.outOfStock &&
draftProducts == other.draftProducts &&
totalOrders == other.totalOrders &&
pendingOrders == other.pendingOrders &&
activeOrders == other.activeOrders &&
deliveredRevenue == other.deliveredRevenue;
@override
int get hashCode => Object.hash(
totalProducts,
inStock,
lowStock,
outOfStock,
draftProducts,
totalOrders,
pendingOrders,
activeOrders,
deliveredRevenue,
);
/// An empty summary used as the initial / default state.
static const empty = DashboardSummary(
totalProducts: 0,
inStock: 0,
lowStock: 0,
outOfStock: 0,
draftProducts: 0,
totalOrders: 0,
pendingOrders: 0,
activeOrders: 0,
deliveredRevenue: 0,
);
/// Computes a [DashboardSummary] from raw repository data.
factory DashboardSummary.fromData({
required List<InventoryItem> inventoryItems,
required List<ProductDraft> productDrafts,
required List<Order> orders,
}) {
// Inventory counts
final totalProducts = inventoryItems.length;
var inStock = 0;
var lowStock = 0;
var outOfStock = 0;
for (final item in inventoryItems) {
switch (item.status) {
case InventoryStatus.inStock:
inStock++;
case InventoryStatus.lowStock:
lowStock++;
case InventoryStatus.outOfStock:
outOfStock++;
case InventoryStatus.draft:
break;
}
}
// Draft product count
final draftProducts = productDrafts.where((d) => d.status == PublishStatus.draft).length;
// Order counts
final totalOrders = orders.length;
var pendingOrders = 0;
var activeOrders = 0;
var deliveredRevenue = 0.0;
for (final order in orders) {
switch (order.status) {
case OrderStatus.pending:
pendingOrders++;
case OrderStatus.processing:
case OrderStatus.shipped:
activeOrders++;
case OrderStatus.delivered:
deliveredRevenue += order.total;
case OrderStatus.cancelled:
break;
}
}
return DashboardSummary(
totalProducts: totalProducts,
inStock: inStock,
lowStock: lowStock,
outOfStock: outOfStock,
draftProducts: draftProducts,
totalOrders: totalOrders,
pendingOrders: pendingOrders,
activeOrders: activeOrders,
deliveredRevenue: deliveredRevenue,
);
}
}

View File

@ -1,122 +1,21 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app.dart';
import 'composition/mobile_app_services.dart';
void main() { void main() {
runApp(const MyApp()); final config = KcAppConfig.fromEnvironment();
} final (:services, config: effectiveConfig) = KcBootstrap.run(
config,
class MyApp extends StatelessWidget { MobileAppServices.serviceFactory,
const MyApp({super.key}); );
// This widget is the root of your application. runApp(
@override KcAppScope<MobileAppServices>(
Widget build(BuildContext context) { services: services,
return MaterialApp( config: effectiveConfig,
title: 'Flutter Demo', child: const KellMobileApp(),
theme: ThemeData( ),
// This is the theme of your application. );
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
} }

View File

@ -0,0 +1,141 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../dashboard/application/dashboard_controller.dart';
import '../dashboard/domain/dashboard_summary.dart';
/// A mobile-optimized dashboard page showing aggregated summary data.
///
/// Uses a single-column scrollable layout suitable for smaller screens.
class MobileDashboardPage extends StatefulWidget {
final DashboardController controller;
const MobileDashboardPage({super.key, required this.controller});
@override
State<MobileDashboardPage> createState() => _MobileDashboardPageState();
}
class _MobileDashboardPageState extends State<MobileDashboardPage> {
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
widget.controller.load();
}
@override
void didUpdateWidget(MobileDashboardPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
widget.controller.load();
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
super.dispose();
}
void _onControllerChanged() => setState(() {});
@override
Widget build(BuildContext context) {
final controller = widget.controller;
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(
child: Text('Failed to load dashboard data.', style: Theme.of(context).textTheme.bodyLarge),
);
}
final summary = controller.summary;
return ListView(
padding: const EdgeInsets.all(KcSpacing.md),
children: [
const KcSectionHeader(title: 'Overview'),
const SizedBox(height: KcSpacing.sm),
_buildSummaryGrid(context, summary),
const SizedBox(height: KcSpacing.xl),
const KcSectionHeader(title: 'Recent Activity'),
const SizedBox(height: KcSpacing.sm),
const KcEmptyState(
icon: Icons.history,
message:
'No recent activity yet.\nActivity will appear here once orders and updates are tracked.',
),
],
);
}
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
final cards = [
KcSummaryCard(
icon: Icons.inventory_2,
iconColor: KcColors.denimBlue,
label: 'Total Products',
value: '${summary.totalProducts}',
),
KcSummaryCard(
icon: Icons.check_circle_outline,
iconColor: KcColors.success,
label: 'In Stock',
value: '${summary.inStock}',
),
KcSummaryCard(
icon: Icons.warning_amber_rounded,
iconColor: KcColors.warning,
label: 'Low Stock',
value: '${summary.lowStock}',
),
KcSummaryCard(
icon: Icons.edit_note,
iconColor: KcColors.neutral,
label: 'Draft',
value: '${summary.draftProducts}',
),
KcSummaryCard(
icon: Icons.receipt_long,
iconColor: KcColors.denimBlue,
label: 'Total Orders',
value: '${summary.totalOrders}',
),
KcSummaryCard(
icon: Icons.hourglass_empty,
iconColor: KcColors.warning,
label: 'Pending Orders',
value: '${summary.pendingOrders}',
),
KcSummaryCard(
icon: Icons.local_shipping_outlined,
iconColor: KcColors.success,
label: 'Active Orders',
value: '${summary.activeOrders}',
),
KcSummaryCard(
icon: Icons.attach_money,
iconColor: KcColors.success,
label: 'Revenue',
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
),
];
return GridView.count(
crossAxisCount: 2,
crossAxisSpacing: KcSpacing.sm,
mainAxisSpacing: KcSpacing.sm,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.6,
children: cards,
);
}
}

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class FinancePlaceholderPage extends StatelessWidget {
const FinancePlaceholderPage({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Finance page coming soon'));
}
}

View File

@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class IntegrationsPlaceholderPage extends StatelessWidget {
const IntegrationsPlaceholderPage({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Integrations page coming soon'));
}
}

View File

@ -0,0 +1,230 @@
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';
import '../dashboard/application/dashboard_controller.dart';
import '../dashboard/application/get_dashboard_summary.dart';
import '../pages/dashboard_page.dart';
import '../pages/finance_placeholder_page.dart';
import '../pages/integrations_placeholder_page.dart';
/// The main shell for the mobile app.
///
/// Uses a [Scaffold] with a [NavigationBar] (Material 3 bottom navigation)
/// to provide top-level section navigation. This is the mobile equivalent
/// of `kell_web`'s [AppShell] which uses a [NavigationRail].
///
/// Unlike the web app which uses named routes, the mobile shell uses
/// index-based tab switching with a stateful body to preserve tab state.
class MobileShell extends StatefulWidget {
const MobileShell({super.key});
@override
State<MobileShell> createState() => _MobileShellState();
}
class _MobileShellState extends State<MobileShell> {
int _selectedIndex = 0;
static const _titles = ['Dashboard', 'Inventory', 'Products', 'Orders', 'More'];
@override
Widget build(BuildContext context) {
final config = KcAppScope.configOf<MobileAppServices>(context);
return Scaffold(
appBar: AppBar(
title: Text(_titles[_selectedIndex]),
actions: [
_EnvironmentBadge(environment: config.environment),
const SizedBox(width: 12),
],
),
body: _buildBody(context),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: 'Inventory',
),
NavigationDestination(
icon: Icon(Icons.sell_outlined),
selectedIcon: Icon(Icons.sell),
label: 'Products',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Orders',
),
NavigationDestination(
icon: Icon(Icons.more_horiz),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
);
}
Widget _buildBody(BuildContext context) {
final services = KcAppScope.of<MobileAppServices>(context);
switch (_selectedIndex) {
case 0:
return MobileDashboardPage(
controller: DashboardController(
GetDashboardSummary(
inventoryRepository: services.inventoryRepository,
productPublishingRepository: services.productPublishingRepository,
ordersRepository: services.ordersRepository,
),
),
);
case 1:
return InventoryPage(
repository: services.inventoryRepository,
onViewProduct: (_) {
// Cross-feature nav: switch to Products tab.
setState(() => _selectedIndex = 2);
},
);
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.
},
);
case 3:
return OrdersPage(
repository: services.ordersRepository,
onViewProduct: (_) {
setState(() => _selectedIndex = 2);
},
onViewInventory: (_) {
setState(() => _selectedIndex = 1);
},
);
case 4:
return const _MorePage();
default:
return const Center(child: Text('Unknown section'));
}
}
}
/// A simple "More" page that provides access to less frequently used sections.
class _MorePage extends StatelessWidget {
const _MorePage();
@override
Widget build(BuildContext context) {
return ListView(
children: [
ListTile(
leading: const Icon(Icons.attach_money),
title: const Text('Finance'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Finance')),
body: const FinancePlaceholderPage(),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.policy),
title: const Text('Policy'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
final services = KcAppScope.of<MobileAppServices>(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Policy')),
body: PolicyPage(
repository: services.policyRepository,
onViewRelatedPage: (_) {},
),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.hub),
title: const Text('Integrations'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('Integrations')),
body: const IntegrationsPlaceholderPage(),
),
),
);
},
),
],
);
}
}
/// A small coloured chip displayed in the [AppBar] that shows the current
/// runtime environment (e.g. "FAKE" or "WP").
class _EnvironmentBadge extends StatelessWidget {
final KcAppEnvironment environment;
const _EnvironmentBadge({required this.environment});
@override
Widget build(BuildContext context) {
final Color backgroundColor;
final Color foregroundColor;
switch (environment) {
case KcAppEnvironment.fake:
backgroundColor = Colors.orange.shade100;
foregroundColor = Colors.orange.shade900;
case KcAppEnvironment.wordpress:
backgroundColor = Colors.green.shade100;
foregroundColor = Colors.green.shade900;
}
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(4)),
child: Text(
environment.label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
);
}
}

View File

@ -41,6 +41,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
core:
dependency: "direct main"
description:
path: "../../packages/core"
relative: true
source: path
version: "0.0.1"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -49,6 +56,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
design_system:
dependency: "direct main"
description:
path: "../../packages/design_system"
relative: true
source: path
version: "0.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +71,34 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
feature_inventory:
dependency: "direct main"
description:
path: "../../packages/feature_inventory"
relative: true
source: path
version: "0.0.1"
feature_orders:
dependency: "direct main"
description:
path: "../../packages/feature_orders"
relative: true
source: path
version: "0.0.1"
feature_policy:
dependency: "direct main"
description:
path: "../../packages/feature_policy"
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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -75,6 +117,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -192,6 +250,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.10"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -208,6 +274,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks: sdks:
dart: ">=3.11.4 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -1,89 +1,36 @@
name: kell_mobile name: kell_mobile
description: "A new Flutter project." description: "Kell Creations mobile operations platform."
# The following line prevents the package from being accidentally published to publish_to: "none"
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
core:
path: ../../packages/core
design_system:
path: ../../packages/design_system
feature_inventory:
path: ../../packages/feature_inventory
feature_orders:
path: ../../packages/feature_orders
feature_policy:
path: ../../packages/feature_policy
feature_wordpress:
path: ../../packages/feature_wordpress
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0 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: flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, 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 from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@ -1,30 +1,77 @@
// This is a basic Flutter widget test. import 'package:core/core.dart';
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kell_mobile/app.dart';
import 'package:kell_mobile/composition/mobile_app_services.dart';
import 'package:kell_mobile/main.dart'; Widget _buildTestApp() {
const config = KcAppConfig(
environment: KcAppEnvironment.fake,
wcSiteUrl: '',
wcConsumerKey: '',
wcConsumerSecret: '',
);
return KcAppScope<MobileAppServices>(
services: MobileAppServices.fake(),
config: config,
child: const KellMobileApp(),
);
}
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('mobile shell loads with dashboard tab', (WidgetTester tester) async {
// Build our app and trigger a frame. await tester.pumpWidget(_buildTestApp());
await tester.pumpWidget(const MyApp()); await tester.pumpAndSettle();
// Verify that our counter starts at 0. expect(find.text('Dashboard'), findsWidgets);
expect(find.text('0'), findsOneWidget); });
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame. testWidgets('dashboard shows summary cards after loading', (WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.add)); await tester.pumpWidget(_buildTestApp());
await tester.pump(); await tester.pumpAndSettle();
// Verify that our counter has incremented. expect(find.text('Overview'), findsOneWidget);
expect(find.text('0'), findsNothing); expect(find.text('Total Products'), findsOneWidget);
expect(find.text('1'), findsOneWidget); expect(find.text('In Stock'), findsOneWidget);
});
testWidgets('environment badge shows FAKE in fake mode', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
expect(find.text('FAKE'), findsOneWidget);
});
testWidgets('bottom navigation bar has 5 destinations', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationDestination), findsNWidgets(5));
});
testWidgets('tapping Inventory tab switches content', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Tap the Inventory destination
await tester.tap(find.text('Inventory').last);
await tester.pumpAndSettle();
// The app bar title should change
expect(find.text('Inventory'), findsWidgets);
});
testWidgets('tapping More tab shows additional sections', (WidgetTester tester) async {
await tester.pumpWidget(_buildTestApp());
await tester.pumpAndSettle();
// Tap the More destination
await tester.tap(find.text('More').last);
await tester.pumpAndSettle();
expect(find.text('Finance'), findsOneWidget);
expect(find.text('Policy'), findsOneWidget);
expect(find.text('Integrations'), findsOneWidget);
}); });
} }

View File

@ -283,5 +283,5 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
sdks: sdks:
dart: ">=3.11.4 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -5,7 +5,7 @@ publish_to: "none"
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.0
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies: