CtrlK
BlogDocsLog inGet started
Tessl Logo

flutter-development

Conocimiento especializado de Flutter para desarrollo de apps financieras. Incluye Clean Architecture, BLoC, Drift, fl_chart y speech_to_text. Usar cuando se trabaje con c\u00f3digo Flutter, arquitectura de la app, o implementaci\u00f3n de features.

Install with Tessl CLI

npx tessl i github:iru97/Savvy-ai --skill flutter-development
What are skills?

71

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Flutter Development Skill - Savvy AI

Eres un experto en desarrollo Flutter especializado en aplicaciones financieras.

Arquitectura Clean + BLoC

Estructura de Carpetas

lib/
\u251c\u2500\u2500 core/
\u2502   \u251c\u2500\u2500 constants/       # Constantes globales
\u2502   \u251c\u2500\u2500 utils/           # Utilidades compartidas
\u2502   \u251c\u2500\u2500 errors/          # Excepciones y failures
\u2502   \u2514\u2500\u2500 theme/           # Tema de la app
\u251c\u2500\u2500 features/
\u2502   \u2514\u2500\u2500 [feature_name]/
\u2502       \u251c\u2500\u2500 data/
\u2502       \u2502   \u251c\u2500\u2500 models/      # DTOs, modelos de datos
\u2502       \u2502   \u251c\u2500\u2500 datasources/ # Local/Remote data sources
\u2502       \u2502   \u2514\u2500\u2500 repositories/ # Implementaci\u00f3n de repos
\u2502       \u251c\u2500\u2500 domain/
\u2502       \u2502   \u251c\u2500\u2500 entities/    # Objetos de dominio puros
\u2502       \u2502   \u251c\u2500\u2500 repositories/ # Interfaces/contratos
\u2502       \u2502   \u2514\u2500\u2500 usecases/    # L\u00f3gica de negocio
\u2502       \u2514\u2500\u2500 presentation/
\u2502           \u251c\u2500\u2500 bloc/        # BLoCs y estados
\u2502           \u251c\u2500\u2500 pages/       # Pantallas
\u2502           \u2514\u2500\u2500 widgets/     # Widgets reutilizables
\u2514\u2500\u2500 main.dart

Capas y Responsabilidades

Data Layer

  • Models: Serializaci\u00f3n/deserializaci\u00f3n JSON, conversi\u00f3n a entidades
  • DataSources: Acceso a BD local (Drift) o APIs remotas
  • Repositories: Implementan interfaces del dominio

Domain Layer

  • Entities: Objetos puros sin dependencias externas
  • UseCases: Una clase por caso de uso, single responsibility
  • Repository Interfaces: Contratos que implementa data layer

Presentation Layer

  • BLoC: Maneja estados y eventos de UI
  • Pages: Pantallas completas con Scaffold
  • Widgets: Componentes reutilizables

BLoC Pattern

Estructura de un BLoC

// events.dart
abstract class TransactionEvent extends Equatable {
  const TransactionEvent();
}

class LoadTransactions extends TransactionEvent {
  final int month;
  final int year;
  const LoadTransactions({required this.month, required this.year});

  @override
  List<Object> get props => [month, year];
}

class AddTransaction extends TransactionEvent {
  final Transaction transaction;
  const AddTransaction(this.transaction);

  @override
  List<Object> get props => [transaction];
}

// states.dart
abstract class TransactionState extends Equatable {
  const TransactionState();
}

class TransactionInitial extends TransactionState {
  @override
  List<Object> get props => [];
}

class TransactionLoading extends TransactionState {
  @override
  List<Object> get props => [];
}

class TransactionLoaded extends TransactionState {
  final List<Transaction> transactions;
  final double balance;

  const TransactionLoaded({
    required this.transactions,
    required this.balance,
  });

  @override
  List<Object> get props => [transactions, balance];
}

class TransactionError extends TransactionState {
  final String message;
  const TransactionError(this.message);

  @override
  List<Object> get props => [message];
}

// bloc.dart
class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
  final GetTransactions getTransactions;
  final AddTransactionUseCase addTransaction;

  TransactionBloc({
    required this.getTransactions,
    required this.addTransaction,
  }) : super(TransactionInitial()) {
    on<LoadTransactions>(_onLoadTransactions);
    on<AddTransaction>(_onAddTransaction);
  }

  Future<void> _onLoadTransactions(
    LoadTransactions event,
    Emitter<TransactionState> emit,
  ) async {
    emit(TransactionLoading());
    final result = await getTransactions(
      Params(month: event.month, year: event.year),
    );
    result.fold(
      (failure) => emit(TransactionError(failure.message)),
      (transactions) => emit(TransactionLoaded(
        transactions: transactions,
        balance: _calculateBalance(transactions),
      )),
    );
  }
}

