ice_storage

Librería de Flutter para gestión de datos locales de forma unificada, segura y reactiva. Permite manejar autenticación, preferencias de usuario, datos cifrados, caché de imágenes/archivos y copias de respaldo. Incluye un Firestore Offline Gateway que simplifica la sincronización con Firebase, ofreciendo soporte offline-first para que las aplicaciones se mantengan rápidas y funcionales aun sin conexión. Todo con una API consistente, encriptación opcional y señales reactivas en tiempo real.

Homepage: icedigital.pe
Desarrollado por: iCe-Digital

Instalación

dependencies:
  ice_storage: ^1.5.0

main.dart:

import 'package:flutter/material.dart';
import 'package:ice_storage/ice_storage.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await IceStorage.init();
  runApp(MyApp());
}

Guía rápida de uso

Almacenamiento cifrado (claves, credenciales, etc.)

// Guardar clave privada
await IceStorage.instance.secure.write('private_key', 'RSA2048_private_key_xyz');

// Leer clave privada
String? privateKey = await IceStorage.instance.secure.read<String>('private_key');

// Observar cambios (reactivo)
StreamBuilder<String?>(
  stream: IceStorage.instance.secure.watch<String>('private_key'),
  builder: (context, snapshot) {
    return Text('Key: ${snapshot.data != null ? '***secured***' : 'No key'}');
  },
);

// Eliminar clave privada
await IceStorage.instance.secure.delete('private_key');

Autenticación (MFA)

// Guardar sesión
await IceStorage.instance.auth.save(
  uid: 'user123',
  token: 'jwt_token',
  role: 'admin', // opcional
  custom: {'deviceId': 'SM-A528B'}, // opcional
);

// Guardar código MFA con TTL
await IceStorage.instance.auth.saveMfaCode(
  '123456',
  validFor: Duration(minutes: 5),
);

// Verificar código ingresado
final isValid = IceStorage.instance.auth.verifyMfaCode(userInput);

// Observar autenticación (reactivo)
ValueListenableBuilder<bool>(
  valueListenable: IceStorage.instance.auth.isAuthenticated,
  builder: (context, isAuth, _) =>
      isAuth ? const HomeScreen() : const LoginScreen(),
);

// Accesos directos
final uid    = IceStorage.instance.auth.uid.value;
final token  = IceStorage.instance.auth.token.value;
final role   = IceStorage.instance.auth.role.value;
final hasMfa = IceStorage.instance.auth.isMfaCodeValid.value;

// Accesos directos (Opcional)
await IceStorage.instance.auth.setCustomField('deviceId', 'SM-A528B');
String deviceId = IceStorage.instance.auth.customFields.value['deviceId'];

// Cerrar sesión (limpia todo Auth)
await IceStorage.instance.auth.clearAuth();

Preferencias de usuario

// Guardar preferencias
await IceStorage.instance.prefs.setBool('dark_mode', true);
await IceStorage.instance.prefs.setString('language', 'es');
await IceStorage.instance.prefs.setInt('fontSize', 16);

// Leer preferencias
bool? darkMode = IceStorage.instance.prefs.getBool('dark_mode');
String? language = IceStorage.instance.prefs.getString('language');

// Observar cambios (reactivo)
StreamBuilder<bool?>(
  stream: IceStorage.instance.prefs.watch<bool>('dark_mode'),
  builder: (context, snapshot) {
    final isDark = snapshot.data ?? false;
    return Switch(
      value: isDark,
      onChanged: (v) async {
        await IceStorage.instance.prefs.setBool('dark_mode', v);
      },
    );
  },
);

// Eliminar una preferencia específica
await IceStorage.instance.prefs.delete('dark_mode');

// Verificar si existe antes de eliminar
if (IceStorage.instance.prefs.contains('language')) {
  await IceStorage.instance.prefs.delete('language');
}

Listas dinámicas

// Agregar a lista
await IceStorage.instance.prefs.addToList('favorites', 'item1');

// Leer lista
List<String>? favorites = await IceStorage.instance.prefs.read<List<String>>('favorites');

// Eliminar de lista
await IceStorage.instance.prefs.removeFromList('favorites', 'item1');

// Limpiar lista
await IceStorage.instance.prefs.clearList('favorites');

Mapas y objetos complejos

// Guardar mapa
final userData = {
  'name': 'Juan',
  'age': 25,
  'preferences': {'theme': 'dark', 'lang': 'es'}
};
await IceStorage.instance.prefs.write('user_data', userData);

// Leer mapa
Map<String, dynamic>? data = await IceStorage.instance.prefs.read<Map<String, dynamic>>('user_data');

