Files
FocusGram-Android/lib/services/level_service.dart
T
2026-06-13 13:06:25 +05:45

415 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppFeature> all = [
effortFriction,
downloadMedia,
ghostMode,
baitMe,
appLock,
];
}
/// XP thresholds for each level.
/// Level 1 = 0 XP (always start here).
const Map<int, int> 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<int> _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.01.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<void> 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<int>();
}
_totalReelsAllTime = (_cache.get('totalReelsAllTime') ?? 0) as int;
_adsWatchedTotal = (_cache.get('adsWatchedTotal') ?? 0) as int;
} catch (_) {
// Fall back to defaults
}
}
Future<void> _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<Map<String, dynamic>> 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<void> 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<void> 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<void> 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<void> awardDayUnderLimit() async {
await _awardXp(15, reason: 'Day under limit');
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<int>(0, (a, b) => a + b);
return sum / recent.length;
}
double _allTimeAverage() {
if (_dailyReelCounts.isEmpty) return 0;
final sum = _dailyReelCounts.fold<int>(0, (a, b) => a + b);
return sum / _dailyReelCounts.length;
}
/// Call this at the end of each day to award "day under limit" XP.
Future<void> 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<void> grantDebugXp(int amount, String reason) async {
await _awardXp(amount, reason: reason);
}
// ─── Debug Methods ─────────────────────────────────────────
/// Force-set level and XP (debug only).
Future<void> 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<void> debugReset() async {
_level = 1;
_xp = 0;
_dailyReelCounts = [];
_totalReelsAllTime = 0;
_adsWatchedTotal = 0;
_adsWatchedToday = 0;
_lastResetDate = DateTime.now();
_dailyReelCountsAddedToday = false;
await _saveToCache();
notifyListeners();
}*/
}