Communitygithub.com

Dart/Flutter Patterns

Produktionsreife Dart- und Flutter-Patterns mit Nullsicherheit, immutablem State, asynchroner Komposition, Widget-Architektur und den gängigen State-Management-Frameworks (BLoC, Riverpod, Provider), GoRouter-Navigation, Dio-Netzwerk, Freezed-Codegenerierung und Clean Architecture.

Was ist Flutter Patterns?

Dieses Skill bietet eine umfassende Sammlung von produktionsreifen Code-Patterns für Dart- und Flutter-Anwendungen. Es deckt neun Kernbereiche ab: Nullsicherheit mit Pattern Matching (statt des '!' Operators), immutable State mittels 'freezed' und 'copyWith', asynchrone Komposition mit 'Future.wait', Widget-Architektur mit 'const'-Propagierung und Extraktion in separate Widget-Klassen, State Management mit BLoC/Cubit, Riverpod (Notifier und abgeleitete Provider) und Provider, Navigation mit GoRouter inklusive reaktivem Auth-Redirect, Netzwerkkommunikation mit Dio (inklusive Interceptor-Pattern und Token-Refresh), sowie Best Practices für Fehlerbehandlung und Testing. Ziel ist es, häufige Fallstricke zu vermeiden und gleichzeitig die besten Features von Dart 3 (sealed classes, records, pattern matching) voll auszuschöpfen. Das Skill eignet sich besonders für Entwickler, die ihre Codebasis konsolidieren, neue Features mit idiomatischem Flutter umsetzen oder ein neues Projekt mit einer skalierbaren Architektur starten möchten.

Funktioniert mit~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/dart-flutter-patterns

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

Was macht Flutter Patterns?

Wann verwenden

Dieses Skill verwenden, wenn:

  • Ein neues Flutter-Feature gestartet wird und idiomatische Patterns für State Management, Navigation oder Datenzugriff benötigt werden
  • Dart-Code überprüft oder geschrieben wird und Anleitung für Nullsicherheit, sealed Types oder asynchrone Komposition benötigt wird
  • Ein neues Flutter-Projekt eingerichtet wird und eine Entscheidung zwischen BLoC, Riverpod oder Provider getroffen werden muss
  • Sichere HTTP-Clients, WebView-Integration oder lokaler Speicher implementiert werden
  • Tests für Flutter-Widgets, Cubits oder Riverpod-Provider geschrieben werden
  • GoRouter mit Authentifizierungs-Guards verdrahtet wird

Wie es funktioniert

Dieses Skill bietet kopierfertige Dart/Flutter-Code-Patterns, geordnet nach Themenbereichen:

  1. Nullsicherheit – ! vermeiden, ?., ?? und Pattern Matching bevorzugen
  2. Immutable State – sealed classes, freezed, copyWith
  3. Asynchrone Komposition – gleichzeitiges Future.wait, sicheres BuildContext nach await
  4. Widget-Architektur – Extraktion in Klassen (nicht Methoden), const-Propagierung, eingegrenzte Rebuilds
  5. State Management – BLoC/Cubit-Events, Riverpod-Notifier und abgeleitete Provider
  6. Navigation – GoRouter mit reaktiven Auth-Guards via refreshListenable
  7. Netzwerk – Dio mit Interceptoren, Token-Refresh mit einmaliger Wiederholungssperre
  8. Fehlerbehandlung – globale Erfassung, ErrorWidget.builder, Crashlytics-Anbindung
  9. Tests – Unit-Tests (BLoC), Widget-Tests (ProviderScope-Overrides), Fakes statt Mocks

Beispiele

// Sealed State – verhindert unmögliche Zustände
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }

// GoRouter mit reaktivem Auth-Redirect
final router = GoRouter(
  refreshListenable: GoRouterRefreshStream(authCubit.stream),
  redirect: (context, state) {
    final authed = context.read<AuthCubit>().state is AuthAuthenticated;
    if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
    return null;
  },
  routes: [...],
);

// Riverpod abgeleiteter Provider mit sicherer firstWhereOrNull
@riverpod
double cartTotal(Ref ref) {
  final cart = ref.watch(cartNotifierProvider);
  final products = ref.watch(productsProvider).valueOrNull ?? [];
  return cart.fold(0.0, (total, item) {
    final product = products.firstWhereOrNull((p) => p.id == item.productId);
    return total + (product?.price ?? 0) * item.quantity;
  });
}

Praktische, produktionsreife Patterns für Dart- und Flutter-Anwendungen. Bibliotheksagnostisch, wo möglich, mit expliziter Abdeckung der gängigsten Ökosystem-Pakete.


