Skip to content

Admin Frontend Package

The admin_frontend package is the presentation layer of the CO2 Asset Management admin application. It contains all Flutter UI code, views, reusable components, and state management using Riverpod notifiers. This package works in concert with admin_composition (domain wiring) and admin (entry point and tests).

The admin_frontend package provides:

  • Presentation Layers: Feature-specific views organized by domain module
  • State Management: Riverpod-based notifiers replacing GetX patterns
  • Shared Components: Reusable UI elements across features
  • Navigation: GoRouter integration with route definitions
  • Layout System: Responsive layouts for desktop and mobile
  • Application Services: Cross-cutting concerns and orchestration

The admin_frontend follows Clean Architecture principles integrated with the domain-driven design:

Presentation Layer (admin_frontend)
├── Feature Modules (estates, login, admin, etc.)
│ ├── Views (screens)
│ ├── Controllers/Notifiers (state management)
│ └── Components (feature-specific UI)
├── Shared Components (reusable across modules)
├── Layouts (responsive structure)
├── Orchestration (service coordination)
└── Routes (navigation configuration)
Nomos Domain Layer (via admin_composition)
├── Intents (user commands)
├── Directives (business logic)
└── Event-Sourced Aggregates
  • Directorylib/src/
    • Directorypresentation_modules/ - Feature modules organized by domain
      • Directoryestates/ - Estate management (flagship module)
        • Directorysite/ - Site management with floor plans
          • Directorysite-floor-plan/ - Interactive floor plan editor
        • Directoryasset/ - Asset management views
        • Directoryestate-overview/ - Estate dashboard
        • Directoryestate-map/ - Geographic visualization
        • Directoryusers-access/ - Permission management
        • Directoryestate-files/ - Document management
      • Directorylogin/ - Authentication workflow
      • Directoryregister/ - User registration
      • Directoryuser-profile/ - Profile and preferences
      • Directoryadmin/ - System administration
      • Directoryprivacy-policy/ - GDPR/compliance
    • Directorycomponents/ - Shared UI components
      • Directorynavigation_content/ - Sidebar navigation components
      • Directorygeneric_form_input/ - Form field components
      • floorplan_annotation_view.dart - Floor plan annotations
      • pencil_selection_view.dart - Drawing tools UI
    • Directorylayouts/ - Application layout structure
      • layout.dart - Main authenticated layout
      • auth_layout.dart - Authentication pages layout
      • top_bar.dart - Top navigation bar
      • left_bar.dart - Left sidebar
      • right_bar.dart - Right sidebar panel
    • Directoryhelpers/ - Utility functions and widgets
      • Directoryutils/ - Formatting, validators, helpers
      • Directorywidgets/ - Reusable utility widgets
      • Directorylocalizations/ - Multi-language support
    • Directoryapplication/ - Application layer services
      • Directoryservices/ - File handling, preferences
    • Directoryorchestration/ - Command/query coordination
    • Directorydomain/ - Domain interfaces and models
    • Directoryroutes/ - GoRouter configuration
    • Directoryconfig/ - App configuration

The admin_frontend is transitioning from GetX to Riverpod for state management. New code uses Riverpod notifiers while legacy GetX patterns are gradually refactored.

The standard pattern for state management. State classes use either @freezed for new code or plain classes with copyWith() during migration:

