mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-02 17:35:46 +02:00
415 lines
13 KiB
Dart
415 lines
13 KiB
Dart
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.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<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();
|
||
}*/
|
||
}
|