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'),
          ),
        ],
      ),
    );
  }
}

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();

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);
    }
  }
}
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');
}

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.