// Observar cambios
StreamBuilder<Map<String, dynamic>?>(
  stream: IceStorage.instance.prefs.watch<Map<String, dynamic>>('user_data'),
  builder: (context, snapshot) {
    final user = snapshot.data;
    return Text('Usuario: ${user?['name']}');
  },
);

Limpieza de datos

// Limpiar solo datos seguros
await IceStorage.instance.clearByType(StorageType.secure);

// Limpiar solo preferencias
await IceStorage.instance.clearByType(StorageType.prefs);

// Limpiar solo imágenes
await IceStorage.instance.clearByType(StorageType.images);

// Limpiar todo
await IceStorage.instance.clearAll();

Estado de red

  • IceStorage.instance.networkStatus: ConnectionStatus actual: connected, noInternet (hay wifi/datos pero sin salida a internet) o disconnected (sin wifi ni datos)
  • IceStorage.instance.connectionStatus: Stream
  • No retrasa el arranque: el estado de wifi/datos llega al instante y la salida real a internet se confirma en segundo plano con un sondeo liviano (backend de Firestore + generate_204 de Google). Solo sondea cuando hace falta: al cambiar de red, al volver a la app, con actividad si el último sondeo tiene más de 5 s o cuando una operación no responde. Sin internet re-sondea cada 5–10 s, así su regreso se detecta en ≤10 s aunque no haya actividad; conectado, cada 30 s como respaldo. Nunca en segundo plano.

Imágenes de red

// Descargar y cachear imagen
final bytes = await IceStorage.instance.images.downloadAndCacheImage(
  'https://example.com/image.jpg',
  headers: {'Authorization': 'Bearer $token'},
);

// Obtener imagen cacheada
final cachedBytes = await IceStorage.instance.images.getCachedImage(
  'https://example.com/image.jpg',
);

// Verificar si está cacheada
bool isCached = await IceStorage.instance.images.isImageCached(
  'https://example.com/image.jpg',
);

// Precargar múltiples imágenes
await IceStorage.instance.images.preloadImages([
  'https://example.com/img1.jpg',
  'https://example.com/img2.jpg',
]);

// Eliminar imagen específica
await IceStorage.instance.images.deleteImage(
  'https://example.com/image.jpg',
);

// Eliminar múltiples imágenes
await IceStorage.instance.images.deleteImages([
  'https://example.com/img1.jpg',
  'https://example.com/img2.jpg',
]);

// Configurar duración del caché (opcional)
IceStorage.instance.images.configureCacheDuration(
  Duration(days: 30),
);

Firestore Gateway (Sincronización Offline)

// Inicializar después de Firebase.initializeApp()
await IceStorage.initFirestoreGateway();              // Modo smart (por defecto)
await IceStorage.initFirestoreGateway(offline: true); // Modo 100% offline

final gateway = IceStorage.instance.gateway!;

// CRUD Operations
await gateway.setDocument(
  docRef: FirebaseFirestore.instance.collection('users').doc(),
  data: {'name': 'Juan', 'age': 25},
);

await gateway.updateDocument(
  docRef: FirebaseFirestore.instance.collection('users').doc('id'),
  data: {'lastSeen': DateTime.now()},
);

await gateway.deleteDocument(
  docRef: FirebaseFirestore.instance.collection('users').doc('id'),
);

// Streams reactivos
final stream = gateway.streamDocuments(
  query: FirebaseFirestore.instance.collection('users').where('age', isGreaterThan: 18),
);

final docStream = gateway.streamDocument(
  docRef: FirebaseFirestore.instance.collection('users').doc('userId'),
);

// Obtener documentos
final querySnapshot = await gateway.getDocuments(
  query: FirebaseFirestore.instance.collection('users'),
);

final docSnapshot = await gateway.getDocument(
  docRef: FirebaseFirestore.instance.collection('users').doc('userId'),
);

// Estado del gateway
StreamBuilder<FirestoreGatewayState>(
  stream: gateway.state,
  builder: (context, snapshot) {
    final state = snapshot.data;
    return Column(
      children: [
        Icon(state?.isOnline ?? false ? Icons.cloud_done : Icons.cloud_off),
        if (state?.pendingWrites ?? 0 > 0)
          Text('${state!.pendingWrites} pendientes'),
      ],
    );
  },
);

// Batch y transacciones
await gateway.batchWrite((batch) {
  batch.set(doc1, data1);
  batch.update(doc2, data2);
  batch.delete(doc3);
});

await gateway.runTransaction<void>((transaction) async {
  final doc = await transaction.get(docRef);
  transaction.update(docRef, {'count': doc.data()?['count'] + 1});
});