Best Practices BLoC

  1. Un BLoC por feature
  2. Events son inmutables y describen QU\u00c9 pas\u00f3
  3. States son inmutables y usan Equatable
  4. Usar emit para cambios de estado
  5. Manejar errores con estados espec\u00edficos

Drift (SQLite)

Definici\u00f3n de Tablas

// database.dart
import 'package:drift/drift.dart';

class Transactions extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get uuid => text().unique()();
  DateTimeColumn get fecha => dateTime()();
  TextColumn get tipo => text()(); // 'ingreso' | 'egreso'
  TextColumn get categoriaId => text().references(Categories, #id)();
  TextColumn get subcategoriaId => text().nullable()();
  RealColumn get monto => real()();
  TextColumn get concepto => text()();
  TextColumn get metodoPago => text().withDefault(const Constant('efectivo'))();
  BoolColumn get esRecurrente => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

class Categories extends Table {
  TextColumn get id => text()();
  TextColumn get nombre => text()();
  TextColumn get tipo => text()(); // 'ingreso' | 'egreso'
  TextColumn get clasificacion => text()(); // 'fijo' | 'variable' | 'ahorro'
  TextColumn get icono => text()();
  TextColumn get color => text()();
  BoolColumn get activa => boolean().withDefault(const Constant(true))();

  @override
  Set<Column> get primaryKey => {id};
}

@DriftDatabase(tables: [Transactions, Categories])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  // Queries personalizadas
  Future<List<Transaction>> getTransactionsByMonth(int month, int year) {
    return (select(transactions)
      ..where((t) => t.fecha.month.equals(month) & t.fecha.year.equals(year))
      ..orderBy([(t) => OrderingTerm.desc(t.fecha)]))
    .get();
  }

  Future<double> getBalanceByMonth(int month, int year) async {
    final txns = await getTransactionsByMonth(month, year);
    return txns.fold<double>(0, (sum, t) {
      return t.tipo == 'ingreso' ? sum + t.monto : sum - t.monto;
    });
  }

  Stream<List<Transaction>> watchTransactions() {
    return select(transactions).watch();
  }
}

Migraciones

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (Migrator m) async {
    await m.createAll();
    // Insertar categor\u00edas por defecto
    await _insertDefaultCategories();
  },
  onUpgrade: (Migrator m, int from, int to) async {
    if (from < 2) {
      // Migraci\u00f3n de v1 a v2
      await m.addColumn(transactions, transactions.subcategoriaId);
    }
  },
);

fl_chart - Gr\u00e1ficos

Pie Chart (Distribuci\u00f3n por Categor\u00eda)

PieChart(
  PieChartData(
    sections: categories.map((cat) {
      final percentage = (cat.total / totalGastos) * 100;
      return PieChartSectionData(
        value: cat.total,
        title: '${percentage.toStringAsFixed(1)}%',
        color: Color(int.parse(cat.color)),
        radius: 100,
        titleStyle: const TextStyle(
          fontSize: 12,
          fontWeight: FontWeight.bold,
          color: Colors.white,
        ),
      );
    }).toList(),
    centerSpaceRadius: 40,
    sectionsSpace: 2,
  ),
)

Line Chart (Tendencia Mensual)

LineChart(
  LineChartData(
    lineBarsData: [
      LineChartBarData(
        spots: monthlyData.asMap().entries.map((e) {
          return FlSpot(e.key.toDouble(), e.value);
        }).toList(),
        isCurved: true,
        color: Colors.blue,
        barWidth: 3,
        dotData: FlDotData(show: false),
        belowBarData: BarAreaData(
          show: true,
          color: Colors.blue.withOpacity(0.2),
        ),
      ),
    ],
    titlesData: FlTitlesData(
      bottomTitles: AxisTitles(
        sideTitles: SideTitles(
          showTitles: true,
          getTitlesWidget: (value, meta) {
            return Text(months[value.toInt()]);
          },
        ),
      ),
    ),
    gridData: FlGridData(show: true),
    borderData: FlBorderData(show: false),
  ),
)

Bar Chart (Comparativa)

BarChart(
  BarChartData(
    barGroups: data.asMap().entries.map((e) {
      return BarChartGroupData(
        x: e.key,
        barRods: [
          BarChartRodData(
            toY: e.value.presupuestado,
            color: Colors.grey.shade300,
            width: 12,
          ),
          BarChartRodData(
            toY: e.value.gastado,
            color: e.value.gastado > e.value.presupuestado
              ? Colors.red
              : Colors.green,
            width: 12,
          ),
        ],
      );
    }).toList(),
    alignment: BarChartAlignment.spaceAround,
  ),
)

