Flutter API Integration: Robuste REST-Architektur
# Flutter API-Integration: Zuverlässige REST-Service-Architektur
Eine robuste API-Schicht beeinflusst die Nutzererfahrung unmittelbar. In jeder Produktions-App, die ich mit Flutter gebaut habe, ist die API-Schicht die kritischste Infrastrukturkomponente. Wird sie falsch aufgebaut, jagt man zufälligen Abstürzen, inkonsistenten Zuständen und frustrierten Nutzern hinterher. Wird sie richtig aufgebaut, funktioniert jedes Feature, das man darauf aufbaut, einfach.
Dieser Artikel beschreibt die Architektur, die ich in jedem Projekt einsetze: Dio-Konfiguration, Interceptoren, das Repository-Pattern, Fehlerbehandlung mit dem Result-Pattern, Offline-Caching und die Fehler, die ich in Teams immer wieder sehe.
Warum Dio statt http?
Das eingebaute `http`-Paket reicht für Prototypen, aber Produktions-Apps brauchen Interceptoren, Cancel-Tokens, Form-Data-Uploads und feingranulare Timeout-Steuerung. Dio liefert all das von Haus aus.
import class="code-string">'package:dio/dio.dart';
class DioClient {
late final Dio _dio;
DioClient({required String baseUrl, String? authToken}) {
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: class="code-number">10),
receiveTimeout: const Duration(seconds: class="code-number">15),
sendTimeout: const Duration(seconds: class="code-number">10),
headers: {
class="code-string">'Content-Type': class="code-string">'application/json',
class="code-string">'Accept': class="code-string">'application/json',
if (authToken != null) class="code-string">'Authorization': class="code-string">'Bearer $authToken',
},
),
);
_dio.interceptors.addAll([
LoggingInterceptor(),
AuthInterceptor(),
RetryInterceptor(dio: _dio),
]);
}
Dio get dio => _dio;
}Einige wichtige Punkte hierzu: Ich setze alle drei Timeout-Werte immer explizit. Die Standardwerte von Dio sind recht grosszuegig, und bei mobilen Netzen ist es besser, schnell einen Fehler zu melden, als den Nutzer auf einen Spinner starren zu lassen. Das Auth-Token injiziere ich beim Erstellen, aktualisiere es aber ueber einen Interceptor -- den zeige ich gleich.
Interceptoren in der Praxis
Interceptoren sind die grosse Staerke von Dio. In Produktions-Apps richte ich immer Interceptoren fuer drei Dinge ein: Logging, Token-Erneuerung und Retry-Logik.
Logging-Interceptor
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final method = options.method.toUpperCase();
final url = options.uri.toString();
debugPrint(class="code-string">'→ $method $url');
if (options.data != null) {
debugPrint(class="code-string">' Body: ${options.data}');
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final code = response.statusCode;
final url = response.requestOptions.uri.toString();
debugPrint(class="code-string">'← $code $url');
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
debugPrint(class="code-string">'✗ ${err.type}: ${err.message}');
handler.next(err);
}
}Auth-Interceptor mit Token-Erneuerung
Das ist der Interceptor, den ich am haeufigsten verwende. Wenn eine 401-Antwort kommt, versucht er eine Token-Erneuerung und wiederholt die urspruengliche Anfrage genau einmal. Schlaegt die Erneuerung selbst fehl, wird der Nutzer abgemeldet.
class AuthInterceptor extends Interceptor {
final TokenStorage _tokenStorage;
final AuthService _authService;
bool _isRefreshing = false;
AuthInterceptor({
required TokenStorage tokenStorage,
required AuthService authService,
}) : _tokenStorage = tokenStorage,
_authService = authService;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokenStorage.getAccessToken();
if (token != null) {
options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != class="code-number">401 || _isRefreshing) {
return handler.next(err);
}
_isRefreshing = true;
try {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) throw Exception(class="code-string">'Kein Refresh-Token');
final newTokens = await _authService.refreshToken(refreshToken);
await _tokenStorage.saveTokens(newTokens);
class=class="code-string">"code-comment">// Urspruengliche Anfrage mit neuem Token wiederholen
final options = err.requestOptions;
options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer ${newTokens.accessToken}';
final response = await Dio().fetch(options);
handler.resolve(response);
} catch (_) {
await _tokenStorage.clear();
handler.reject(err);
} finally {
_isRefreshing = false;
}
}
}Retry-Interceptor
Mobile Netzwerkbedingungen sind unvorhersehbar. Ein einfacher Retry mit exponentiellem Backoff spart viele Nutzerbeschwerden.
class RetryInterceptor extends Interceptor {
final Dio dio;
final int maxRetries;
RetryInterceptor({required this.dio, this.maxRetries = class="code-number">2});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final isRetryable = err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.receiveTimeout ||
err.type == DioExceptionType.connectionError;
if (!isRetryable) return handler.next(err);
int attempt = err.requestOptions.extra[class="code-string">'retryCount'] ?? class="code-number">0;
if (attempt >= maxRetries) return handler.next(err);
attempt++;
err.requestOptions.extra[class="code-string">'retryCount'] = attempt;
final delay = Duration(milliseconds: class="code-number">300 * (class="code-number">1 << attempt));
await Future.delayed(delay);
try {
final response = await dio.fetch(err.requestOptions);
handler.resolve(response);
} on DioException catch (e) {
handler.next(e);
}
}
}Vollstaendige API-Schicht-Architektur
Die Architektur, die ich verwende, folgt Clean-Architecture-Prinzipien, ohne in uebertriebene Abstraktion zu verfallen. Der Gesamtueberblick:
UI (Widgets)
↓
BLoC / Cubit / Riverpod
↓
Repository (Abstraktionen)
↓
Remote Data Source ←→ Local Data Source
↓ ↓
Dio (HTTP) Hive / Drift / SharedPrefsData Transfer Objects (DTOs)
DTOs bilden API-Antworten eins-zu-eins ab. Lassen Sie JSON-Strukturen der API niemals in Ihre Domain-Schicht durchsickern.
class UserDto {
final int id;
final String userName;
final String emailAddress;
final String? avatarUrl;
final String createdAt;
UserDto({
required this.id,
required this.userName,
required this.emailAddress,
this.avatarUrl,
required this.createdAt,
});
factory UserDto.fromJson(Map<String, dynamic> json) => UserDto(
id: json[class="code-string">'id'] as int,
userName: json[class="code-string">'user_name'] as String,
emailAddress: json[class="code-string">'email_address'] as String,
avatarUrl: json[class="code-string">'avatar_url'] as String?,
createdAt: json[class="code-string">'created_at'] as String,
);
Map<String, dynamic> toJson() => {
class="code-string">'id': id,
class="code-string">'user_name': userName,
class="code-string">'email_address': emailAddress,
class="code-string">'avatar_url': avatarUrl,
class="code-string">'created_at': createdAt,
};
User toDomain() => User(
id: id,
name: userName,
email: emailAddress,
avatarUrl: avatarUrl,
memberSince: DateTime.parse(createdAt),
);
}Domain-Modell
class User {
final int id;
final String name;
final String email;
final String? avatarUrl;
final DateTime memberSince;
const User({
required this.id,
required this.name,
required this.email,
this.avatarUrl,
required this.memberSince,
});
}Remote Data Source
abstract class UserRemoteDataSource {
Future<UserDto> getUser(int id);
Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20});
Future<UserDto> updateUser(int id, Map<String, dynamic> data);
}
class UserRemoteDataSourceImpl implements UserRemoteDataSource {
final Dio _dio;
UserRemoteDataSourceImpl(this._dio);
@override
Future<UserDto> getUser(int id) async {
final response = await _dio.get(class="code-string">'/users/$id');
return UserDto.fromJson(response.data);
}
@override
Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20}) async {
final response = await _dio.get(
class="code-string">'/users',
queryParameters: {class="code-string">'page': page, class="code-string">'limit': limit},
);
final list = response.data[class="code-string">'data'] as List;
return list.map((e) => UserDto.fromJson(e)).toList();
}
@override
Future<UserDto> updateUser(int id, Map<String, dynamic> data) async {
final response = await _dio.put(class="code-string">'/users/$id', data: data);
return UserDto.fromJson(response.data);
}
}Repository
Das Repository ist das Tor zwischen Domain- und Datenschicht. Es entscheidet, ob Daten aus dem Netzwerk oder aus dem Cache geholt werden, und wandelt DTOs in Domain-Modelle um.
abstract class UserRepository {
Future<Result<User>> getUser(int id);
Future<Result<List<User>>> getUsers({int page = class="code-number">1});
Future<Result<User>> updateUser(int id, Map<String, dynamic> data);
}
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remoteDataSource;
final UserLocalDataSource _localDataSource;
UserRepositoryImpl({
required UserRemoteDataSource remoteDataSource,
required UserLocalDataSource localDataSource,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource;
@override
Future<Result<User>> getUser(int id) async {
try {
final dto = await _remoteDataSource.getUser(id);
await _localDataSource.cacheUser(dto);
return Result.success(dto.toDomain());
} on DioException catch (e) {
class=class="code-string">"code-comment">// Bei Netzwerkfehler auf Cache zurueckfallen
final cached = await _localDataSource.getCachedUser(id);
if (cached != null) return Result.success(cached.toDomain());
return Result.failure(NetworkFailure.fromDioException(e));
} catch (e) {
return Result.failure(UnexpectedFailure(e.toString()));
}
}
@override
Future<Result<List<User>>> getUsers({int page = class="code-number">1}) async {
try {
final dtos = await _remoteDataSource.getUsers(page: page);
return Result.success(dtos.map((d) => d.toDomain()).toList());
} on DioException catch (e) {
return Result.failure(NetworkFailure.fromDioException(e));
}
}
@override
Future<Result<User>> updateUser(int id, Map<String, dynamic> data) async {
try {
final dto = await _remoteDataSource.updateUser(id, data);
await _localDataSource.cacheUser(dto);
return Result.success(dto.toDomain());
} on DioException catch (e) {
return Result.failure(NetworkFailure.fromDioException(e));
}
}
}Fehlerbehandlungsstrategie
Exceptions ueber Schichtgrenzen hinweg zu werfen, fuehrt zu vergessenen Catch-Bloecken und Abstuerzen in Produktion. Stattdessen verwende ich einen `Result`-Typ, der den Aufrufer zwingt, sowohl Erfolg als auch Fehler explizit zu behandeln.
Der Result-Typ
sealed class Result<T> {
const Result();
factory Result.success(T data) = Success<T>;
factory Result.failure(Failure failure) = Err<T>;
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
});
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
@override
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
}) => success(data);
}
class Err<T> extends Result<T> {
final Failure failure;
const Err(this.failure);
@override
R when<R>({
required R Function(T data) success,
required R Function(Failure failure) failure,
}) => failure(this.failure);
}Typisierte Fehler
sealed class Failure {
final String message;
const Failure(this.message);
}
class NetworkFailure extends Failure {
final int? statusCode;
const NetworkFailure(super.message, {this.statusCode});
factory NetworkFailure.fromDioException(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout =>
const NetworkFailure(class="code-string">'Verbindungs-Timeout'),
DioExceptionType.receiveTimeout =>
const NetworkFailure(class="code-string">'Server hat zu lange gebraucht'),
DioExceptionType.badResponse => NetworkFailure(
e.response?.data?[class="code-string">'message'] ?? class="code-string">'Serverfehler',
statusCode: e.response?.statusCode,
),
DioExceptionType.connectionError =>
const NetworkFailure(class="code-string">'Keine Internetverbindung'),
_ => NetworkFailure(class="code-string">'Netzwerkfehler: ${e.message}'),
};
}
}
class CacheFailure extends Failure {
const CacheFailure(super.message);
}
class UnexpectedFailure extends Failure {
const UnexpectedFailure(super.message);
}Result in der UI-Schicht verwenden
class UserCubit extends Cubit<UserState> {
final UserRepository _repository;
UserCubit(this._repository) : super(const UserState.loading());
Future<void> loadUser(int id) async {
emit(const UserState.loading());
final result = await _repository.getUser(id);
result.when(
success: (user) => emit(UserState.loaded(user)),
failure: (failure) => emit(UserState.error(failure.message)),
);
}
}Dieses Pattern eliminiert die Moeglichkeit unbehandelter Exceptions. Jede Aufrufstelle muss sich mit dem Fehlerfall befassen, und der Compiler hilft dabei.
Offline-Unterstuetzung und Caching
Mobile Nutzer verlieren staendig die Verbindung. Eine gute API-Schicht degradiert elegant, statt Fehlerbildschirme anzuzeigen.
Local Data Source mit Hive
abstract class UserLocalDataSource {
Future<void> cacheUser(UserDto user);
Future<UserDto?> getCachedUser(int id);
Future<void> cacheUsers(List<UserDto> users);
Future<List<UserDto>> getCachedUsers();
Future<void> clearCache();
}
class UserLocalDataSourceImpl implements UserLocalDataSource {
final Box<Map> _userBox;
UserLocalDataSourceImpl(this._userBox);
@override
Future<void> cacheUser(UserDto user) async {
await _userBox.put(user.id.toString(), user.toJson());
}
@override
Future<UserDto?> getCachedUser(int id) async {
final json = _userBox.get(id.toString());
if (json == null) return null;
return UserDto.fromJson(Map<String, dynamic>.from(json));
}
@override
Future<void> cacheUsers(List<UserDto> users) async {
final entries = {for (var u in users) u.id.toString(): u.toJson()};
await _userBox.putAll(entries);
}
@override
Future<List<UserDto>> getCachedUsers() async {
return _userBox.values
.map((e) => UserDto.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
@override
Future<void> clearCache() async => await _userBox.clear();
}Cache-First-Strategie
In der Praxis verwende ich ein Cache-First-Pattern fuer Daten, die sich selten aendern (Nutzerprofile, Einstellungen), und ein Network-First-Pattern fuer alles andere. Das Repository entscheidet, welche Strategie angewandt wird:
Future<Result<User>> getUser(int id) async {
class=class="code-string">"code-comment">// Zuerst Cache pruefen
final cached = await _localDataSource.getCachedUser(id);
if (cached != null) {
class=class="code-string">"code-comment">// Cached-Daten sofort zurueckgeben, im Hintergrund aktualisieren
_refreshUserInBackground(id);
return Result.success(cached.toDomain());
}
class=class="code-string">"code-comment">// Kein Cache, muss vom Netzwerk holen
try {
final dto = await _remoteDataSource.getUser(id);
await _localDataSource.cacheUser(dto);
return Result.success(dto.toDomain());
} on DioException catch (e) {
return Result.failure(NetworkFailure.fromDioException(e));
}
}
void _refreshUserInBackground(int id) async {
try {
final dto = await _remoteDataSource.getUser(id);
await _localDataSource.cacheUser(dto);
} catch (_) {
class=class="code-string">"code-comment">// Stiller Fehler — gecachte Daten sind weiterhin gueltig
}
}Konnektivitaetsueberwachung
Ich kombiniere Caching mit einem Konnektivitaets-Listener, damit die UI ein Offline-Banner anzeigen und sinnlose Netzwerkanfragen vermeiden kann:
class ConnectivityService {
final Connectivity _connectivity = Connectivity();
Stream<bool> get onlineStream =>
_connectivity.onConnectivityChanged.map(
(result) => result != ConnectivityResult.none,
);
Future<bool> get isOnline async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
}JSON-Serialisierung
Fuer alles jenseits eines trivialen Projekts verwende ich `json_serializable` mit `freezed`. Ein praktisches Setup:
class=class="code-string">"code-comment">// pubspec.yaml dependencies
class=class="code-string">"code-comment">// json_annotation, freezed_annotation
class=class="code-string">"code-comment">// dev_dependencies: json_serializable, freezed, build_runner
@freezed
class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse({
required bool success,
required T data,
String? message,
ApiMeta? meta,
}) = _ApiResponse;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object?) fromJsonT,
) => _$ApiResponseFromJson(json, fromJsonT);
}
@freezed
class ApiMeta with _$ApiMeta {
const factory ApiMeta({
required int currentPage,
required int lastPage,
required int perPage,
required int total,
}) = _ApiMeta;
factory ApiMeta.fromJson(Map<String, dynamic> json) =>
_$ApiMetaFromJson(json);
}Codegenerierung ausfuehren mit:
dart run build_runner build --delete-conflicting-outputsHaeufige API-Integrationsfehler
Das sind Probleme, die ich wiederholt in Codebasen gesehen habe, die ich reviewt oder uebernommen habe.
1. API-Aufrufe direkt aus Widgets
Das ist der haeufigste Fehler. Wenn Dio direkt im Widget aufgerufen wird, verliert man Testbarkeit, Wiederverwendbarkeit und jede Moeglichkeit, die Datenquelle spaeter auszutauschen.
class=class="code-string">"code-comment">// Schlecht: API-Aufruf im Widget
class UserScreen extends StatefulWidget { ... }
class _UserScreenState extends State<UserScreen> {
Future<void> _loadUser() async {
final response = await Dio().get(class="code-string">'https:class=class="code-string">"code-comment">//api.example.com/users/class="code-number">1');
setState(() { _user = User.fromJson(response.data); });
}
}
class=class="code-string">"code-comment">// Gut: Ueber ein Repository gehen
class _UserScreenState extends State<UserScreen> {
Future<void> _loadUser() async {
final result = await userRepository.getUser(class="code-number">1);
result.when(
success: (user) => setState(() { _user = user; }),
failure: (f) => _showError(f.message),
);
}
}2. Abbruch ignorieren
Wenn ein Nutzer die Seite verlaesst, waehrend eine Anfrage laeuft, sollte man sie abbrechen. Sonst verschwendet man Bandbreite und riskiert, `setState` auf einem disposed Widget aufzurufen.
class _UserScreenState extends State<UserScreen> {
final CancelToken _cancelToken = CancelToken();
Future<void> _loadUser() async {
final response = await dio.get(
class="code-string">'/users/class="code-number">1',
cancelToken: _cancelToken,
);
class=class="code-string">"code-comment">// ...
}
@override
void dispose() {
_cancelToken.cancel(class="code-string">'Widget disposed');
super.dispose();
}
}3. Fehlende Paginierung
Alle Datensaetze auf einmal zu laden, funktioniert in der Entwicklung mit 10 Eintraegen. In Produktion mit 10.000 bricht es zusammen. Implementieren Sie von Anfang an Cursor- oder Offset-basierte Paginierung.
class PaginatedResult<T> {
final List<T> items;
final int currentPage;
final int totalPages;
final bool hasMore;
PaginatedResult({
required this.items,
required this.currentPage,
required this.totalPages,
}) : hasMore = currentPage < totalPages;
}4. Base-URLs hartcodieren
Verwenden Sie umgebungsspezifische Konfiguration. Ich definiere Base-URLs typischerweise pro Flavor:
enum Environment { dev, staging, prod }
class AppConfig {
final String baseUrl;
final Duration timeout;
const AppConfig._({required this.baseUrl, required this.timeout});
factory AppConfig.fromEnvironment(Environment env) {
return switch (env) {
Environment.dev => const AppConfig._(
baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//dev-api.example.com',
timeout: Duration(seconds: class="code-number">30),
),
Environment.staging => const AppConfig._(
baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//staging-api.example.com',
timeout: Duration(seconds: class="code-number">15),
),
Environment.prod => const AppConfig._(
baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//api.example.com',
timeout: Duration(seconds: class="code-number">10),
),
};
}
}5. Fehler stillschweigend verschlucken
Leere Catch-Bloecke sind der Feind der Fehlersuche. Loggen Sie mindestens den Fehler. Besser noch, verwenden Sie das Result-Pattern, damit Fehler immer sichtbar werden.
class=class="code-string">"code-comment">// Furchtbar: stiller Fehler
try {
await dio.post(class="code-string">'/orders', data: order.toJson());
} catch (_) {}
class=class="code-string">"code-comment">// Besser: als typisierten Fehler weiterreichen
try {
await dio.post(class="code-string">'/orders', data: order.toJson());
} on DioException catch (e) {
return Result.failure(NetworkFailure.fromDioException(e));
}Die API-Schicht testen
Eine sauber geschichtete API-Architektur ist trivial testbar. Mocken Sie die Data Source, testen Sie die Repository-Logik isoliert.
class MockUserRemoteDataSource extends Mock
implements UserRemoteDataSource {}
void main() {
late UserRepositoryImpl repository;
late MockUserRemoteDataSource mockRemote;
late MockUserLocalDataSource mockLocal;
setUp(() {
mockRemote = MockUserRemoteDataSource();
mockLocal = MockUserLocalDataSource();
repository = UserRepositoryImpl(
remoteDataSource: mockRemote,
localDataSource: mockLocal,
);
});
test(class="code-string">'holt Nutzer vom Server und cached ihn', () async {
final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
when(() => mockRemote.getUser(class="code-number">1)).thenAnswer((_) async => dto);
when(() => mockLocal.cacheUser(dto)).thenAnswer((_) async {});
final result = await repository.getUser(class="code-number">1);
expect(result, isA<Success<User>>());
verify(() => mockLocal.cacheUser(dto)).called(class="code-number">1);
});
test(class="code-string">'faellt bei Netzwerkfehler auf Cache zurueck', () async {
final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
when(() => mockRemote.getUser(class="code-number">1)).thenThrow(
DioException(type: DioExceptionType.connectionError, ...),
);
when(() => mockLocal.getCachedUser(class="code-number">1)).thenAnswer((_) async => dto);
final result = await repository.getUser(class="code-number">1);
expect(result, isA<Success<User>>());
});
}Fazit
Eine saubere API-Schicht ist das Fundament jeder zuverlaessigen Flutter-App. Die hier beschriebene Architektur -- Dio mit Interceptoren, das Repository-Pattern, typisierte Fehlerbehandlung mit Result und Offline-Caching -- hat mir in Dutzenden von Produktionsprojekten gute Dienste geleistet. Im Vergleich zu rohen HTTP-Aufrufen erfordert sie etwas mehr Einrichtungsaufwand, aber der Gewinn an Wartbarkeit, Testbarkeit und Nutzererfahrung ist enorm.
Ich unterstuetze gern bei Review und Refactoring Ihrer API-Schicht.
Verwandte Artikel
Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Lernen Sie, Clean Architecture in Flutter praxisnah umzusetzen. Ein Leitfaden für Schichten, Dependency Management und testbaren Code.
Flutter Performance-Optimierung: Vollständiger Leitfaden
Steigern Sie die Performance Ihrer Flutter-App systematisch. Lernen Sie Rebuild-Optimierung, Speichermanagement, Lazy Loading und Profiling.
Flutter Firebase Integration: Auth, Firestore und Push
Richten Sie Firebase in Flutter ein: Auth-Flows, Firestore-Nutzung und grundlegende Sicherheit für Produktion.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen