Geliştiriciler · API v1.0.0

Flutter Entegrasyonu

Bu rehber Flutter ile geliştirilen Android ve iOS uygulamaları içindir. Genel API referansı için Genel bakış.

Desteklenen sürümler

Bileşen Minimum Önerilen
Flutter 3.22 güncel stable
Dart 3.4 güncel
firebase_messaging 15.x güncel
Android 6.0 (API 23) targetSdk 35+
iOS 13.0 15.0+

Nasıl çalışır?

NotiPilot 1.0.0 bildirimleri Expo Push Service üzerinden teslim eder. Flutter uygulamanız:

  1. firebase_messaging ile native push token'ı alır (Android: FCM token, iOS: APNs token),
  2. Bu token'ı Expo token servisinde bir Expo Push Token'a dönüştürür,
  3. Expo Push Token'ı NotiPilot'a register-device ile kaydeder.

Uygulamanızın Expo ile yazılmış olması gerekmez; Expo yalnızca teslimat altyapısıdır.

1. Ön hazırlık (bir kez)

  1. Firebase projesini Flutter uygulamanıza bağlayın:
    Terminal
    dart pub global activate flutterfire_cli
    flutterfire configure
  2. expo.dev üzerinde bir proje oluşturun ve proje ID'sini (UUID) not edin.
  3. Kimlik bilgilerini Expo projesine yükleyin (expo.dev → Proje → Credentials):
    • Android: Firebase → Proje Ayarları → Hizmet Hesapları → Yeni özel anahtar ile indirilen JSON → FCM V1 service account key
    • iOS: Apple Developer'dan oluşturulan APNs .p8 anahtarı → Push Key (adımlar için iOS rehberi)
  4. NotiPilot panelinde uygulamanızı ekleyin: Expo Project ID, Expo Access Token, Android Package, iOS Bundle ID.

2. Bağımlılıklar

Terminal
flutter pub add firebase_core firebase_messaging http flutter_secure_storage uuid package_info_plus

iOS: Xcode'da ios/Runner.xcworkspace → Runner target → Signing & Capabilities → Push Notifications ve Background Modes → Remote notifications ekleyin.

Android: android/app/build.gradle içinde minSdk 23 olmalıdır. Android 13+ bildirim izni firebase_messaging tarafından istenir.

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 = 'EXPO-PROJE-ID-NIZ'; // Paneldeki "Expo Project ID"
  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;
  }

  /// Uygulama açılışında çağırın. İzin ister, token'ı alır ve NotiPilot'a kaydeder.
  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,              // örn. {'city': 'Istanbul'}
      },
    });
  }

  /// Kullanıcı giriş yaptığında çağırın.
  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,
    });
  }

  /// Token yenilendiğinde NotiPilot'u güncel tutar. Uygulama başlangıcında bir kez çağırın.
  void listenForTokenRefresh() {
    _messaging.onTokenRefresh.listen((_) => registerDevice());
  }

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

    if (isIOS) {
      // APNs token uygulama açılışından kısa süre sonra hazır olur
      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 build'leri APNs sandbox kullanır
        '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;
  }

  /// Panelden gönderilen özel `data` alanını döndürür.
  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: uygulama ön plandayken de bildirimi göster
  await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
    alert: true, badge: true, sound: true,
  );

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

  // Bildirime tıklanınca
  FirebaseMessaging.onMessageOpenedApp.listen((message) {
    final data = NotiPilot.customData(message);
    // örn. data['screen'] == 'product' → Navigator ile yönlendir
  });

  runApp(const MyApp());
}

Kullanıcı giriş yaptığında: await NotiPilot.instance.identify(user.id);

Android ön plan bildirimleri: Uygulama açıkken Android bildirimi otomatik göstermez. Ön planda da bildirim göstermek istiyorsanız flutter_local_notifications paketi ile FirebaseMessaging.onMessage içinde, default ID'li bir kanal üzerinden bildirim oluşturun. Başlık için message.notification?.title ?? message.data['title'], metin için message.notification?.body ?? message.data['message'] kullanın.

Kontrol listesi

  • flutterfire configure çalıştırıldı
  • FCM V1 anahtarı ve APNs .p8 anahtarı Expo projesine yüklendi
  • Paneldeki Expo Project ID = _appId, paket adı / bundle ID panelde doğru
  • iOS'ta Push Notifications + Remote notifications capability'leri açık
  • listenForTokenRefresh() başlangıçta çağrılıyor
  • country ve mümkünse city attributes olarak gönderiliyor

Sık karşılaşılan sorunlar

Belirti Çözüm
iOS'ta getAPNSToken() null Fiziksel cihaz kullanın, capability'leri ve bildirim iznini kontrol edin.
Token dönüşümü 4xx dönüyor projectId ve appId (paket adı / bundle ID) değerlerini kontrol edin.
Kayıt başarılı, bildirim gelmiyor Expo projesinde ilgili platformun kimlik bilgisi eksik.
404 unknown_app app_id panelde kayıtlı değil.