Entwickler · API v1.0.0

Flutter-Integration

Dieser Leitfaden richtet sich an Android- und iOS-Apps, die mit Flutter entwickelt werden. Die allgemeine API-Referenz finden Sie in der Überblick.

Unterstützte Versionen

Komponente Minimum Empfohlen
Flutter 3.22 aktuelles Stable
Dart 3.4 aktuell
firebase_messaging 15.x aktuell
Android 6.0 (API 23) targetSdk 35+
iOS 13.0 15.0+

Wie funktioniert es?

NotiPilot 1.0.0 stellt Benachrichtigungen über den Expo Push Service zu. Ihre Flutter-App:

  1. ruft mit firebase_messaging den nativen Push-Token ab (Android: FCM-Token, iOS: APNs-Token),
  2. konvertiert diesen Token über den Expo-Token-Service in einen Expo Push Token,
  3. registriert den Expo Push Token mit register-device bei NotiPilot.

Ihre App muss nicht mit Expo entwickelt sein; Expo dient lediglich als Zustellungsinfrastruktur.

1. Vorbereitung (einmalig)

  1. Verbinden Sie das Firebase-Projekt mit Ihrer Flutter-App:
    Terminal
    dart pub global activate flutterfire_cli
    flutterfire configure
  2. Legen Sie auf expo.dev ein Projekt an und notieren Sie die Projekt-ID (UUID).
  3. Laden Sie die Zugangsdaten in das Expo-Projekt hoch (expo.dev → Projekt → Credentials):
    • Android: Firebase → Projekteinstellungen → Dienstkonten → über Neuen privaten Schlüssel generieren heruntergeladenes JSON → FCM V1 service account key
    • iOS: Im Apple Developer Portal erstellter APNs-Schlüssel (.p8) → Push Key (Schritte im iOS-Leitfaden)
  4. Legen Sie Ihre App im NotiPilot-Dashboard an: Expo Project ID, Expo Access Token, Android Package, iOS Bundle ID.

2. Abhängigkeiten

Terminal
flutter pub add firebase_core firebase_messaging http flutter_secure_storage uuid package_info_plus

iOS: Fügen Sie in Xcode unter ios/Runner.xcworkspace → Runner-Target → Signing & Capabilities die Capabilities Push Notifications und Background Modes → Remote notifications hinzu.

Android: In android/app/build.gradle muss minSdk 23 gesetzt sein. Die Benachrichtigungsberechtigung unter Android 13+ wird von firebase_messaging angefragt.

3. lib/notipilot.dart

Dart
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:ui' show PlatformDispatcher;

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:uuid/uuid.dart';

class NotiPilot {
  NotiPilot._();
  static final NotiPilot instance = NotiPilot._();

  static const _baseUrl = 'https://app.notipilot.com/api/v1';
  static const _appId = 'IHRE-EXPO-PROJEKT-ID'; // "Expo Project ID" aus dem Dashboard
  static const _uidKey = 'notipilot_device_uid';

  final _storage = const FlutterSecureStorage();
  final _messaging = FirebaseMessaging.instance;

  Future<String> deviceUid() async {
    var uid = await _storage.read(key: _uidKey);
    if (uid == null) {
      uid = const Uuid().v4();
      await _storage.write(key: _uidKey, value: uid);
    }
    return uid;
  }

  /// Beim App-Start aufrufen. Fragt die Berechtigung an, ruft den Token ab und registriert ihn bei NotiPilot.
  Future<Map<String, dynamic>?> registerDevice({Map<String, Object?> extraAttributes = const {}}) async {
    final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true);
    if (settings.authorizationStatus == AuthorizationStatus.denied) return null;

    final uid = await deviceUid();
    final expoToken = await _getExpoPushToken(uid);
    if (expoToken == null) return null;

    final info = await PackageInfo.fromPlatform();
    final locale = PlatformDispatcher.instance.locale;