Speech-to-Text

Información General (Actualizado 2026)

Paquete Recomendado: speech_to_text v7.4.0+

  • Wrapper nativo puro: Usa iOS Speech Framework y Android SpeechRecognizer
  • Plataformas: iOS, Android, macOS, Web, Windows
  • Casos de uso: Comandos cortos y frases (NO conversación continua)
  • Latencia: 200-500ms (iOS on-device), 300-800ms (Android)

Capacidades Offline

iOS:

  • Soporta reconocimiento on-device (offline) con modelos descargados
  • Español e inglés funcionan offline si el usuario tiene los modelos
  • Mayor precisión con server-side (online)

Android:

  • Depende del motor de reconocimiento instalado (típicamente Google)
  • Offline no garantizado en todos los dispositivos
  • Requiere conexión en la mayoría de casos

Limitaciones Importantes

  1. Límite de tiempo: 1 minuto máximo por tarea en iOS (límite del framework)
  2. No continuous listening: Diseñado para uso intermitente
  3. Offline condicional: Depende de la plataforma y modelos descargados

Configuración Básica

class VoiceInputService {
  final SpeechToText _speech = SpeechToText();
  bool _isAvailable = false;

  Future<bool> initialize() async {
    _isAvailable = await _speech.initialize(
      onError: (error) => print('Error: $error'),
      onStatus: (status) => print('Status: $status'),
    );
    return _isAvailable;
  }

  Future<void> startListening({
    required Function(String) onResult,
    String localeId = 'es_MX', // Español mexicano
  }) async {
    if (!_isAvailable) return;

    await _speech.listen(
      onResult: (result) {
        if (result.finalResult) {
          onResult(result.recognizedWords);
        }
      },
      localeId: localeId,
      listenFor: Duration(seconds: 30), // Max 30 segundos recomendado
      pauseFor: Duration(seconds: 3), // Pausa = fin de comando
      listenMode: ListenMode.confirmation,
    );
  }

  Future<void> stopListening() async {
    await _speech.stop();
  }

  bool get isListening => _speech.isListening;
}

Permisos Necesarios

Android (AndroidManifest.xml)

<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.INTERNET"/>

<!-- Requerido para SpeechRecognizer -->
<queries>
  <intent>
    <action android:name="android.speech.RecognitionService" />
  </intent>
</queries>

iOS (Info.plist)

<key>NSMicrophoneUsageDescription</key>
<string>Se necesita acceso al micrófono para entrada por voz</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Se necesita reconocimiento de voz para procesar comandos</string>

Alternativas (Si se requiere offline 100%)

Vosk (Open Source, offline total):

dependencies:
  vosk_flutter: ^latest
  • 100% offline, modelos embebidos (~50 MB)
  • Español e inglés soportados
  • Sin límite de tiempo
  • Integración más compleja

Picovoice Cheetah (Comercial, ultra-baja latencia):

dependencies:
  cheetah_flutter: ^latest
  • Latencia 100-200ms
  • 100% offline
  • Requiere licencia comercial

Paquetes Principales

dependencies:
  flutter:
    sdk: flutter

  # State Management
  flutter_bloc: ^8.1.3
  equatable: ^2.0.5

  # Database
  drift: ^2.14.0
  sqlite3_flutter_libs: ^0.5.18
  path_provider: ^2.1.1
  path: ^1.8.3

  # Charts
  fl_chart: ^0.66.0

  # Voice
  speech_to_text: ^7.4.0
  permission_handler: ^11.1.0

  # DI
  get_it: ^7.6.4

  # Functional Programming
  dartz: ^0.10.1

  # Utils
  intl: ^0.19.0
  uuid: ^4.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  drift_dev: ^2.14.0
  build_runner: ^2.4.7
  bloc_test: ^9.1.5
  mocktail: ^1.0.1

Seguridad

Almacenamiento Seguro

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  final _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
  );

  Future<void> write(String key, String value) async {
    await _storage.write(key: key, value: value);
  }

  Future<String?> read(String key) async {
    return await _storage.read(key: key);
  }

  Future<void> delete(String key) async {
    await _storage.delete(key: key);
  }
}

Biometrics

import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  Future<bool> canAuthenticate() async {
    return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
  }

  Future<bool> authenticate() async {
    return await _auth.authenticate(
      localizedReason: 'Verifica tu identidad para acceder',
      options: const AuthenticationOptions(
        stickyAuth: true,
        biometricOnly: false,
      ),
    );
  }
}

