mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-02 09:35:31 +02:00
Feature Pack with bug fixes for V2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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.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. 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();
|
||||
}*/
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] (1–60).
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user