Layouts & Views
Flexible layout system for creating multiple views for your entities
Layouts and Views System
The Vyuh Entity System provides a powerful and flexible layout system that allows you to create multiple views for your entities. This guide covers all aspects of creating, configuring, and using layouts.
Layout System Overview
The layout system is built on these core concepts:
- Layout Descriptors - Define available layouts for each view type
- Layout Implementations - Actual widget implementations
- Layout Selection - Logic to choose appropriate layouts
- Layout Configuration - Customization options
EntityLayoutDescriptor
The EntityLayoutDescriptor organizes layouts by view type:
class EntityLayoutDescriptor<T extends EntityBase> {
final List<EntityLayout<T>> list; // List view layouts
final List<EntityLayout<T>> details; // Detail view layouts
final List<EntityLayout<T>>? summary; // Card/summary layouts
final List<EntityLayout<T>>? dashboard; // Dashboard widgets
final List<EntityLayout<T>>? analytics; // Analytics views
}Built-in Layouts
TableListLayout
The most common list layout for desktop views:
TableListLayout<User>(
title: 'Users',
columns: [
TableColumn<User>(
key: 'name',
label: 'Name',
getValue: (user) => user.name,
width: 200,
sortable: true,
searchable: true,
buildCell: (context, user) => Row(
children: [
CircleAvatar(
radius: 16,
child: Text(user.name[0]),
),
const SizedBox(width: 8),
Expanded(child: Text(user.name)),
],
),
),
TableColumn<User>(
key: 'email',
label: 'Email',
getValue: (user) => user.email,
sortable: true,
copyable: true,
),
TableColumn<User>(
key: 'status',
label: 'Status',
getValue: (user) => user.isActive ? 'Active' : 'Inactive',
width: 100,
buildCell: (context, user) => Chip(
label: Text(user.isActive ? 'Active' : 'Inactive'),
backgroundColor: user.isActive
? Colors.green.shade100
: Colors.grey.shade300,
labelStyle: TextStyle(
color: user.isActive
? Colors.green.shade800
: Colors.grey.shade700,
),
),
),
TableColumn<User>(
key: 'lastLogin',
label: 'Last Login',
getValue: (user) => user.lastLogin?.toLocal().toString() ?? 'Never',
sortable: true,
formatter: (value) {
if (value == 'Never') return value;
final date = DateTime.parse(value);
return timeago.format(date);
},
),
],
// Row actions
onRowTap: (context, user) {
context.go('/users/${user.id}');
},
onRowDoubleTap: (context, user) {
context.go('/users/${user.id}/edit');
},
rowActions: [
RowAction(
icon: Icons.edit,
tooltip: 'Edit',
onTap: (context, user) => context.go('/users/${user.id}/edit'),
),
RowAction(
icon: Icons.delete,
tooltip: 'Delete',
onTap: (context, user) async {
final confirmed = await showDeleteConfirmation(context);
if (confirmed) {
await UserApi().delete(user.id);
}
},
),
],
// Bulk actions
bulkActions: [
BulkAction(
label: 'Activate',
icon: Icons.check_circle,
onTap: (context, users) async {
for (final user in users) {
await UserApi().update(
user.id,
user.copyWith(isActive: true),
);
}
},
),
BulkAction(
label: 'Export',
icon: Icons.download,
onTap: (context, users) async {
await exportUsers(users);
},
),
],
// Filtering
filters: [
Filter<User>(
key: 'status',
label: 'Status',
options: [
FilterOption(value: 'active', label: 'Active'),
FilterOption(value: 'inactive', label: 'Inactive'),
],
apply: (users, value) {
if (value == 'active') {
return users.where((u) => u.isActive).toList();
} else if (value == 'inactive') {
return users.where((u) => !u.isActive).toList();
}
return users;
},
),
Filter<User>(
key: 'role',
label: 'Role',
options: [
FilterOption(value: 'admin', label: 'Admin'),
FilterOption(value: 'manager', label: 'Manager'),
FilterOption(value: 'user', label: 'User'),
],
apply: (users, value) {
return users.where((u) => u.role == value).toList();
},
),
],
// Grouping
groupBy: GroupByConfig<User>(
options: [
GroupByOption(
key: 'department',
label: 'Department',
getGroupKey: (user) => user.department,
),
GroupByOption(
key: 'location',
label: 'Location',
getGroupKey: (user) => user.location,
),
],
),
// Empty state
emptyStateBuilder: (context) => EmptyState(
icon: Icons.people_outline,
title: 'No users found',
subtitle: 'Start by adding your first user',
action: ElevatedButton.icon(
onPressed: () => context.go('/users/new'),
icon: const Icon(Icons.add),
label: const Text('Add User'),
),
),
// Advanced options
showRowNumbers: true,
alternateRowColors: true,
stickyHeader: true,
resizableColumns: true,
reorderableColumns: true,
exportable: true,
defaultSort: SortConfig(column: 'name', ascending: true),
pageSize: 25,
pageSizeOptions: [10, 25, 50, 100],
)GridListLayout
Card-based layout for visual entities:
GridListLayout<Equipment>(
title: 'Equipment',
crossAxisCount: 3,
aspectRatio: 1.2,
spacing: 16,
buildCard: (context, equipment) => EquipmentCard(
equipment: equipment,
onTap: () => context.go('/equipment/${equipment.id}'),
showActions: true,
),
// Responsive grid
responsive: ResponsiveGridConfig(
mobile: 1,
tablet: 2,
desktop: 3,
widescreen: 4,
),
// Card actions
cardActions: [
CardAction(
icon: Icons.qr_code,
tooltip: 'Show QR Code',
onTap: (context, equipment) => showQrCode(context, equipment),
),
],
// Loading state
loadingBuilder: (context) => GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: 6,
itemBuilder: (context, index) => const ShimmerCard(),
),
)Custom List Layouts
Create specialized layouts for specific needs:
class TimelineListLayout<T extends EntityBase> extends EntityLayout<T> {
final String Function(T) getTimestamp;
final Widget Function(BuildContext, T) buildContent;
final String Function(T)? getTitle;
final IconData Function(T)? getIcon;
@override
Widget build(BuildContext context, EntityListViewState<T> state) {
final groupedByDate = groupBy(
state.filteredEntities,
(entity) => DateFormat('yyyy-MM-dd').format(
DateTime.parse(getTimestamp(entity)),
),
);
return ListView.builder(
itemCount: groupedByDate.length,
itemBuilder: (context, index) {
final date = groupedByDate.keys.elementAt(index);
final entities = groupedByDate[date]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date header
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_formatDate(date),
style: Theme.of(context).textTheme.titleMedium,
),
),
// Timeline items
...entities.map((entity) => TimelineItem(
timestamp: getTimestamp(entity),
title: getTitle?.call(entity),
icon: getIcon?.call(entity),
content: buildContent(context, entity),
onTap: () => _handleEntityTap(context, entity),
)),
],
);
},
);
}
}Detail View Layouts
Standard Detail Layout
class StandardDetailLayout<T extends EntityBase> extends EntityLayout<T> {
final List<DetailSection<T>> sections;
final Widget Function(BuildContext, T)? headerBuilder;
final List<EntityAction<T>>? actions;
@override
Widget build(BuildContext context, T entity) {
return Scaffold(
body: CustomScrollView(
slivers: [
// Header
if (headerBuilder != null)
SliverToBoxAdapter(
child: headerBuilder!(context, entity),
),
// Action bar
if (actions != null && actions!.isNotEmpty)
SliverToBoxAdapter(
child: ActionBar(
actions: actions!,
entity: entity,
),
),
// Content sections
...sections.map((section) => SliverToBoxAdapter(
child: DetailSectionWidget(
section: section,
entity: entity,
),
)),
],
),
);
}
}
// Usage
StandardDetailLayout<User>(
headerBuilder: (context, user) => UserHeader(user: user),
sections: [
DetailSection(
title: 'Personal Information',
icon: Icons.person,
fields: [
DetailField(
label: 'Full Name',
getValue: (user) => user.name,
),
DetailField(
label: 'Email',
getValue: (user) => user.email,
copyable: true,
),
DetailField(
label: 'Phone',
getValue: (user) => user.phone ?? 'Not provided',
action: DetailFieldAction(
icon: Icons.phone,
onTap: (context, value) => launchPhone(value),
),
),
],
),
DetailSection(
title: 'Account Information',
icon: Icons.security,
collapsible: true,
initiallyExpanded: false,
fields: [
DetailField(
label: 'Role',
getValue: (user) => user.role.toUpperCase(),
builder: (context, value) => Chip(
label: Text(value),
backgroundColor: _getRoleColor(value),
),
),
DetailField(
label: 'Status',
getValue: (user) => user.isActive ? 'Active' : 'Inactive',
builder: (context, value) => Row(
children: [
Icon(
value == 'Active' ? Icons.check_circle : Icons.cancel,
color: value == 'Active' ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 4),
Text(value),
],
),
),
],
),
],
actions: [
EntityAction(
label: 'Edit',
icon: Icons.edit,
onTap: (context, [user]) => context.go('/users/${user.id}/edit'),
),
EntityAction(
label: 'Reset Password',
icon: Icons.lock_reset,
onTap: (context, [user]) => resetPassword(user),
),
],
)Tabbed Detail Layout
class TabbedDetailLayout<T extends EntityBase> extends EntityLayout<T> {
final List<DetailTab<T>> tabs;
final Widget Function(BuildContext, T)? headerBuilder;
@override
Widget build(BuildContext context, T entity) {
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: Text(_getTitle(entity)),
bottom: TabBar(
tabs: tabs.map((tab) => Tab(
text: tab.label,
icon: tab.icon != null ? Icon(tab.icon) : null,
)).toList(),
),
),
body: Column(
children: [
if (headerBuilder != null) headerBuilder!(context, entity),
Expanded(
child: TabBarView(
children: tabs.map((tab) => tab.builder(context, entity)).toList(),
),
),
],
),
),
);
}
}
// Usage
TabbedDetailLayout<Location>(
tabs: [
DetailTab(
label: 'Overview',
icon: Icons.info,
builder: (context, location) => LocationOverview(location: location),
),
DetailTab(
label: 'Equipment',
icon: Icons.devices,
builder: (context, location) => LocationEquipmentList(
locationId: location.id,
),
),
DetailTab(
label: 'Users',
icon: Icons.people,
builder: (context, location) => LocationUsersList(
locationId: location.id,
),
),
DetailTab(
label: 'Activity',
icon: Icons.timeline,
builder: (context, location) => ActivityTimeline(
entityType: 'locations',
entityId: location.id,
),
),
],
)Dashboard Layouts
Dashboard layouts contribute widgets to the main dashboard:
class EntityDashboardWidget<T extends EntityBase> extends EntityLayout<T> {
final String title;
final IconData icon;
final int gridWidth;
final int gridHeight;
@override
Widget build(BuildContext context, DashboardState state) {
return DashboardCard(
title: title,
icon: icon,
gridWidth: gridWidth,
gridHeight: gridHeight,
content: _buildContent(context, state),
);
}
}
// Statistics widget
class UserStatsWidget extends EntityDashboardWidget<User> {
UserStatsWidget() : super(
title: 'User Statistics',
icon: Icons.people,
gridWidth: 2,
gridHeight: 1,
);
@override
Widget _buildContent(BuildContext context, DashboardState state) {
return FutureBuilder<UserStats>(
future: UserApi().getStatistics(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const LoadingIndicator();
}
final stats = snapshot.data!;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_StatItem(
label: 'Total Users',
value: stats.total.toString(),
icon: Icons.people,
color: Colors.blue,
),
_StatItem(
label: 'Active',
value: stats.active.toString(),
icon: Icons.check_circle,
color: Colors.green,
),
_StatItem(
label: 'New This Month',
value: stats.newThisMonth.toString(),
icon: Icons.trending_up,
color: Colors.orange,
),
],
);
},
);
}
}
// Chart widget
class EquipmentUtilizationChart extends EntityDashboardWidget<Equipment> {
EquipmentUtilizationChart() : super(
title: 'Equipment Utilization',
icon: Icons.bar_chart,
gridWidth: 3,
gridHeight: 2,
);
@override
Widget _buildContent(BuildContext context, DashboardState state) {
return FutureBuilder<List<UtilizationData>>(
future: EquipmentApi().getUtilizationData(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const LoadingIndicator();
}
return UtilizationChart(
data: snapshot.data!,
interactive: true,
showLegend: true,
);
},
);
}
}Layout Selection
The system automatically selects appropriate layouts based on context:
class LayoutSelector<T extends EntityBase> {
final List<EntityLayout<T>> layouts;
EntityLayout<T> selectLayout(BuildContext context) {
// Check device type
final isDesktop = MediaQuery.of(context).size.width > 1200;
final isTablet = MediaQuery.of(context).size.width > 600;
// Check user preferences
final preferences = context.read<UserPreferences>();
final preferredLayout = preferences.getLayoutPreference(T.toString());
// Find matching layout
for (final layout in layouts) {
if (layout.canHandle(context)) {
if (preferredLayout != null &&
layout.runtimeType.toString() == preferredLayout) {
return layout;
}
if (isDesktop && layout is TableListLayout) {
return layout;
}
if (!isDesktop && layout is GridListLayout) {
return layout;
}
}
}
// Return first available layout
return layouts.first;
}
}Custom Layout Implementation
Create completely custom layouts:
class KanbanBoardLayout<T extends EntityBase> extends EntityLayout<T> {
final List<KanbanColumn<T>> columns;
final String Function(T) getColumnKey;
final bool allowDragDrop;
@override
Widget build(BuildContext context, EntityListViewState<T> state) {
final groupedEntities = groupBy(
state.filteredEntities,
getColumnKey,
);
return KanbanBoard(
columns: columns.map((column) => KanbanColumnWidget(
column: column,
entities: groupedEntities[column.key] ?? [],
onDrop: allowDragDrop ? (entity, newColumn) {
_handleDrop(context, entity, newColumn);
} : null,
)).toList(),
);
}
void _handleDrop(BuildContext context, T entity, String newColumn) {
// Update entity with new column value
final updated = _updateEntityColumn(entity, newColumn);
context.read<EntityProvider<T>>().update(entity.id, updated);
}
}
// Usage for task management
KanbanBoardLayout<Task>(
columns: [
KanbanColumn(key: 'todo', label: 'To Do', color: Colors.grey),
KanbanColumn(key: 'in_progress', label: 'In Progress', color: Colors.blue),
KanbanColumn(key: 'review', label: 'Review', color: Colors.orange),
KanbanColumn(key: 'done', label: 'Done', color: Colors.green),
],
getColumnKey: (task) => task.status,
allowDragDrop: true,
)Responsive Layouts
Build layouts that adapt to screen size:
class ResponsiveEntityLayout<T extends EntityBase> extends EntityLayout<T> {
final EntityLayout<T> mobile;
final EntityLayout<T> tablet;
final EntityLayout<T> desktop;
@override
Widget build(BuildContext context, dynamic state) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return mobile.build(context, state);
} else if (constraints.maxWidth < 1200) {
return tablet.build(context, state);
} else {
return desktop.build(context, state);
}
},
);
}
}
// Usage
ResponsiveEntityLayout<Product>(
mobile: ProductListLayout(), // Simple list
tablet: ProductGridLayout(), // 2-column grid
desktop: ProductTableLayout(), // Full table
)Layout Composition
Combine multiple layouts:
class CompositeLayout<T extends EntityBase> extends EntityLayout<T> {
final List<LayoutComponent<T>> components;
@override
Widget build(BuildContext context, EntityListViewState<T> state) {
return Column(
children: [
// Summary bar
if (components.any((c) => c is SummaryComponent))
SummaryBar(entities: state.entities),
// Main content
Expanded(
child: Row(
children: [
// Sidebar filters
if (components.any((c) => c is FilterSidebar))
SizedBox(
width: 250,
child: FilterSidebar(
onFiltersChanged: state.updateFilters,
),
),
// Main view
Expanded(
child: _buildMainView(context, state),
),
],
),
),
],
);
}
}Performance Optimization
Virtual Scrolling
For large datasets:
class VirtualizedTableLayout<T extends EntityBase> extends TableListLayout<T> {
@override
Widget buildTable(BuildContext context, List<T> entities) {
return VirtualizedDataTable(
rowCount: entities.length,
rowHeight: 48,
columns: columns,
rowBuilder: (context, index) {
final entity = entities[index];
return DataRow(
cells: columns.map((column) => DataCell(
column.buildCell?.call(context, entity) ??
Text(column.getValue(entity)),
)).toList(),
);
},
);
}
}Lazy Loading
Load data as needed:
class LazyLoadLayout<T extends EntityBase> extends EntityLayout<T> {
@override
Widget build(BuildContext context, EntityListViewState<T> state) {
return LazyLoadScrollView(
onEndOfPage: () => state.loadMore(),
child: ListView.builder(
itemCount: state.entities.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.entities.length) {
return const LoadingIndicator();
}
return EntityListItem(entity: state.entities[index]);
},
),
);
}
}EntityView Widget
The EntityView widget provides a unified way to handle loading, error, empty, and success states for entity data fetching and display. It uses MobX internally to manage state transitions.
Basic Usage
// With Future
EntityView<List<Equipment>>(
future: api.getEquipment(),
builder: (context, items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => EquipmentTile(equipment: items[index]),
),
)
// With Stream
EntityView<User>(
stream: userStream,
builder: (context, user) => UserDetailView(user: user),
)
// With Direct Data (useful with MobX observables)
Observer(
builder: (_) => EntityView<List<Task>>(
data: taskStore.tasks,
builder: (context, tasks) => TaskBoard(tasks: tasks),
),
)Custom States
EntityView<AnalyticsData>(
future: fetchAnalytics(),
title: 'Sales Analytics',
// Custom loading widget
loadingWidget: ShimmerLoading(
child: AnalyticsChartSkeleton(),
),
// Custom error builder
errorBuilder: (context, error) => ErrorCard(
title: 'Failed to load analytics',
error: error,
actions: [
TextButton(
onPressed: () => refetch(),
child: const Text('Retry'),
),
],
),
// Custom empty widget
emptyWidget: EmptyStateIllustration(
image: 'assets/no_data.svg',
title: 'No analytics data',
subtitle: 'Data will appear once sales are recorded',
),
// Success state builder
builder: (context, data) => AnalyticsChart(data: data),
)Empty Detection
The widget automatically detects empty states for common types:
EntityView<Map<String, dynamic>>(
future: api.getStats(),
builder: (context, stats) => StatsGrid(stats: stats),
// Automatically shows empty state if map is empty
)
// Custom empty detection
EntityView<CustomData>(
future: api.getData(),
isEmpty: (data) => data.items.isEmpty && data.total == 0,
builder: (context, data) => CustomDataView(data: data),
)With Entity Types
When used with entity types that extend EntityBase, the widget automatically uses typed state widgets:
EntityView<List<User>>(
future: userApi.getAll(),
showEntityContext: true, // Shows entity icon and name in loading state
showCreateButton: true, // Shows create button in empty state
builder: (context, users) => UserTable(users: users),
)Retry Functionality
EntityView<ServerStatus>(
future: checkServerStatus(),
onRetry: () {
// Custom retry logic
setState(() {
_statusFuture = checkServerStatus();
});
},
showRetryButton: true,
builder: (context, status) => ServerStatusCard(status: status),
)Complex Example
class EquipmentDashboard extends StatefulWidget {
@override
State<EquipmentDashboard> createState() => _EquipmentDashboardState();
}
class _EquipmentDashboardState extends State<EquipmentDashboard> {
late Future<DashboardData> _dashboardFuture;
@override
void initState() {
super.initState();
_dashboardFuture = _loadDashboard();
}
Future<DashboardData> _loadDashboard() async {
final api = vyuh.di.get<EquipmentApi>();
final results = await Future.wait([
api.getStats(),
api.getRecentActivity(),
api.getMaintenanceSchedule(),
]);
return DashboardData(
stats: results[0] as EquipmentStats,
activities: results[1] as List<Activity>,
maintenance: results[2] as List<MaintenanceItem>,
);
}
@override
Widget build(BuildContext context) {
return EntityView<DashboardData>(
future: _dashboardFuture,
title: 'Equipment Dashboard',
loadingWidget: DashboardSkeleton(),
errorBuilder: (context, error) => DashboardError(
error: error,
onRetry: () {
setState(() {
_dashboardFuture = _loadDashboard();
});
},
),
isEmpty: (data) => data.stats.totalEquipment == 0,
emptyWidget: EmptyDashboard(
onGetStarted: () => context.go('/equipment/new'),
),
builder: (context, data) => Column(
children: [
StatsRow(stats: data.stats),
const SizedBox(height: 24),
Expanded(
child: Row(
children: [
Expanded(
flex: 2,
child: ActivityFeed(activities: data.activities),
),
const SizedBox(width: 16),
Expanded(
child: MaintenanceCalendar(items: data.maintenance),
),
],
),
),
],
),
);
}
}Migration Guide
To migrate existing FutureBuilder/manual state management to EntityView:
Before:
FutureBuilder<List<Equipment>>(
future: _loadEquipment(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
final equipment = snapshot.data ?? [];
if (equipment.isEmpty) {
return const Center(
child: Text('No equipment found'),
);
}
return EquipmentList(equipment: equipment);
},
)After:
EntityView<List<Equipment>>(
future: _loadEquipment(),
builder: (context, equipment) => EquipmentList(equipment: equipment),
)Manual State Management Before:
class _EquipmentViewState extends State<EquipmentView> {
final _equipment = ObservableList<Equipment>();
final _isLoading = Observable(true);
final _error = Observable<String?>(null);
@override
void initState() {
super.initState();
_loadEquipment();
}
Future<void> _loadEquipment() async {
try {
runInAction(() {
_isLoading.value = true;
_error.value = null;
});
final data = await api.getEquipment();
runInAction(() {
_equipment.clear();
_equipment.addAll(data);
_isLoading.value = false;
});
} catch (e) {
runInAction(() {
_error.value = e.toString();
_isLoading.value = false;
});
}
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) {
if (_isLoading.value) {
return const LoadingState();
}
if (_error.value != null) {
return ErrorState(error: _error.value);
}
if (_equipment.isEmpty) {
return const EmptyState();
}
return EquipmentList(equipment: _equipment);
},
);
}
}After:
class EquipmentView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return EntityView<List<Equipment>>(
future: api.getEquipment(),
builder: (context, equipment) => EquipmentList(equipment: equipment),
);
}
}Best Practices
- Multiple Layouts - Provide different layouts for different use cases
- Responsive Design - Ensure layouts work on all screen sizes
- Performance - Use virtualization for large datasets
- Accessibility - Include proper labels and navigation
- Consistency - Follow platform design guidelines
- Customization - Allow users to choose preferred layouts
- Empty States - Always handle empty data gracefully
- Use EntityView - Prefer EntityView over manual state management for consistency
Next: Forms and Validation - Complete guide to form management