# Flutter için App Store Operator event rehberi

> **Durum:** v1 taslağı, 2026-10-23. Burada anlatılan API planlandı ama henüz yayında değil. API yayına girince bu satırı kaldıracağız.
>
> **Kimin için:** Bir Flutter uygulamasının iOS build'ine event takibi ekleyen Flutter geliştiricileri ve bu belgeyi verdikleri yapay zekâ kodlama ajanları. Entegrasyon için gereken her şey bu dosyada; bir ajan işi yalnızca bu dosyayla bitirebilir. App Store Operator şimdilik yalnızca iOS uygulamalarını destekliyor. Tracker, Android, web ve masaüstü build'lerinden hiçbir şey göndermez (Android desteği planlanıyor).

---

## Yapay zekâ kodlama ajanıysanız önce burayı okuyun

Bir Flutter uygulamasına event takibi ekliyorsunuz. Uygulamanın sahibi bu event'lerle App Store Operator'da funnel'lar oluşturacak. Uygulamanın iOS build'i event'leri `X-Write-Key` header'ıyla `POST https://api.app-store-operator.com/v1/events` adresine gönderecek. Diğer platformlarda tracker hiçbir şey yapmaz.

**Sırasıyla yapılacaklar**

1. **Yazma anahtarını alın.** Bu belgede hâlâ `YOUR_WRITE_KEY` yazıyorsa anahtarı geliştiriciden isteyin (Dashboard → uygulama → Events → Setup; anahtar `aso_wk_` ile başlar). Kendiniz anahtar uydurmayın, yer tutucuyu da kodda bırakmayın.
2. **Tracker'ı ekleyin.** [§5](#5-referans-kod)'teki dört paketi `flutter pub add` ile ekleyin. Sonra §5'teki dosyayı hiç değiştirmeden `lib/event_tracker.dart` olarak projeye kopyalayın. iOS tarafında native extension'lar (widget, share, notification extension'ları) varsa onlardan event izlemeyin.
3. **Uygulamaya bağlayın** ([§5.1](#51-uygulamaya-bağlama)). `main()` içinde, `WidgetsFlutterBinding.ensureInitialized()` çağrısından sonra `configure(writeKey: ...)`'u bir kez çağırın ve `app_opened` event'ini izleyin. Uygulama arka plana geçince `flush()` çağırın, arka plandan her dönüşte de `app_opened` event'ini tekrar izleyin.
4. **`track(...)` çağrılarını ekleyin.** [§6](#6-hangi-eventler-izlenmeli)'daki event'lerden akışı bu uygulamada olan her biri için bir çağrı ekleyin. Akışı olmayanları atlayın.
5. **`identify` ve `reset` çağrılarını** yalnızca uygulamanın kendi oturum açma (login) sistemi varsa ekleyin ([§5.2](#52-event-takibi)).
6. **Bitirince** [§9 Kontrol listesi](#9-kontrol-listesi)'ni madde madde gözden geçirin. Her maddeyi tamamlandı, uygulanamaz veya beklemede olarak raporlayın ve yanına bunu gösteren dosyayı ve satırı yazın.

**Kurallar**

- `lib/event_tracker.dart`'ın davranışını (batch'leme, retry, endpoint, kuyruk, yalnızca iOS kontrolü) değiştirmeyin. Uygulamaya uymuyorsa dosyayı yeniden yazmayın, nedenini raporlayın.
- §5'teki dört paket (`path_provider`, `shared_preferences`, `package_info_plus`, `device_info_plus`) dışında üçüncü taraf bağımlılık eklemeyin. Uygulama bunlardan birini zaten kullanıyorsa olduğu gibi bırakın; sürüm çakışması olursa `flutter pub add` zaten uyarır.
- Tracker'ı Android, web veya masaüstünde çalışır hâle getirmeye uğraşmayın. App Store Operator şimdilik yalnızca iOS install'larını sayıyor.
- Kişiyi doğrudan tanımlayan hiçbir veri göndermeyin: ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metin ([§7](#7-gizlilik)). Uygulama App Store'da Kids kategorisindeyse durun ve geliştiriciye bu tracker'ın eklenmemesi gerektiğini söyleyin ([§7.8](#78-çocuklara-yönelik-uygulamalar)). Uygulama analitik için kullanıcıdan onay alıyorsa, izlemenin bu onayı bekleyip beklemeyeceğini geliştiriciye sorun ([§7.5](#75-gizlilik-politikanız-ve-kullanıcı-onayı)).
- Property değerleri düz olmalı: `String`, `int`, `double`, `bool` veya `null`. İç içe map veya liste göndermeyin ([§4.3](#43-event-nesnesi)); tracker başka türdeki değerleri atar.
- §6'daki event adlarını birebir kullanın. §6'da olmayan bir akışa event adı koymadan önce geliştiriciye sorun.
- `identify`'a yalnızca uygulamanın kendi kalıcı, dahili kullanıcı ID'sini verin; e-posta, telefon numarası veya ad asla vermeyin. Hangi ID olduğundan emin değilseniz sorun.
- Dosyanın en üstündeki durum satırı duruyorsa **API henüz yayında değil** demektir. Yine her şeyi kurup bağlayın, ama §9'daki ağ kontrollerini beklemede olarak raporlayın. İsteğin başarısız olması kodunuzda hata olduğu anlamına gelmez.

Yazma anahtarı bilerek herkese açık tasarlandı (yalnızca tek bir uygulamaya event yazabilir). Bu yüzden kaynak kodda durmasında sakınca yok.

---

## 1. Temel bilgiler

| | |
|---|---|
| Temel URL | `https://api.app-store-operator.com/v1` |
| Kimlik doğrulama | Header `X-Write-Key: <your write key>` (Dashboard → uygulamanız → Events → Setup) |
| Event gönderme | `POST /v1/events` (batch başına 1–100 event) |
| Kullanıcı eşleme | Event hem `anonymous_id` hem `user_id` taşıyorsa otomatik; ya da `POST /v1/identify` |
| İçerik türü | `application/json`, UTF-8 |
| Rate limit | Yazma anahtarı başına dakikada 10.000 event (tüm install'ların toplamı), install (`anonymous_id`) başına dakikada 300 event |
| Aylık event | Hesap başına, tüm uygulamaların toplamı; her ayın 1'inde (UTC) sıfırlanır. Free: ayda 25.000. Planınızın limitinin %110'si aşılınca yeni event'ler bir sonraki aya kadar reddedilir (`403 quota_exceeded`) |
| Event adları | Uygulama başına en fazla 500 farklı ad; bundan sonra yeni adlı bir event reddedilir (`too_many_event_names`). Catalog, event adı başına en fazla 200 property anahtarı listeler |
| Referans kod için minimum sürümler | Dart 3.11 ve Flutter 3.41 (§5'teki paket sürümlerinin gereği); iOS 13. Event'leri yalnızca iOS'ten gönderir |
| Saklama süresi | 13 ay |

## 2. Nasıl çalışır

1. Uygulama ilk açılışta rastgele bir **anonim ID** üretir ve saklar.
2. Takip etmek istediğiniz her eylem bir **event** olur. Event'in bir adı, bir zamanı, isteğe bağlı property'leri ve biraz device bilgisi vardır.
3. Event'ler device'ta kuyruğa girer ve **batch**'ler hâlinde gönderilir. Device çevrimdışıysa event'ler bekler, bağlantı gelince gönderilir.
4. Kullanıcı *sizin* uygulamanızda oturum açınca ona bir **kullanıcı ID'si** verebilirsiniz. Bundan sonraki event'ler iki ID'yi de taşır. App Store Operator da oturum açmadan önceki anonim event'leri ve sonrakileri aynı kişiye ait sayar.
5. Event'lerin canlı olarak geldiğini Dashboard'da görürsünüz ve bunlardan **funnel**'lar kurarsınız. Funnel, tekil kişi sayısıyla ölçülen sıralı adımlardır; örneğin `onboarding_started → paywall_viewed → purchase_completed`.

## 3. Yazma anahtarınızı alın

1. App Store Operator Dashboard'unda **Uygulamalar** → uygulamanız → **Events** → **Setup**'ı açın.
2. Yazma anahtarını kopyalayın (`aso_wk_` ile başlar).
3. Anahtarı terminalden (macOS veya Linux) deneyin. Komut her seferinde yeni bir `event_id` ve o anki zamanı ürettiği için istediğiniz kadar çalıştırabilirsiniz:

```bash
curl -sS https://api.app-store-operator.com/v1/events \
  -H "Content-Type: application/json" \
  -H "X-Write-Key: YOUR_WRITE_KEY" \
  -d '{
    "events": [{
      "event_id": "'"$(uuidgen)"'",
      "name": "integration_test",
      "anonymous_id": "curl-test",
      "occurred_at": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'",
      "properties": { "source": "curl" }
    }]
  }'
```

Yanıtta `accepted` değeri `1`, `rejected` ise boş olmalı. Event birkaç saniye içinde **Events → Canlı**'da görünür.

Neden her seferinde yeni değer? Aynı `event_id` yalnızca bir kez saklanır; aynı ID ile ikinci kez çalıştırırsanız Canlı'da yeni bir şey görmezsiniz. 7 günden eski bir zaman gönderirseniz de onun yerine event'in bize ulaştığı zamanı yazarız.

### 3.1 Anahtarı yenileme

Anahtarı yalnızca kötüye kullanılıyorsa yenileyin, örneğin biri bu anahtarla sahte event'ler gönderiyorsa. Anahtar zaten herkese açık; uygulamanızın içinden görülebiliyor olması yenilemek için bir neden değil.

1. Setup → **Anahtarı yenile**'ye tıklayın. Bunu çalışma alanındaki herkes yapabilir ve işlem kayda geçer.
2. Yeni anahtarı uygulamaya koyun ve bir güncelleme yayınlayın.
3. Kullanıcılar güncellemeye vakit bulsun diye eski anahtar **30 gün** daha çalışır. Bu süreden sonra eski anahtarı kullanan build'ler `401` alır ve referans kod bu build'lerin event'lerini atar. Bu build'leri kullananlar güncelleyene kadar sayılmaz.
4. Eski anahtar hâlâ kötüye kullanılıyorsa Setup → **Eski anahtarı şimdi iptal et** ile 30 günlük süreyi hemen bitirin. Bundan sonra eski anahtarı kullanan build'ler sayılmaz. Bunu da çalışma alanındaki herkes yapabilir ve işlem kayda geçer.
5. Eski anahtarın 30 günü dolmadan **Anahtarı yenile** tekrar kullanılamaz. Bir daha yenilemek için önce eski anahtarı iptal edin ya da 30 günün dolmasını bekleyin.

## 4. API referansı

### 4.1 `POST /v1/events`

Tek istekte 1 ile 100 arası event gönderir.

**Header'lar**

| Header | Zorunlu | Değer |
|---|---|---|
| `Content-Type` | evet | `application/json` |
| `X-Write-Key` | evet | uygulamanızın yazma anahtarı |

**Gövde**

```json
{
  "events": [ <event object>, ... ]
}
```

Gövde en fazla **1 MB** olabilir. Gövdede veya event'lerde tanınmayan alanlar yok sayılır.

### 4.2 Yanıt

Batch'in kendisi geçerliyse, içindeki bazı event'ler reddedilse bile yanıt `200 OK` olur:

```json
{
  "accepted": 2,
  "rejected": [
    { "index": 2, "event_id": "…", "code": "invalid_name", "message": "Event name must match ^[A-Za-z0-9_.:-]{1,64}$" }
  ],
  "clamped": [
    { "index": 0, "event_id": "…" }
  ]
}
```

- `accepted`: saklanan event'ler. Daha önce saklanmış tekrar event'ler de buna dahil (`event_id`'ye bakın).
- `rejected`: saklanmayan event'ler. Bunları değiştirmeden tekrar göndermeyin.
- `clamped`: `occurred_at` değeri izin verilen aralığın dışında kaldığı için yerine sunucuya ulaşma zamanı yazılan event'ler. Bunlar da saklanır ve `accepted`'a dahildir.

Üç alan da her yanıtta vardır; `rejected` ve `clamped` boş olabilir.

### 4.3 Event nesnesi

| Alan | Tür | Zorunlu | Kurallar |
|---|---|---|---|
| `event_id` | string (UUID) | evet | Her event için benzersiz. Gönderirken değil, event'i oluştururken üretin; böylece tekrar denenen bir batch iki kez sayılmaz. |
| `name` | string | evet | `^[A-Za-z0-9_.:-]{1,64}$`. `$` ile başlayıp bu karakterlerden 1–63 tane ile devam eden adlar (ör. `$session_start`) App Store Operator'a ayrılmıştır ve `reserved_name` ile reddedilir. Geçersiz diğer adlar `invalid_name` alır. `snake_case` ve `object_action` biçimini kullanın, ör. `paywall_viewed`. |
| `anonymous_id` | string | evet | 1–128 karakter. Install başına sabittir. |
| `user_id` | string veya null | hayır | 1–128 karakter. Sizin sisteminizdeki kalıcı kullanıcı ID'si. E-posta veya telefon numarası olamaz. |
| `occurred_at` | string | evet | Saat dilimi içeren ISO 8601, ör. `2026-10-23T12:00:00.123Z`. 7 gün öncesinden 5 dakika sonrasına kadar olan zamanlar kabul edilir. |
| `properties` | object | hayır | Düz nesne, en fazla 50 anahtar. Anahtar 1–64 karakter. Değer string (≤ 1024 karakter), sayı, boolean veya `null` olabilir. **İç içe nesne veya dizi olamaz.** |
| `context` | object | hayır | Aşağıya bakın. Göndermenizi öneririz; Dashboard funnel'ları bu alanlara göre filtreler ve kırılımlara ayırır. |

**`context`**

| Alan | Örnek | Notlar |
|---|---|---|
| `app_version` | `"2.4.0"` | `CFBundleShortVersionString` |
| `build` | `"412"` | `CFBundleVersion` |
| `os_version` | `"18.1"` | |
| `device_model` | `"iPhone16,2"` | Donanım kodu, pazarlama adı değil |
| `locale` | `"en_US"` | Device'ın dil ve bölge ayarı (`language_REGION`) |
| `environment` | `"app_store"` | `"debug"`, `"testflight"` veya `"app_store"`. Siz filtreyi değiştirmedikçe Dashboard yalnızca `app_store` event'lerini (ve bu alanı olmayanları) gösterir |

Her `context` değeri en fazla 64 karakterlik bir string olmalı. Bu kurala uymayan değerler ve tabloda olmayan anahtarlar atılır; event'in kendisi reddedilmez.

Sunucu, isteğin geldiği ağ konumuna bakarak `country` alanını ekler. IP adresi hiçbir zaman saklanmaz.

Serialize edilmiş bir event en fazla **8 KB** olabilir.

**Tam örnek**

```json
{
  "event_id": "0b6f6a3e-3c3e-4d8a-8f6e-1f9b8a2c7d10",
  "name": "purchase_completed",
  "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012",
  "user_id": "u_81723",
  "occurred_at": "2026-10-23T12:04:31.512Z",
  "properties": {
    "product_id": "pro_yearly",
    "price": 39.99,
    "currency": "USD",
    "trial": true
  },
  "context": {
    "app_version": "2.4.0",
    "build": "412",
    "os_version": "18.1",
    "device_model": "iPhone16,2",
    "locale": "en_US",
    "environment": "app_store"
  }
}
```

### 4.4 `POST /v1/identify` (isteğe bağlı)

Event göndermeden bir anonim ID'yi bir kullanıcı ID'sine bağlar. Buna pek ihtiyacınız olmaz, çünkü iki ID'yi de taşıyan her event aynı işi görür. Eşleşmeyi yalnızca sunucunuz biliyorsa kullanın.

```json
{ "anonymous_id": "C5E1B0B2-4F7A-4E0F-9C1D-2A6B7E8F9012", "user_id": "u_81723" }
```

Yanıt: `204 No Content`. Bir anonim ID için ilk eşleşme geçerlidir; aynı anonim ID'ye sonradan gelen farklı kullanıcı ID'leri yok sayılır.

### 4.5 Hatalar

| Durum | `code` | Anlamı | İstemci ne yapmalı |
|---|---|---|---|
| 400 | `invalid_body` | Gövde geçerli bir JSON veya `{ "events": [...] }` değil ya da event sayısı 0 veya 100'den fazla | Kodu düzeltin; tekrar denemeyin |
| 401 | `invalid_write_key` | Anahtar eksik, tanınmıyor veya iptal edilmiş | Tekrar denemeyin; batch'i atın ve anahtarı kontrol edin |
| 413 | `payload_too_large` | Gövde 1 MB'tan büyük | Daha küçük batch'ler gönderin |
| 403 | `quota_exceeded` | Hesap aylık event limitinin %110'sini kullandı. İstekteki hiçbir şey saklanmadı | Tekrar denemeyin; batch'i silin. Gönderim ayın 1'inde (UTC) ya da plan yükseltilince yeniden başlar |
| 429 | `rate_limited` | Anahtar başına dakikada 10.000 event ya da tek bir `anonymous_id` için 300 event sınırı aşıldı. İstekteki hiçbir şey saklanmadı | Batch'i saklayın; `Retry-After` kadar saniye sonra tekrar deneyin |
| 5xx | `server_error` | Bizim tarafımızda bir hata | Batch'i saklayın; artan bekleme süresiyle (backoff) tekrar deneyin |

Hata gövdesi: `{ "code": "invalid_write_key", "message": "…" }`.

**Event bazında ret kodları** (`200` yanıtının içinde döner):

| Kod | Ne zaman |
|---|---|
| `invalid_event` | `events` dizisindeki öğe bir JSON nesnesi değil |
| `invalid_name` | `name` eksik veya `^[A-Za-z0-9_.:-]{1,64}$` ile eşleşmiyor |
| `reserved_name` | `name`, `$` ve ardından `[A-Za-z0-9_.:-]` kümesinden 1–63 karakterden oluşuyor |
| `invalid_id` | `event_id` eksik veya UUID değil; `anonymous_id` eksik veya 1–128 karakterlik bir string değil; `user_id` var ama 1–128 karakterlik bir string veya `null` değil |
| `invalid_timestamp` | `occurred_at` eksik, parse edilemiyor veya saat dilimi içermiyor. (Aralık dışında olması ret nedeni değil; `clamped`'e bakın) |
| `invalid_properties` | `properties` düz bir nesne değil; bir anahtar 1–64 karakter aralığının dışında; iç içe bir nesne veya dizi var; bir string 1.024 karakterden uzun |
| `too_many_properties` | 50'den fazla property |
| `event_too_large` | Serialize edilmiş event 8 KB'tan büyük |
| `too_many_event_names` | Uygulamada zaten 500 farklı event adı var ve bu ad yeni. Var olan bir adı kullanın, ayrıntıyı bir property'de gönderin |

**Kısaca retry kuralı:** ağ hatasında, `429`'da ve `5xx`'te aynı `event_id`'lerle tekrar deneyin. Diğer bütün durumlarda batch'i atın.

## 5. Referans kod

Tek bir Dart dosyası. Projeye `lib/event_tracker.dart` olarak ekleyin ve hiç değiştirmeden kullanın. Dört pakete ihtiyacı var, fazlasına değil:

```bash
flutter pub add path_provider shared_preferences package_info_plus device_info_plus
```

`flutter pub add` en güncel sürümleri seçer. Kodu şu sürümlerle test ettik (Flutter 3.44, Dart 3.12):

```yaml
dependencies:
  path_provider: ^2.1.6
  shared_preferences: ^2.5.6
  package_info_plus: ^10.2.2
  device_info_plus: ^13.3.0
```

| Paket | Ne için |
|---|---|
| `path_provider` | Uygulama destek dizinindeki kuyruk dosyası |
| `shared_preferences` | Anonim ID, kullanıcı ID'si ve açık/kapalı ayarı (iOS'te `UserDefaults`) |
| `package_info_plus` | `app_version` ve `build` |
| `device_info_plus` | `os_version` ve `device_model` |

İstekler `dart:io`'daki `HttpClient` ile gider; ayrı bir HTTP paketine gerek yok.

Dosya şunları yapar:

- **Yalnızca iOS'te çalışır.** Android, web ve masaüstünde her metot hiçbir şey yapmadan hemen döner: ID yok, dosya yok, istek yok. App Store Operator şimdilik yalnızca iOS uygulamalarını destekliyor; bu sayede Android install'ları App Store funnel'larınıza karışmaz. Android desteği planlanıyor.
- İlk event izlendiğinde rastgele bir anonim ID (`Random.secure()` ile üretilen bir v4 UUID) üretir ve `shared_preferences`'ta saklar. `currentAnonymousId` bu ID'yi döndürür.
- `setEnabled(...)` ile izlemeyi açıp kapatabilirsiniz. Varsayılan olarak açıktır ve seçim uygulama yeniden açıldığında da hatırlanır. Kapatınca ID'ler ve gönderilmemiş bütün event'ler silinir; tekrar açılana kadar `track` hiçbir şey yapmaz.
- Her event'i diskteki bir kuyruk dosyasına yazar; uygulama kapansa da event'ler kaybolmaz.
- Kuyruğu en fazla 100 event'lik batch'ler hâlinde gönderir. Gönderim 30 saniyede bir, kuyrukta 20 veya daha fazla event biriktiğinde ve siz `flush()` çağırdığınızda olur.
- Çevrimdışıyken veya `5xx` aldığında kuyruğu korur ve tekrar denemeden önce bekler: 30 saniye, sonra 60, 120… en fazla 10 dakika. `429` aldığında kuyruğu korur ve sunucunun `Retry-After` değeri kadar bekler. Başka bir yanıt gelirse batch'i kuyruktan çıkarır.
- Kuyrukta en fazla 1.000 event tutar; sınır aşılınca en eskileri atar.
- Yalnızca debug build'lerinde (`kDebugMode`), reddedilen ve clamp'lenen event'leri, atılan batch'leri ve attığı property değerlerini `debugPrint` ile Flutter konsoluna `[EventTracker] …` diye yazar. Release build'ler konsola hiçbir şey yazmaz.
- Debug ve profile build'lerinden gelen event'leri `debug` olarak etiketler; böylece testleriniz funnel'larınıza karışmaz. Release build'lerin event'leri ya `configure`'a verdiğiniz `environment` değerini taşır ya da hiç ortam bilgisi taşımaz (aşağıdaki **Test build'leri** kısmına bakın).
- `track` hemen döner, ağı hiç beklemez. `configure`, `track`, `identify`, `reset` ve `setEnabled` hangi sırayla çağrıldıysa o sırayla işlenir; sıra bozulmasın diye bunları `await` etmeniz gerekmez.

`track`'i `configure`'dan önce de çağırabilirsiniz; event'ler anahtar verilene kadar kuyrukta bekler.

**Her uygulamaya bir anahtar.** Dashboard'daki her uygulamanın kendi yazma anahtarı var. Bir anahtarı yalnızca kendi uygulamasının binary'sinde kullanın.

**Extension'larda kullanmayın.** iOS uygulamanızın native extension'ları (widget, share, notification extension'ları) varsa onlardan event izlemeyin. Extension'ların kendi depolama alanı vardır. Bu yüzden her biri kendi anonim ID'sini alır ve ayrı bir kişi olarak sayılır. Event'leri yalnızca Flutter uygulamasından izleyin.

**Test build'leri.** Flutter'da bir release build, native kod yazmadan TestFlight'ı App Store'dan ayırt edemez. Bu yüzden `configure` isteğe bağlı bir `environment` parametresi alır: `"testflight"` veya `"app_store"` (başka değerler yok sayılır; debug ve profile build'ler her zaman `debug` olur). [§5.1](#51-uygulamaya-bağlama)'deki kod bu değeri build sırasında verilen bir define'dan okur:

```bash
flutter build ipa --dart-define=ASO_ENV=testflight
```

Define verilmezse release event'leri ortam bilgisi taşımaz ve Dashboard bunları App Store event'i sayar; TestFlight test kullanıcılarınınkiler de buna dahil. Dikkat: Apple, TestFlight'a yüklediğiniz build'in aynısını yayınlar. `ASO_ENV=testflight` ile alınmış bir build'i sonradan App Store'da yayınlarsanız etiket build'de kalır ve App Store kullanıcılarınız Dashboard'un varsayılan görünümünden kaybolur. Define'ı yalnızca hiç yayınlanmayacak build'lerde kullanın, örneğin ayrı bir dahili test build'inde.

```dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Event'leri App Store Operator'a gönderir. Belgeler: https://app-store-operator.com/docs/events/flutter
///
/// Yalnızca iOS: Android, web ve masaüstünde hiçbir metot bir şey yapmaz.
class EventTracker {
  EventTracker._();

  static final EventTracker shared = EventTracker._();

  static final Uri _endpoint = Uri.parse('https://api.app-store-operator.com/v1/events');
  static const int _maxBatch = 100;
  static const int _maxQueue = 1000;
  static const int _flushAt = 20;
  static const String _anonymousIdKey = 'aso.events.anonymousId';
  static const String _userIdKey = 'aso.events.userId';
  static const String _disabledKey = 'aso.events.disabled';
  static const Set<String> _environments = {'debug', 'testflight', 'app_store'};

  static final bool _supported = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
  static final Random _random = Random.secure();

  late final Future<void> _ready = _load(); // ilk çağrıyla başlar
  final HttpClient _http = HttpClient()..connectionTimeout = const Duration(seconds: 30);

  SharedPreferencesWithCache? _prefs;
  File? _store;
  Map<String, String> _context = const {};
  String? _writeKey;
  String? _environment;
  String? _anonymousId;
  String? _userId;
  bool _enabled = true;
  List<Map<String, Object?>> _queue = [];
  bool _flushing = false;
  Timer? _timer;
  DateTime? _retryAt;
  int _backoff = 0; // saniye
  Future<void>? _writing;
  bool _dirty = false;
  bool _warned = false;

  /// Açılışta, `WidgetsFlutterBinding.ensureInitialized()` sonrasında bir kez çağırın.
  ///
  /// Debug ve profile build'leri `debug` olarak işaretlenir. Release build'leri, verilirse [environment]
  /// (`testflight` veya `app_store`) ile işaretlenir; verilmezse ortam taşımaz.
  void configure({required String writeKey, String? environment}) {
    if (!_isSupported()) return;
    _writeKey = writeKey;
    if (!kReleaseMode) {
      _environment = 'debug';
    } else if (environment != null && _environments.contains(environment)) {
      _environment = environment;
    }
    _timer ??= Timer.periodic(const Duration(seconds: 30), (_) => flush());
    unawaited(_ready);
  }

  /// Bu install'ın kimliği; ilk izlenen event'ten önce ve izleme kapalıyken null.
  /// Kullanıcının verilerinin silinmesini isteyebilmesi için bir destek ekranında gösterin.
  Future<String?> get currentAnonymousId async {
    if (!_isSupported()) return null;
    await _ready;
    return _anonymousId;
  }

  /// İzlemeyi açar veya kapatır ve seçimi açılışlar arasında hatırlar. İzleme varsayılan olarak açıktır.
  /// Kapatmak bu install'ın kimliklerini ve henüz gönderilmemiş tüm event'leri siler; ardından `track` hiçbir şey yapmaz.
  /// Uygulamanız önce kullanıcının onayını almak zorundaysa, kullanıcı kabul edene kadar açılışta `setEnabled(false)` çağırın.
  Future<void> setEnabled(bool enabled) async {
    if (!_isSupported()) return;
    await _ready;
    _enabled = enabled;
    unawaited(_prefs?.setBool(_disabledKey, !enabled));
    if (enabled) return;
    _anonymousId = null;
    _userId = null;
    _queue = [];
    unawaited(_prefs?.remove(_anonymousIdKey));
    unawaited(_prefs?.remove(_userIdKey));
    _persist(); // boş kuyruk dosyayı siler
  }

  /// Bir event kaydeder. Hafiftir; hemen döner ve asla ağı beklemez.
  /// İzleme kapalıyken hiçbir şey yapmaz. Property değerleri: String, int, double, bool veya null.
  void track(String name, [Map<String, Object?> properties = const {}]) {
    if (!_isSupported()) return;
    final eventId = _uuid();
    final occurredAt = _now();
    final props = _flat(name, properties);
    unawaited(_ready.then((_) {
      if (!_enabled) return;
      _queue.add({
        'event_id': eventId,
        'name': name,
        'anonymous_id': _anonymousId ?? _newAnonymousId(),
        if (_userId != null) 'user_id': _userId,
        'occurred_at': occurredAt,
        'properties': props,
        'context': _context,
      });
      if (_queue.length > _maxQueue) _queue.removeRange(0, _queue.length - _maxQueue);
      _persist();
      if (_queue.length >= _flushAt) unawaited(flush());
    }));
  }

  /// Kullanıcı uygulamanızda oturum açtıktan sonra çağırın. Kendi kalıcı kullanıcı kimliğinizi kullanın, asla e-posta değil.
  Future<void> identify(String userId) async {
    if (!_isSupported()) return;
    await _ready;
    if (!_enabled) return;
    _userId = userId;
    unawaited(_prefs?.setString(_userIdKey, userId));
  }

  /// Oturum kapatılırken çağırın: sonraki event'ler yeni bir anonim kişiye ait olur.
  Future<void> reset() async {
    if (!_isSupported()) return;
    await _ready;
    _userId = null;
    _anonymousId = null;
    unawaited(_prefs?.remove(_userIdKey));
    unawaited(_prefs?.remove(_anonymousIdKey));
  }

  /// Kuyruktaki event'leri gönderir. Bir yeniden deneme beklemesi sürerken hiçbir şey yapmaz.
  Future<void> flush() async {
    if (!_isSupported()) return;
    await _ready;
    final writeKey = _writeKey;
    if (!_enabled || _flushing || writeKey == null || _queue.isEmpty) return;
    final retryAt = _retryAt;
    if (retryAt != null && retryAt.isAfter(DateTime.now())) return;
    _flushing = true;
    try {
      while (_queue.isNotEmpty) {
        final batch = _queue.take(_maxBatch).toList();
        final events = [
          for (final event in batch)
            {
              ...event,
              'context': {
                if (_environment != null) 'environment': _environment,
                ...(event['context'] as Map? ?? const {}),
              },
            },
        ];
        final response = await _post(writeKey, jsonEncode({'events': events}));
        if (response == null) {
          _delayRetry(null); // çevrimdışı: kuyruğu tut
          return;
        }
        if (!_enabled) return; // gönderim sırasında kapatıldı: kuyruk zaten silindi
        final (status, retryAfter, body) = response;
        if (status == 429 || status >= 500) {
          _delayRetry(status == 429 ? double.tryParse(retryAfter ?? '') : null); // kuyruğu tut
          return;
        }
        _retryAt = null;
        _backoff = 0;
        if (kDebugMode) _report(status, body);
        // 2xx: saklandı. Diğer 4xx: aynı batch'i tekrar denemenin anlamı yok.
        final sent = {for (final event in batch) event['event_id']};
        _queue.removeWhere((event) => sent.contains(event['event_id']));
        _persist();
      }
    } finally {
      _flushing = false;
    }
  }

  Future<void> _load() async {
    try {
      final prefs = await SharedPreferencesWithCache.create(
        cacheOptions: const SharedPreferencesWithCacheOptions(
          allowList: {_anonymousIdKey, _userIdKey, _disabledKey},
        ),
      );
      _prefs = prefs;
      _enabled = !(prefs.getBool(_disabledKey) ?? false);
      _anonymousId = prefs.getString(_anonymousIdKey); // ilk izlenen event'le oluşturulur
      _userId = prefs.getString(_userIdKey);
    } catch (error) {
      _debug('settings unavailable, keeping IDs in memory: $error');
    }

    try {
      final dir = await getApplicationSupportDirectory();
      await dir.create(recursive: true);
      final store = File('${dir.path}/aso-events-queue.json');
      _store = store;
      if (await store.exists()) {
        final saved = jsonDecode(await store.readAsString()) as List<dynamic>;
        _queue = [for (final event in saved) Map<String, Object?>.from(event as Map)];
      }
    } catch (error) {
      _debug('queue file unavailable, keeping events in memory: $error');
    }

    var appVersion = '', build = '', osVersion = '', deviceModel = '';
    try {
      final package = await PackageInfo.fromPlatform();
      appVersion = package.version;
      build = package.buildNumber;
    } catch (_) {}
    try {
      final ios = await DeviceInfoPlugin().iosInfo;
      osVersion = ios.systemVersion;
      deviceModel = ios.utsname.machine;
    } catch (_) {}
    _context = {
      'app_version': appVersion,
      'build': build,
      'os_version': osVersion,
      'device_model': deviceModel,
      'locale': PlatformDispatcher.instance.locale.toString(), // en_US
    };
  }

  Future<(int, String?, String)?> _post(String writeKey, String body) async {
    try {
      return await () async {
        final request = await _http.postUrl(_endpoint);
        request.headers.contentType = ContentType.json;
        request.headers.set('X-Write-Key', writeKey);
        request.add(utf8.encode(body));
        final response = await request.close();
        final text = await response.transform(utf8.decoder).join();
        return (response.statusCode, response.headers.value('retry-after'), text);
      }().timeout(const Duration(seconds: 60));
    } catch (_) {
      return null;
    }
  }

  /// Sunucu belirtirse `seconds` kadar, aksi hâlde 30 sn, 60 sn, 120 sn… en fazla 10 dakika bekler.
  void _delayRetry(double? seconds) {
    _backoff = min(max(_backoff * 2, 30), 600);
    final wait = Duration(milliseconds: ((seconds ?? _backoff) * 1000).round());
    _retryAt = DateTime.now().add(wait);
  }

  /// Kuyruğu diske her seferinde tek bir yazmayla yazar; son yazma her zaman en güncel kuyruğu içerir.
  void _persist() {
    if (_store == null) return;
    _dirty = true;
    _writing ??= _write();
  }

  Future<void> _write() async {
    while (_dirty) {
      _dirty = false;
      final store = _store!;
      try {
        if (_queue.isEmpty) {
          if (await store.exists()) await store.delete();
        } else {
          final temp = File('${store.path}.tmp');
          await temp.writeAsString(jsonEncode(_queue), flush: true);
          await temp.rename(store.path);
        }
      } catch (_) {}
    }
    _writing = null;
  }

  String _newAnonymousId() {
    final id = _uuid();
    _anonymousId = id;
    unawaited(_prefs?.setString(_anonymousIdKey, id));
    return id;
  }

  /// String, int, sonlu double, bool ve null değerleri tutar; diğer her şeyi atar.
  Map<String, Object?> _flat(String name, Map<String, Object?> properties) {
    final result = <String, Object?>{};
    properties.forEach((key, value) {
      if (value == null || value is String || value is int || value is bool ||
          (value is double && value.isFinite)) {
        result[key] = value;
      } else {
        _debug('$name: property "$key" dropped, ${value.runtimeType} is not a flat value');
      }
    });
    return result;
  }

  /// Yalnızca debug build'lerde: atılan batch'leri ve reddedilen veya clamp'lenen event'leri Flutter konsoluna yazdırır.
  void _report(int status, String body) {
    Object? json;
    try {
      json = jsonDecode(body);
    } catch (_) {}
    if (status < 200 || status >= 300) {
      _debug('HTTP $status, batch dropped: ${json ?? body}');
      return;
    }
    if (json is! Map) return;
    for (final key in ['rejected', 'clamped']) {
      final items = json[key];
      if (items is List && items.isNotEmpty) _debug('$key: $items');
    }
  }

  bool _isSupported() {
    if (_supported) return true;
    if (!_warned) {
      _warned = true;
      _debug('iOS only: tracking is off on ${kIsWeb ? 'web' : defaultTargetPlatform.name}');
    }
    return false;
  }

  static void _debug(String message) {
    if (kDebugMode) debugPrint('[EventTracker] $message');
  }

  static String _now() {
    final now = DateTime.now().millisecondsSinceEpoch;
    return DateTime.fromMillisecondsSinceEpoch(now, isUtc: true).toIso8601String();
  }

  /// Rastgele (sürüm 4) bir UUID.
  static String _uuid() {
    final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
    bytes[6] = (bytes[6] & 0x0f) | 0x40;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
    return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
        '${hex.substring(16, 20)}-${hex.substring(20)}';
  }
}
```

### 5.1 Uygulamaya bağlama

```dart
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'event_tracker.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  EventTracker.shared.configure(
    writeKey: 'YOUR_WRITE_KEY',
    environment: const String.fromEnvironment('ASO_ENV'), // bkz. §5'teki "Test build'leri"
  );
  EventTracker.shared.track('app_opened', {'source': 'cold'});

  var wasInBackground = false;
  // Uygulama çalıştığı sürece kayıtlı kalır; referans tutmanız gerekmez.
  AppLifecycleListener(
    onHide: () {
      // Uygulama arka plana geçiyor: kuyruktakileri gönder.
      wasInBackground = true;
      unawaited(EventTracker.shared.flush());
    },
    onResume: () {
      if (!wasInBackground) return; // uyarılar, Denetim Merkezi ve Face ID uygulamayı yalnızca etkin olmayan duruma getirir
      wasInBackground = false;
      EventTracker.shared.track('app_opened', {'source': 'warm'});
    },
  );

  runApp(const MyApp());
}
```

`main()`'de başka kurulum kodu varsa olduğu gibi bırakın; bu çağrıları `WidgetsFlutterBinding.ensureInitialized()`'dan sonra, `runApp`'ten önce koyun. Tracker, sakladığı ID'leri ve device bilgilerini binding'e ihtiyaç duyan plugin'lerle okur. `AppLifecycleListener` için Flutter 3.13 veya üstü gerekir. Uygulamada zaten bir `WidgetsBindingObserver` varsa onu da kullanabilirsiniz: `AppLifecycleState.hidden`'da `flush()` çağırın, sonraki `resumed`'da warm `app_opened` event'ini izleyin.

`app_opened` her resume'da değil, yalnızca arka plandan dönüşte gönderilir. Çünkü sistem uyarıları, Denetim Merkezi ve Face ID de uygulamayı gizlemeden inactive yapıp tekrar active yapar.

**Arka plan süresi.** iOS, arka plana geçen uygulamaya birkaç saniye verir; `flush()`'ın bitmesi için bu genelde yeterlidir. Flutter uygulaması native kod olmadan iOS'ten ek süre isteyemez. Uygulama daha önce askıya alınırsa event'ler kuyruk dosyasında kalır ve bir sonraki açılışta gider.

### 5.2 Event takibi

```dart
EventTracker.shared.track('paywall_viewed', {'placement': 'onboarding'});
EventTracker.shared.track('onboarding_completed', {'duration_s': 42});

// Değişkenler de aynı şekilde çalışır: String, int, double, bool veya null değerler.
EventTracker.shared.track('onboarding_step_viewed', {'step': step, 'step_name': stepName});

// Uygulamanızda oturum açıldıktan / kapatıldıktan sonra:
await EventTracker.shared.identify(user.id);
await EventTracker.shared.reset();

// Onay ya da uygulama içi "Analitik verilerini paylaş" ayarı: açılışta, configure ve ilk track'ten önce
// kullanıcının güncel seçimiyle çağırın; seçim değiştikçe tekrar çağırın.
await EventTracker.shared.setEnabled(userAllowsAnalytics);

// Destek ekranı: kullanıcı verilerinin silinmesini isteyebilsin diye bunu gösterin (henüz hiçbir şey izlenmediyse null).
final installId = await EventTracker.shared.currentAnonymousId;
```

İzleme kullanıcı onayına bağlıysa `setEnabled`'ı [§5.1](#51-uygulamaya-bağlama)'deki açılış `app_opened` event'inden önce çağırın. İzleme kapalıyken o çağrı hiçbir şey yapmaz ve ID üretilmez.

Property değerleri `String`, `int`, `double`, `bool` veya `null` olmalı. Tracker başka türdeki değerleri (`DateTime`, enum, iç içe map veya liste, `double.nan`) atar ve debug build'de `[EventTracker] … dropped` yazar. Bu değerleri önce dönüştürün: `date.toIso8601String()`, `plan.name`.

**Satın almalar.** Satın almaları, kullanıcının satın al butonuna bastığı akışın içinde izleyin. Satın alma çağrınızı şu wrapper ile sarın:

```dart
/// Satın almanın nasıl sonuçlandığı, satın alma kodunuzun bildirdiği şekliyle.
enum PurchaseOutcome { success, cancelled, pending, failed }

/// Satın alma çağrınızı sarar. [purchase] onu çalıştırır ve nasıl sonuçlandığını bildirir.
Future<void> buy({
  required String productId,
  required double price,
  required String currency,
  required bool startsTrial,
  required Future<PurchaseOutcome> Function() purchase,
}) async {
  final base = {'product_id': productId};
  EventTracker.shared.track('purchase_started', base);
  PurchaseOutcome outcome;
  try {
    outcome = await purchase();
  } catch (_) {
    outcome = PurchaseOutcome.failed;
  }
  switch (outcome) {
    case PurchaseOutcome.success:
      EventTracker.shared.track('purchase_completed', {
        ...base,
        'price': price,
        'currency': currency,
        'trial': startsTrial,
      });
      if (startsTrial) EventTracker.shared.track('trial_started', base);
    case PurchaseOutcome.cancelled:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'cancelled'});
    case PurchaseOutcome.failed:
      EventTracker.shared.track('purchase_failed', {...base, 'reason': 'failed'});
    case PurchaseOutcome.pending:
      break; // Ask to Buy veya ek onay: henüz tamamlanmadı, hiçbir şey izlemeyin
  }
}
```

`purchase`, store kütüphanenizin satın alma metodunu saran kendi fonksiyonunuz. Satın alma tamamlanıp doğrulandıysa `success`, kullanıcı vazgeçtiyse `cancelled`, Ask to Buy veya başka bir onay bekleniyorsa `pending`, diğer durumlarda `failed` döndürmeli (exception fırlatmak da `failed` sayılır). `price` (formatlanmış bir string değil, sayı) ve `currency` (`"USD"` gibi bir ISO 4217 kodu) ürün bilgilerinizden gelir. `startsTrial` ise paywall'unuzun zaten bildiği bir değer: bu satın alma ücretsiz trial başlatıyorsa true.

- **`in_app_purchase`:** `buyNonConsumable` ve `buyConsumable` satın almayı yalnızca başlatır; sonuç `purchaseStream`'den gelir. `purchase` fonksiyonunuz satın almayı başlatıp o ürün için gelen ilk güncellemeyi beklesin. Eşleme şöyle: `PurchaseStatus.purchased` → `success`, `canceled` → `cancelled`, `pending` → `pending`, `error` → `failed`. Fiyat ve para birimi için `ProductDetails.rawPrice` ve `currencyCode`'u kullanın.
- **RevenueCat (`purchases_flutter`):** satın alma metotları satın alma tamamlanınca döner, tamamlanmazsa `PlatformException` fırlatır. `PurchasesErrorCode.purchaseCancelledError`'ı `cancelled`, `paymentPendingError`'ı `pending`, diğer hataları `failed` olarak eşleyin.
- **Diğer kütüphaneler:** aynı çağrıları kütüphanenin satın alma metodunun etrafına koyun.

Satın almaları, `in_app_purchase`'ın `purchaseStream` listener'ı gibi her transaction'ı alan bir listener'da izlemeyin. Oraya geri yüklenen satın almalar (`PurchaseStatus.restored`), yenilemeler ve sonraki bir açılışta tamamlanan satın almalar da düşer ve hepsi yeni satın alma gibi sayılır.

## 6. Hangi event'ler izlenmeli

Bir funnel, arkasındaki event'ler kadar iyidir. Aşağıdaki event'lerle başlayın, yeni sorular çıktıkça yenilerini ekleyin.

**Adlandırma kuralları**

- `snake_case`, `object_action`, geçmiş zaman: `paywall_viewed`, `purchase_completed`.
- Her eyleme tek bir ad verin. Farklılıkları adda değil (`settings_paywall_viewed`), property'lerde tutun (`placement: "settings"` ile `paywall_viewed`).
- Ada ID, tarih veya kullanıcı verisi koymayın.
- Funnel'ı bir property'ye göre bölmek istiyorsanız o property'ye serbest metin değil, az sayıda sabit değer verin (`plan: "yearly"`). Dashboard en sık görülen 10 değeri gösterir, gerisini "Diğer" altında toplar.
- Adlar ve property değerleri büyük/küçük harfe duyarlıdır: `Paywall_Viewed` ile `paywall_viewed` iki ayrı event'tir.

**Önerilen başlangıç seti**

Akışı uygulamanızda olan event'leri izleyin, gerisini atlayın. *isteğe bağlı* diye işaretli property'leri göndermeyebilirsiniz.

| Event | Ne zaman | Property'ler |
|---|---|---|
| `app_opened` | Uygulama açılışı ve arka plandan her dönüş ([§5.1](#51-uygulamaya-bağlama)'deki kod bunu gönderir) | `source` (`"cold"` açılış, `"warm"` dönüş) |
| `onboarding_started` | İlk onboarding ekranı açıldı | |
| `onboarding_step_viewed` | İlki dahil her onboarding ekranı açıldığında | `step` (sayı, 1'den başlar), `step_name` |
| `onboarding_completed` | Son onboarding adımı bitti | `duration_s` *isteğe bağlı* (sayı) |
| `signup_completed` | Uygulamanızda hesap oluşturuldu | `method` (`"apple"`, `"google"`, `"email"`) |
| `paywall_viewed` | Paywall açıldı | `placement` (nerede açıldığı: `"onboarding"`, `"settings"`, `"feature_gate"`), `paywall_id` *isteğe bağlı* |
| `purchase_started` | Kullanıcı satın al butonuna bastı | `product_id` |
| `purchase_completed` | Satın alma tamamlandı ve doğrulandı; satın alma akışının içinde izlenir ([§5.2](#52-event-takibi)). Ücretsiz trial başlatan satın almalar da buna dahil | `product_id`, `price` (sayı), `currency`, `trial` (boolean) |
| `purchase_failed` | Kullanıcı vazgeçti veya satın alma başarısız oldu. Ask to Buy (beklemede) bunların hiçbiri sayılmaz; o durumda hiçbir şey izlemeyin | `product_id`, `reason` (`"cancelled"`, `"failed"`) |
| `trial_started` | Satın alma ücretsiz trial başlattıysa `purchase_completed`'a **ek olarak** gönderilir | `product_id` |
| `purchase_restored` | Satın almaları geri yükleme aktif bir subscription buldu | `product_id` *isteğe bağlı* |
| Ana eyleminiz | Uygulamanızın asıl işi olan eylem. Adını o eyleme göre koyun: `scan_completed`, `workout_finished`, `photo_exported` | eylemi anlatan property'ler |

**Uygulama event'lerinin göremedikleri.** Yenilemeler, ücretli subscription'a dönen trial'lar, iptaller ve refund'lar Apple tarafında, çoğu zaman uygulamanız kapalıyken olur. Uygulama bunları hiç görmediği için bu event'lerde de yoklar. Buradaki funnel'lar satın almada veya trial başlangıcında biter.

**Bu event'lerle kurabileceğiniz örnek funnel'lar**

| Funnel | Adımlar | Süre | Yanıtladığı soru |
|---|---|---|---|
| İlk session | `onboarding_started → onboarding_completed → paywall_viewed (placement = onboarding) → purchase_completed` | 1 gün | Yeni kullanıcıların kaçı paywall'a gelip satın alıyor |
| Onboarding kaybı | `onboarding_step_viewed (step = 1) → (step = 2) → (step = 3) → onboarding_completed` | 1 gün | En çok kullanıcı hangi onboarding ekranında bırakıyor |
| Paywall dönüşümü | `paywall_viewed → purchase_started → purchase_completed`, `placement` kırılımıyla | 1 saat | Hangi paywall konumu daha iyi dönüştürüyor; kullanıcılar fiyatı görünce mi, Apple'ın satın alma ekranında mı bırakıyor |
| Aktivasyon | `onboarding_completed → scan_completed` (ana eyleminiz) | 1 gün | Yeni kullanıcıların kaçı uygulamanın asıl işini yapıyor |

## 7. Gizlilik

Bu bölüm hukuki tavsiye değil, yol göstermek için var. Uygulamanızın ne gönderdiğini ve kullanıcılarınızın nerede olduğunu siz biliyorsunuz. Son karar sizin; mümkünse hukuk danışmanınızla birlikte verin.

Bu bölümdeki her konu için gizlilik iletişim adresimiz: `privacy@app-store-operator.com`.

### 7.1 App Store Operator'a ulaşan veriler

- Event adları ve property'leri.
- `anonymous_id`: rastgele bir UUID. Referans kod bunu izleme açıkken ilk event'te üretir ve izleme kapatılana veya `reset` çağrılana kadar o install için saklar. IDFA değildir, başka uygulamalarla paylaşılan bir tanımlayıcı da değildir.
- `user_id` (yalnızca `identify` çağırırsanız).
- [§4.3](#43-event-nesnesi)'teki device bilgileri: uygulama version'ı, build, işletim sistemi sürümü, device modeli, dil ve bölge ayarı ve build ortamı.
- İsteğin geldiği ağ konumundan çıkarılan ülke ([§7.3](#73-ip-adresi-ve-ülke)).

Referans kod event'leri yalnızca iOS build'inden gönderir; Android, web ve masaüstü build'leri hiçbir şey göndermez.

**Asla göndermeyin:** ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı serbest metin. Bunlar ne event adında, ne property'lerde, ne de `user_id`'de olmalı. `user_id` uygulamanızın kendi dahili ID'si olmalı (örneğin bir veritabanı anahtarı); e-posta, telefon numarası veya başka bir şirketin verdiği bir ID olmamalı.

### 7.2 Roller ve hizmet sağlayıcılar

- **Roller.** Uygulamanızın gönderdiği event'lerin veri sorumlusu (KVKK'daki adıyla *veri sorumlusu*) sizsiniz: neyin, neden toplanacağına siz karar verirsiniz. App Store Operator'ı işleten Nefarius ise event'leri saklamak ve Dashboard'da size göstermek için sizin adınıza işler; yani veri işleyendir (KVKK'daki adıyla *veri işleyen*).
- **Yapmadıklarımız.** Event verilerini satmayız, reklam için kullanmayız ve başka müşterilerle paylaşmayız.
- **Hizmet sağlayıcılar (alt işleyenler).** Barındırılan her hizmet gibi biz de event verilerini yalnızca bizim adımıza işleyen sağlayıcılarla çalışırız:
  - **Cloudflare**: ağ iletimi ve güvenlik. Uygulamanızdan gelen her istek Cloudflare'den geçer.
  - **Sunucu barındırma sağlayıcımız**: sunucuları ve event'lerin saklandığı veritabanını çalıştırır.
- **Konumlar.** Cloudflare dünyanın her yerinde çalıştığı için bir istek, kullanıcınızın bulunduğu ülkenin dışında işlenebilir.
- **Veri işleme sözleşmesi (DPA).** DPA (örneğin GDPR madde 28 kapsamında), hizmet sağlayıcıların konumlarıyla güncel listesi veya yurt dışına aktarım güvencelerinin ayrıntıları gerekiyorsa yukarıdaki gizlilik adresine yazın.

### 7.3 IP adresi ve ülke

Uygulamanızın istekleri bize Cloudflare üzerinden gelir. Cloudflare isteği iletmek için IP adresine ihtiyaç duyar ve ülkeyi bu adresten bulur. App Store Operator yalnızca ülkeyi saklar (şehir veya bölge saklamaz); IP adresini hiçbir zaman saklamaz ve loglamaz.

### 7.4 Saklama süresi ve silme talepleri

- Event'ler 13 ay saklanır, sonra otomatik olarak silinir.
- Tek bir kişinin event'lerini Dashboard'dan silmek henüz mümkün değil. Bu özellik gelene kadar, bir kullanıcı verilerinin silinmesini isterse (örneğin GDPR madde 17 veya KVKK madde 11 kapsamında) App Store Operator çalışma alanınızdaki birinin e-posta adresinden yukarıdaki gizlilik adresine yazın. E-postaya şunları ekleyin:
  - Uygulamanın Dashboard'da görünen adı;
  - Kişinin `user_id`'si ve/veya `anonymous_id`'si. Bir `user_id`, ona bağlı bütün `anonymous_id`'leri de kapsar. Hesabı olmayan kullanıcılar için `anonymous_id`'yi `await EventTracker.shared.currentAnonymousId` ile alıp örneğin bir destek ekranında gösterebilirsiniz ([§5.2](#52-event-takibi)). Bu değeri `setEnabled(false)` veya `reset()` çağırmadan önce okuyun. İkisi de değeri device'tan siler ve bu değer olmadan o install'ın event'lerini bulamayız.
- Bu ID'leri taşıyan bütün event'leri siler, sonucu e-postayla bildiririz. Talepleri bekletmeden gönderin; kullanıcıya yanıt vermek için sizin de yasal bir süreniz var (GDPR'da genelde bir ay, KVKK'da 30 gün).
- Çalışma alanınızın üyesi olmayan birinden gelen talepleri işleme almayız; o kişiyi size yönlendiririz.

### 7.5 Gizlilik politikanız ve kullanıcı onayı

- **Gizlilik politikası.** Uygulamanızın gizlilik politikasında şunlar yazmalı: kullanım analitiği topladığınız, bunun hangi verileri kapsadığı (§7.1), bu verileri sizin adınıza bir hizmet sağlayıcının (Nefarius'un işlettiği App Store Operator) işlediği ve verilerin 13 ay saklandığı.
- **Hukuki dayanak ve onay.** Analitiğin hukuki dayanağına ve önce kullanıcıdan onay (açık rıza) almanız gerekip gerekmediğine siz karar verirsiniz. AB ve Birleşik Krallık'ta analitik için device'ta bir tanımlayıcı saklamak veya okumak çoğu zaman onay gerektirir; KVKK'da ise kullanıcıyı en azından bilgilendirmeniz gerekir. Onay gerekiyorsa açılışta her şeyden önce `EventTracker.shared.setEnabled(false)` çağırın, kullanıcı kabul edene kadar öyle kalsın, kabul edince de `setEnabled(true)` çağırın ([§5.2](#52-event-takibi)). İzleme kapalıyken device'ta hiçbir ID veya event saklanmaz (yalnızca "kapalı" ayarının kendisi saklanır) ve hiçbir şey gönderilmez. Tracker `anonymous_id`'yi yalnızca izleme açıkken ilk event'te üretir. Kullanıcı onayını sonradan geri çekerse `setEnabled(false)` izlemeyi durdurur, device'taki ID'leri ve gönderilmemiş event'leri siler. Daha önce gönderilmiş event'leri ise silmez. Tek hukuki dayanağınız onaysa veya kullanıcı silinmelerini isterse önce `currentAnonymousId` değerini okuyun, sonra [§7.4](#74-saklama-süresi-ve-silme-talepleri)'teki gibi silme talebi gönderin.

### 7.6 App Tracking Transparency

App Store Operator event'lerinizi reklam veya reklam ölçümü için başka şirketlerin uygulamalarından, sitelerinden ya da çevrimdışı kaynaklarından gelen verilerle birleştirmez. Event'leri veri simsarlarıyla paylaşmaz, device bilgilerini fingerprinting için kullanmaz. Bu yüzden entegrasyon tek başına Apple'ın tanımıyla tracking sayılmaz ve App Tracking Transparency (ATT) izni gerektirmez.

ATT kararı entegrasyon başına değil, uygulamanın tamamı için verilir. Uygulamanız veya içindeki başka bir SDK bu event'leri ya da `user_id`'nizi reklam veya reklam ölçümü için başka şirketlerin verileriyle eşliyorsa ya da bir veri simsarıyla paylaşıyorsa bu tracking olur. O zaman önce kullanıcıdan ATT izni almanız gerekir ve gizlilik etiketinizde verilerin tracking için kullanıldığı yazmalıdır. Ayrıntılar için Apple'ın [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) sayfasına bakın.

### 7.7 App Store gizlilik etiketi ve gizlilik manifesti

Apple'ın gizlilik sorularını uygulamanın tamamı için yanıtlarsınız. Apple, eklediğiniz üçüncü taraf kodların topladığı verileri de saymanızı ister; bu kod da onlardan biri. App Store Operator'a giden veriler için genelde şu yanıtlar verilir:

| Apple veri türü | Ne zaman beyan edilir | Amaç |
|---|---|---|
| Usage Data → Product Interaction | Her zaman | Analytics |
| Identifiers → Device ID | Her zaman; `anonymous_id` install başına kalıcı bir tanımlayıcıdır | Analytics |
| Location → Coarse Location | Her zaman; ülke, kullanıcının ağ konumundan çıkarılır. Apple'ın örneklerindekinden de kaba bir konum, ama beyan etmek daha güvenli | Analytics |
| Purchases → Purchase History | §6'daki satın alma event'lerini izliyorsanız | Analytics |
| Identifiers → User ID | `identify` çağırıyorsanız veya `user_id` gönderiyorsanız | Analytics |

- **Linked to You.** `user_id` gönderiyorsanız yukarıdaki bütün satırları *Linked to You* olarak beyan edin, çünkü event'ler sisteminizdeki bir hesap ID'siyle eşleşir. Hiç `user_id` göndermiyorsanız ve event'leri başka bir yerde de bir hesaba veya kimliği belli eden başka verilere bağlamıyorsanız, veri yalnızca rastgele bir install ID'sine bağlıdır; *Not Linked to You* diyebilirsiniz. Emin değilseniz *Linked to You* seçin.
- **Tracking.** Uygulamanız §7.6'da anlatılan anlamda tracking yapmıyorsa *Not used to track you*.
- **Gizlilik manifesti.** Apple bazı API'ler için hem sizin kodunuzda hem de her plugin'de bir kullanım gerekçesi ister. Bu entegrasyonda durum şöyle:
  - `shared_preferences`, ID'leri ve açık/kapalı ayarını `UserDefaults`'ta tutar. iOS plugin'i `UserDefaults` (`NSPrivacyAccessedAPICategoryUserDefaults`) kullanımını beyan eden kendi gizlilik manifestiyle gelir; tracker için ayrıca girdi eklemeniz gerekmez.
  - Kuyruk dosyası `dart:io` ile yazılır; Flutter engine kullandığı dosya API'leri için kendi gizlilik manifestiyle gelir.
  - `device_info_plus` (13.3.0), tracker işletim sistemi sürümünü ve modeli isterken device'ın boş disk alanını da okur. Tracker bu değeri kullanmaz, plugin'in manifesti de bunun için gerekçe beyan etmez. Yüklemeden sonra App Store Connect `NSPrivacyAccessedAPICategoryDiskSpace` için eksik gerekçe uyarısı verirse (ITMS-91053), bu kategoriyi Apple'ın listesinden uygulamanıza uyan bir gerekçeyle `PrivacyInfo.xcprivacy` dosyanıza ekleyin ya da plugin'in geliştiricilerine sorun.
  - Sizin kontrol etmeniz gerekenler: uygulamanızın kendi `PrivacyInfo.xcprivacy` dosyası (Flutter bunu oluşturmaz; Xcode'da `Runner` target'ına ekleyin) etiketinizle uyuşan toplanan veri türlerini listelemeli. App Store Connect'in her yüklemeden sonra gönderdiği e-posta da diğer plugin'ler dahil bütün uygulamadaki eksik gerekçeleri listeler.

Apple'ın tanımları: [App privacy details on the App Store](https://developer.apple.com/app-store/app-privacy-details/) ve [Privacy manifest files](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files).

### 7.8 Çocuklara yönelik uygulamalar

Apple'ın [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) kuralları (1.3 ve 5.1.4), Kids kategorisindeki uygulamalarda üçüncü taraf analitiğe izin vermez. Tek istisna, hizmetin çocuklar hakkında device bilgileri dahil hiçbir tanımlayıcı bilgi toplamadığı sınırlı durumlardır. App Store Operator kalıcı bir install ID'si, device modeli ve ülke bilgisi aldığı için **Kids kategorisindeki bir uygulamaya eklemeyin**. Çocuklara yönelik başka bir uygulamanız varsa, entegrasyondan önce size hangi kuralların uygulandığını (örneğin ABD'de COPPA veya GDPR'ın çocuk onayı kuralları) hukuk danışmanınızla kontrol edin.

## 8. Doğrulama ve sorun giderme

1. Uygulamayı iOS simülatöründe veya device'ta çalıştırın (`flutter run`), birkaç event tetikleyin, sonra uygulamayı arka plana alın (kuyruk bu sırada gönderilir).
2. Dashboard → uygulamanız → **Events → Canlı**'yı açın. Event'leriniz birkaç saniye içinde görünür.
3. **Events → Katalog** her event adını property anahtarlarıyla birlikte listeler. Adların ve anahtarların istediğiniz gibi olduğunu kontrol edin.

Android, web ve masaüstü build'leri hiçbir şey göndermez. Debug build'de konsola bir kez `[EventTracker] iOS only: tracking is off on android` (ya da hangi platformsa onun adı) yazılır.

**Test build'leri.** Her uygulamanın tek bir yazma anahtarı var; test build'leri de App Store build'leri de aynı anahtarı kullanır. Referans kod debug ve profile build'lerinden (`flutter run`) gelen event'leri `debug` olarak etiketler. Release build'lerin event'leri ise `--dart-define=ASO_ENV=...` ile verilen `environment` değerini (`testflight` veya `app_store`) taşır ya da hiç etiket taşımaz. Dashboard varsayılan olarak `app_store` event'lerini ve ortam bilgisi olmayanları gösterir. Testlerinizi görmek için ortam filtresini **Tüm build'ler** yapın. Define verilmeden alınan release build TestFlight'ı App Store'dan ayırt edemez; bu yüzden TestFlight test kullanıcılarınınkiler dahil event'leri App Store event'i sayılır ([§5](#5-referans-kod)). curl testi ortam bilgisi göndermediği için varsayılan görünümde çıkar; `anonymous_id` değeri `curl-test` olan event'e bakın.

| Belirti | Olası neden |
|---|---|
| Canlı'da hiçbir şey yok | Anahtar yanlış (`401`); `configure` hiç çağrılmamış; uygulama Android'de ya da iOS dışında bir platformda çalışıyor; ya da kuyrukta 20'den az event var ve uygulama henüz arka plana geçmedi (30 sn bekleyin) |
| `401 invalid_write_key` | Anahtarda yazım hatası var; anahtar 30 günden uzun süre önce yenilenmiş; ya da eski anahtar iptal edilmiş ([§3.1](#31-anahtarı-yenileme)) |
| Bir event eksik, diğerleri geliyor | Event reddedilmiş. Debug build'de Flutter konsolunda hata koduyla birlikte `[EventTracker] rejected: …` görürsünüz ([§4.5](#45-hatalar)) |
| Bir event'te bir property eksik | Değeri `String`, `int`, `double`, `bool` veya `null` değildi. Debug build'de konsolda `[EventTracker] … dropped` görürsünüz |
| Konsolda `[EventTracker] settings unavailable` veya `queue file unavailable` | Tracker `WidgetsFlutterBinding.ensureInitialized()`'dan önce çalışmış ya da `flutter pub add`'den sonra uygulama yeniden build edilmemiş (uygulamayı durdurup tekrar çalıştırın; hot restart yeni plugin'leri yüklemez). Bu durumda ID'ler ve event'ler yalnızca bellekte tutulur |
| Test build'inden Canlı'ya hiçbir şey gelmiyor | Varsayılan görünüm yalnızca App Store event'lerini ve ortam bilgisi olmayanları gösterir. Ortam filtresini **Tüm build'ler** yapın |
| App Store kullanıcıları varsayılan görünümde yok | Yayınlanan build `--dart-define=ASO_ENV=testflight` ile alınmış; sonraki version'ı bu define olmadan build edin |
| Event'ler gecikmeli ve toplu geliyor | Rate limit aşılmış (`429`), sunucu veya ağ bir süre çalışmamış ya da uygulama `flush()` bitmeden askıya alınmış. Referans kod event'leri saklar ve sonra gönderir |
| Zamanlar yanlış | Device saati çok kaymış. 7 günden eski veya 5 dakikadan ileri zamanların yerine event'in bize ulaştığı zamanı yazarız |
| Aynı kişi bir funnel'da iki kez sayılıyor | `reset()` yanlış bir yerde çağrılıyor ya da `identify` aynı kullanıcı için farklı ID'lerle çağrılıyor |
| Funnel adımı sıfır | Adımdaki event adı veya property filtresi birebir eşleşmiyor (adlar ve değerler büyük/küçük harfe duyarlıdır) |

## 9. Kontrol listesi

Bütün maddeler doğruysa entegrasyon bitmiştir. Raporda her kod maddesi için dosyayı ve satırı yazın. Uymayan bir maddeyi gerekçesiyle *uygulanamaz*, canlı API gerektiren bir maddeyi *beklemede* olarak işaretleyin.

- [ ] `path_provider`, `shared_preferences`, `package_info_plus` ve `device_info_plus` `pubspec.yaml`'da var; izleme için başka bağımlılık eklenmedi.
- [ ] `lib/event_tracker.dart` §5'tekiyle aynı (yalnızca iOS kontrolü dahil), `flutter analyze` hata vermiyor ve uygulama iOS için build alıyor (`flutter build ios --no-codesign`).
- [ ] `configure(writeKey: ...)`, `main()` içinde `WidgetsFlutterBinding.ensureInitialized()`'dan sonra ve `runApp`'ten önce, bu uygulamanın gerçek anahtarıyla bir kez çağrılıyor; projede hiçbir yerde `YOUR_WRITE_KEY` kalmadı.
- [ ] `EventTracker.shared.flush()` uygulama arka plana geçince çağrılıyor.
- [ ] `app_opened` açılışta (`source: "cold"`) ve arka plandan her dönüşte (`source: "warm"`) izleniyor.
- [ ] §6'daki event'lerden akışı bu uygulamada olan her biri, tablodaki property'lerle izleniyor. Her birini `event → file:line` diye raporlayın; atladığınız §6 event'lerini de nedenleriyle yazın.
- [ ] Satın alma event'leri, geri yüklemelerin de düştüğü `purchaseStream` gibi bir listener'da değil, satın alma akışının içinde (§5.2) izleniyor.
- [ ] Her event adı `^[A-Za-z0-9_.:-]{1,64}$` ile eşleşiyor ve §6'daki adlandırma kurallarına uyuyor; her property değeri `String`, `int`, `double`, `bool` veya `null`.
- [ ] Hiçbir `track` çağrısı ad, e-posta, telefon numarası, hassas konum, IDFA veya kullanıcının yazdığı metni göndermiyor.
- [ ] Uygulamada hesap sistemi varsa `identify` oturum açıldıktan sonra uygulamanın kendi kullanıcı ID'siyle, `reset` de oturum kapatıldıktan sonra çağrılıyor. Hesap sistemi yoksa ikisi de çağrılmıyor.
- [ ] *Canlı API:* §3'teki curl testi `accepted: 1` döndürüyor ve uygulamayı çalıştırınca gelen event'ler Dashboard → Events → Canlı'da görünüyor.
- [ ] *Geliştirici:* yukarıdaki event ve property listesi geliştiriciye verildi. Geliştirici bunu §7'ye, App Store gizlilik etiketine, uygulamanın `PrivacyInfo.xcprivacy` dosyasına, gizlilik politikasına ve varsa onay şartına göre kontrol edecek; bir build'de `--dart-define=ASO_ENV` kullanılıp kullanılmayacağına (§5) da o karar verecek. Uygulama Kids kategorisinde değil.