// Option 1: New code - Freezed (immutable)
@freezed
class AssetState with _$AssetState {
const factory AssetState({
@Default(false) bool isLoading,
TrackableAssetAggregate? currentAsset,
@Default([]) List<AttachmentAggregate> attachments,
}) = _AssetState;
}
// Option 2: Legacy/Migration - Plain class with copyWith()
class AssetState {
final bool isLoading;
final TrackableAssetAggregate? currentAsset;
final List<AttachmentAggregate> attachments;
AssetState({
this.isLoading = false,
this.currentAsset,
this.attachments = const [],
});
AssetState copyWith({
bool? isLoading,
TrackableAssetAggregate? currentAsset,
List<AttachmentAggregate>? attachments,
}) {
return AssetState(
isLoading: isLoading ?? this.isLoading,
currentAsset: currentAsset ?? this.currentAsset,
attachments: attachments ?? this.attachments,
);
}
}
// Notifier with @riverpod annotation
@riverpod
class AssetNotifier extends _$AssetNotifier {
@override
AssetState build({
required EstateId estateId,
required SiteId siteId,
required String assetId,
}) {
// Setup subscriptions and initialize state
_setupAssetSubscription();
return AssetState(isLoading: true);
}
// Reactive subscriptions
void _setupAssetSubscription() {
final subscription = ref.read(nomosAppProvider).watch<TrackableAssetAggregate>(
EstateWorkspace(estateId).toNomos,
EstateWorkspace.defaultTimeline,
AggregateId(assetId),
).listen(_onAssetReceived);
ref.onDispose(() => subscription.cancel());
}
// State mutations
Future<void> updateAsset(Map<String, dynamic> data) async {
try {
state = state.copyWith(isLoading: true);
final intent = UpdateTrackableAssetIntent(
assetId: TrackableAssetId.fromString(assetId),
// ... map data to intent
);
await ref.read(nomosAppProvider).dispatch(intent, ctx: /*...*/);
} finally {
state = state.copyWith(isLoading: false);
}
}
}
// Watch notifier state in widgets
class AssetDetailView extends ConsumerWidget {
final EstateId estateId;
final SiteId siteId;
final String assetId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the notifier's state
final assetState = ref.watch(assetNotifierProvider(
estateId: estateId,
siteId: siteId,
assetId: assetId,
));
if (assetState.isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
Text(assetState.currentAsset?.name.value ?? 'Unknown'),
// More UI...
],
);
}
}
ConceptPurposeExample
@riverpod classState container with lifecycleAssetNotifier manages asset state
WidgetRefAccess to providers in widgetsref.watch(), ref.read()
ref.watch()Reactive subscription to stateRebuilds when state changes
ref.read()One-time access to stateGetting current value without watching
ref.onDispose()Cleanup when provider is destroyedCancel subscriptions

Run after modifying notifiers:

Terminal window
cd apps/admin_frontend
dart run build_runner build --delete-conflicting-outputs

This generates .g.dart files for @riverpod annotations.

The most comprehensive module, managing complete estate administration:

Path: lib/src/presentation_modules/estates/

Key Sub-Modules:

  • Site Floor Plan (site-floor-plan/) - Interactive PDF-based floor plans with:

    • Multi-node asset placement and editing
    • Real-time collaborative drawing
    • Zone and region management
    • Asset drag-and-drop operations
  • Estate Overview - Dashboard with analytics and metrics

  • Site Management - Site CRUD operations and configuration

  • Asset Management - Track 25+ asset types with lifecycle

  • User Access - Estate-level permission management

  • File Management - Document organization and versioning

Key Controllers:

  • EstateOverviewNotifier - Dashboard state and analytics
  • SitesListNotifier - Site listing with search and filtering
  • AssetNotifier - Asset detail view with attachments and comments
  • SiteTopologyNotifier - Floor plan topology and structure

User authentication and session management:

Path: lib/src/presentation_modules/login/

Features:

  • Firebase authentication integration
  • Email verification workflow
  • Password reset functionality
  • Session persistence

Key Components:

  • LoginNotifier - Authentication state
  • EmailVerificationNotifier - Email verification process
  • ForgotPasswordNotifier - Password recovery

System administration and configuration:

Path: lib/src/presentation_modules/admin/

Features:

  • User management
  • Permission configuration
  • System settings
  • Catalogue management

Reusable UI components used across multiple modules:

Located in lib/src/components/navigation_content/:

  • TopLevelNavigationContent - Main navigation menu
  • EstateSelectedLeftBarContent - Estate-specific navigation
  • SiteSelectedLeftBarContent - Site-specific navigation
  • AdminLeftBarContent - Admin module navigation

Located in lib/src/components/generic_form_input/:

  • Text input fields
  • Dropdown selectors
  • Date pickers
  • Custom field renderers
  • Validation utilities

