Understanding Modules
Modules are the heart of Mosaic. Think of them as independent pieces of your app that can work alone but also connect with other pieces when needed.
What is a Module?
A module is like a mini-app within your app. Each module:
- Has its own screen(s) - what users see and interact with
- Handles one main feature - like user profiles, settings, or a shopping cart
- Can talk to other modules - but doesn’t need to know about them directly
- Manages its own state - keeps track of its own data and user interactions
Real-World Example
Imagine you’re building a music app. You might have these modules:
- Player Module - controls music playback (play, pause, skip)
- Library Module - shows all the user’s music
- Search Module - finds new songs and artists
- Profile Module - user account and preferences
Each module focuses on just one thing, making your code easier to understand and maintain.
Module Lifecycle
Every module goes through stages, like a person’s life:
- Birth - Module is created
- Setup - Module prepares itself (
onInit()
) - Active - Module is being used (
onActive()
) - Inactive - Module is hidden but still alive
- Death - Module is destroyed and cleaned up
Understanding this lifecycle helps you know when to set things up and when to clean them up.
Creating Your First Module
Let’s build a simple profile module step by step:
Step 1: Basic Structure
import 'package:flutter/material.dart';
import 'package:mosaic/mosaic.dart';
class ProfileModule extends Module {
// Give your module a name
ProfileModule() : super(name: 'profile');
@override
Widget build(BuildContext context) {
// This is what users see
return Scaffold(
appBar: AppBar(title: Text('My Profile')),
body: Text('Profile content goes here'),
);
}
}
That’s it! You have a basic module that shows a screen.
Step 2: Add Initialization
class ProfileModule extends Module {
ProfileModule() : super(name: 'profile');
String? userName; // Store user data
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('My Profile')),
body: Center(
child: Column(
children: [
Text('Welcome, ${userName ?? "Guest"}!'),
ElevatedButton(
onPressed: () {
// Tell other modules something happened
events.emit<String>('profile/viewed', userName ?? 'guest');
},
child: Text('View Profile'),
),
],
),
),
);
}
@override
Future<void> onInit() async {
// This runs once when the module is set up
logger.info('Profile module is initializing');
// Listen for user login events
events.on<String>('auth/user_logged_in', (context) {
userName = context.data; // Save the user's name
logger.info('User logged in: $userName');
});
// Load any saved user data
await loadUserData();
}
Future<void> loadUserData() async {
// Simulate loading user data
await Future.delayed(Duration(seconds: 1));
logger.info('User data loaded');
}
}
Step 3: Handle Activation
class ProfileModule extends Module {
ProfileModule() : super(name: 'profile');
String? userName;
int viewCount = 0; // Track how many times profile was viewed
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('My Profile')),
body: Center(
child: Column(
children: [
Text('Welcome, ${userName ?? "Guest"}!'),
Text('Profile views: $viewCount'),
ElevatedButton(
onPressed: () {
events.emit<String>('profile/viewed', userName ?? 'guest');
},
child: Text('View Profile'),
),
],
),
),
);
}
@override
Future<void> onInit() async {
logger.info('Profile module is initializing');
events.on<String>('auth/user_logged_in', (context) {
userName = context.data;
});
await loadUserData();
}
@override
void onActive() {
// This runs every time user switches to this module
viewCount++;
logger.info('Profile module is now active (view #$viewCount)');
// Tell analytics about the page view
events.emit<Map<String, dynamic>>('analytics/page_view', {
'page': 'profile',
'user': userName,
'timestamp': DateTime.now().toIso8601String(),
});
}
Future<void> loadUserData() async {
await Future.delayed(Duration(seconds: 1));
logger.info('User data loaded');
}
}
Module Communication
Modules talk to each other using events, not by calling each other directly. This keeps them independent.
Sending Messages (Emitting Events)
// In any module, you can send a message:
events.emit<String>('user/profile_updated', 'john_doe');
events.emit<bool>('settings/theme_changed', true);
events.emit<Map<String, dynamic>>('order/completed', {
'orderId': '12345',
'amount': 99.99,
'items': ['coffee', 'muffin']
});
Listening for Messages
@override
Future<void> onInit() async {
// Listen for specific events
events.on<String>('user/profile_updated', (context) {
String userId = context.data;
refreshUserProfile(userId);
});
// Listen for any user-related events
events.on<dynamic>('user/*', (context) {
logger.info('User event happened: ${context.name}');
});
// Listen for all events everywhere (useful for logging)
events.on<dynamic>('*/#', (context) {
logger.debug('Event: ${context.name} with data: ${context.data}');
});
}
Internal Navigation
Each module can have its own navigation stack - like a pile of screens within that module.
class ProfileModule extends Module {
// ... other code ...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Profile')),
body: Column(
children: [
Text('Main profile screen'),
ElevatedButton(
onPressed: () async {
// Push a new screen onto this module's stack
final result = await router.push<String>(EditProfileScreen());
if (result != null) {
logger.info('Profile updated: $result');
}
},
child: Text('Edit Profile'),
),
// Show any screens pushed onto the stack
...stack,
],
),
);
}
}
class EditProfileScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text('Edit Profile Screen'),
TextField(decoration: InputDecoration(labelText: 'Name')),
Row(
children: [
ElevatedButton(
onPressed: () {
// Pop this screen and return data
router.pop<String>('Profile saved successfully');
},
child: Text('Save'),
),
TextButton(
onPressed: () {
// Pop without returning data
router.pop();
},
child: Text('Cancel'),
),
],
),
],
),
),
);
}
}
Module States
Modules can be in different states:
Active vs Inactive
- Active - module is currently being shown to the user
- Inactive - module exists but is not visible (user switched to another module)
Enabled vs Disabled
- Enabled - module is available and can be used
- Disabled - module is turned off (useful for feature flags)
// Check if a module is active
if (moduleManager.current?.name == 'profile') {
print('User is viewing profile');
}
// Enable/disable modules
moduleManager.modules['experimental_feature']?.active = false;
Best Practices for Modules
1. Keep Modules Focused
Each module should do one main thing well:
// ✅ Good - focused on one feature
class ChatModule extends Module { /* handles messaging */ }
class PaymentModule extends Module { /* handles payments */ }
// ❌ Bad - doing too many things
class EverythingModule extends Module { /* handles chat, payments, profiles, etc. */ }
2. Use Clear Names
Module names should immediately tell you what they do:
// ✅ Good - clear purpose
enum ModuleEnum {
userProfile,
shoppingCart,
messageCenter,
accountSettings
}
// ❌ Bad - unclear purpose
enum ModuleEnum {
module1,
stuff,
misc,
things
}
3. Clean Up After Yourself
Always clean up when your module is destroyed:
@override
void onDestroy() {
// Cancel any timers
_timer?.cancel();
// Close any open connections
_websocket?.close();
// Remove event listeners (Mosaic does this automatically, but you can be explicit)
events.off('user/login', _handleLogin);
super.onDestroy();
}
4. Handle Errors Gracefully
Don’t let one module crash the whole app:
@override
Future<void> onInit() async {
try {
await loadUserData();
} catch (error) {
logger.error('Failed to load user data: $error');
// Show error state instead of crashing
showErrorState = true;
}
}
Testing Modules
Test your modules in isolation:
void main() {
group('ProfileModule Tests', () {
late ProfileModule module;
setUp(() {
module = ProfileModule();
});
test('should initialize correctly', () async {
await module.onInit();
expect(module.name, equals('profile'));
});
test('should handle user login event', () async {
await module.onInit();
// Simulate user login
events.emit<String>('auth/user_logged_in', 'john_doe');
// Give events time to process
await Future.delayed(Duration.zero);
expect(module.userName, equals('john_doe'));
});
});
}
Common Module Patterns
1. Data Loading Module
class DataModule extends Module {
bool isLoading = false;
String? errorMessage;
List<dynamic> data = [];
@override
Future<void> onInit() async {
await loadData();
}
Future<void> loadData() async {
isLoading = true;
errorMessage = null;
try {
data = await apiService.fetchData();
} catch (error) {
errorMessage = error.toString();
} finally {
isLoading = false;
}
}
}
2. Settings Module
class SettingsModule extends Module {
Map<String, dynamic> settings = {};
@override
Future<void> onInit() async {
settings = await loadSettings();
events.on<Map<String, dynamic>>('settings/update', (context) {
updateSetting(context.data);
});
}
void updateSetting(Map<String, dynamic> newSetting) {
settings.addAll(newSetting);
saveSettings(settings);
events.emit<Map<String, dynamic>>('settings/changed', settings);
}
}
3. Authentication Module
class AuthModule extends Module {
User? currentUser;
bool isLoggedIn = false;
Future<void> login(String username, String password) async {
try {
currentUser = await authService.login(username, password);
isLoggedIn = true;
events.emit<String>('auth/user_logged_in', currentUser!.id);
router.goto(ModuleEnum.home);
} catch (error) {
events.emit<String>('auth/login_failed', error.toString());
}
}
void logout() {
currentUser = null;
isLoggedIn = false;
events.emit<void>('auth/user_logged_out', null);
router.goto(ModuleEnum.login);
}
}
Modules are the building blocks of your Mosaic app. Start simple, keep them focused, and use events to make them work together. As you get comfortable with basic modules, you can explore more advanced features like UI injection and complex navigation patterns.