Skip to main content
LarpLand uses a hybrid state management approach combining the Provider pattern for reactive state and a singleton pattern for authentication state.

State Management Architecture

┌────────────────────────────────────────┐
│        Widget Tree                     │
└──────────┬─────────────────────────────┘

     ┌─────▼─────┐
     │ Provider  │ (CartProvider)
     └─────┬─────┘

     ┌─────▼─────┐
     │ Singleton │ (AuthSession)
     └───────────┘

Provider Pattern (CartProvider)

The shopping cart uses Flutter’s Provider package for reactive state management.

Implementation

// From lib/provider/cart_provider.dart
import 'package:flutter/material.dart';
import 'package:larpland/model/product.dart';

class CartProvider with ChangeNotifier {
  final Map<int, Product> _items = {};

  Map<int, Product> get items => {..._items};

  void addProduct(Product product) {
    if (_items.containsKey(product.id)) {
      if (_items[product.id]!.cantidadCarrito < product.cantidad) {
        _items.update(
          product.id,
          (existingProduct) => Product(
            id: existingProduct.id,
            nombre: existingProduct.nombre,
            precio: existingProduct.precio,
            cantidad: existingProduct.cantidad,
            cantidadCarrito: existingProduct.cantidadCarrito + 1,
            descripcion: existingProduct.descripcion,
            imagen: existingProduct.imagen,
            valoracionTotal: existingProduct.valoracionTotal,
            categoria: existingProduct.categoria,
          ),
        );
      } else {
        throw 'Producto fuera de stock';
      }
    } else {
      _items.putIfAbsent(
        product.id,
        () => Product(
          id: product.id,
          nombre: product.nombre,
          precio: product.precio,
          cantidad: product.cantidad,
          cantidadCarrito: 1,
          descripcion: product.descripcion,
          imagen: product.imagen,
          valoracionTotal: product.valoracionTotal,
          categoria: product.categoria,
        ),
      );
    }
    notifyListeners();
  }

  void removeProduct(int productId) {
    if (_items.containsKey(productId)) {
      if (_items[productId]!.cantidadCarrito > 1) {
        _items.update(
          productId,
          (existingProduct) => Product(
            id: existingProduct.id,
            nombre: existingProduct.nombre,
            precio: existingProduct.precio,
            cantidad: existingProduct.cantidad,
            cantidadCarrito: existingProduct.cantidadCarrito - 1,
            descripcion: existingProduct.descripcion,
            imagen: existingProduct.imagen,
            valoracionTotal: existingProduct.valoracionTotal,
            categoria: existingProduct.categoria,
          ),
        );
      } else {
        _items.remove(productId);
      }
    }
    notifyListeners();
  }

  void clearCart() {
    _items.clear();
    notifyListeners();
  }

  double get totalAmount {
    double total = 0.0;
    _items.forEach((key, product) {
      total += double.parse(product.precio) * product.cantidadCarrito;
    });
    return total;
  }

  int get totalItemsCount {
    int total = 0;
    for (final product in _items.values) {
      total += product.cantidadCarrito;
    }
    return total;
  }
}

Key Features

ChangeNotifier Mixin

  • Extends ChangeNotifier to enable reactive updates
  • Calls notifyListeners() after state changes
  • Automatically rebuilds listening widgets

Private State

  • _items map stores cart contents (product ID → Product)
  • Only exposed via getter that returns a copy
  • Prevents external mutation of internal state

Computed Properties

  • totalAmount: Calculates total cart value
  • totalItemsCount: Counts total items in cart
  • Both are getters that compute on-demand

Provider Setup

The provider is initialized at the app root:
// From lib/main.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => CartProvider(),
      child: MaterialApp(
        // ...
      ),
    );
  }
}

Consuming the Provider

Widgets access the cart state using Provider.of or context.watch:
// Read cart state
final cart = Provider.of<CartProvider>(context);
final itemCount = cart.totalItemsCount;

// Or using extension method
final cart = context.watch<CartProvider>();

// Modify cart state
cart.addProduct(product);
cart.removeProduct(productId);
cart.clearCart();
Use context.watch<CartProvider>() when you need the widget to rebuild on state changes. Use context.read<CartProvider>() when you only need to call methods without rebuilding.

Singleton Pattern (AuthSession)

Authentication state uses a static singleton for global access across the app.

Implementation

// From lib/service/auth_session.dart
import 'package:firebase_auth/firebase_auth.dart' as fb_auth;
import 'package:larpland/service/firebase_backend.dart';