Located in lib/src/components/:

  • FloorplanAnnotationView - Floor plan annotation interface
  • PencilSelectionView - Drawing tool palette
  • PencilCursor - Custom cursor for drawing
  • NavigationHistoryWidget - Breadcrumb navigation
  • CustomSwitch - Reusable toggle switches
  • CustomPopMenu - Popup menu widget
  • EnvironmentTag - Environment indicator

The layout system provides responsive structure for the entire application:

Main Components:

  • Layout (layout.dart) - Primary authenticated app layout

    • Three-panel design: left sidebar, main content, right panel
    • Responsive adaptation for mobile/tablet/desktop
    • Scrollable content area
  • AuthLayout (auth_layout.dart) - Authentication pages layout

    • Centered, minimal layout for login/register
  • TopBar (top_bar.dart) - Top navigation with:

    • Estate selection dropdown
    • Global search
    • Theme toggle
    • Notifications panel
    • User menu
  • LeftBar (left_bar.dart) - Left sidebar with:

    • Route-aware navigation content
    • Responsive collapse/expand
    • Navigation history
  • RightBar (right_bar.dart) - Optional right panel for details/properties

The layout adapts based on screen size:

// Desktop (width > 1024px)
Row(
children: [
LeftBar(), // Full width sidebar
Expanded(
child: Column(
children: [
TopBar(), // Full width top bar
Expanded(child: content),
],
),
),
RightBar(), // Optional details panel
],
)
// Mobile (width ≤ 600px)
Scaffold(
appBar: MobileAppBar(),
drawer: MobileDrawer(),
body: content,
)

The admin_frontend connects to the domain layer through dependency injection provided by admin_composition. This enables:

// Watch domain aggregates via Nomos
final assets = ref.read(nomosAppProvider).watch<TrackableAssetAggregate>(
EstateWorkspace(estateId).toNomos,
EstateWorkspace.defaultTimeline,
aggregateId,
);
// Dispatch intents to the domain layer
await ref.read(nomosAppProvider).dispatch(
CreateAssetIntent(
assetId: assetId,
name: assetName,
// ... other properties
),
ctx: IntentContext(
workspaceId: EstateWorkspace(estateId).toNomos,
timelineId: EstateWorkspace.defaultTimeline,
actorId: actorId,
),
);

The application uses several bounded contexts:

ContextPurposeAggregates
EstateProperty and asset managementEstateAggregate, SiteRootAggregate, TrackableAssetAggregate
CatalogueAsset catalogues and listingsCatalogueAggregate, ListingAggregate
PermissionsAccess control and rolesPermissionAggregate
AttachmentsFile and media managementAttachmentAggregate

Cross-cutting concerns handled by application services:

File Handling (application/services/):

  • NomosAttachmentsService - Upload/download attachments
  • File type validation and transformation
  • Blob storage coordination

User Preferences (application/services/):

  • Layout preferences (sidebar state, theme)
  • Notification settings
  • Search history

Navigation (via admin_composition):

  • Route management through GoRouter
  • Deep linking support
  • Navigation state persistence

Core dependencies defined in pubspec.yaml:

  • flutter_riverpod: ^2.4.0 - Reactive state management
  • riverpod_annotation: ^2.3.0 - Code generation annotations
  • riverpod_generator: ^2.3.0 - Code generator (dev)
  • flutter - Core Flutter SDK
  • go_router: ^14.6.1 - Navigation and routing
  • provider: ^6.0.5 - Dependency injection
  • admin_composition - Domain registration and wiring
  • co2_nomos_client - Type-safe query access
  • intents_v1 - Domain command definitions
  • contracts_v1 - Domain aggregate definitions
  • flutter_map: ^8.2.1 - Interactive maps
  • pdfx: ^2.9.2 - PDF viewing
  • syncfusion_flutter_charts: ^29.1.37 - Charts and analytics
  • cloud_firestore: ^5.5.1 - Real-time database
  • firebase_storage: ^12.4.5 - Cloud storage
  • firebase_auth: ^5.5.1 - Authentication
  • hive: ^2.2.3 - Local storage
  • freezed_annotation: ^3.0.0 - Immutable classes (dev: freezed: ^3.1.0)
  • json_serializable: ^6.8.0 - JSON serialization
  • uuid: ^4.3.3 - ID generation
  • intl: ^0.20.2 - Localization
  1. Load Asset - Notifier subscribes to asset aggregate
  2. Fetch Related Data - Estate name, site name, linked listings
  3. Render UI - View displays asset details
  4. User Edits - Form captures changes
  5. Save Changes - Dispatch UpdateAssetIntent to domain
  6. Reactive Update - Aggregate stream updates notifier
  7. UI Reflects Changes - Consumer widgets rebuild with new state
  1. Initialize - Load floor plan PDF and topology
  2. Setup Canvas - Render floor plan with asset markers
  3. User Interaction - Click to select assets, drag to move
  4. Multi-Selection - Hold Ctrl/Cmd to select multiple
  5. Bulk Operations - Apply changes to selected assets
  6. Publish Changes - Dispatch topology updates
  7. Real-time Sync - Other users see changes immediately
