Token Sistemi Dokümantasyonu#
İçindekiler#
- Genel Bakış
- Veritabanı Şeması
- Flutter Modelleri
- Servisler
- State Management
- UI Bileşenleri
- Entegrasyon Noktaları
- Kullanım Akışları
Genel Bakış#
Token sistemi, öğretmenlerin öğrenci kağıtlarını yüklemek için kullandıkları kullanım hakkı yönetim sistemidir. Sistem, üç farklı plan tipi ve günlük token limitleri ile çalışır.
Sistem Mimarisi#
┌─────────────────────────────────────────────────────────────┐
│ Flutter Mobile App │
├─────────────────────────────────────────────────────────────┤
│ TokenProvider (State Management) │
│ ├─ Token Balance │
│ ├─ Transaction History │
│ └─ Plan Information │
├─────────────────────────────────────────────────────────────┤
│ TokenService (Business Logic) │
│ ├─ getUserTokenInfo() │
│ ├─ canUploadPapers() │
│ ├─ deductTokensForPapers() │
│ └─ getTokenHistory() │
├─────────────────────────────────────────────────────────────┤
│ UI Components │
│ ├─ TokenBalanceCard │
│ ├─ InsufficientTokensDialog │
│ ├─ UpgradeToSupporterDialog │
│ └─ TokenHistoryScreen │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Supabase Backend │
├─────────────────────────────────────────────────────────────┤
│ Database Tables │
│ ├─ token_plans │
│ ├─ user_tokens │
│ └─ token_transactions │
├─────────────────────────────────────────────────────────────┤
│ RPC Functions │
│ ├─ get_user_token_info() │
│ ├─ can_upload_papers() │
│ ├─ deduct_tokens_for_papers() │
│ └─ get_token_history() │
├─────────────────────────────────────────────────────────────┤
│ Triggers │
│ └─ on_user_created_add_tokens │
└─────────────────────────────────────────────────────────────┘Plan Tipleri#
| Plan Tipi | Günlük Limit | Kümülatif | Fiyat | Hedef Kullanıcı |
|---|---|---|---|---|
| default | 10 token | Hayır (her gün 10’a sıfırlanır) | Ücretsiz | Standart öğretmenler |
| supporter | Sınırsız | - | Ücretli (gelecekte) | Premium öğretmenler |
| admin | Sınırsız | - | - | Sistem yöneticileri |
Token Maliyeti#
- 1 fotoğraf = 1 token
- Sadece öğrenci kağıdı yükleme işlemlerinde token harcanır
- Diğer işlemler (sınıf oluşturma, sınav oluşturma vb.) ücretsizdir
- Token sadece öğretmenler için geçerlidir, öğrenciler token kullanmaz
İş Akışı#
- Yeni öğretmen kaydı → Otomatik olarak “default” plan atanır
- Her gün saat 00:00’da default plan kullanıcılarının tokenları 10’a sıfırlanır
- Öğretmen öğrenci kağıdı yüklemek ister → Sistem token kontrolü yapar
- Yeterli token varsa → Token düşürülür ve upload işlemi gerçekleşir
- Yetersiz token varsa → Kullanıcıya uyarı gösterilir ve “supporter” plana yükseltme seçeneği sunulur
Veritabanı Şeması#
Tablo: token_plans#
Token plan tiplerini tanımlar.
Kolon Yapısı:
CREATE TABLE IF NOT EXISTS token_plans (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
plan_type TEXT NOT NULL UNIQUE CHECK (plan_type IN ('default', 'supporter', 'admin')),
daily_limit INTEGER NOT NULL,
is_unlimited BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);Kolonlar:
id(SERIAL PRIMARY KEY): Benzersiz plan kimliğiname(TEXT NOT NULL): Plan adı (örn: “Ücretsiz Plan”, “Destekçi Planı”)plan_type(TEXT NOT NULL UNIQUE): Plan tipi enum değeridaily_limit(INTEGER NOT NULL): Günlük token limiti (-1 = sınırsız)is_unlimited(BOOLEAN): Plan sınırsız mı?created_at(TIMESTAMPTZ): Oluşturulma zamanı
Varsayılan Veriler:
INSERT INTO token_plans (name, plan_type, daily_limit, is_unlimited) VALUES
('Ücretsiz Plan', 'default', 10, FALSE),
('Destekçi Planı', 'supporter', -1, TRUE),
('Admin Planı', 'admin', -1, TRUE);RLS Politikaları:
- Herkes okuma yetkisine sahip (
SELECT) - Admin ve editor’lar yazma yetkisine sahip (
INSERT,UPDATE,DELETE)
Tablo: user_tokens#
Kullanıcıların token bakiyelerini ve istatistiklerini saklar.
Kolon Yapısı:
CREATE TABLE IF NOT EXISTS user_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
token_balance INTEGER NOT NULL DEFAULT 10,
last_reset_at TIMESTAMPTZ DEFAULT NOW(),
lifetime_tokens_spent INTEGER DEFAULT 0,
total_papers_graded INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);Kolonlar:
id(SERIAL PRIMARY KEY): Benzersiz kayıt kimliğiuser_id(INTEGER UNIQUE): Kullanıcı kimliği (foreign key)token_balance(INTEGER): Mevcut token bakiyesilast_reset_at(TIMESTAMPTZ): Son token sıfırlama zamanılifetime_tokens_spent(INTEGER): Toplam harcanan token sayısıtotal_papers_graded(INTEGER): Toplam değerlendirilen kağıt sayısıcreated_at(TIMESTAMPTZ): Kayıt oluşturulma zamanıupdated_at(TIMESTAMPTZ): Son güncelleme zamanı
RLS Politikaları:
- Kullanıcılar sadece kendi token bilgilerini görebilir
- Sadece authenticated kullanıcılar erişebilir
İndeksler:
CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id);Tablo: token_transactions#
Token kullanım geçmişini kaydeder.
Kolon Yapısı:
CREATE TABLE IF NOT EXISTS token_transactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
transaction_type TEXT NOT NULL CHECK (transaction_type IN ('deduct', 'add', 'reset')),
amount INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
description TEXT,
exam_id INTEGER REFERENCES exams(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);Kolonlar:
id(SERIAL PRIMARY KEY): Benzersiz işlem kimliğiuser_id(INTEGER): Kullanıcı kimliği (foreign key)transaction_type(TEXT): İşlem tipideduct: Token düşürme (kağıt yükleme)add: Token ekleme (yönetici işlemi)reset: Token sıfırlama (günlük otomatik sıfırlama)
amount(INTEGER): İşlem miktarı (pozitif veya negatif)balance_after(INTEGER): İşlem sonrası bakiyedescription(TEXT): İşlem açıklamasıexam_id(INTEGER): İlişkili sınav kimliği (opsiyonel)created_at(TIMESTAMPTZ): İşlem zamanı
RLS Politikaları:
- Kullanıcılar sadece kendi işlem geçmişlerini görebilir
İndeksler:
CREATE INDEX idx_token_transactions_user_id ON token_transactions(user_id);
CREATE INDEX idx_token_transactions_created_at ON token_transactions(created_at DESC);
CREATE INDEX idx_token_transactions_exam_id ON token_transactions(exam_id);Kolon Ekleme: users.account_type#
Kullanıcıların plan tipini belirler.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS account_type TEXT
REFERENCES token_plans(plan_type)
DEFAULT 'default';Varsayılan Değer: 'default'
İlişki: token_plans(plan_type) foreign key
RPC Fonksiyon: get_user_token_info()#
Kullanıcının token bilgilerini, plan detaylarını ve sonraki sıfırlamaya kalan süreyi döndürür.
Parametreler:
- Yok (mevcut kullanıcı session’dan alınır)
Dönen Veri Yapısı:
{
"success": true,
"error": null,
"plan_type": "default",
"is_unlimited": false,
"token_balance": 7,
"daily_limit": 10,
"last_reset_at": "2026-01-22T00:00:00Z",
"hours_until_reset": 12.5
}İş Akışı:
- Kullanıcının
role_id‘sini kontrol eder (sadece öğretmenler için) - Kullanıcının plan tipini
users.account_type‘dan alır - Plan detaylarını
token_plans‘dan okur - Token bakiyesini
user_tokens‘dan okur - Sonraki sıfırlamaya kalan süreyi hesaplar
- Tüm bilgileri JSON formatında döndürür
SQL Kodu:
CREATE OR REPLACE FUNCTION get_user_token_info()
RETURNS JSON AS $$
DECLARE
v_user_id INTEGER;
v_role_id INTEGER;
v_account_type TEXT;
v_plan RECORD;
v_token RECORD;
v_hours_until_reset NUMERIC;
BEGIN
-- Get current user ID
v_user_id := auth.uid()::INTEGER;
IF v_user_id IS NULL THEN
RETURN json_build_object(
'success', FALSE,
'error', 'User not authenticated'
);
END IF;
-- Get user role and account type
SELECT role_id, COALESCE(account_type, 'default')
INTO v_role_id, v_account_type
FROM users
WHERE id = v_user_id;
-- Only teachers (role_id = 2) need tokens
IF v_role_id != 2 THEN
RETURN json_build_object(
'success', FALSE,
'error', 'Token system is only for teachers'
);
END IF;
-- Get plan details
SELECT * INTO v_plan
FROM token_plans
WHERE plan_type = v_account_type;
-- Get token balance
SELECT * INTO v_token
FROM user_tokens
WHERE user_id = v_user_id;
-- Calculate hours until next reset (midnight)
v_hours_until_reset := EXTRACT(EPOCH FROM (
(CURRENT_DATE + INTERVAL '1 day')::TIMESTAMPTZ - NOW()
)) / 3600;
RETURN json_build_object(
'success', TRUE,
'error', NULL,
'plan_type', v_plan.plan_type,
'is_unlimited', v_plan.is_unlimited,
'token_balance', COALESCE(v_token.token_balance, 0),
'daily_limit', v_plan.daily_limit,
'last_reset_at', v_token.last_reset_at,
'hours_until_reset', v_hours_until_reset
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;RPC Fonksiyon: can_upload_papers(p_paper_count INTEGER)#
Kullanıcının belirtilen sayıda kağıt yüklemeye yeterli tokene sahip olup olmadığını kontrol eder.
Parametreler:
p_paper_count(INTEGER): Yüklenecek kağıt sayısı
Dönen Veri Yapısı:
{
"success": true,
"can_upload": true,
"is_unlimited": false,
"required": 5,
"available": 7,
"shortage": 0
}İş Akışı:
- Kullanıcının plan tipini kontrol eder
- Unlimited plan ise →
can_upload: true - Default/supporter plan ise → Mevcut bakiye ile gerekli token sayısını karşılaştırır
- Eksik token sayısını hesaplar (
shortage) - Sonucu JSON formatında döndürür
SQL Kodu:
CREATE OR REPLACE FUNCTION can_upload_papers(p_paper_count INTEGER)
RETURNS JSON AS $$
DECLARE
v_user_id INTEGER;
v_token_balance INTEGER;
v_is_unlimited BOOLEAN;
v_account_type TEXT;
BEGIN
v_user_id := auth.uid()::INTEGER;
-- Get account type and check if unlimited
SELECT
u.account_type,
tp.is_unlimited
INTO v_account_type, v_is_unlimited
FROM users u
JOIN token_plans tp ON tp.plan_type = u.account_type
WHERE u.id = v_user_id;
-- If unlimited, always return true
IF v_is_unlimited THEN
RETURN json_build_object(
'success', TRUE,
'can_upload', TRUE,
'is_unlimited', TRUE,
'required', p_paper_count,
'available', -1,
'shortage', 0
);
END IF;
-- Get current token balance
SELECT token_balance INTO v_token_balance
FROM user_tokens
WHERE user_id = v_user_id;
-- Check if enough tokens
RETURN json_build_object(
'success', TRUE,
'can_upload', v_token_balance >= p_paper_count,
'is_unlimited', FALSE,
'required', p_paper_count,
'available', v_token_balance,
'shortage', GREATEST(0, p_paper_count - v_token_balance)
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;RPC Fonksiyon: deduct_tokens_for_papers(p_paper_count INTEGER, p_exam_id INTEGER)#
Kullanıcının token bakiyesinden belirtilen miktarı düşürür ve işlem kaydı oluşturur.
Parametreler:
p_paper_count(INTEGER): Düşürülecek token sayısıp_exam_id(INTEGER, OPTIONAL): İlişkili sınav kimliği
Dönen Veri Yapısı:
{
"success": true,
"error": null,
"is_unlimited": false,
"tokens_deducted": 5,
"balance_before": 7,
"balance_after": 2
}İş Akışı:
- Kullanıcının plan tipini kontrol eder
- Unlimited plan ise → İşlem yapmadan başarılı döner
- Default/supporter plan ise:
- Mevcut bakiyeyi kontrol eder
- Yeterli token yoksa hata döndürür
- Token bakiyesini düşürür
- İşlem kaydı oluşturur (
token_transactions) - İstatistikleri günceller (
lifetime_tokens_spent,total_papers_graded)
- Sonucu JSON formatında döndürür
SQL Kodu:
CREATE OR REPLACE FUNCTION deduct_tokens_for_papers(
p_paper_count INTEGER,
p_exam_id INTEGER DEFAULT NULL
)
RETURNS JSON AS $$
DECLARE
v_user_id INTEGER;
v_token_balance INTEGER;
v_new_balance INTEGER;
v_is_unlimited BOOLEAN;
v_account_type TEXT;
BEGIN
v_user_id := auth.uid()::INTEGER;
-- Get account type and check if unlimited
SELECT
u.account_type,
tp.is_unlimited
INTO v_account_type, v_is_unlimited
FROM users u
JOIN token_plans tp ON tp.plan_type = u.account_type
WHERE u.id = v_user_id;
-- If unlimited, no deduction needed
IF v_is_unlimited THEN
RETURN json_build_object(
'success', TRUE,
'error', NULL,
'is_unlimited', TRUE,
'tokens_deducted', 0,
'balance_before', -1,
'balance_after', -1
);
END IF;
-- Get current balance
SELECT token_balance INTO v_token_balance
FROM user_tokens
WHERE user_id = v_user_id;
-- Check sufficient balance
IF v_token_balance < p_paper_count THEN
RETURN json_build_object(
'success', FALSE,
'error', 'Insufficient tokens',
'is_unlimited', FALSE,
'tokens_deducted', 0,
'balance_before', v_token_balance,
'balance_after', v_token_balance
);
END IF;
-- Deduct tokens
v_new_balance := v_token_balance - p_paper_count;
UPDATE user_tokens
SET
token_balance = v_new_balance,
lifetime_tokens_spent = lifetime_tokens_spent + p_paper_count,
total_papers_graded = total_papers_graded + p_paper_count,
updated_at = NOW()
WHERE user_id = v_user_id;
-- Record transaction
INSERT INTO token_transactions (
user_id,
transaction_type,
amount,
balance_after,
description,
exam_id
) VALUES (
v_user_id,
'deduct',
p_paper_count,
v_new_balance,
FORMAT('%s kağıt yükleme için token kullanıldı', p_paper_count),
p_exam_id
);
RETURN json_build_object(
'success', TRUE,
'error', NULL,
'is_unlimited', FALSE,
'tokens_deducted', p_paper_count,
'balance_before', v_token_balance,
'balance_after', v_new_balance
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;RPC Fonksiyon: get_token_history(p_limit INTEGER, p_offset INTEGER)#
Kullanıcının token işlem geçmişini sayfalama ile getirir.
Parametreler:
p_limit(INTEGER, DEFAULT 20): Sayfa başına kayıt sayısıp_offset(INTEGER, DEFAULT 0): Başlangıç offset değeri
Dönen Veri Yapısı:
{
"success": true,
"error": null,
"transactions": [
{
"id": 123,
"transaction_type": "deduct",
"amount": 5,
"balance_after": 5,
"description": "5 kağıt yükleme için token kullanıldı",
"exam_id": 42,
"created_at": "2026-01-22T14:30:00Z"
}
],
"total_count": 150,
"has_more": true
}İş Akışı:
- Kullanıcının toplam işlem sayısını hesaplar
- Belirtilen sayfalama parametreleri ile işlemleri getirir
- Tarih sırasına göre (yeniden eskiye) sıralar
- Sonucu JSON formatında döndürür
SQL Kodu:
CREATE OR REPLACE FUNCTION get_token_history(
p_limit INTEGER DEFAULT 20,
p_offset INTEGER DEFAULT 0
)
RETURNS JSON AS $$
DECLARE
v_user_id INTEGER;
v_transactions JSON;
v_total_count INTEGER;
BEGIN
v_user_id := auth.uid()::INTEGER;
-- Get total count
SELECT COUNT(*) INTO v_total_count
FROM token_transactions
WHERE user_id = v_user_id;
-- Get transactions with pagination
SELECT json_agg(row_to_json(t)) INTO v_transactions
FROM (
SELECT
id,
transaction_type,
amount,
balance_after,
description,
exam_id,
created_at
FROM token_transactions
WHERE user_id = v_user_id
ORDER BY created_at DESC
LIMIT p_limit
OFFSET p_offset
) t;
RETURN json_build_object(
'success', TRUE,
'error', NULL,
'transactions', COALESCE(v_transactions, '[]'::JSON),
'total_count', v_total_count,
'has_more', v_total_count > (p_offset + p_limit)
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;Trigger: on_user_created_add_tokens#
Yeni öğretmen kaydı oluşturulduğunda otomatik olarak default plan atanır ve token kaydı oluşturulur.
Tetiklenme Koşulu:
userstablosuna yeni kayıt eklenmesi (INSERT)- Kullanıcının
role_id = 2(öğretmen) olması
İş Akışı:
- Yeni kullanıcının
role_id‘sini kontrol eder - Eğer öğretmen ise:
account_type‘ı ‘default’ olarak ayarlar (eğer NULL ise)user_tokenstablosuna yeni kayıt oluşturur- Başlangıç bakiyesi: 10 token
- Trigger sonucu döndürür
SQL Kodu:
CREATE OR REPLACE FUNCTION add_tokens_for_new_user()
RETURNS TRIGGER AS $$
BEGIN
-- Only for teachers (role_id = 2)
IF NEW.role_id = 2 THEN
-- Set default account type if not set
IF NEW.account_type IS NULL THEN
NEW.account_type := 'default';
END IF;
-- Create token record
INSERT INTO user_tokens (user_id, token_balance, last_reset_at)
VALUES (NEW.id, 10, NOW())
ON CONFLICT (user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_user_created_add_tokens
BEFORE INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION add_tokens_for_new_user();Flutter Modelleri#
Dosya: lib/common/models/token_plan_model.dart#
Token plan bilgilerini temsil eden model sınıfı.
Sınıf: TokenPlan
class TokenPlan {
final int id;
final String name;
final String planType;
final int dailyLimit;
final bool isUnlimited;
final DateTime createdAt;
TokenPlan({
required this.id,
required this.name,
required this.planType,
required this.dailyLimit,
required this.isUnlimited,
required this.createdAt,
});
}Özellikler:
id: Plan benzersiz kimliğiname: Plan adı (örn: “Ücretsiz Plan”)planType: Plan tipi (‘default’, ‘supporter’, ‘admin’)dailyLimit: Günlük token limiti (-1 sınırsız için)isUnlimited: Plan sınırsız mı? (boolean)createdAt: Plan oluşturulma tarihi
Metotlar:
factory TokenPlan.fromJson(Map<String, dynamic> json)
- JSON verisinden TokenPlan nesnesi oluşturur
- Veritabanından gelen veriyi parse eder
- Null safety kontrolü yapar
Map<String, dynamic> toJson()
- TokenPlan nesnesini JSON formatına dönüştürür
- Veritabanına kaydetmek için kullanılır
String toString()
- Debug amaçlı string temsili döndürür
Örnek Kullanım:
final plan = TokenPlan.fromJson({
'id': 1,
'name': 'Ücretsiz Plan',
'plan_type': 'default',
'daily_limit': 10,
'is_unlimited': false,
'created_at': '2026-01-01T00:00:00Z',
});
print(plan.isUnlimited); // false
print(plan.dailyLimit); // 10Dosya: lib/common/models/user_token_model.dart#
Kullanıcı token bilgilerini temsil eden model sınıfları.
Sınıf 1: UserToken
Kullanıcının token bakiyesi ve istatistiklerini temsil eder.
class UserToken {
final int id;
final int userId;
final int tokenBalance;
final DateTime lastResetAt;
final int lifetimeTokensSpent;
final int totalPapersGraded;
final DateTime createdAt;
final DateTime updatedAt;
UserToken({
required this.id,
required this.userId,
required this.tokenBalance,
required this.lastResetAt,
this.lifetimeTokensSpent = 0,
this.totalPapersGraded = 0,
required this.createdAt,
required this.updatedAt,
});
}Özellikler:
id: Kayıt benzersiz kimliğiuserId: Kullanıcı kimliğitokenBalance: Mevcut token bakiyesilastResetAt: Son sıfırlama tarihilifetimeTokensSpent: Toplam harcanan tokentotalPapersGraded: Toplam değerlendirilen kağıtcreatedAt: Kayıt oluşturma tarihiupdatedAt: Son güncelleme tarihi
Sınıf 2: TokenInfo
RPC get_user_token_info() fonksiyonundan dönen detaylı token bilgisini temsil eder.
class TokenInfo {
final bool success;
final String? error;
final String planType;
final bool isUnlimited;
final int tokenBalance;
final int dailyLimit;
final DateTime? lastResetAt;
final double hoursUntilReset;
TokenInfo({
required this.success,
this.error,
required this.planType,
required this.isUnlimited,
required this.tokenBalance,
required this.dailyLimit,
this.lastResetAt,
required this.hoursUntilReset,
});
}Özellikler:
success: İşlem başarılı mı?error: Hata mesajı (varsa)planType: Kullanıcının plan tipiisUnlimited: Sınırsız plan mı?tokenBalance: Mevcut token bakiyesidailyLimit: Günlük limitlastResetAt: Son sıfırlama tarihihoursUntilReset: Sonraki sıfırlamaya kalan saat
Metotlar:
factory TokenInfo.fromJson(Map<String, dynamic> json)
- JSON verisinden TokenInfo nesnesi oluşturur
- RPC fonksiyon yanıtını parse eder
String getPlanDisplayName()
- Plan tipine göre Türkçe plan adı döndürür
- ‘default’ → “Ücretsiz Plan”
- ‘supporter’ → “Destekçi Planı”
- ‘admin’ → “Admin Planı”
String getBalanceDisplayText()
- Token bakiyesi gösterim metnini döndürür
- Sınırsız plan: “Sınırsız”
- Default plan: “7 / 10”
String getResetTimeText()
- Sıfırlanma süresini okunabilir formatta döndürür
- Örnek: “12 saat 30 dakika sonra sıfırlanacak”
Örnek Kullanım:
final tokenInfo = TokenInfo.fromJson({
'success': true,
'error': null,
'plan_type': 'default',
'is_unlimited': false,
'token_balance': 7,
'daily_limit': 10,
'last_reset_at': '2026-01-22T00:00:00Z',
'hours_until_reset': 12.5,
});
print(tokenInfo.getBalanceDisplayText()); // "7 / 10"
print(tokenInfo.getResetTimeText()); // "12 saat 30 dakika sonra sıfırlanacak"Sınıf 3: CanUploadResult
Kağıt yükleme yeterlilik kontrolü sonucunu temsil eder.
class CanUploadResult {
final bool success;
final bool canUpload;
final bool isUnlimited;
final int required;
final int available;
final int shortage;
CanUploadResult({
required this.success,
required this.canUpload,
required this.isUnlimited,
required this.required,
required this.available,
required this.shortage,
});
}Özellikler:
success: RPC çağrısı başarılı mı?canUpload: Yükleme yapabilir mi?isUnlimited: Sınırsız plan mı?required: Gerekli token sayısıavailable: Mevcut token sayısıshortage: Eksik token sayısı (0 veya pozitif)
Metotlar:
factory CanUploadResult.fromJson(Map<String, dynamic> json)
- RPC
can_upload_papers()yanıtını parse eder
Örnek Kullanım:
final result = CanUploadResult.fromJson({
'success': true,
'can_upload': false,
'is_unlimited': false,
'required': 10,
'available': 7,
'shortage': 3,
});
if (!result.canUpload) {
print('${result.shortage} token eksik');
}Sınıf 4: DeductTokensResult
Token düşürme işlemi sonucunu temsil eder.
class DeductTokensResult {
final bool success;
final String? error;
final bool isUnlimited;
final int tokensDeducted;
final int balanceBefore;
final int balanceAfter;
DeductTokensResult({
required this.success,
this.error,
required this.isUnlimited,
required this.tokensDeducted,
required this.balanceBefore,
required this.balanceAfter,
});
}Özellikler:
success: İşlem başarılı mı?error: Hata mesajı (varsa)isUnlimited: Sınırsız plan mı?tokensDeducted: Düşürülen token miktarıbalanceBefore: İşlem öncesi bakiyebalanceAfter: İşlem sonrası bakiye
Metotlar:
factory DeductTokensResult.fromJson(Map<String, dynamic> json)
- RPC
deduct_tokens_for_papers()yanıtını parse eder
Dosya: lib/common/models/token_transaction_model.dart#
Token işlem geçmişi kayıtlarını temsil eden model sınıfları.
Enum: TokenTransactionType
İşlem tiplerini tanımlar.
enum TokenTransactionType {
deduct, // Token düşürme (kağıt yükleme)
add, // Token ekleme (yönetici işlemi)
reset, // Token sıfırlama (günlük otomatik)
}Sınıf 1: TokenTransaction
Tek bir token işlemini temsil eder.
class TokenTransaction {
final int id;
final int userId;
final TokenTransactionType transactionType;
final int amount;
final int balanceAfter;
final String? description;
final int? examId;
final DateTime createdAt;
TokenTransaction({
required this.id,
required this.userId,
required this.transactionType,
required this.amount,
required this.balanceAfter,
this.description,
this.examId,
required this.createdAt,
});
}Özellikler:
id: İşlem benzersiz kimliğiuserId: Kullanıcı kimliğitransactionType: İşlem tipi enumamount: İşlem miktarıbalanceAfter: İşlem sonrası bakiyedescription: İşlem açıklamasıexamId: İlişkili sınav kimliği (opsiyonel)createdAt: İşlem tarihi
Metotlar:
factory TokenTransaction.fromJson(Map<String, dynamic> json)
- JSON verisinden TokenTransaction nesnesi oluşturur
transaction_typestring’ini enum’a çevirir
static TokenTransactionType _parseTransactionType(String type)
- String değeri TokenTransactionType enum’una dönüştürür
String getTypeDisplayText()
- İşlem tipinin Türkçe karşılığını döndürür
- ‘deduct’ → “Token Kullanımı”
- ‘add’ → “Token Eklendi”
- ‘reset’ → “Token Sıfırlandı”
Color getTypeColor()
- İşlem tipine göre renk döndürür
- deduct → Kırmızı
- add → Yeşil
- reset → Mavi
IconData getTypeIcon()
- İşlem tipine göre ikon döndürür
- deduct → Icons.remove_circle
- add → Icons.add_circle
- reset → Icons.refresh
Sınıf 2: TokenHistoryResult
RPC get_token_history() fonksiyonundan dönen sayfalanmış geçmiş sonucunu temsil eder.
class TokenHistoryResult {
final bool success;
final String? error;
final List<TokenTransaction> transactions;
final int totalCount;
final bool hasMore;
TokenHistoryResult({
required this.success,
this.error,
required this.transactions,
required this.totalCount,
required this.hasMore,
});
}Özellikler:
success: İşlem başarılı mı?error: Hata mesajı (varsa)transactions: İşlem listesitotalCount: Toplam işlem sayısıhasMore: Daha fazla kayıt var mı?
Metotlar:
factory TokenHistoryResult.fromJson(Map<String, dynamic> json)
- RPC yanıtını parse eder
- İşlem listesini oluşturur
Örnek Kullanım:
final result = TokenHistoryResult.fromJson(apiResponse);
print('Toplam ${result.totalCount} işlem');
for (var transaction in result.transactions) {
print('${transaction.getTypeDisplayText()}: ${transaction.amount} token');
}
if (result.hasMore) {
print('Daha fazla kayıt var');
}Servisler#
Dosya: lib/common/services/token_service.dart#
Token işlemleri için Supabase RPC fonksiyonlarını çağıran servis katmanı.
Sınıf: TokenService
Statik metodlarla token işlemlerini yönetir.
Metod: getUserTokenInfo()
Kullanıcının token bilgilerini getirir.
static Future<TokenInfo> getUserTokenInfo() async {
try {
final response = await Supabase.instance.client
.rpc('get_user_token_info');
print('Token info fetched: ${response}');
return TokenInfo.fromJson(response as Map<String, dynamic>);
} catch (e) {
print('Error fetching token info: $e');
throw Exception('Token bilgisi alınamadı: $e');
}
}Parametreler: Yok
Dönen Değer: Future<TokenInfo>
Hata Yönetimi: Exception fırlatır
Kullanım:
try {
final tokenInfo = await TokenService.getUserTokenInfo();
print('Bakiye: ${tokenInfo.tokenBalance}');
} catch (e) {
print('Hata: $e');
}Metod: canUploadPapers(int paperCount)
Belirtilen sayıda kağıt yüklemek için yeterli token olup olmadığını kontrol eder.
static Future<CanUploadResult> canUploadPapers(int paperCount) async {
try {
final response = await Supabase.instance.client
.rpc('can_upload_papers', params: {
'p_paper_count': paperCount,
});
print('Can upload check: ${response}');
return CanUploadResult.fromJson(response as Map<String, dynamic>);
} catch (e) {
print('Error checking upload permission: $e');
throw Exception('Yükleme kontrolü yapılamadı: $e');
}
}Parametreler:
paperCount(int): Yüklenecek kağıt sayısı
Dönen Değer: Future<CanUploadResult>
Kullanım:
final result = await TokenService.canUploadPapers(5);
if (result.canUpload) {
print('Yükleme yapılabilir');
} else {
print('${result.shortage} token eksik');
}Metod: deductTokensForPapers({required int paperCount, int? examId})
Token bakiyesinden belirtilen miktarı düşürür.
static Future<DeductTokensResult> deductTokensForPapers({
required int paperCount,
int? examId,
}) async {
try {
final params = <String, dynamic>{
'p_paper_count': paperCount,
};
if (examId != null) {
params['p_exam_id'] = examId;
}
final response = await Supabase.instance.client
.rpc('deduct_tokens_for_papers', params: params);
print('Tokens deducted: ${response}');
return DeductTokensResult.fromJson(response as Map<String, dynamic>);
} catch (e) {
print('Error deducting tokens: $e');
throw Exception('Token düşürme hatası: $e');
}
}Parametreler:
paperCount(int, required): Düşürülecek token miktarıexamId(int?, optional): İlişkili sınav kimliği
Dönen Değer: Future<DeductTokensResult>
Kullanım:
final result = await TokenService.deductTokensForPapers(
paperCount: 5,
examId: 42,
);
if (result.success) {
print('${result.tokensDeducted} token düşürüldü');
print('Yeni bakiye: ${result.balanceAfter}');
}Metod: getTokenHistory({int limit = 20, int offset = 0})
Kullanıcının token işlem geçmişini sayfalama ile getirir.
static Future<TokenHistoryResult> getTokenHistory({
int limit = 20,
int offset = 0,
}) async {
try {
final response = await Supabase.instance.client
.rpc('get_token_history', params: {
'p_limit': limit,
'p_offset': offset,
});
print('Token history fetched: ${response}');
return TokenHistoryResult.fromJson(response as Map<String, dynamic>);
} catch (e) {
print('Error fetching token history: $e');
throw Exception('Token geçmişi alınamadı: $e');
}
}Parametreler:
limit(int, default: 20): Sayfa başına kayıt sayısıoffset(int, default: 0): Başlangıç offset değeri
Dönen Değer: Future<TokenHistoryResult>
Kullanım:
// İlk 20 kayıt
final result1 = await TokenService.getTokenHistory();
// Sonraki 20 kayıt
final result2 = await TokenService.getTokenHistory(offset: 20);
print('Toplam ${result1.totalCount} işlem');State Management#
Dosya: lib/common/core/providers/token_provider.dart#
Token state yönetimi için Provider (ChangeNotifier) implementasyonu.
Sınıf: TokenProvider extends ChangeNotifier
Uygulama genelinde token durumunu yönetir ve UI’a bildirim gönderir.
Özellikler (Properties):
class TokenProvider extends ChangeNotifier {
TokenInfo? _tokenInfo;
TokenPlan? _userPlan;
List<TokenTransaction> _transactionHistory = [];
bool _isLoading = false;
bool _isLoadingHistory = false;
int _currentHistoryOffset = 0;
bool _hasMoreHistory = false;
int _totalHistoryCount = 0;
// Getters
TokenInfo? get tokenInfo => _tokenInfo;
TokenPlan? get userPlan => _userPlan;
List<TokenTransaction> get transactionHistory => _transactionHistory;
bool get isLoading => _isLoading;
bool get isLoadingHistory => _isLoadingHistory;
bool get hasMoreHistory => _hasMoreHistory;
int get totalHistoryCount => _totalHistoryCount;
}State Değişkenleri:
_tokenInfo: Mevcut token bilgisi_userPlan: Kullanıcının aktif planı_transactionHistory: İşlem geçmişi listesi_isLoading: Ana yükleme durumu_isLoadingHistory: Geçmiş yükleme durumu_currentHistoryOffset: Sayfalama offset’i_hasMoreHistory: Daha fazla kayıt var mı?_totalHistoryCount: Toplam işlem sayısı
Metod: initialize()
Provider’ı başlatır ve token bilgilerini yükler.
Future<void> initialize() async {
await refreshTokenInfo();
await _loadUserPlan();
}Ne Yapar:
- Token bilgilerini yeniler
- Kullanıcının planını yükler
Kullanım:
// Login sonrası
await tokenProvider.initialize();Metod: refreshTokenInfo()
Token bilgilerini sunucudan yeniler.
Future<void> refreshTokenInfo() async {
try {
_isLoading = true;
notifyListeners();
_tokenInfo = await TokenService.getUserTokenInfo();
print('Token info refreshed: $_tokenInfo');
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error refreshing token info: $e');
_isLoading = false;
notifyListeners();
}
}Ne Yapar:
- Yükleme durumunu aktif eder
- TokenService’den güncel bilgiyi çeker
- State’i günceller ve listeners’a bildirir
Kullanım:
// Upload sonrası bakiye güncellemesi
await tokenProvider.refreshTokenInfo();Metod: canUploadPapers(int paperCount)
Belirtilen sayıda kağıt yüklemek için yeterli token kontrolü yapar.
Future<CanUploadResult> canUploadPapers(int paperCount) async {
try {
final result = await TokenService.canUploadPapers(paperCount);
return result;
} catch (e) {
print('Error checking upload permission: $e');
return CanUploadResult(
success: false,
canUpload: false,
isUnlimited: false,
required: paperCount,
available: 0,
shortage: paperCount,
);
}
}Parametreler:
paperCount(int): Kontrol edilecek kağıt sayısı
Dönen Değer: Future<CanUploadResult>
Kullanım:
final result = await tokenProvider.canUploadPapers(5);
if (!result.canUpload) {
// Yetersiz token dialog göster
}Metod: deductTokensForPapers({required int paperCount, int? examId})
Token düşürme işlemini gerçekleştirir ve bakiyeyi günceller.
Future<DeductTokensResult> deductTokensForPapers({
required int paperCount,
int? examId,
}) async {
try {
final result = await TokenService.deductTokensForPapers(
paperCount: paperCount,
examId: examId,
);
// Refresh token info after deduction
if (result.success) {
await refreshTokenInfo();
}
return result;
} catch (e) {
print('Error deducting tokens: $e');
return DeductTokensResult(
success: false,
error: e.toString(),
isUnlimited: false,
tokensDeducted: 0,
balanceBefore: 0,
balanceAfter: 0,
);
}
}Parametreler:
paperCount(int, required): Düşürülecek token miktarıexamId(int?, optional): Sınav kimliği
Dönen Değer: Future<DeductTokensResult>
Ne Yapar:
- Token düşürme işlemini gerçekleştirir
- İşlem başarılıysa token bilgilerini yeniler
- Sonucu döndürür
Kullanım:
final result = await tokenProvider.deductTokensForPapers(
paperCount: 5,
examId: examId,
);
if (result.success) {
print('İşlem başarılı, yeni bakiye: ${result.balanceAfter}');
}Metod: loadTransactionHistory({bool refresh = false})
Token işlem geçmişini yükler (sayfalama destekli).
Future<void> loadTransactionHistory({bool refresh = false}) async {
if (_isLoadingHistory) return;
// Reset pagination on refresh
if (refresh) {
_currentHistoryOffset = 0;
_transactionHistory.clear();
}
try {
_isLoadingHistory = true;
notifyListeners();
final result = await TokenService.getTokenHistory(
limit: 20,
offset: _currentHistoryOffset,
);
print('Loaded ${result.transactions.length} transactions');
_transactionHistory.addAll(result.transactions);
_totalHistoryCount = result.totalCount;
_hasMoreHistory = result.hasMore;
_currentHistoryOffset += result.transactions.length;
_isLoadingHistory = false;
notifyListeners();
} catch (e) {
print('Error loading transaction history: $e');
_isLoadingHistory = false;
notifyListeners();
}
}Parametreler:
refresh(bool, default: false): True ise liste sıfırlanır
Ne Yapar:
- Refresh modunda listeyi sıfırlar
- Sunucudan 20 kayıt çeker
- Mevcut listeye ekler
- Sayfalama state’ini günceller
Kullanım:
// İlk yükleme
await tokenProvider.loadTransactionHistory(refresh: true);
// Daha fazla yükleme (scroll)
if (tokenProvider.hasMoreHistory) {
await tokenProvider.loadTransactionHistory();
}Metod: _loadUserPlan()
Kullanıcının aktif planını yükler (private).
Future<void> _loadUserPlan() async {
try {
final userId = Supabase.instance.client.auth.currentUser?.id;
if (userId == null) return;
_userPlan = await TokenService.getUserPlan(int.parse(userId));
print('User plan loaded: $_userPlan');
notifyListeners();
} catch (e) {
print('Error loading user plan: $e');
}
}Metod: reset()
Provider state’ini sıfırlar (logout için).
void reset() {
_tokenInfo = null;
_userPlan = null;
_transactionHistory.clear();
_currentHistoryOffset = 0;
_hasMoreHistory = false;
_totalHistoryCount = 0;
_isLoading = false;
_isLoadingHistory = false;
notifyListeners();
}Kullanım:
// Logout işlemi
await AuthService.logout();
tokenProvider.reset();UI Bileşenleri#
Dosya: lib/common/widgets/token_balance_card.dart#
Token bakiyesini ve plan bilgilerini gösteren kart widget’ı.
Widget: TokenBalanceCard
class TokenBalanceCard extends StatelessWidget {
final VoidCallback? onHistoryTap;
final VoidCallback? onUpgradeTap;
const TokenBalanceCard({
super.key,
this.onHistoryTap,
this.onUpgradeTap,
});
}Parametreler:
onHistoryTap(VoidCallback?): Geçmiş butonu callback’ionUpgradeTap(VoidCallback?): Yükseltme butonu callback’i
Görsel Özellikler:
Plan Tipine Göre Renkler:
- Default Plan: Mavi gradient (
#4A7FC7→#3164B0) - Supporter Plan: Altın gradient (
#FFD700→#FFA500) - Admin Plan: Mor gradient (
#9B59B6→#8E44AD)
İçerik:
- Plan adı ve rozet ikonu
- Token bakiyesi (büyük font)
- Progress bar (sadece default plan için)
- Sıfırlanma zamanı (sadece default plan için)
- İşlem geçmişi butonu
- Yükseltme butonu (sadece default plan için)
Örnek Kullanım:
TokenBalanceCard(
onHistoryTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TokenHistoryScreen(),
),
);
},
onUpgradeTap: () {
UpgradeToSupporterDialog.show(context);
},
)Görünüm Örneği:
┌─────────────────────────────────────┐
│ Ücretsiz Plan 🎟️ │
│ │
│ 7 / 10 │
│ [======== ] │
│ 12 saat 30 dakika sonra │
│ │
│ [İşlem Geçmişi] [Yükselt] │
└─────────────────────────────────────┘Dosya: lib/common/widgets/insufficient_tokens_dialog.dart#
Yetersiz token durumunda gösterilen uyarı dialog’u.
Widget: InsufficientTokensDialog
class InsufficientTokensDialog extends StatelessWidget {
final int required;
final int available;
final int shortage;
final VoidCallback? onUpgradeTap;
const InsufficientTokensDialog({
super.key,
required this.required,
required this.available,
required this.shortage,
this.onUpgradeTap,
});
}Parametreler:
required(int): Gerekli token sayısıavailable(int): Mevcut token sayısıshortage(int): Eksik token sayısıonUpgradeTap(VoidCallback?): Yükseltme butonu callback’i
Statik Metod: show()
static Future<bool?> show(
BuildContext context, {
required int required,
required int available,
VoidCallback? onUpgradeTap,
}) {
final shortage = required - available;
return showDialog<bool>(
context: context,
builder: (context) => InsufficientTokensDialog(
required: required,
available: available,
shortage: shortage,
onUpgradeTap: onUpgradeTap,
),
);
}Dönen Değer: Future<bool?> (true: devam et, false/null: iptal)
İçerik:
- Uyarı başlığı ve ikonu
- Gerekli/mevcut/eksik token bilgisi
- İptal butonu
- Yükseltme butonu
Örnek Kullanım:
final result = await InsufficientTokensDialog.show(
context,
required: 10,
available: 7,
onUpgradeTap: () {
UpgradeToSupporterDialog.show(context);
},
);
if (result != true) {
// Kullanıcı iptal etti
return;
}Görünüm Örneği:
┌─────────────────────────────────────┐
│ Yetersiz Token │
│ ⚠️ │
│ │
│ Gerekli Token: 10 │
│ Mevcut Token: 7 │
│ Eksik Token: 3 │
│ │
│ [İptal] [Destekçi Ol] │
└─────────────────────────────────────┘Dosya: lib/common/widgets/upgrade_to_supporter_dialog.dart#
Supporter plan tanıtımı ve yükseltme dialog’u.
Widget: UpgradeToSupporterDialog
class UpgradeToSupporterDialog extends StatelessWidget {
const UpgradeToSupporterDialog({super.key});
}Statik Metod: show()
static Future<void> show(BuildContext context) {
return showDialog(
context: context,
builder: (context) => const UpgradeToSupporterDialog(),
);
}İçerik:
- “Destekçi Planı” başlığı
- Plan özellikleri listesi:
- Sınırsız token
- Tüm özelliklere erişim
- Öncelikli destek
- Gelecek özelliklere erken erişim
- “Yakında” uyarısı
- Kapat butonu
Örnek Kullanım:
UpgradeToSupporterDialog.show(context);Görünüm Örneği:
┌─────────────────────────────────────┐
│ Destekçi Planı │
│ │
│ ✓ Sınırsız token │
│ ✓ Tüm özelliklere erişim │
│ ✓ Öncelikli destek │
│ ✓ Gelecek özelliklere erken │
│ erişim │
│ │
│ ℹ️ Yakında: Ödeme entegrasyonu │
│ gelecek │
│ │
│ [Kapat] │
└─────────────────────────────────────┘Dosya: lib/common/pages/token_history_screen.dart#
Token işlem geçmişini liste halinde gösteren tam sayfa ekranı.
Widget: TokenHistoryScreen
class TokenHistoryScreen extends StatefulWidget {
const TokenHistoryScreen({super.key});
@override
State<TokenHistoryScreen> createState() => _TokenHistoryScreenState();
}State: _TokenHistoryScreenState
Özellikler:
- Scroll controller ile sayfalama
- Pull-to-refresh desteği
- Tarih gruplandırması
- Sonsuz scroll yükleme
Lifecycle Metodları:
initState()
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// Build sonrası history yükle
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadHistory();
});
}_loadHistory()
void _loadHistory() {
final provider = Provider.of<TokenProvider>(context, listen: false);
provider.loadTransactionHistory(refresh: true);
}_onScroll()
void _onScroll() {
// Sayfanın %80'ine ulaşıldığında daha fazla yükle
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
final provider = Provider.of<TokenProvider>(context, listen: false);
if (!provider.isLoadingHistory && provider.hasMoreHistory) {
provider.loadTransactionHistory();
}
}
}UI Bileşenleri:
1. AppBar
- Başlık: “Token Geçmişi”
- Geri butonu
2. Token Özeti Kartı
- Mevcut bakiye
- Toplam harcanan token
- Toplam işlem sayısı
3. İşlem Listesi
- Tarih gruplandırması
- İşlem tipi ikonu ve rengi
- İşlem açıklaması
- Token miktarı (+/-)
- İşlem sonrası bakiye
4. Yükleme Göstergeleri
- Liste başında: CircularProgressIndicator
- Liste sonunda: Mini loading indicator
Örnek Kullanım:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TokenHistoryScreen(),
),
);Görünüm Örneği:
┌─────────────────────────────────────┐
│ ← Token Geçmişi │
├─────────────────────────────────────┤
│ Mevcut Bakiye: 7 token │
│ Toplam Harcanan: 43 token │
│ Toplam İşlem: 15 │
├─────────────────────────────────────┤
│ 22 Ocak 2026 │
│ ├─ 🔴 Token Kullanımı │
│ │ 5 kağıt yükleme -5 │
│ │ Bakiye: 7 │
│ │ 14:30 │
│ │ │
│ └─ 🔵 Token Sıfırlandı │
│ Günlük sıfırlama +10 │
│ Bakiye: 10 │
│ 00:00 │
├─────────────────────────────────────┤
│ 21 Ocak 2026 │
│ └─ 🔴 Token Kullanımı │
│ 3 kağıt yükleme -3 │
│ Bakiye: 7 │
│ 16:45 │
└─────────────────────────────────────┘Entegrasyon Noktaları#
1. Dosya: lib/main.dart#
TokenProvider Eklenmesi:
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => TokenProvider()),
],
child: MyApp(),
)İşlev: TokenProvider’ı uygulama genelinde erişilebilir yapar.
AuthWrapper’da Başlatma:
class _AuthWrapperState extends State<AuthWrapper> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_session != null) {
// Initialize TokenProvider for logged-in users
WidgetsBinding.instance.addPostFrameCallback((_) {
final tokenProvider = Provider.of<TokenProvider>(
context,
listen: false,
);
tokenProvider.initialize();
});
}
}
}İşlev: Kullanıcı giriş yaptığında TokenProvider’ı başlatır.
2. Dosya: lib/common/pages/login_screen.dart#
Login Sonrası TokenProvider Başlatma:
Future<void> _handleLogin() async {
// ... login logic ...
if (mounted) {
// Initialize TokenProvider after successful login
final tokenProvider = Provider.of<TokenProvider>(
context,
listen: false,
);
await tokenProvider.initialize();
print('DEBUG AUTH: TokenProvider initialized');
// Navigate to dashboard
Navigator.pushReplacement(context, ...);
}
}İşlev: Login başarılı olduğunda token bilgilerini yükler.
3. Dosya: lib/teacher/pages/profile/teacher_profile_screen.dart#
TokenBalanceCard Entegrasyonu:
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
// Kullanıcı bilgileri
_buildUserInfoCard(),
// Token bakiye kartı
Padding(
padding: const EdgeInsets.all(16.0),
child: TokenBalanceCard(
onHistoryTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const TokenHistoryScreen(),
),
);
},
onUpgradeTap: () {
UpgradeToSupporterDialog.show(context);
},
),
),
// Diğer profil öğeleri
_buildProfileOptions(),
],
),
),
);
}İşlev: Öğretmen profil sayfasında token bakiyesini gösterir.
Logout’ta TokenProvider Sıfırlama:
void _handleLogout() async {
await AuthService.logout();
// Reset TokenProvider
if (context.mounted) {
Provider.of<TokenProvider>(context, listen: false).reset();
}
// Navigate to login
Navigator.pushReplacement(context, ...);
}İşlev: Çıkış yapıldığında token state’ini temizler.
4. Dosya: lib/teacher/pages/home/teacher_dashboard_screen.dart#
Header’da Token Badge Eklenmesi:
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Row(
children: [
// Avatar
CircleAvatar(...),
const SizedBox(width: 12),
// Kullanıcı bilgileri
Expanded(child: ...),
// Token badge
_buildTokenBadge(context),
const SizedBox(width: 8),
// Logout butonu
IconButton(...),
],
),
);
}_buildTokenBadge() Widget’ı:
Widget _buildTokenBadge(BuildContext context) {
return Consumer<TokenProvider>(
builder: (context, tokenProvider, child) {
final tokenInfo = tokenProvider.tokenInfo;
if (tokenProvider.isLoading || tokenInfo == null) {
return CircularProgressIndicator();
}
// Plan tipine göre badge rengi ve içeriği
Color badgeColor;
Color textColor;
String displayText;
IconData badgeIcon;
if (tokenInfo.planType == 'admin') {
badgeColor = const Color(0xFF9B59B6).withValues(alpha: 0.2);
textColor = const Color(0xFF9B59B6);
displayText = '∞';
badgeIcon = Icons.admin_panel_settings;
} else if (tokenInfo.isUnlimited) {
badgeColor = const Color(0xFFF39C12).withValues(alpha: 0.2);
textColor = const Color(0xFFF39C12);
displayText = '∞';
badgeIcon = Icons.star;
} else {
// Default plan - token durumuna göre renk
final balance = tokenInfo.tokenBalance;
final limit = tokenInfo.dailyLimit;
final ratio = limit > 0 ? balance / limit : 1.0;
if (ratio > 0.5) {
badgeColor = AppColors.primary.withValues(alpha: 0.15);
textColor = AppColors.primary;
} else if (ratio > 0.2) {
badgeColor = Colors.orange.withValues(alpha: 0.15);
textColor = Colors.orange;
} else {
badgeColor = Colors.red.withValues(alpha: 0.15);
textColor = Colors.red;
}
displayText = '$balance';
badgeIcon = Icons.token;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(badgeIcon, size: 14, color: textColor),
const SizedBox(width: 4),
Text(
displayText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
);
},
);
}İşlev: Dashboard header’ında kompakt token göstergesi sağlar.
Logout’ta TokenProvider Sıfırlama:
void _handleLogout() async {
await AuthService.logout();
// Reset TokenProvider state
if (context.mounted) {
Provider.of<TokenProvider>(context, listen: false).reset();
}
Navigator.pushAndRemoveUntil(context, ...);
}5. Dosya: lib/teacher/pages/exams/exam_student_list_screen.dart#
İmportlar:
import 'package:provider/provider.dart';
import '../../../common/core/providers/token_provider.dart';
import '../../../common/widgets/insufficient_tokens_dialog.dart';
import '../../../common/widgets/upgrade_to_supporter_dialog.dart';Tekli Upload - _handleSendSingleStudent() Fonksiyonu:
Future<void> _handleSendSingleStudent(
BuildContext context,
StudentListItem student,
List<File> files,
) async {
// Token kontrolü
final photoCount = files.length;
final tokenProvider = Provider.of<TokenProvider>(context, listen: false);
// 1. Token yeterliliği kontrolü
final canUploadResult = await tokenProvider.canUploadPapers(photoCount);
if (!canUploadResult.canUpload && !canUploadResult.isUnlimited) {
// Yetersiz token - dialog göster
if (context.mounted) {
final result = await InsufficientTokensDialog.show(
context,
required: canUploadResult.required,
available: canUploadResult.available,
onUpgradeTap: () => UpgradeToSupporterDialog.show(context),
);
if (result != true) {
return; // Kullanıcı iptal etti
}
}
return;
}
// 2. Token düşürme işlemi
final deductResult = await tokenProvider.deductTokensForPapers(
paperCount: photoCount,
examId: int.tryParse(widget.examId),
);
if (!deductResult.success && !deductResult.isUnlimited) {
// Token düşürme hatası
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Token düşürme hatası: ${deductResult.error}'),
backgroundColor: Colors.red,
),
);
}
return;
}
// 3. Upload işlemi
setState(() {
_uploadingStudentIds.add(student.id);
});
try {
final success = await _uploadStudentPaper(student, files);
if (success) {
// Başarılı - bakiyeyi yenile ve göster
await tokenProvider.refreshTokenInfo();
final tokenInfo = tokenProvider.tokenInfo;
final tokenText = tokenInfo?.isUnlimited == true
? '(Sınırsız)'
: '(Kalan: ${tokenInfo?.tokenBalance ?? 0} token)';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Kağıt başarıyla yüklendi ${student.studentName} $tokenText',
),
backgroundColor: Colors.green,
),
);
}
setState(() {
_uploadedStudentIds.add(student.id);
});
}
} finally {
setState(() {
_uploadingStudentIds.remove(student.id);
});
}
}İş Akışı:
- Fotoğraf sayısını hesapla
- Token yeterliliği kontrol et
- Yetersizse dialog göster
- Yeterliyse token düş
- Upload işlemini gerçekleştir
- Başarılıysa bakiyeyi yenile ve göster
Toplu Upload - _handleProcessExams() Fonksiyonu:
Future<void> _handleProcessExams() async {
if (_studentPaperFiles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('En az bir öğrenci kağıdı yüklemelisiniz'),
backgroundColor: Colors.red,
),
);
return;
}
// 1. Toplam fotoğraf sayısını hesapla
int totalPhotoCount = 0;
for (final files in _studentPaperFiles.values) {
totalPhotoCount += files.length;
}
// 2. Token kontrolü
final tokenProvider = Provider.of<TokenProvider>(context, listen: false);
final canUploadResult = await tokenProvider.canUploadPapers(totalPhotoCount);
if (!canUploadResult.canUpload && !canUploadResult.isUnlimited) {
// Yetersiz token
if (mounted) {
final result = await InsufficientTokensDialog.show(
context,
required: canUploadResult.required,
available: canUploadResult.available,
onUpgradeTap: () => UpgradeToSupporterDialog.show(context),
);
if (result != true || canUploadResult.available == 0) {
return;
}
// Uyarı göster - toplu upload için token yetersiz
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$totalPhotoCount fotoğraf için yeterli token yok. '
'Daha az fotoğraf yükleyin.',
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
),
);
return;
}
return;
}
// 3. Token düş (upload'tan ÖNCE - toplu olarak)
final deductResult = await tokenProvider.deductTokensForPapers(
paperCount: totalPhotoCount,
examId: int.tryParse(widget.examId),
);
if (!deductResult.success && !deductResult.isUnlimited) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Token düşürme hatası: ${deductResult.error}'),
backgroundColor: Colors.red,
),
);
}
return;
}
// 4. Upload işlemi
setState(() {
_isProcessing = true;
});
try {
int successCount = 0;
int failCount = 0;
// Her öğrenci için upload
for (final entry in _studentPaperFiles.entries) {
final studentId = entry.key;
final files = entry.value;
// Zaten yüklenmiş öğrencileri atla
if (_uploadedStudentIds.contains(studentId)) {
continue;
}
final student = _students.firstWhere((s) => s.id == studentId);
final success = await _uploadStudentPaper(student, files);
if (success) {
successCount++;
_uploadedStudentIds.add(studentId);
} else {
failCount++;
}
}
setState(() {
_isProcessing = false;
});
// 5. Token bilgisini yenile ve sonuç göster
await tokenProvider.refreshTokenInfo();
final tokenInfo = tokenProvider.tokenInfo;
final tokenText = tokenInfo?.isUnlimited == true
? '(Sınırsız)'
: '(Kalan: ${tokenInfo?.tokenBalance ?? 0} token)';
if (failCount == 0) {
// Tümü başarılı
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Tüm sınav kağıtları başarıyla yüklendi! '
'($successCount kağıt) $tokenText',
),
backgroundColor: AppColors.cardGreen,
duration: const Duration(seconds: 3),
),
);
Navigator.pop(context, true);
}
} else if (successCount > 0) {
// Kısmi başarı
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$successCount kağıt yüklendi, $failCount kağıt başarısız oldu '
'$tokenText',
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 4),
),
);
}
} else {
// Tümü başarısız
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Hiçbir kağıt yüklenemedi ($failCount başarısız)',
),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
}
}
} catch (e) {
setState(() {
_isProcessing = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Hata: $e'),
backgroundColor: Colors.red,
),
);
}
}
}İş Akışı:
- Toplam fotoğraf sayısını hesapla
- Token yeterliliği kontrol et
- Yetersizse uyarı göster ve çık
- Yeterliyse tüm tokenları bir kerede düş
- Her öğrenci için upload yap
- Sonuçları topla
- Bakiyeyi yenile ve sonuç mesajı göster
6. Dosya: lib/student/pages/student_profile_screen.dart#
Logout’ta TokenProvider Sıfırlama:
void _handleLogout() async {
await AuthService.logout();
// Reset TokenProvider
if (context.mounted) {
Provider.of<TokenProvider>(context, listen: false).reset();
}
Navigator.pushReplacement(context, ...);
}Not: Öğrencilerin token sistemi yok, ancak logout sırasında state temizliği için reset çağrılır.
Kullanım Akışları#
Akış 1: Yeni Öğretmen Kaydı#
1. Öğretmen kayıt formunu doldurur
└─> POST /auth/signup
2. Supabase users tablosuna kayıt eklenir
└─> Trigger: on_user_created_add_tokens çalışır
3. Trigger otomatik olarak:
- account_type = 'default' atar
- user_tokens tablosuna kayıt oluşturur
- Başlangıç bakiyesi: 10 token
4. Login ekranına yönlendirilirAkış 2: Login ve Token Bilgisi Yükleme#
1. Öğretmen giriş yapar
└─> AuthService.login()
2. Login başarılı olunca:
└─> TokenProvider.initialize() çağrılır
3. TokenProvider.initialize():
└─> refreshTokenInfo()
└─> RPC: get_user_token_info()
└─> TokenInfo nesnesi oluşturulur
└─> _loadUserPlan()
└─> TokenService.getUserPlan()
└─> TokenPlan nesnesi oluşturulur
4. Dashboard açılır
└─> Header'da token badge gösterilir
└─> Profil sayfasında TokenBalanceCard gösterilirAkış 3: Tekli Kağıt Yükleme#
1. Öğretmen öğrenci listesinde bir öğrenci seçer
└─> Fotoğraf seçer (örn: 5 fotoğraf)
2. "Gönder" butonuna basar
└─> _handleSendSingleStudent() çağrılır
3. Token kontrolü:
└─> tokenProvider.canUploadPapers(5)
└─> RPC: can_upload_papers(5)
└─> Result: canUpload = true/false
4a. Yetersiz token durumunda:
└─> InsufficientTokensDialog gösterilir
├─> Kullanıcı "İptal" yaparsa → İşlem durur
└─> Kullanıcı "Destekçi Ol" yaparsa
└─> UpgradeToSupporterDialog gösterilir
4b. Yeterli token durumunda:
└─> tokenProvider.deductTokensForPapers(5)
└─> RPC: deduct_tokens_for_papers(5)
├─> user_tokens tablosunda bakiye güncellenir
├─> token_transactions tablosuna kayıt eklenir
└─> Result: balanceAfter = 5
5. Token düşürme başarılı:
└─> Upload işlemi başlar
└─> FileUploadService.uploadPaper()
6. Upload başarılı:
└─> tokenProvider.refreshTokenInfo()
└─> Başarı mesajı: "Kağıt yüklendi (Kalan: 5 token)"Akış 4: Toplu Kağıt Yükleme#
1. Öğretmen birden fazla öğrenci için fotoğraf seçer
└─> Örn: 3 öğrenci, toplam 15 fotoğraf
2. "Tümünü Yükle" butonuna basar
└─> _handleProcessExams() çağrılır
3. Toplam fotoğraf sayısı hesaplanır: 15
4. Token kontrolü:
└─> tokenProvider.canUploadPapers(15)
└─> RPC: can_upload_papers(15)
5a. Yetersiz token (örn: 10 mevcut):
└─> InsufficientTokensDialog
└─> "15 fotoğraf için yeterli token yok.
Daha az fotoğraf yükleyin."
└─> İşlem iptal edilir
5b. Yeterli token:
└─> tokenProvider.deductTokensForPapers(15)
└─> RPC: deduct_tokens_for_papers(15)
└─> Tüm 15 token bir kerede düşürülür
6. Upload işlemi başlar:
└─> Her öğrenci için sırayla:
├─> Öğrenci 1: 5 fotoğraf → Upload
├─> Öğrenci 2: 4 fotoğraf → Upload
└─> Öğrenci 3: 6 fotoğraf → Upload
7. Sonuç gösterilir:
└─> tokenProvider.refreshTokenInfo()
└─> "Tüm kağıtlar yüklendi (3 öğrenci) (Kalan: 0 token)"Akış 5: Token Sıfırlanması (Günlük Otomatik)#
1. Sistem saati 00:00'a gelir
2. Cron job veya scheduled task çalışır
└─> Tüm default plan kullanıcıları için:
3. Her kullanıcı için:
└─> UPDATE user_tokens
SET token_balance = 10,
last_reset_at = NOW()
WHERE user_id = ? AND account_type = 'default'
└─> INSERT INTO token_transactions
(user_id, transaction_type, amount, balance_after, description)
VALUES (?, 'reset', 10, 10, 'Günlük token sıfırlaması')
4. Kullanıcı uygulamayı açtığında:
└─> TokenProvider.refreshTokenInfo()
└─> Güncellenmiş bakiye (10 token) görülürNot: Bu cron job şu anda manuel olarak ayarlanmalıdır. Gelecekte Supabase Edge Functions ile otomatikleştirilebilir.
Akış 6: Token Geçmişi Görüntüleme#
1. Öğretmen profil sayfasında "Token Geçmişi" butonuna basar
└─> Navigator.push(TokenHistoryScreen)
2. TokenHistoryScreen açılır:
└─> initState()
└─> WidgetsBinding.addPostFrameCallback
└─> tokenProvider.loadTransactionHistory(refresh: true)
3. loadTransactionHistory():
└─> RPC: get_token_history(limit: 20, offset: 0)
└─> Result: {
transactions: [...],
total_count: 150,
has_more: true
}
4. İlk 20 işlem listelenir:
└─> Tarih gruplandırması
└─> Her işlem için:
├─> İkon ve renk (tipi göre)
├─> Açıklama
├─> Token miktarı
└─> İşlem sonrası bakiye
5. Kullanıcı aşağı kaydırır:
└─> _onScroll() tetiklenir
└─> %80'e ulaşıldığında:
└─> tokenProvider.loadTransactionHistory()
└─> RPC: get_token_history(limit: 20, offset: 20)
6. Yeni 20 işlem listeye eklenir
7. has_more = false olana kadar devam ederAkış 7: Supporter Plana Yükseltme (Gelecek)#
1. Öğretmen "Destekçi Ol" butonuna basar
└─> UpgradeToSupporterDialog gösterilir
2. Dialog içeriği:
├─> Özellikler listesi
└─> "Yakında" uyarısı
3. Gelecekte (Iyzico entegrasyonu sonrası):
└─> Ödeme sayfasına yönlendirilir
└─> Ödeme başarılı:
├─> UPDATE users
SET account_type = 'supporter'
WHERE id = ?
├─> INSERT token_transactions
(transaction_type = 'add', description = 'Supporter plana yükseltme')
└─> tokenProvider.refreshTokenInfo()
└─> UI güncellenir: "Destekçi Planı" badge'iGelecek Geliştirmeler#
1. Ödeme Entegrasyonu (Iyzico)#
Hedef: Supporter plan için ödeme sistemi
Yapılacaklar:
- Iyzico SDK entegrasyonu
- Ödeme formu ekranı
- Webhook endpoint (ödeme onayı için)
- Plan upgrade logic
- Fatura/makbuz sistemi
Dosyalar:
lib/common/services/payment_service.dartlib/common/pages/payment_screen.dart- Backend webhook handler
2. Otomatik Token Sıfırlama#
Hedef: Günlük otomatik token sıfırlama
Yapılacaklar:
- Supabase Edge Function oluşturma
- Cron job yapılandırması (her gün 00:00)
- RPC fonksiyon:
reset_daily_tokens() - Notification gönderimi (opsiyonel)
Cron Job Yapılandırması:
# Supabase Dashboard > Database > Cron Jobs
# Her gün saat 00:00'da çalışacak
0 0 * * * http://your-project.supabase.co/functions/v1/reset-daily-tokens3. Token Satın Alma (Ek Token Paketi)#
Hedef: Kullanıcılar ek token paketleri satın alabilir
5. Token Kullanım Analitikleri#
Hedef: Öğretmenlere token kullanım istatistikleri
Özellikler:
- Haftalık/aylık kullanım grafikleri
- En çok token harcanan günler
- Ortalama günlük harcama
- Toplam değerlendirilen kağıt sayısı
Yapılacaklar:
- Analytics ekranı
- Chart widget’ları (fl_chart paketi)
6. Bildirim Sistemi#
Hedef: Token durumu bildirimleri
Özellikler:
- “Token bakiyeniz azaldı (3 kalan)” - %30 altı
- “Tokenleriniz sıfırlandı (10 yeni token)” - günlük sıfırlama
- “Supporter planına hoş geldiniz!” - upgrade
Yapılacaklar:
- Push notification (Firebase Cloud Messaging)
- In-app notification
- Email bildirimleri (Supabase Email)
7. Referans Programı#
Hedef: Kullanıcı davet ederek token kazanma
Özellikler:
- Benzersiz referans kodu
- Her başarılı davet: +5 token (bonus)
- Davet edilen: +2 token (hoş geldin bonusu)
8. Token Abonelik Modeli#
Hedef: Aylık/yıllık token abonelikleri
Notlar#
Güvenlik#
- Tüm RPC fonksiyonlar
SECURITY DEFINERile çalışır - Row Level Security (RLS) politikaları aktif
- Token işlemleri sadece authenticated kullanıcılar için
- Kullanıcılar sadece kendi token bilgilerine erişebilir
Performans#
- Token bilgileri cache’lenmez (her zaman güncel veri)
- İşlem geçmişi sayfalama ile yüklenir (20 kayıt/sayfa)
- İndeksler query performansını optimize eder