Feature Pack with bug fixes for V2

This commit is contained in:
Ujwal223
2026-06-09 23:39:43 +05:45
parent f1bd12f0bd
commit 39b6545e4a
53 changed files with 7314 additions and 328 deletions
+184
View File
@@ -0,0 +1,184 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages app lock: PIN, biometrics, and two independent lock modes.
///
/// Modes (both can be on at the same time):
/// - **App-wide lock** — shown on cold start (before WebView) and after
/// background timeout.
/// - **Messages tab lock** — shown when navigating to Instagram DMs.
///
/// Both use the same PIN (stored in secure storage).
class AppLockService extends ChangeNotifier {
static const _pinAppWideKey = 'app_lock_pin_app_wide';
static const _pinMessagesKey = 'app_lock_pin_messages';
static const _prefAppWide = 'app_lock_app_wide';
static const _prefLockMessages = 'app_lock_lock_messages';
static const _prefScramble = 'app_lock_scramble_keypad';
static const _prefBio = 'app_lock_biometrics_enabled';
static const _prefTimeout = 'app_lock_timeout_ms';
final _secure = const FlutterSecureStorage();
final _auth = LocalAuthentication();
// ─── Mode toggles ──────────────────────────────────────────
bool _lockAppWide = false; // locks the whole app on start / bg timeout
bool _lockMessages = false; // locks only the DMs tab
// ─── Settings ──────────────────────────────────────────────
bool _scramble = false;
bool _bioEnabled = true;
int _timeoutMs = 120000; // 2 min
bool _hasPin = false;
// ─── Runtime state ─────────────────────────────────────────
bool _isShowingLock = false; // true while lock screen is displayed
DateTime? _bgAt;
// ─── Getters ───────────────────────────────────────────────
bool get lockAppWide => _lockAppWide;
bool get lockMessages => _lockMessages;
bool get isShowingLock => _isShowingLock;
bool get scrambleKeypad => _scramble;
bool get biometricsEnabled => _bioEnabled;
bool get hasPin => _hasPin;
bool get anyLockEnabled => _lockAppWide || _lockMessages;
/// Whether the app-wide lock screen should show on cold start.
bool get needsUnlockOnStart => _lockAppWide && _hasPin;
/// Whether the messages tab lock is enabled and can function.
bool get messagesLockReady => _lockMessages && _hasPin;
// ─── Init ──────────────────────────────────────────────────
Future<void> init() async {
final p = await SharedPreferences.getInstance();
_lockAppWide = p.getBool(_prefAppWide) ?? false;
_lockMessages = p.getBool(_prefLockMessages) ?? false;
_scramble = p.getBool(_prefScramble) ?? false;
_bioEnabled = p.getBool(_prefBio) ?? true;
_timeoutMs = p.getInt(_prefTimeout) ?? 120000;
// Check if either PIN exists
final hashA = await _secure.read(key: _pinAppWideKey);
final hashM = await _secure.read(key: _pinMessagesKey);
_hasPin = (hashA != null && hashA.isNotEmpty) ||
(hashM != null && hashM.isNotEmpty);
}
// ─── PIN management ────────────────────────────────────────
String _hash(String pin) =>
utf8.encode('fg_${pin}_salt26')
.map((x) => x.toRadixString(16).padLeft(2, '0'))
.join();
/// Set PIN for a specific lock mode.
Future<void> setPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
await _secure.write(key: key, value: _hash(pin));
_hasPin = true;
notifyListeners();
}
/// Verify PIN for the given mode.
Future<bool> verifyPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final stored = await _secure.read(key: key);
return stored != null && stored == _hash(pin);
}
/// Check whether a specific mode has a PIN set.
Future<bool> hasPinFor({required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final hash = await _secure.read(key: key);
return hash != null && hash.isNotEmpty;
}
// ─── Toggles ───────────────────────────────────────────────
Future<void> setLockAppWide(bool v) async {
_lockAppWide = v;
(await SharedPreferences.getInstance()).setBool(_prefAppWide, v);
if (!v && !_isShowingLock) _isShowingLock = false;
notifyListeners();
}
Future<void> setLockMessages(bool v) async {
_lockMessages = v;
(await SharedPreferences.getInstance()).setBool(_prefLockMessages, v);
notifyListeners();
}
Future<void> setScrambleKeypad(bool v) async {
_scramble = v;
(await SharedPreferences.getInstance()).setBool(_prefScramble, v);
notifyListeners();
}
Future<void> setBiometricsEnabled(bool v) async {
_bioEnabled = v;
(await SharedPreferences.getInstance()).setBool(_prefBio, v);
notifyListeners();
}
// ─── Lock / Unlock lifecycle ───────────────────────────────
/// Call when app-wide lock screen is opened.
void onLockScreenShown() {
_isShowingLock = true;
notifyListeners();
}
/// Call after successful unlock (PIN or biometric).
void onUnlocked() {
_isShowingLock = false;
_bgAt = null;
notifyListeners();
}
/// Call when app goes to background.
void onBackgrounded() {
_bgAt = DateTime.now();
}
/// Whether the app-wide lock should trigger on resume.
bool get shouldLockOnResume {
if (!_lockAppWide || !_hasPin || _bgAt == null) return false;
return DateTime.now().difference(_bgAt!).inMilliseconds >= _timeoutMs;
}
// ─── Biometrics ────────────────────────────────────────────
Future<bool> isBiometricsAvailable() async {
try {
return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
} catch (_) {
return false;
}
}
Future<bool> authenticateWithBiometrics() async {
if (!_bioEnabled) return false;
try {
return await _auth.authenticate(
localizedReason: 'Unlock FocusGram',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
),
);
} catch (_) {
return false;
}
}
// ─── Scrambled keypad ──────────────────────────────────────
List<int> getScrambledDigits() {
final d = List<int>.generate(10, (i) => i);
d.shuffle(Random());
return d;
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Outcome of a Bait Me activation.
enum BaitOutcome {
/// Opens your ad website and resets the reels session.
openAdSiteAndReset,
/// Adds 10 minutes to the session credit balance.
addTenMinutes,
/// Opens an external ad URL and ends the session.
openExternalAdAndEnd,
/// Randomly reduces session time (1-5 min).
reduceSessionTime,
/// Increases cooldown by 10 min.
increaseCooldown,
/// Ends the current reel session.
endReelSession,
/// Ends the current app session.
endAppSession,
}
/// Weighted random outcome engine for the Bait Me button.
class BaitEngine extends ChangeNotifier {
static const String _boxName = 'bait_engine';
late Box _box;
final Random _random = Random();
// ── Hardcoded ad URLs ──────────────────────────────────────
String _adWebsiteUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
String _externalAdUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
// ── Cooldown ───────────────────────────────────────────────
static const int _cooldownMinutes = 30;
DateTime? _lastActivation;
// ── Callbacks ──────────────────────────────────────────────
void Function(int minutes)? onAddMinutes;
void Function()? onResetSession;
void Function()? onEndReelSession;
void Function()? onEndAppSession;
void Function(String url)? onOpenUrl;
void Function(int minutes)? onReduceSessionTime;
void Function(int minutes)? onIncreaseCooldown;
// ── Getters ────────────────────────────────────────────────
String get adWebsiteUrl => _adWebsiteUrl;
String get externalAdUrl => _externalAdUrl;
bool get isOnCooldown {
if (_lastActivation == null) return false;
return DateTime.now().difference(_lastActivation!).inMinutes <
_cooldownMinutes;
}
int get cooldownRemainingMinutes {
if (_lastActivation == null) return 0;
final elapsed = DateTime.now().difference(_lastActivation!).inMinutes;
return (_cooldownMinutes - elapsed).clamp(0, _cooldownMinutes);
}
// ─── Init ───────────────────────────────────────────────────
Future<void> init() async {
_box = await Hive.openBox(_boxName);
final lastMs = _box.get('last_activation_ms', defaultValue: 0) as int;
if (lastMs > 0) {
_lastActivation = DateTime.fromMillisecondsSinceEpoch(lastMs);
}
}
// ─── Activation ─────────────────────────────────────────────
BaitOutcome roll() {
final r = _random.nextInt(100);
// 30% open ad site + reset (permanent — always happens when rolled)
// 20% add 10 min
// 15% reduce session time
// 15% increase cooldown
// 10% end reel session
// 10% end app session
if (r < 30) return BaitOutcome.openAdSiteAndReset;
if (r < 50) return BaitOutcome.addTenMinutes;
if (r < 65) return BaitOutcome.reduceSessionTime;
if (r < 80) return BaitOutcome.increaseCooldown;
if (r < 90) return BaitOutcome.endReelSession;
return BaitOutcome.endAppSession;
}
Future<BaitOutcome> activate() async {
final outcome = roll();
_lastActivation = DateTime.now();
await _box.put(
'last_activation_ms', _lastActivation!.millisecondsSinceEpoch);
notifyListeners();
switch (outcome) {
case BaitOutcome.openAdSiteAndReset:
onResetSession?.call();
onOpenUrl?.call(_adWebsiteUrl);
break;
case BaitOutcome.addTenMinutes:
onAddMinutes?.call(10);
break;
case BaitOutcome.openExternalAdAndEnd:
onOpenUrl?.call(_externalAdUrl);
onResetSession?.call();
break;
case BaitOutcome.reduceSessionTime:
final min = 1 + _random.nextInt(5); // 1-5 min
onReduceSessionTime?.call(min);
break;
case BaitOutcome.increaseCooldown:
onIncreaseCooldown?.call(10);
break;
case BaitOutcome.endReelSession:
onEndReelSession?.call();
break;
case BaitOutcome.endAppSession:
onEndAppSession?.call();
break;
}
return outcome;
}
static String outcomeLabel(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return '💸 Session Reset!';
case BaitOutcome.addTenMinutes:
return '⏰ +10 Minutes!';
case BaitOutcome.openExternalAdAndEnd:
return '🚫 Session Ended!';
case BaitOutcome.reduceSessionTime:
return '⏳ Time Deducted!';
case BaitOutcome.increaseCooldown:
return '🧊 Cooldown Increased!';
case BaitOutcome.endReelSession:
return '🎬 Reel Session Ended!';
case BaitOutcome.endAppSession:
return '📱 App Session Ended!';
}
}
static String outcomeSubtext(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return 'All session credits have been reset. Better luck next time.';
case BaitOutcome.addTenMinutes:
return 'You earned 10 extra minutes. Use them wisely!';
case BaitOutcome.openExternalAdAndEnd:
return 'Session forcefully ended. Time for a break.';
case BaitOutcome.reduceSessionTime:
return 'The Bait Me took some time away!';
case BaitOutcome.increaseCooldown:
return 'Cooldown period extended by 10 minutes.';
case BaitOutcome.endReelSession:
return 'Your reel session has been cut short.';
case BaitOutcome.endAppSession:
return 'Your Instagram session has been ended.';
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Manages time credit balances earned by watching rewarded ads.
///
/// Two balances: [reelsMinutesRemaining] for reel sessions and
/// [instaMinutesRemaining] for Instagram app sessions.
///
/// Also tracks ad watch counts for the Ad Counter dashboard (Phase 5).
class CreditStore extends ChangeNotifier {
static const String _boxName = 'credit_store';
late Box _box;
// ─── Balances ──────────────────────────────────────────────
int _reelsMinutes = 0;
int _instaMinutes = 0;
// ─── Ad counters ───────────────────────────────────────────
int _adsWatchedToday = 0;
int _adsWatchedAllTime = 0;
String _todayKey = '';
// ─── Gettters ──────────────────────────────────────────────
int get reelsMinutes => _reelsMinutes;
int get instaMinutes => _instaMinutes;
int get adsWatchedToday => _adsWatchedToday;
int get adsWatchedAllTime => _adsWatchedAllTime;
int get timeEarnedViaAds => (_adsWatchedAllTime * minutesPerAd);
bool get hasReelsCredits => _reelsMinutes > 0;
bool get hasInstaCredits => _instaMinutes > 0;
bool get canWatchAdToday => _adsWatchedToday < maxDailyAds;
/// Minutes earned per rewarded ad watch.
static const int minutesPerAd = 2;
static const int maxDailyAds = 5;
// ─── Init ──────────────────────────────────────────────────
Future<void> init() async {
_box = await Hive.openBox(_boxName);
_reelsMinutes = (_box.get('reels_min', defaultValue: 0) as num).toInt();
_instaMinutes = (_box.get('insta_min', defaultValue: 0) as num).toInt();
_adsWatchedAllTime = (_box.get('ads_all_time', defaultValue: 0) as num)
.toInt();
_todayKey = _dayKey();
// Restore today's count, reset if date changed
final savedDate = _box.get('ads_today_date', defaultValue: '') as String;
if (savedDate == _todayKey) {
_adsWatchedToday = (_box.get('ads_today_count', defaultValue: 0) as num)
.toInt();
} else {
_adsWatchedToday = 0;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', 0);
}
}
// ─── Credit operations ─────────────────────────────────────
/// Add minutes earned from watching an ad.
Future<void> addReelsMinutes({int amount = minutesPerAd}) async {
_reelsMinutes += amount;
await _box.put('reels_min', _reelsMinutes);
_incrementAdCounters();
notifyListeners();
}
Future<void> addInstaMinutes({int amount = minutesPerAd}) async {
_instaMinutes += amount;
await _box.put('insta_min', _instaMinutes);
_incrementAdCounters();
notifyListeners();
}
/// Drain 1 minute from the reel balance (called every minute during a session).
Future<void> drainReelsMinute() async {
if (_reelsMinutes <= 0) return;
_reelsMinutes--;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
/// Drain 1 minute from the Instagram balance.
Future<void> drainInstaMinute() async {
if (_instaMinutes <= 0) return;
_instaMinutes--;
await _box.put('insta_min', _instaMinutes);
notifyListeners();
}
/// Reset all balances (e.g. on settings toggle off).
Future<void> resetBalances() async {
_reelsMinutes = 0;
_instaMinutes = 0;
await _box.put('reels_min', 0);
await _box.put('insta_min', 0);
notifyListeners();
}
/// Add minutes directly from the Bait Me feature.
Future<void> addBonusMinutes(int minutes) async {
// Add to reels balance (bait me rewards are for reels)
_reelsMinutes += minutes;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
// ─── Ad counter helpers ────────────────────────────────────
void _incrementAdCounters() {
_adsWatchedToday++;
_adsWatchedAllTime++;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', _adsWatchedToday);
_box.put('ads_all_time', _adsWatchedAllTime);
}
/// Reset daily ad counter (call on day change).
Future<void> resetDailyIfNeeded() async {
final newKey = _dayKey();
if (newKey != _todayKey) {
_todayKey = newKey;
_adsWatchedToday = 0;
await _box.put('ads_today_date', _todayKey);
await _box.put('ads_today_count', 0);
notifyListeners();
}
}
String _dayKey() {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
}
+421
View File
@@ -0,0 +1,421 @@
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',
2,
);
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
int _todayReelCount = 0;
String _todayKey = '';
// ─── 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. Set up today tracking
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
// 3. 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 = [];
List<_XpEvent> get xpHistory => List.unmodifiable(_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();
}*/
}
+6 -7
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
@@ -55,7 +54,7 @@ class NotificationService {
>()
?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) {
debugPrint('iOS permission request error: $e');
// debugPrint('iOS permission request error: $e');
}
}
@@ -67,7 +66,7 @@ class NotificationService {
>()
?.requestNotificationsPermission();
} catch (e) {
debugPrint('Android permission request error: $e');
// debugPrint('Android permission request error: $e');
}
}
@@ -105,7 +104,7 @@ class NotificationService {
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Notification error: $e');
// debugPrint('Notification error: $e');
}
}
@@ -149,7 +148,7 @@ class NotificationService {
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Persistent notification error: $e');
// debugPrint('Persistent notification error: $e');
}
}
@@ -158,7 +157,7 @@ class NotificationService {
try {
await _notificationsPlugin.cancel(id: id);
} catch (e) {
debugPrint('Cancel persistent notification error: $e');
// debugPrint('Cancel persistent notification error: $e');
}
}
@@ -167,7 +166,7 @@ class NotificationService {
try {
await _notificationsPlugin.cancelAll();
} catch (e) {
debugPrint('Cancel all notifications error: $e');
// debugPrint('Cancel all notifications error: $e');
}
}
}
+26
View File
@@ -462,6 +462,13 @@ class SessionManager extends ChangeNotifier {
return true;
}
/// Temporarily increase the daily limit by [minutes] (for ad rewards).
void addBonusDailyMinutes(int minutes) {
_dailyLimitSeconds += minutes * 60;
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
void endSession() {
if (!_isSessionActive) return;
// Don't show notification when user manually ends the session
@@ -482,6 +489,13 @@ class SessionManager extends ChangeNotifier {
notifyListeners();
}
/// Whether the user needs to go through the Effort Friction gate
/// before starting a reel session.
bool needsEffortFrictionGate(bool effortModeEnabled, int creditBalance) {
if (!effortModeEnabled) return false;
return creditBalance <= 0;
}
// ── App session API ────────────────────────────────────────
/// Start an app session of [minutes] (160).
@@ -498,6 +512,18 @@ class SessionManager extends ChangeNotifier {
}
/// Extend the app session by 10 minutes. Only works once.
/// Increase daily limit by [minutes] and return whether it succeeded.
bool increaseDailyLimit(int minutes) {
final current = _dailyLimitSeconds;
final added = minutes * 60;
_dailyLimitSeconds = (current + added).clamp(0, 7200); // max 2 hours
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
_dailyUsedSeconds = 0; // reset used counter so they can use the new quota
_prefs?.setInt(_keyDailyUsedSeconds, 0);
notifyListeners();
return true;
}
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
+68 -4
View File
@@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
@@ -63,6 +63,16 @@ class SettingsService extends ChangeNotifier {
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
// ── Adsterra fallback ─────────────────────────────────────
static const _keyAdsterraZoneUrl = 'adsterra_zone_url';
static const _keyAdsterraAdCode = 'adsterra_ad_code';
// ── Startup page ──────────────────────────────────────────
static const _keyStartupPage = 'startup_page';
// ── Effort Friction Mode ──────────────────────────────────
static const _keyEffortFrictionEnabled = 'effort_friction_enabled';
// Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms';
@@ -139,6 +149,10 @@ class SettingsService extends ChangeNotifier {
bool _notifyPersistent = false;
// Focus mode settings
bool _effortFrictionEnabled = false;
String _startupPage = 'home'; // home, following, favorites, direct
String _adsterraZoneUrl = '';
String _adsterraAdCode = '';
bool _ghostMode = false;
bool _noAds = false;
bool _noStories = false;
@@ -196,6 +210,23 @@ class SettingsService extends ChangeNotifier {
bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get effortFrictionEnabled => _effortFrictionEnabled;
String get startupPage => _startupPage;
String get startupUrl {
switch (_startupPage) {
case 'following':
return 'https://www.instagram.com/?variant=following';
case 'favorites':
return 'https://www.instagram.com/?variant=favorites';
case 'direct':
return 'https://www.instagram.com/direct/inbox/';
default:
return 'https://www.instagram.com/';
}
}
String get adsterraZoneUrl => _adsterraZoneUrl;
String get adsterraAdCode => _adsterraAdCode;
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
@@ -290,7 +321,8 @@ class SettingsService extends ChangeNotifier {
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules
// Load grayscale toggle + schedules
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
@@ -330,6 +362,11 @@ class SettingsService extends ChangeNotifier {
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_effortFrictionEnabled =
_prefs!.getBool(_keyEffortFrictionEnabled) ?? false;
_startupPage = _prefs!.getString(_keyStartupPage) ?? 'home';
_adsterraZoneUrl = _prefs!.getString(_keyAdsterraZoneUrl) ?? '';
_adsterraAdCode = _prefs!.getString(_keyAdsterraAdCode) ?? '';
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
@@ -411,8 +448,9 @@ class SettingsService extends ChangeNotifier {
final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
// Defer notifyListeners to next microtask to avoid rebuild conflicts
Future.microtask(notifyListeners);
// Defer notifyListeners to after the current frame to avoid
// Flutter's 'Dependents.isEmpty' assertion error.
WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners());
}
Future<void> setWordChallengeCount(int count) async {
@@ -771,7 +809,33 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
// ── Startup page ─────────────────────────────────────────────────────────────
Future<void> setStartupPage(String page) async {
_startupPage = page;
await _prefs?.setString(_keyStartupPage, page);
notifyListeners();
}
// ── Adsterra zone config ────────────────────────────────────────────────────
Future<void> setAdsterraZoneUrl(String url) async {
_adsterraZoneUrl = url;
await _prefs?.setString(_keyAdsterraZoneUrl, url);
notifyListeners();
}
Future<void> setAdsterraAdCode(String code) async {
_adsterraAdCode = code;
await _prefs?.setString(_keyAdsterraAdCode, code);
notifyListeners();
}
// ── Focus mode settings ──────────────────────────────────────────────────────
Future<void> setEffortFrictionEnabled(bool v) async {
_effortFrictionEnabled = v;
await _prefs?.setBool(_keyEffortFrictionEnabled, v);
notifyListeners();
}
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// A saved page that can be viewed offline via WebView cache.
/// No API calls — just bookmarks URLs you've already visited
/// so the WebView's built-in cache (`LOAD_CACHE_ELSE_NETWORK`)
/// can serve them when offline.
class SavedPage {
final String id;
final String url;
final String title;
final DateTime savedAt;
final String? htmlContent; // captured page HTML for offline viewing
const SavedPage({
required this.id,
required this.url,
required this.title,
required this.savedAt,
this.htmlContent,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'savedAt': savedAt.toIso8601String(),
if (htmlContent != null) 'html': htmlContent,
};
factory SavedPage.fromJson(Map<String, dynamic> json) => SavedPage(
id: json['id'] as String? ?? '',
url: json['url'] as String? ?? '',
title: json['title'] as String? ?? 'Instagram',
savedAt: DateTime.tryParse(json['savedAt'] as String? ?? '') ??
DateTime.now(),
htmlContent: json['html'] as String?,
);
}
/// Manages saved pages for offline viewing.
///
/// How it works:
/// 1. The WebView already has `cacheMode: LOAD_CACHE_ELSE_NETWORK`
/// 2. When you visit a page online, the WebView caches it automatically
/// 3. This service just bookmarks URLs so you can navigate to them offline
/// 4. The WebView serves the cached version when there's no internet
///
/// No Instagram API needed. No content downloading. Just cache + bookmarks.
class SnapshotService extends ChangeNotifier {
static const String _hiveBox = 'saved_pages';
late Box _box;
List<SavedPage> _savedPages = [];
List<SavedPage> get savedPages => List.unmodifiable(_savedPages);
int get totalSaved => _savedPages.length;
Future<void> init() async {
_box = await Hive.openBox(_hiveBox);
_loadFromCache();
}
void _loadFromCache() {
try {
final raw = _box.get('page_list') as String?;
if (raw != null) {
final decoded = jsonDecode(raw) as List;
_savedPages = decoded
.map((e) => SavedPage.fromJson(e as Map<String, dynamic>))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
}
} catch (_) {}
}
Future<void> _saveToCache() async {
final json = jsonEncode(_savedPages.map((e) => e.toJson()).toList());
await _box.put('page_list', json);
}
/// Save a page. Optionally pass [htmlContent] captured from the WebView.
Future<void> savePage(String url, {String title = 'Instagram', String? htmlContent}) async {
if (url.isEmpty) return;
// Avoid duplicates
if (_savedPages.any((p) => p.url == url)) return;
final page = SavedPage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
url: url,
title: title,
savedAt: DateTime.now(),
htmlContent: htmlContent,
);
_savedPages.insert(0, page);
await _saveToCache();
notifyListeners();
}
/// Remove a saved page.
Future<void> deletePage(String id) async {
_savedPages.removeWhere((p) => p.id == id);
await _saveToCache();
notifyListeners();
}
/// Remove all saved pages.
Future<void> deleteAll() async {
_savedPages.clear();
await _saveToCache();
notifyListeners();
}
/// Get the total count.
int get count => _savedPages.length;
}