test('should update asset when changes saved', () async {
// Arrange
final container = ProviderContainer();
final notifier = container.read(assetNotifierProvider(
estateId: testEstateId,
siteId: testSiteId,
assetId: testAssetId,
).notifier);
// Act
await notifier.updateAsset({'name': 'New Name'});
// Assert
final state = container.read(assetNotifierProvider(/*...*/));
expect(state.currentAsset?.name.value, equals('New Name'));
});
testWidgets('should render asset details', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: AssetDetailView(/*...*/),
),
),
);
expect(find.text('Asset Name'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsWidgets);
});
  • ✅ Use @riverpod for new state management
  • ✅ Keep notifiers focused on a single feature/screen
  • ✅ Use ref.watch() for reactive UI updates
  • ✅ Dispatch intents to domain layer via nomosAppProvider
  • ✅ Subscribe to domain aggregates in build() method
  • ✅ Create reusable components in components/ directory
  • ✅ Organize feature code in presentation_modules/
  • ✅ Use response layouts with MediaQuery breakpoints
  • ❌ Don’t mix GetX and Riverpod in new code
  • ❌ Don’t put business logic in UI views
  • ❌ Don’t use ref.read() for reactive data that should be watched
  • ❌ Don’t create components that manage their own domain state
  • ❌ Don’t directly instantiate services (use dependency injection)
  • ❌ Don’t duplicate components across modules
  • ❌ Don’t leave subscriptions without cleanup in ref.onDispose()

After modifying notifiers or state classes:

Terminal window
# From admin_frontend directory
dart run build_runner build --delete-conflicting-outputs
# Watch mode for development
dart run build_runner watch

Routes are defined in routes/route_names.dart and use GoRouter:

Key Routes:

  • / - Home/estates list
  • /estate/:estateId - Estate overview
  • /estate/:estateId/site/:siteId - Site details
  • /estate/:estateId/site/:siteId/floor-plan - Floor plan editor
  • /estate/:estateId/site/:siteId/asset/:assetId - Asset details
  • /login - Authentication
  • /admin - Admin panel
  • /profile - User profile

Navigation is handled through notifiers:

ref.read(navigationNotifierProvider.notifier).toNamed(
Paths.ASSET_DETAIL,
parameters: {
'estateId': estateId.value,
'siteId': siteId.value,
'assetId': assetId,
},
);
  • Lazy Loading: Sub-modules load on-demand
  • Pagination: Asset lists paginate large datasets
  • Caching: Frequent queries cached in notifier state
  • Selective Subscriptions: Only watch needed aggregates
  • Debouncing: Debounce rapid form inputs before dispatch

For estates with 1000+ assets:

  1. Use filtering/search to reduce visible data
  2. Paginate asset listings
  3. Lazy-load floor plan tiles
  4. Cache computed values in state
  5. Unsubscribe from unused aggregates