Gemini AI Integration

Paquete Oficial

dependencies:
  google_generative_ai: ^latest  # SDK oficial de Google
  flutter_secure_storage: ^latest

Modelo Recomendado: Gemini 3 Flash

Características (Enero 2026):

  • Precio: $0.50/1M input, $3/1M output
  • Velocidad: 3x más rápido que 2.5 Pro
  • Latencia: <1s con thinking_level: low
  • Context caching: 90% descuento en prompts repetidos

Ideal para Savvy AI:

  • ✅ Parsing de comandos de voz
  • ✅ Categorización automática
  • ✅ Extracción de entidades (monto, fecha, concepto)
  • ✅ Structured JSON output nativo

Configuración Básica

import 'package:google_generative_ai/google_generative_ai.dart';

class GeminiService {
  late GenerativeModel _model;
  CachedContent? _systemCache;

  Future<void> initialize(String apiKey) async {
    _model = GenerativeModel(
      model: 'gemini-3-flash',
      apiKey: apiKey,
      generationConfig: GenerationConfig(
        temperature: 0.1,  // Baja para consistencia
        topK: 1,
        topP: 1,
        maxOutputTokens: 500,
        responseMimeType: 'application/json',
      ),
    );

    // Crear context cache del system prompt
    await _createCache();
  }

  Future<void> _createCache() async {
    _systemCache = await _model.cacheContent(
      [Content.system(systemPrompt)],
      ttl: Duration(hours: 1),  // Ahorra 90% del costo
    );
  }
}

Structured Output con JSON Schema

// Definir schema para output estructurado
final expenseSchema = Schema.object(
  properties: {
    'tipo': Schema.enumString(
      enumValues: ['ingreso', 'egreso'],
    ),
    'monto': Schema.number(),
    'concepto': Schema.string(),
    'categoria': Schema.string(),
    'subcategoria': Schema.string(nullable: true),
    'fecha': Schema.string(),
    'metodoPago': Schema.enumString(
      enumValues: ['efectivo', 'tarjeta', 'transferencia'],
    ),
    'esRecurrente': Schema.boolean(),
    'confianza': Schema.number(),
  },
  requiredProperties: [
    'tipo', 'monto', 'concepto', 'categoria',
    'fecha', 'metodoPago', 'esRecurrente', 'confianza',
  ],
);

// Parsear comando con schema enforcement
Future<ParsedExpense> parseCommand(String command) async {
  final response = await _model.generateContent(
    [Content.text('COMANDO: "$command"')],
    generationConfig: GenerationConfig(
      responseMimeType: 'application/json',
      responseSchema: expenseSchema,
    ),
    requestOptions: RequestOptions(
      cachedContent: _systemCache,  // Usar cache
    ),
  );

  return ParsedExpense.fromJson(jsonDecode(response.text!));
}

Retry Logic con Exponential Backoff

class ApiRetryHandler {
  static Future<T> withRetry<T>({
    required Future<T> Function() operation,
    int maxRetries = 3,
  }) async {
    int attempt = 0;

    while (attempt < maxRetries) {
      try {
        return await operation();
      } on QuotaExceededException catch (e) {
        attempt++;
        if (attempt >= maxRetries) rethrow;

        // Exponential backoff: 1s, 2s, 4s...
        final delay = Duration(seconds: pow(2, attempt).toInt());
        // Jitter: ±25% aleatorio
        final jitter = 0.75 + (Random().nextDouble() * 0.5);
        await Future.delayed(delay * jitter);
      } catch (e) {
        rethrow;  // No reintentar otros errores
      }
    }

    throw Exception('Max retries exceeded');
  }
}

// Uso
final parsed = await ApiRetryHandler.withRetry(
  operation: () => geminiService.parseCommand(voiceInput),
);

Integration en Clean Architecture

// Data Layer - DataSource
class GeminiParserDataSource {
  final GenerativeModel model;

  Future<ParsedExpenseModel> parse(String command) async {
    final response = await model.generateContent([...]);
    return ParsedExpenseModel.fromJson(jsonDecode(response.text!));
  }
}

// Data Layer - Repository Implementation
class VoiceParserRepositoryImpl implements VoiceParserRepository {
  final GeminiParserDataSource dataSource;

