import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:intl/intl.dart'; /// Feature identifiers for level gating. /// Every gated feature checks [LevelService.isFeatureUnlocked]. class AppFeature { final String id; final String name; final int requiredLevel; const AppFeature._(this.id, this.name, this.requiredLevel); static const effortFriction = AppFeature._( 'effort_friction', 'Effort Friction Mode', 3, ); static const reelsHistory = AppFeature._('reels_history', 'Reels History', 2); static const downloadMedia = AppFeature._( 'download_media', 'Download Media', 2, ); static const fullDmGhost = AppFeature._('full_dm_ghost', 'Full DM Ghost', 1); static const ghostMode = AppFeature._('ghost_mode', 'Ghost Mode', 2); static const baitMe = AppFeature._('bait_me', 'Bait Me Button', 3); static const appLock = AppFeature._('app_lock', 'App Lock', 3); static const customFriction = AppFeature._( 'custom_friction', 'Custom Friction Rules', 4, ); static const List all = [ effortFriction, downloadMedia, ghostMode, baitMe, appLock, ]; } /// XP thresholds for each level. /// Level 1 = 0 XP (always start here). const Map levelThresholds = {1: 0, 2: 100, 3: 250, 4: 450, 5: 700}; const int maxLevel = 5; /// A single XP event — logged for the XP history view. class _XpEvent { final int amount; final String reason; final DateTime time; _XpEvent(this.amount, this.reason, this.time); } /// Tracks XP, level progression, degradation, and monthly resets. /// /// Always-on (not toggleable). All new features are gated behind levels. /// /// **Storage:** Hive box `level_cache` (persistent local storage). class LevelService extends ChangeNotifier { // ─── Hive box ────────────────────────────────────────────── static const String _hiveBox = 'level_cache'; late Box _cache; // ─── Runtime state ───────────────────────────────────────── int _level = 1; int _xp = 0; DateTime? _lastResetDate; List _dailyReelCounts = []; // last 30 days int _totalReelsAllTime = 0; int _adsWatchedTotal = 0; // Track today for daily reel logging // ─── Getters ─────────────────────────────────────────────── int get level => _level; int get xp => _xp; int get totalReelsAllTime => _totalReelsAllTime; int get adsWatchedTotal => _adsWatchedTotal; /// XP needed for the current level (cumulative threshold for this level). int get xpForCurrentLevel => levelThresholds[_level] ?? 0; /// XP needed to reach the next level (or current if at max). int get xpForNextLevel { if (_level >= maxLevel) return levelThresholds[maxLevel]!; return levelThresholds[_level + 1] ?? xpForCurrentLevel; } /// Progress 0.0–1.0 within the current level. double get levelProgress { final current = _xp - xpForCurrentLevel; final needed = xpForNextLevel - xpForCurrentLevel; if (needed <= 0) return 1.0; return (current / needed).clamp(0.0, 1.0); } /// Whether the user has reached (or exceeded) the required level. bool isFeatureUnlocked(AppFeature feature) => _level >= feature.requiredLevel; /// The next locked feature with level requirement — for "What's next?" display. AppFeature? get nextLockedFeature { for (final f in AppFeature.all) { if (!isFeatureUnlocked(f)) return f; } return null; } // ─── Initialization ──────────────────────────────────────── Future init() async { // 1. Open Hive cache box _cache = await Hive.openBox(_hiveBox); _loadFromCache(); // 2. Check monthly reset await _checkMonthlyReset(); // 4. Check daily degradation await _checkDailyDegradation(); notifyListeners(); } void _loadFromCache() { try { _level = (_cache.get('level') ?? 1) as int; _xp = (_cache.get('xp') ?? 0) as int; final lastReset = _cache.get('lastResetDate') as String?; if (lastReset != null) { _lastResetDate = DateTime.tryParse(lastReset); } final countsRaw = _cache.get('dailyReelCounts') as String?; if (countsRaw != null) { _dailyReelCounts = (jsonDecode(countsRaw) as List).cast(); } _totalReelsAllTime = (_cache.get('totalReelsAllTime') ?? 0) as int; _adsWatchedTotal = (_cache.get('adsWatchedTotal') ?? 0) as int; } catch (_) { // Fall back to defaults } } Future _saveToCache() async { await _cache.put('level', _level); await _cache.put('xp', _xp); await _cache.put('lastResetDate', _lastResetDate?.toIso8601String()); await _cache.put('dailyReelCounts', jsonEncode(_dailyReelCounts)); await _cache.put('totalReelsAllTime', _totalReelsAllTime); await _cache.put('adsWatchedTotal', _adsWatchedTotal); } // ─── XP History ──────────────────────────────────────────── final List<_XpEvent> _xpHistory = []; /// Human-readable recent XP log for "Your Journey". List> get recentXpLog { return _xpHistory.reversed .take(50) .map( (e) => { 'amount': e.amount, 'reason': e.reason, 'time': e.time.toIso8601String(), }, ) .toList(); } // ─── XP Earning ──────────────────────────────────────────── static const int _dailyAdXpCap = 20; int _adsWatchedToday = 0; /// Call when a rewarded ad is completed. Future addXpForAd() async { if (_adsWatchedToday >= _dailyAdXpCap) return; // Cap reached _adsWatchedToday++; _adsWatchedTotal++; await _awardXp(10, reason: 'Watched an ad'); } /// Call when a session ends — awards XP for self-control. /// [reelsWatchedToday] = total reels watched so far today. Future evaluateDailyReelControl(int reelsWatchedToday) async { // Calculate 7-day average final avg7 = _sevenDayAverage(); if (avg7 <= 0) return; // Not enough data yet if (reelsWatchedToday < avg7) { // User watched fewer reels than average — award XP final reelsSaved = (avg7 - reelsWatchedToday).floor(); final xpGain = min(reelsSaved * 10, 50); // Max +50 XP per day await _awardXp(xpGain, reason: 'Reduced reel count'); } // Log today's count await _logDailyReelCount(reelsWatchedToday); } /// Call once per day when the user opens the app. Future addDailyCheckinXp() async { final prefs = await SharedPreferences.getInstance(); final today = DateFormat('yyyy-MM-dd').format(DateTime.now()); final lastCheckin = prefs.getString('level_last_checkin') ?? ''; if (lastCheckin == today) return; // Already checked in today await prefs.setString('level_last_checkin', today); await _awardXp(1, reason: 'Daily check-in'); } /// Complete a full day under the daily reel limit. Future awardDayUnderLimit() async { await _awardXp(15, reason: 'Day under limit'); } Future _awardXp(int amount, {String reason = 'general'}) async { _xp += amount; _xp = max(0, min(_xp, levelThresholds[maxLevel]!)); // Log to history _xpHistory.add(_XpEvent(amount, reason, DateTime.now())); // Keep last 200 entries if (_xpHistory.length > 200) { _xpHistory.removeRange(0, _xpHistory.length - 200); } await _checkLevelUp(); await _saveToCache(); notifyListeners(); } Future _checkLevelUp() async { while (_level < maxLevel) { final nextThreshold = levelThresholds[_level + 1]!; if (_xp >= nextThreshold) { _level++; //debugPrint('🎉 Level up! Now Level $_level'); } else { break; } } } // ─── XP Decay / Degradation ──────────────────────────────── Future _checkDailyDegradation() async { if (_dailyReelCounts.isEmpty) return; final avg7 = _sevenDayAverage(); final allTimeAvg = _allTimeAverage(); // Check if today's count (from yesterday, since this runs at startup) // exceeds both averages final yesterdayCount = _dailyReelCounts.isNotEmpty ? _dailyReelCounts.last : 0; if (yesterdayCount > avg7 && yesterdayCount > allTimeAvg && avg7 > 0) { // Deduct XP _xp = max(0, _xp - 20); notifyListeners(); } // Check for level drop: exceeded app time limit 3 days in a row // (We check via a streak counter stored in prefs) await _checkLevelDropStreak(); } Future _checkLevelDropStreak() async { final prefs = await SharedPreferences.getInstance(); final streakKey = 'level_drop_streak'; int streak = prefs.getInt(streakKey) ?? 0; if (_dailyReelCounts.length >= 3) { final last3 = _dailyReelCounts.sublist(_dailyReelCounts.length - 3); final avg7 = _sevenDayAverage(); final allExceeded = last3.every((c) => c > avg7 && avg7 > 0); if (allExceeded) { streak++; await prefs.setInt(streakKey, streak); } else { // Reset streak await prefs.setInt(streakKey, 0); } if (streak >= 3 && _level > 1) { // Drop one full level _level = max(1, _level - 1); // Also reduce XP to the threshold of the new level _xp = levelThresholds[_level]!; await prefs.setInt(streakKey, 0); //debugPrint('⚠️ Level dropped to $_level due to 3-day streak'); } } await _saveToCache(); } // ─── Monthly Reset ───────────────────────────────────────── Future _checkMonthlyReset() async { if (_lastResetDate == null) { _lastResetDate = DateTime.now(); return; } final daysSinceReset = DateTime.now().difference(_lastResetDate!).inDays; if (daysSinceReset >= 30) { _xp = 0; // Reset XP to 0 // Level is preserved (loss aversion) _lastResetDate = DateTime.now(); _dailyReelCounts = []; // Clear daily history _dailyReelCountsAddedToday = false; await _saveToCache(); notifyListeners(); // Show monthly summary (handled by the UI layer by checking a flag) _showMonthlySummary = true; } } /// Flag consumed by UI to show "New month, fresh start" screen. bool _showMonthlySummary = false; bool get showMonthlySummary => _showMonthlySummary; void dismissMonthlySummary() { _showMonthlySummary = false; notifyListeners(); } // ─── Daily Reel Logging ──────────────────────────────────── bool _dailyReelCountsAddedToday = false; Future _logDailyReelCount(int reelCount) async { if (_dailyReelCountsAddedToday) return; _dailyReelCounts.add(reelCount); _totalReelsAllTime += reelCount; // Keep only last 30 days if (_dailyReelCounts.length > 30) { _dailyReelCounts.removeRange(0, _dailyReelCounts.length - 30); } _dailyReelCountsAddedToday = true; await _saveToCache(); } double _sevenDayAverage() { if (_dailyReelCounts.isEmpty) return 0; final recent = _dailyReelCounts.length >= 7 ? _dailyReelCounts.sublist(_dailyReelCounts.length - 7) : _dailyReelCounts; final sum = recent.fold(0, (a, b) => a + b); return sum / recent.length; } double _allTimeAverage() { if (_dailyReelCounts.isEmpty) return 0; final sum = _dailyReelCounts.fold(0, (a, b) => a + b); return sum / _dailyReelCounts.length; } /// Call this at the end of each day to award "day under limit" XP. Future finalizeDay( int reelsWatchedToday, int dailyReelLimitMinutes, ) async { final dailyReelCount = reelsWatchedToday; // in minutes if (dailyReelCount <= dailyReelLimitMinutes) { await awardDayUnderLimit(); } } /// Reset the daily ad counter (call at midnight). void resetDailyAdCounter() { _adsWatchedToday = 0; } /*/// Grant XP with a custom reason (used from the debug section in settings). Future grantDebugXp(int amount, String reason) async { await _awardXp(amount, reason: reason); } // ─── Debug Methods ───────────────────────────────────────── /// Force-set level and XP (debug only). Future debugSetLevel(int level, int xp) async { _level = level.clamp(1, maxLevel); _xp = xp.clamp(0, levelThresholds[maxLevel]!); await _saveToCache(); notifyListeners(); } /// Reset all level data (debug only). Future debugReset() async { _level = 1; _xp = 0; _dailyReelCounts = []; _totalReelsAllTime = 0; _adsWatchedTotal = 0; _adsWatchedToday = 0; _lastResetDate = DateTime.now(); _dailyReelCountsAddedToday = false; await _saveToCache(); notifyListeners(); }*/ }