@freezed
class MyState with _$MyState {
const factory MyState({
@Default(false) bool isLoading,
MyData? data,
String? error,
}) = _MyState;
}
// In notifier
state = state.copyWith(isLoading: true);
try {
final data = await fetchData();
state = state.copyWith(data: data, isLoading: false);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
// Accumulate form state in notifier
void updateFormField(String key, dynamic value) {
state = state.copyWith(
formData: {...state.formData, key: value},
);
}
// Validate and save
Future<void> submitForm() async {
if (!_validateForm(state.formData)) {
state = state.copyWith(error: 'Validation failed');
return;
}
await saveToDatabase(state.formData);
}
try {
await someOperation();
} catch (e) {
Obs.error(Obs.nomosCommand, 'Operation failed: $e');
state = state.copyWith(error: 'Operation failed');
rethrow; // Propagate if needed
}

For legacy GetX code being converted to Riverpod:

  1. Create State Class: Use @freezed for immutable state
  2. Create Notifier: Use @riverpod class extending _$NotifierName
  3. Setup Subscriptions: Use ref.onDispose() for cleanup
  4. Update Views: Replace GetView with ConsumerWidget
  5. Update Access: Change Get.find() to ref.read()/ref.watch()
  6. Run Generator: Execute build_runner build
class MyController extends GetxController {
final data = Rxn<MyData>();
void loadData() async {
data.value = await fetchData();
}
}
class MyView extends GetView<MyController> {
@override
Widget build(context) {
return Obx(() => Text(controller.data.value?.name ?? 'Loading'));
}
}
@freezed
class MyState with _$MyState {
const factory MyState({
MyData? data,
}) = _MyState;
}
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => const MyState();
Future<void> loadData() async {
state = state.copyWith(data: await fetchData());
}
}
class MyView extends ConsumerWidget {
@override
Widget build(context, ref) {
final myState = ref.watch(myNotifierProvider);
return Text(myState.data?.name ?? 'Loading');
}
}

After (Riverpod with Plain Class - Transition Pattern)

Section titled “After (Riverpod with Plain Class - Transition Pattern)”
class MyState {
final MyData? data;
MyState({this.data});
MyState copyWith({MyData? data}) {
return MyState(data: data ?? this.data);
}
}
@riverpod
class MyNotifier extends _$MyNotifier {
@override
MyState build() => MyState();
Future<void> loadData() async {
state = state.copyWith(data: await fetchData());
}
}
class MyView extends ConsumerWidget {
@override
Widget build(context, ref) {
final myState = ref.watch(myNotifierProvider);
return Text(myState.data?.name ?? 'Loading');
}
}
Terminal window
# 1. Create directory structure
mkdir -p lib/src/presentation_modules/my_feature/{controllers,views,components}
# 2. Create state class (my_feature_state.dart)
@freezed
class MyFeatureState with _$MyFeatureState {
const factory MyFeatureState({
@Default(false) bool isLoading,
}) = _MyFeatureState;
}
# 3. Create notifier (my_feature_notifier.dart)
@riverpod
class MyFeatureNotifier extends _$MyFeatureNotifier {
@override
MyFeatureState build() => const MyFeatureState();
}
# 4. Create view (my_feature_view.dart)
class MyFeatureView extends ConsumerWidget {
@override
Widget build(context, ref) {
final state = ref.watch(myFeatureNotifierProvider);
return Scaffold(body: Center(child: Text('Feature')));
}
}
# 5. Run code generation
dart run build_runner build
// 1. Add to notifier class
Future<void> myAction() async {
try {
state = state.copyWith(isLoading: true);
// Do work
state = state.copyWith(isLoading: false);
} catch (e) {
state = state.copyWith(isLoading: false);
rethrow;
}
}
// 2. Call from view
ElevatedButton(
onPressed: () => ref.read(myNotifierProvider.notifier).myAction(),
child: Text('Click'),
)
Terminal window
# Clean and rebuild
flutter clean
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
  • Ensure you’re using ref.watch() in widgets, not ref.read()
  • Verify state is immutable (@freezed or copyWith())
  • Check that copyWith() returns a new instance
  • Verify notifier methods update state, not external variables
  • Always use ref.onDispose() to cancel subscriptions
  • Don’t hold references to providers outside of widgets
  • Use ProviderContainer for testing, not global instances
  • Ensure GoRouter is properly configured in app
  • Use named routes from Paths constants
  • Verify parameters match route definitions
  • Check that route is added to GoRouter configuration