1. Grundlagen der Nullsicherheit

Patterns dem Bang-Operator vorziehen

// SCHLECHT – stürzt zur Laufzeit ab, wenn null
final name = user!.name;

// GUT – Fallback bereitstellen
final name = user?.name ?? 'Unbekannt';

// GUT – Dart 3 Pattern Matching (bevorzugt für komplexe Fälle)
final display = switch (user) {
  User(:final name, :final email) => '$name <$email>',
  null => 'Gast',
};

// GUT – frühe Rückkehr mit Guard
String getUserName(User? user) {
  if (user == null) return 'Unbekannt';
  return user.name; // nach der Überprüfung auf nicht-null heraufgestuft
}

Übermäßige Verwendung von late vermeiden

// SCHLECHT – verschiebt Null-Fehler zur Laufzeit
late String userId;

// GUT – nullable mit expliziter Initialisierung
String? userId;

// OK – late nur verwenden, wenn die Initialisierung vor dem ersten Zugriff garantiert ist
// (z. B. in initState() vor jeder Widget-Interaktion)
late final AnimationController _controller;

@override
void initState() {
  super.initState();
  _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}

2. Immutable State

Sealed Classes für Zustandshierarchien

sealed class UserState {}

final class UserInitial extends UserState {}

final class UserLoading extends UserState {}

final class UserLoaded extends UserState {
  const UserLoaded(this.user);
  final User user;
}

final class UserError extends UserState {
  const UserError(this.message);
  final String message;
}

// Erschöpfender Switch – Compiler erzwingt alle Zweige
Widget buildFrom(UserState state) => switch (state) {
  UserInitial() => const SizedBox.shrink(),
  UserLoading() => const CircularProgressIndicator(),
  UserLoaded(:final user) => UserCard(user: user),
  UserError(:final message) => ErrorText(message),
};

Freezed für boilerplate-freie Unveränderlichkeit

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    required String email,
    @Default(false) bool isAdmin,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// Verwendung
final user = User(id: '1', name: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Schmidt'); // unveränderliches Update
final json = user.toJson();
final fromJson = User.fromJson(json);

3. Asynchrone Komposition

Strukturierte Nebenläufigkeit mit Future.wait

Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
  // Gleichzeitig ausführen – nicht sequentiell awaiten
  final (userList, orderList) = await (
    users.getAll(),
    orders.getRecent(),
  ).wait; // Dart 3 Record-Destrukturierung + Future.wait-Erweiterung

  return DashboardData(users: userList, orders: orderList);
}

Stream-Patterns

// Repository stellt reaktive Streams für Live-Daten bereit
Stream<List<Item>> watchCartItems() => _db
    .watchTable('cart_items')
    .map((rows) => rows.map(Item.fromRow).toList());

// In der Widget-Ebene – deklarativ, kein manuelles Abonnement
StreamBuilder<List<Item>>(
  stream: cartRepository.watchCartItems(),
  builder: (context, snapshot) => switch (snapshot) {
    AsyncSnapshot(connectionState: ConnectionState.waiting) =>
        const CircularProgressIndicator(),
    AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
    AsyncSnapshot(:final data?) => CartList(items: data),
    _ => const SizedBox.shrink(),
  },
)

BuildContext nach Await

// KRITISCH – nach jedem await in StatefulWidget immer auf mounted prüfen
Future<void> _handleSubmit() async {
  setState(() => _isLoading = true);
  try {
    await authService.login(_email, _password);
    if (!mounted) return; // ← Guard vor der Verwendung von context
    context.go('/home');
  } on AuthException catch (e) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
  } finally {
    if (mounted) setState(() => _isLoading = false);
  }
}

4. Widget-Architektur

In Klassen extrahieren, nicht in Methoden

// SCHLECHT – private Methode, die ein Widget zurückgibt, verhindert Optimierung
Widget _buildHeader() {
  return Container(
    padding: const EdgeInsets.all(16),
    child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
  );
}

// GUT – separate Widget-Klasse, ermöglicht const, Element-Wiederverwendung
class _PageHeader extends StatelessWidget {
  const _PageHeader(this.title);
  final String title;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
    );
  }
}

const-Propagierung

// SCHLECHT – neue Instanzen bei jedem Rebuild
child: Padding(
  padding: EdgeInsets.all(16.0),       // nicht const
  child: Icon(Icons.home, size: 24.0), // nicht const
)