  @override
  Future<Either<Failure, ParsedExpense>> parseCommand(String cmd) async {
    try {
      final result = await ApiRetryHandler.withRetry(
        operation: () => dataSource.parse(cmd),
      );
      return Right(result.toEntity());
    } on QuotaExceededException {
      return Left(QuotaFailure());
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

// Domain Layer - UseCase
class ParseVoiceCommandUseCase {
  final VoiceParserRepository repository;

  Future<Either<Failure, ParsedExpense>> call(String command) {
    return repository.parseCommand(command);
  }
}

// Presentation Layer - BLoC
class VoiceInputBloc extends Bloc<VoiceInputEvent, VoiceInputState> {
  final ParseVoiceCommandUseCase parseCommand;

  VoiceInputBloc(this.parseCommand) : super(VoiceInputInitial()) {
    on<ParseVoiceCommand>(_onParse);
  }

  Future<void> _onParse(
    ParseVoiceCommand event,
    Emitter<VoiceInputState> emit,
  ) async {
    emit(VoiceInputParsing());

    final result = await parseCommand(event.command);

    result.fold(
      (failure) => emit(VoiceInputError(failure.message)),
      (parsed) => emit(VoiceInputParsed(parsed)),
    );
  }
}

Cost Optimization

Context Caching:

  • System prompt: ~1,500 tokens
  • Sin cache: $0.00075/request
  • Con cache (90% off): $0.000075/request
  • Ahorro: $202.50/mes en 10K requests/día

Thinking Level:

GenerationConfig(
  thinkingLevel: ThinkingLevel.low,  // Balance óptimo
)
  • minimal: Ultra-rápido, categorización simple
  • low: Recomendado para Savvy AI
  • medium: Default
  • high: Razonamiento complejo, mayor latencia

Seguridad en Producción

NO hacer (desarrollo solamente):

// ❌ NUNCA en producción
final model = GenerativeModel(
  model: 'gemini-3-flash',
  apiKey: 'tu-api-key-hardcoded',  // INSEGURO
);

Sí hacer (producción):

// ✅ Con backend proxy (Firebase AI Logic)
import 'package:firebase_ai/firebase_ai.dart';

final model = FirebaseAI.generativeModel(
  model: 'gemini-3-flash',
  // API key manejada server-side
);

Rate Limits (Enero 2026)

TierRPMTPMCosto
Free5-15250K$0
Tier 1 Paid150-300VariablePay-as-you-go

Para Savvy AI:

  • Tier 1 suficiente (150 RPM = 2.5 req/s)
  • Implementar rate limiting por usuario
  • Cola local para requests en pico

Ejemplo Completo

Ver: .claude/examples/gemini_expense_parser_example.dart


Testing

Test de BLoC

void main() {
  late TransactionBloc bloc;
  late MockGetTransactions mockGetTransactions;

  setUp(() {
    mockGetTransactions = MockGetTransactions();
    bloc = TransactionBloc(getTransactions: mockGetTransactions);
  });

  blocTest<TransactionBloc, TransactionState>(
    'emits [Loading, Loaded] when LoadTransactions succeeds',
    build: () {
      when(() => mockGetTransactions(any()))
        .thenAnswer((_) async => Right([testTransaction]));
      return bloc;
    },
    act: (bloc) => bloc.add(LoadTransactions(month: 1, year: 2026)),
    expect: () => [
      TransactionLoading(),
      isA<TransactionLoaded>(),
    ],
  );
}

Test de Gemini Integration

class MockGenerativeModel extends Mock implements GenerativeModel {}

void main() {
  late GeminiParserDataSource dataSource;
  late MockGenerativeModel mockModel;

  setUp(() {
    mockModel = MockGenerativeModel();
    dataSource = GeminiParserDataSource(mockModel);
  });

  test('parseCommand returns ParsedExpense on success', () async {
    // Arrange
    final mockResponse = GenerateContentResponse(
      text: jsonEncode({
        'tipo': 'egreso',
        'monto': 500,
        'concepto': 'supermercado',
        'categoria': 'Alimentación',
        'fecha': '2026-01-08',
        'metodoPago': 'tarjeta',
        'esRecurrente': false,
        'confianza': 0.95,
      }),
    );

    when(() => mockModel.generateContent(any()))
      .thenAnswer((_) async => mockResponse);

    // Act
    final result = await dataSource.parse('Gasté 500 en el super');

    // Assert
    expect(result.monto, 500);
    expect(result.categoria, 'Alimentación');
    expect(result.confianza, greaterThan(0.9));
  });

  test('throws QuotaExceededException on 429 error', () async {
    // Arrange
    when(() => mockModel.generateContent(any()))
      .thenThrow(GenerativeAIException('quota exceeded'));

    // Act & Assert
    expect(
      () => dataSource.parse('test'),
      throwsA(isA<QuotaExceededException>()),
    );
  });
}
Repository
iru97/Savvy-ai
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.