Navigation in Mosaic
Navigation in Mosaic works on two levels: moving between different modules (like switching from Home to Profile) and navigating within a single module (like going from a list to a detail view).
Understanding Mosaic Navigation
Think of your app like a building:
- Modules are different floors (Home floor, Profile floor, Settings floor)
- Internal navigation is moving between rooms on the same floor
- The router is like an elevator that takes you between floors
Module-Level Navigation
This is how you move between major sections of your app.
Setting Up the Router
First, you need to tell the router about your modules:
enum ModuleEnum {
home,
profile,
settings,
chat;
static ModuleEnum? fromString(String name) {
for (ModuleEnum module in ModuleEnum.values) {
if (module.name == name) return module;
}
return null;
}
}
void main() async {
// Register your modules
moduleManager.modules['home'] = HomeModule();
moduleManager.modules['profile'] = ProfileModule();
moduleManager.modules['settings'] = SettingsModule();
moduleManager.modules['chat'] = ChatModule();
// Set which module to show first
moduleManager.defaultModule = 'home';
// Initialize the router
router.init(ModuleEnum.home);
runApp(MyApp());
}
Switching Between Modules
To move from one module to another, use router.goto()
:
class HomeModule extends Module {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Column(
children: [
ElevatedButton(
onPressed: () {
// Go to the profile module
router.goto(ModuleEnum.profile);
},
child: Text('View Profile'),
),
ElevatedButton(
onPressed: () {
// Go to the settings module
router.goto(ModuleEnum.settings);
},
child: Text('Open Settings'),
),
],
),
);
}
}
Navigation History
The router keeps track of where you’ve been:
class NavigationHelper {
void showCurrentModule() {
ModuleEnum? current = router.current;
print('Currently viewing: ${current?.name}');
}
void goBackToPreviousModule() {
// The router maintains a history
router.goBack(); // This method would need to be implemented
}
}
Internal Navigation (Within Modules)
Each module can have its own navigation stack - like a pile of screens that you can add to and remove from.
Understanding the Stack
Imagine your module as a stack of cards:
- The bottom card is your main screen
- Each new screen you open gets placed on top
- When you go back, you remove the top card
Pushing New Screens
To add a new screen to your module’s stack:
class ProfileModule extends Module {
String userName = 'John Doe';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Profile')),
body: Column(
children: [
Text('Welcome, $userName!'),
ElevatedButton(
onPressed: () async {
// Push a new screen onto this module's stack
final result = await router.push<String>(
EditProfileScreen(currentName: userName)
);
// The result comes back when the screen is closed
if (result != null) {
userName = result;
logger.info('Profile updated to: $userName');
}
},
child: Text('Edit Profile'),
),
// Show any screens that were pushed onto the stack
...stack.map((screen) => Expanded(child: screen)),
],
),
);
}
}
class EditProfileScreen extends StatefulWidget {
final String currentName;
EditProfileScreen({required this.currentName});
@override
_EditProfileScreenState createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
late TextEditingController _nameController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.currentName);
}
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.all(16),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text('Edit Your Profile', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 16),
TextField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Your Name'),
),
SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
// Pop this screen and return the new name
router.pop<String>(_nameController.text);
},
child: Text('Save'),
),
TextButton(
onPressed: () {
// Pop this screen without returning data
router.pop();
},
child: Text('Cancel'),
),
],
),
],
),
),
);
}
}
Removing Screens (Popping)
To remove the top screen from your module’s stack:
// Pop without returning data
router.pop();
// Pop and return some data
router.pop<String>('This data goes back to the previous screen');
// Pop and return complex data
router.pop<Map<String, dynamic>>({
'success': true,
'message': 'Profile updated successfully',
'timestamp': DateTime.now().toIso8601String(),
});
Clearing the Entire Stack
Sometimes you want to remove all pushed screens and go back to the module’s main screen:
// Remove all pushed screens in the current module
router.clear();
Navigation with Events
You can also trigger navigation using the event system:
Setting Up Navigation Events
class NavigationEventHandler {
static void init() {
// Listen for navigation requests
events.on<String>('nav/goto_module', (context) {
String moduleName = context.data;
ModuleEnum? module = ModuleEnum.fromString(moduleName);
if (module != null) {
router.goto(module);
logger.info('Navigated to $moduleName via event');
} else {
logger.warning('Unknown module requested: $moduleName');
}
});
// Listen for internal navigation requests
events.on<Map<String, dynamic>>('nav/push_screen', (context) {
String screenType = context.data['type'];
Map<String, dynamic> params = context.data['params'] ?? {};
Widget? screen = _createScreenFromType(screenType, params);
if (screen != null) {
router.push(screen);
}
});
}
static Widget? _createScreenFromType(String type, Map<String, dynamic> params) {
switch (type) {
case 'edit_profile':
return EditProfileScreen(currentName: params['name'] ?? '');
case 'settings':
return SettingsScreen();
// Add more screen types as needed
default:
return null;
}
}
}
Using Navigation Events
class SomeModule extends Module {
void navigateToProfile() {
// Instead of calling router.goto() directly, send an event
events.emit<String>('nav/goto_module', 'profile');
}
void openEditScreen() {
events.emit<Map<String, dynamic>>('nav/push_screen', {
'type': 'edit_profile',
'params': {'name': 'Current User Name'}
});
}
}
Advanced Navigation Patterns
Conditional Navigation
Sometimes you need to check something before navigating:
class AuthenticatedNavigationModule extends Module {
bool get isUserLoggedIn => currentUser != null;
User? currentUser;
void navigateToProfile() {
if (isUserLoggedIn) {
router.goto(ModuleEnum.profile);
} else {
// Show login screen first
showLoginDialog();
}
}
void showLoginDialog() async {
final loginResult = await router.push<bool>(LoginDialog());
if (loginResult == true) {
// User successfully logged in, now go to profile
router.goto(ModuleEnum.profile);
}
}
}
Navigation with Animation Events
class AnimatedNavigationModule extends Module {
void smoothNavigateToSettings() {
// Emit animation event
events.emit<String>('ui/fade_out', 'Preparing to navigate...');
// Wait a bit for animation
Timer(Duration(milliseconds: 300), () {
router.goto(ModuleEnum.settings);
events.emit<String>('ui/fade_in', 'Welcome to Settings!');
});
}
}
Deep Linking Simulation
Handle navigation requests that might come from outside the app:
class DeepLinkHandler {
static void init() {
events.on<String>('deeplink/navigate', (context) {
String deepLink = context.data;
handleDeepLink(deepLink);
});
}
static void handleDeepLink(String link) {
// Parse deep link like: "myapp://profile/edit?name=John"
Uri uri = Uri.parse(link);
switch (uri.pathSegments.first) {
case 'profile':
router.goto(ModuleEnum.profile);
if (uri.pathSegments.length > 1 && uri.pathSegments[1] == 'edit') {
String? name = uri.queryParameters['name'];
if (name != null) {
router.push(EditProfileScreen(currentName: name));
}
}
break;
case 'settings':
router.goto(ModuleEnum.settings);
break;
default:
router.goto(ModuleEnum.home);
}
}
}
// Usage
void simulateDeepLink() {
events.emit<String>('deeplink/navigate', 'myapp://profile/edit?name=John');
}
Navigation Best Practices
1. Keep Module Navigation Simple
// ✅ Good - clear, direct navigation
void goToProfile() {
router.goto(ModuleEnum.profile);
}
// ❌ Bad - unnecessary complexity
void goToProfile() {
if (DateTime.now().hour > 12) {
events.emit('nav/afternoon_profile', '');
} else {
events.emit('nav/morning_profile', '');
}
}
2. Always Handle Navigation Results
// ✅ Good - handle the result
void editUserProfile() async {
final result = await router.push<Map<String, dynamic>>(EditProfileScreen());
if (result != null && result['success'] == true) {
showSuccessMessage(result['message']);
refreshUserData();
}
}
// ❌ Bad - ignore the result
void editUserProfile() {
router.push(EditProfileScreen()); // Result is ignored
}
3. Use Clear Screen Names
// ✅ Good - descriptive screen classes
class UserProfileEditScreen extends StatelessWidget { }
class OrderHistoryListScreen extends StatelessWidget { }
class ProductDetailScreen extends StatelessWidget { }
// ❌ Bad - unclear names
class Screen1 extends StatelessWidget { }
class PageThing extends StatelessWidget { }
class SomeWidget extends StatelessWidget { }
4. Limit Stack Depth
// ✅ Good - reasonable stack depth
class ShoppingModule extends Module {
void navigateToProductDetail(Product product) {
router.push(ProductDetailScreen(product: product));
}
}
// ❌ Bad - too many nested screens
void navigateDeep() {
router.push(Screen1());
router.push(Screen2());
router.push(Screen3());
router.push(Screen4()); // User might get lost
}
Debugging Navigation
Log Navigation Events
class NavigationDebugger {
static void init() {
// Log all navigation events
events.on<dynamic>('nav/*', (context) {
logger.info('Navigation: ${context.name} - ${context.data}');
});
// Track module changes
ModuleEnum? lastModule;
Timer.periodic(Duration(seconds: 1), (_) {
ModuleEnum? current = router.current;
if (current != lastModule) {
logger.info('Module changed: ${lastModule?.name} → ${current?.name}');
lastModule = current;
}
});
}
}
Track Stack Sizes
class StackMonitor {
static void logStackSizes() {
for (var entry in moduleManager.modules.entries) {
String moduleName = entry.key;
Module module = entry.value;
int stackSize = module.stack.length;
if (stackSize > 0) {
logger.debug('$moduleName has $stackSize screens in stack');
}
}
}
}
Testing Navigation
Test your navigation logic:
void main() {
group('Navigation Tests', () {
setUp(() {
// Set up test modules
moduleManager.modules['home'] = HomeModule();
moduleManager.modules['profile'] = ProfileModule();
router.init(ModuleEnum.home);
});
test('should navigate between modules', () {
expect(router.current, equals(ModuleEnum.home));
router.goto(ModuleEnum.profile);
expect(router.current, equals(ModuleEnum.profile));
});
test('should handle internal navigation stack', () async {
HomeModule homeModule = moduleManager.modules['home'] as HomeModule;
expect(homeModule.stack.length, equals(0));
final future = homeModule.push<String>(Text('Test Screen'));
expect(homeModule.stack.length, equals(1));
homeModule.pop<String>('test result');
final result = await future;
expect(result, equals('test result'));
expect(homeModule.stack.length, equals(0));
});
});
}
Navigation in Mosaic gives you the power to create smooth user experiences while keeping your code organized. Start with simple module-to-module navigation, then gradually add internal navigation as your app grows more complex.