// Forzar modo offline
await gateway.setMode(GatewayMode.offline);

// Utilidades
await gateway.waitForPendingWrites();
await gateway.clearCache();

* El gateway usa options (tipo SetOptions) en lugar de merge directamente.

Comportamiento con y sin conexión

  • Escrituras (setDocument, updateDocument, deleteDocument, batchWrite): se aplican en la caché local y retornan al instante; Firestore las sube en segundo plano. pendingWrites cuenta las que el servidor aún no confirma. No lanzan: un rechazo del servidor (p. ej. reglas) se registra en consola y Firestore revierte el cambio local. En Android se despachan en orden con un margen de 20 ms (o hasta su acuse, si llega antes); una lectura de documento solo espera las escrituras de ese documento.
  • Lecturas (getDocument, getDocuments): van al servidor con un plazo que se adapta a la latencia medida (1 a 3 s); si no responde, usan la caché y las lecturas en espera se liberan juntas. Si no hay nada local esperan al servidor hasta 6 s mientras haya red. Un documento que nunca se descargó lanza unavailable sin internet.
  • Sin consultas redundantes: un documento que forma parte de un streamDocuments/streamDocument activo y sincronizado se lee de la caché, sin ir al servidor.
  • Datos activos sin internet: una lectura o transacción sin respuesta corta en el acto la red de Firestore, así nada espera al sondeo (tampoco las lecturas que no pasan por el gateway), y sondea: sin internet queda cortada y todo resuelve de caché; con internet se reconecta enseguida, lo que además rescata una conexión colgada (como máximo un corte por minuto). Al volver internet se enciende y sincroniza de inmediato.
  • Transacciones: necesitan servidor. Sin internet confirmado fallan al instante con FirebaseException(code: 'unavailable'); si se pierde la red a mitad se cortan en el acto y con red lenta a los 10 s (deadline-exceeded).
  • Segundo plano: la red de Firestore queda encendida y la maneja el SDK; al volver a la app se aplica el estado ya sondeado. En web nunca se apaga (Firestore JS maneja su conexión), pero las lecturas sí van a caché sin internet.
  • Para muchas escrituras juntas usa batchWrite: un solo acuse (y en Android un solo hilo nativo esperando) en vez de uno por documento.
  • waitForPendingWrites() sí espera al servidor: sin conexión no retorna hasta reconectar.

⚠️ Inicialización lazy de referencias Firestore en servicios

Cuando usas initFirestoreGateway(), el gateway configura internamente los settings de Firestore (persistencia, caché, etc.) antes de que se cree cualquier instancia de FirebaseFirestore. Si alguna clase accede a FirebaseFirestore.instance como static final, Dart la evalúa al momento de cargar la clase — antes de que main() ejecute initFirestoreGateway() — provocando el error:

[cloud_firestore/unknown] FirebaseFirestore has already been started and its
settings can no longer be changed.

O incluso:

PlatformException(channel-error, Unable to establish connection on channel., null, null)

❌ Incorrecto — causa inicialización prematura:

class MyService {
  static final _usersRef = FirebaseFirestore.instance.collection('users'); // Se evalúa al cargar la clase
  static final _gateway = IceStorage.instance.gateway!;
}

✅ Correcto — usa getters para evaluación lazy:

class MyService {
  static final _gateway = IceStorage.instance.gateway!; // OK: IceStorage se inicializa en main()
  static CollectionReference<Map<String, dynamic>> get _usersRef =>
      FirebaseFirestore.instance.collection('users'); // Se evalúa solo cuando se usa
}

Regla general: Usa static get (getter) para cualquier referencia a FirebaseFirestore.instance.collection(...) o FirebaseStorage.instance.ref(...) en tus servicios. El _gateway puede quedarse como static final porque IceStorage.init() se ejecuta en main() antes de que cualquier servicio se use.

Estado global y estadísticas

// Observar estado global
StreamBuilder<StorageState>(
  stream: IceStorage.instance.state,
  builder: (context, snapshot) {
    final state = snapshot.data;
    return Column(
      children: [
        Text('Datos seguros: ${state?.secureDataCount}'),
        Text('Preferencias: ${state?.prefsDataCount}'),
        Text('Imágenes: ${state?.imageCacheCount}'),
      ],
    );
  },
);

// Obtener estadísticas
final stats = await IceStorage.instance.controller.getStats();
print('Total items: ${stats['total']['items']}');

Backup y restauración

// Exportar todos los datos
final backup = await IceStorage.instance.exportAll();

// Guardar backup donde quieras (Firebase, archivo, etc.)
saveBackupSomewhere(backup);

// Restaurar datos
await IceStorage.instance.importData(backup);