class AuthSession {
  static String? token;
  static String? firebaseUid;
  static int? userId;
  static int? rol;

  static void bind({
    String? idToken,
    String? uid,
    int? sessionUserId,
    int? sessionRol,
  }) {
    token = idToken;
    firebaseUid = uid;
    userId = sessionUserId;
    rol = sessionRol;
  }

  static Future<void> syncFromFirebase() async {
    final user = fb_auth.FirebaseAuth.instance.currentUser;
    if (user == null) {
      clearLocal();
      return;
    }

    final profile = await FirebaseBackend.ensureUserProfile(firebaseUser: user);
    bind(
      idToken: await user.getIdToken(),
      uid: user.uid,
      sessionUserId: profile['id'] is int
          ? profile['id'] as int
          : int.tryParse('${profile['id'] ?? ''}'),
      sessionRol: profile['rol'] is int
          ? profile['rol'] as int
          : int.tryParse('${profile['rol'] ?? ''}'),
    );
  }

  static Future<void> signOut() async {
    try {
      await FirebaseBackend.auth.signOut();
    } finally {
      clearLocal();
    }
  }

  static void clearLocal() {
    token = null;
    firebaseUid = null;
    userId = null;
    rol = null;
  }
}

Key Features

Static Members

  • All properties and methods are static
  • No instance creation needed
  • Global access from anywhere: AuthSession.userId

Session Data

  • token: Firebase ID token for authenticated requests
  • firebaseUid: Firebase Authentication user ID
  • userId: Application-specific numeric user ID
  • rol: User role (0=customer, 1=admin)

Lifecycle Methods

syncFromFirebase()
  • Called on app startup and after login
  • Fetches current Firebase Auth user
  • Ensures Firestore user profile exists
  • Updates session with user data
signOut()
  • Signs out from Firebase Auth
  • Clears local session data
  • Always clears local state (even if Firebase call fails)
clearLocal()
  • Resets all session variables to null
  • Called on logout and when no user is authenticated

Usage Example

// Check if user is authenticated
if (AuthSession.userId != null) {
  // User is logged in
  print('User ID: ${AuthSession.userId}');
  print('Role: ${AuthSession.rol}');
}

// Access from anywhere in the app
class SomeWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final isAdmin = AuthSession.rol == 1;
    return Text(isAdmin ? 'Admin Panel' : 'User Panel');
  }
}

// Logout
await AuthSession.signOut();
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (context) => LoginScreen()),
);

Local Widget State

Some state is managed locally within widgets using StatefulWidget:
// From lib/view/home/home_screen.dart
class _HomeScreenState extends State<HomeScreen> {
  int selectedIndex = 0;
  int _eventsRefreshSignal = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: NavigationBar(
        selectedIndex: selectedIndex,
        onDestinationSelected: (value) {
          setState(() {
            selectedIndex = value;
            if (value == 1) {
              _eventsRefreshSignal++;
            }
          });
        },
        // ...
      ),
    );
  }
}
Local state is appropriate for:
  • UI-only state (selected tab, expanded sections)
  • Temporary form input
  • Animation controllers
  • State that doesn’t need to be shared

State Management Decision Tree

Choose the right state management approach:
Is this state needed across multiple screens?
├─ Yes
│  ├─ Is it authentication/user data?
│  │  └─ Use AuthSession (singleton)
│  └─ Is it reactive/frequently updated?
│     └─ Use Provider (e.g., CartProvider)
└─ No
   └─ Use local StatefulWidget state

Best Practices

Provider Pattern

Keep providers focused on a single concern (e.g., cart, not “app state”)
Always call notifyListeners() after state changes
Return copies of collections from getters to prevent mutation
Use computed getters for derived state

AuthSession Singleton

Only use for truly global, singleton state
Keep session data minimal (IDs and tokens only)
Always clear session on logout
Sync session on app startup and login

Local State

Use for UI-only state that doesn’t leave the widget
Avoid duplicating state from providers
Keep state minimal and close to where it’s used

State Persistence

Currently, cart and session state is not persisted across app restarts. Users will lose their cart contents and need to log in again.
To add persistence:
  1. Cart: Use shared_preferences or hive to save cart items
  2. Auth: Firebase Auth automatically persists user sessions
  3. AuthSession: Load user data on startup if Firebase user exists

Next Steps

Architecture

Understand the overall app architecture

Project Structure

Explore the codebase organization

Build docs developers (and LLMs) love