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.
71
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
Eres un experto en desarrollo Flutter especializado en aplicaciones financieras.
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.dartData Layer
Domain Layer
Presentation Layer
// 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),
)),
);
}
}emit para cambios de estado// 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();
}
}@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);
}
},
);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,
),
)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),
),
)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,
),
)Paquete Recomendado: speech_to_text v7.4.0+
iOS:
Android:
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;
}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>Vosk (Open Source, offline total):
dependencies:
vosk_flutter: ^latestPicovoice Cheetah (Comercial, ultra-baja latencia):
dependencies:
cheetah_flutter: ^latestdependencies:
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.1import '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);
}
}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,
),
);
}
}dependencies:
google_generative_ai: ^latest # SDK oficial de Google
flutter_secure_storage: ^latestCaracterísticas (Enero 2026):
thinking_level: lowIdeal para Savvy AI:
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
);
}
}// 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!));
}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),
);// 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)),
);
}
}Context Caching:
Thinking Level:
GenerationConfig(
thinkingLevel: ThinkingLevel.low, // Balance óptimo
)minimal: Ultra-rápido, categorización simplelow: Recomendado para Savvy AImedium: Defaulthigh: Razonamiento complejo, mayor latenciaNO 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
);| Tier | RPM | TPM | Costo |
|---|---|---|---|
| Free | 5-15 | 250K | $0 |
| Tier 1 Paid | 150-300 | Variable | Pay-as-you-go |
Para Savvy AI:
Ver: .claude/examples/gemini_expense_parser_example.dart
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>(),
],
);
}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>()),
);
});
}1e99f86
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.