    return _post('/register-device', {
      'app_id': _appId,
      'device_uid': uid,
      'token': expoToken,
      'platform': Platform.isIOS ? 'ios' : 'android',
      'provider': 'expo',
      'attributes': {
        'locale': locale.languageCode,   // "tr"
        'country': locale.countryCode,   // "TR"
        'app_version': info.version,
        ...extraAttributes,              // z. B. {'city': 'Istanbul'}
      },
    });
  }

  /// Aufrufen, wenn sich der Nutzer anmeldet.
  Future<Map<String, dynamic>> identify(String externalId, {Map<String, Object?>? attributes}) async {
    return _post('/identify-device', {
      'app_id': _appId,
      'device_uid': await deviceUid(),
      'external_id': externalId,
      if (attributes != null) 'attributes': attributes,
    });
  }

  /// Hält NotiPilot bei Token-Erneuerung aktuell. Einmal beim App-Start aufrufen.
  void listenForTokenRefresh() {
    _messaging.onTokenRefresh.listen((_) => registerDevice());
  }

  Future<String?> _getExpoPushToken(String uid) async {
    final isIOS = Platform.isIOS;
    String? deviceToken;

    if (isIOS) {
      // Der APNs-Token steht kurz nach dem App-Start zur Verfügung
      for (var i = 0; i < 5 && deviceToken == null; i++) {
        deviceToken = await _messaging.getAPNSToken();
        if (deviceToken == null) await Future.delayed(const Duration(seconds: 1));
      }
    } else {
      deviceToken = await _messaging.getToken();
    }
    if (deviceToken == null) return null;

    final info = await PackageInfo.fromPlatform();
    final res = await http.post(
      Uri.parse('https://exp.host/--/api/v2/push/getExpoPushToken'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'type': isIOS ? 'apns' : 'fcm',
        'deviceId': uid.toLowerCase(),
        'development': isIOS && kDebugMode, // iOS-Debug-Builds nutzen die APNs-Sandbox
        'appId': info.packageName,          // Android package / iOS bundle id
        'deviceToken': deviceToken,
        'projectId': _appId,
      }),
    );
    if (res.statusCode != 200) {
      debugPrint('[NotiPilot] Expo token exchange failed: ${res.statusCode} ${res.body}');
      return null;
    }
    return (jsonDecode(res.body)['data'] as Map<String, dynamic>)['expoPushToken'] as String;
  }

  Future<Map<String, dynamic>> _post(String path, Map<String, Object?> body, [int attempt = 0]) async {
    final res = await http.post(
      Uri.parse('$_baseUrl$path'),
      headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
      body: jsonEncode(body),
    );
    final json = res.body.isEmpty ? <String, dynamic>{} : jsonDecode(res.body) as Map<String, dynamic>;

    if ((res.statusCode == 429 || res.statusCode >= 500) && attempt < 3) {
      final wait = (json['retry_after'] as num?)?.toInt() ?? (1 << attempt);
      await Future.delayed(Duration(seconds: wait));
      return _post(path, body, attempt + 1);
    }
    if (res.statusCode >= 400) {
      debugPrint('[NotiPilot] ${res.statusCode} ${json['error']} ${json['errors'] ?? json['message']}');
    }
    return json;
  }

  /// Gibt das vom Dashboard gesendete benutzerdefinierte Feld `data` zurück.
  static Map<String, dynamic> customData(RemoteMessage message) {
    final raw = message.data['body'];
    if (raw is String) {
      try {
        return jsonDecode(raw) as Map<String, dynamic>;
      } catch (_) {}
    }
    return Map<String, dynamic>.from(message.data);
  }
}

4. main.dart

Dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';
import 'notipilot.dart';

@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);

  // iOS: Benachrichtigung auch anzeigen, wenn die App im Vordergrund ist
  await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
    alert: true, badge: true, sound: true,
  );

  NotiPilot.instance.registerDevice(extraAttributes: {'city': 'Istanbul'});
  NotiPilot.instance.listenForTokenRefresh();

  // Beim Tippen auf die Benachrichtigung
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    final data = NotiPilot.customData(message);
    // z. B. data['screen'] == 'product' → mit dem Navigator weiterleiten
  });

  runApp(const MyApp());
}

Wenn sich der Nutzer anmeldet: await NotiPilot.instance.identify(user.id);

Vordergrund-Benachrichtigungen unter Android: Ist die App geöffnet, zeigt Android die Benachrichtigung nicht automatisch an. Wenn Benachrichtigungen auch im Vordergrund erscheinen sollen, erstellen Sie sie mit dem Paket flutter_local_notifications in FirebaseMessaging.onMessage über einen Kanal mit der ID default. Verwenden Sie für den Titel message.notification?.title ?? message.data['title'] und für den Text message.notification?.body ?? message.data['message'].

Checkliste

  • flutterfire configure wurde ausgeführt
  • FCM-V1-Schlüssel und APNs-Schlüssel (.p8) in das Expo-Projekt hochgeladen
  • Expo Project ID im Dashboard = _appId, Paketname / Bundle ID im Dashboard korrekt
  • Unter iOS sind die Capabilities Push Notifications + Remote notifications aktiviert
  • listenForTokenRefresh() wird beim Start aufgerufen
  • country und nach Möglichkeit city werden als Attributes gesendet

Häufige Probleme

Symptom Lösung
getAPNSToken() liefert unter iOS null Verwenden Sie ein physisches Gerät und prüfen Sie die Capabilities sowie die Benachrichtigungsberechtigung.
Token-Konvertierung liefert 4xx Prüfen Sie die Werte von projectId und appId (Paketname / Bundle ID).
Registrierung erfolgreich, aber keine Benachrichtigung Im Expo-Projekt fehlen die Zugangsdaten für die betreffende Plattform.
404 unknown_app app_id ist im Dashboard nicht registriert.