// GUT – const stoppt die Rebuild-Propagierung
child: const Padding(
  padding: EdgeInsets.all(16.0),
  child: Icon(Icons.home, size: 24.0),
)

Eingegrenzte Rebuilds

// SCHLECHT – gesamte Seite wird bei jeder Zähleränderung neu aufgebaut
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider); // baut alles neu
    return Scaffold(
      body: Column(children: [
        const ExpensiveHeader(), // unnötig neugebaut
        Text('$count'),
        const ExpensiveFooter(), // unnötig neugebaut
      ]),
    );
  }
}

// GUT – den sich ändernden Teil isolieren
class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Column(children: [
        ExpensiveHeader(),        // wird niemals neugebaut (const)
        _CounterDisplay(),        // nur dieser wird neugebaut
        ExpensiveFooter(),        // wird niemals neugebaut (const)
      ]),
    );
  }
}

class _CounterDisplay extends ConsumerWidget {
  const _CounterDisplay();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

5. State Management: BLoC/Cubit

// Cubit – synchroner oder einfacher asynchroner State
class AuthCubit extends Cubit<AuthState> {
  AuthCubit(this._authService) : super(const AuthState.initial());
  final AuthService _authService;

  Future<void> login(String email, String password) async {
    emit(const AuthState.loading());
    try {
      final user = await _authService.login(email, password);
      emit(AuthState.authenticated(user));
    } on AuthException catch (e) {
      emit(AuthState.error(e.message));
    }
  }

  void logout() {
    _authService.logout();
    emit(const AuthState.initial());
  }
}

// Im Widget
BlocBuilder<AuthCubit, AuthState>(
  builder: (context, state) => switch (state) {
    AuthInitial() => const LoginForm(),
    AuthLoading() => const CircularProgressIndicator(),
    AuthAuthenticated(:final user) => HomePage(user: user),
    AuthError(:final message) => ErrorView(message: message),
  },
)

6. State Management: Riverpod

// Auto-Dispose-Async-Provider
@riverpod
Future<List<Product>> products(Ref ref) async {
  final repo = ref.watch(productRepositoryProvider);
  return repo.getAll();
}

// Notifier mit komplexen Mutationen
@riverpod
class CartNotifier extends _$CartNotifier {
  @override
  List<CartItem> build() => [];

  void add(Product product) {
    final existing = state.where((i) => i.productId == product.id).firstOrNull;
    if (existing != null) {
      state = [
        for (final item in state)
          if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
          else item,
      ];
    } else {
      state = [...state, CartItem(productId: product.id, quantity: 1)];
    }
  }

  void remove(String productId) =>
      state = state.where((i) => i.productId != productId).toList();

  void clear() => state = [];
}

// Abgeleiteter Provider (Selector-Pattern)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;

@riverpod
double cartTotal(Ref ref) {
  final cart = ref.watch(cartNotifierProvider);
  final products = ref.watch(productsProvider).valueOrNull ?? [];
  return cart.fold(0.0, (total, item) {
    // firstWhereOrNull (aus dem collection-Paket) vermeidet StateError, wenn ein Produkt fehlt
    final product = products.firstWhereOrNull((p) => p.id == item.productId);
    return total + (product?.price ?? 0) * item.quantity;
  });
}

7. Navigation mit GoRouter

final router = GoRouter(
  initialLocation: '/',
  // refreshListenable wertet den Redirect immer dann neu aus, wenn sich der Auth-Status ändert
  refreshListenable: GoRouterRefreshStream(authCubit.stream),
  redirect: (context, state) {
    final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
    final isGoingToLogin = state.matchedLocation == '/login';
    if (!isLoggedIn && !isGoingToLogin) return '/login';
    if (isLoggedIn && isGoingToLogin) return '/';
    return null;
  },
  routes: [
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(path: '/', builder: (_, __) => const HomePage()),
        GoRoute(
          path: '/products/:id',
          builder: (context, state) =>
              ProductDetailPage(id: state.pathParameters['id']!),
        ),
      ],
    ),
  ],
);

8. HTTP mit Dio

final dio = Dio(BaseOptions(
  baseUrl: const String.fromEnvironment('API_URL'),
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
  headers: {'Content-Type': 'application/json'},
));

// Auth-Interceptor hinzufügen
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) async {
    final token = await secureStorage.read(key: 'auth_token');
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  },
  onError: (error, handler) async {
    // Schutz vor unendlichen Wiederholungsschleifen: nur einmal pro Anfrage versuchen
    final isRetry = error.requestOptions.extra['_isRetry'] == true;
    if (!isRetry && error.response?.statusCode == 401) {
      final refreshed = await attemptTokenRefresh();
      if (refreshed) {
        error.requestOptions.extra['_isRetry'] = true;
        return handler.resolve(await dio.fetch(error.requestOptions));
      }
    }
    handler.next(error);
  },
));

// Repository unter Verwendung von Dio
class UserApiDataSource {
  const UserApiDataSource(this._dio);
  final Dio _dio;

  Future<User> getById(String id) async {
    final response = await _dio.get<Map<String, dynamic>>('/users/$id');
    return User.fromJson(response.data!);
  }
}

9. Fehlerbehandlungs-Architektur

// Globale Fehlererfassung – in main() einrichten
void main() {
  FlutterError.onError = (details) {
    FlutterError.presentError(details);
    crashlytics.recordFlutterFatalError(details);
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    crashlytics.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const App());
}

// Benutzerdefiniertes ErrorWidget für die Produktion
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    ErrorWidget.builder = (details) => MaterialApp(
      home: Scaffold(
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 48, color: Colors.red),
                const SizedBox(height: 16),
                Text('Etwas ist schiefgelaufen', style: Theme.of(context).textTheme.titleLarge),
                const SizedBox(height: 8),
                Text(details.exception.toString(), textAlign: TextAlign.center),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

10. Testen

BLoC/Cubit Unit-Tests

void main() {
  late MockAuthService mockAuthService;
  late AuthCubit authCubit;

  setUp(() {
    mockAuthService = MockAuthService();
    authCubit = AuthCubit(mockAuthService);
  });

  group('AuthCubit', () {
    test('startet im Initial-Zustand', () {
      expect(authCubit.state, const AuthState.initial());
    });

    blocTest<AuthCubit, AuthState>(
      'gibt [Loading, Authenticated] bei erfolgreichem Login aus',
      build: () => authCubit,
      act: (cubit) => cubit.login('[email protected]', 'password'),
      setUp: () => when(mockAuthService.login(any, any)).thenAnswer(
        (_) async => User(id: '1', name: 'Test', email: '[email protected]'),
      ),
      expect: () => [
        const AuthState.loading(),
        AuthState.authenticated(User(id: '1', name: 'Test', email: '[email protected]')),
      ],
    );
  });
}

Riverpod-Provider-Tests

void main() {
  test('CartNotifier fügt Produkte hinzu', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    final product = Product(id: '1', name: 'Testprodukt', price: 10.0);

    container.read(cartNotifierProvider.notifier).add(product);
    expect(container.read(cartNotifierProvider), [
      CartItem(productId: '1', quantity: 1),
    ]);

    container.read(cartNotifierProvider.notifier).add(product);
    expect(container.read(cartNotifierProvider), [
      CartItem(productId: '1', quantity: 2),
    ]);
  });

  test('cartTotal berechnet Summe korrekt', () async {
    final container = ProviderContainer(
      overrides: [
        productsProvider.overrideWith((ref) async => [Product(id: '1', name: 'A', price: 10.0)]),
      ],
    );
    addTearDown(container.dispose);

    final product = Product(id: '1', name: 'A', price: 10.0);
    container.read(cartNotifierProvider.notifier).add(product);
    container.read(cartNotifierProvider.notifier).add(product);

    // Providers asynchron aktualisieren lassen
    await Future.delayed(Duration.zero);
    expect(container.read(cartTotalProvider), 20.0);
  });
}

Widget-Tests mit Provider-Overrides

testWidgets('CartPage zeigt Gesamtsumme an', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        cartNotifierProvider.overrideWith(() => _TestCartNotifier()),
      ],
      child: const MaterialApp(home: CartPage()),
    ),
  );

  expect(find.text('Gesamtsumme: \$0.00'), findsOneWidget);

  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();
  expect(find.text('Gesamtsumme: \$10.00'), findsOneWidget);
});

Tipps für Fakes vs. Mocks

// FAKE – leichte Implementierung, vorzuziehen, wenn möglich
class FakeAuthService implements AuthService {
  final bool shouldSucceed;

  FakeAuthService({this.shouldSucceed = true});

  @override
  Future<User> login(String email, String password) async {
    if (!shouldSucceed) throw AuthException('Login fehlgeschlagen');
    return User(id: '1', name: 'Test', email: email);
  }
}

// MOCK – nützlich für Interaktionsverifizierung, aber zerbrechlicher
class MockAuthService extends Mock implements AuthService {}

Produktionsreife Patterns für Dart 3 und aktuelle Flutter-Pakete.

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

Verwandte Skills