Files
FocusGram-Android/lib/services/session_manager.dart
T
2026-02-22 22:00:52 +05:45

329 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages all session logic for FocusGram:
///
/// **App Session** — how long the user plans to use Instagram today.
/// Started by the AppSessionPicker on every cold open.
/// Enforced with a watchdog timer; one 10-min extension allowed.
/// Cooldown enforced between app-opens.
///
/// **Reel Session** — a period during which reels are unblocked.
/// Started manually by the user via the FAB.
/// Deducted from the daily reel quota.
class SessionManager extends ChangeNotifier {
// ── Reel-session keys ──────────────────────────────────────
static const _keyDailyDate = 'sessn_daily_date';
static const _keyDailyUsedSeconds = 'sessn_daily_used_sec';
static const _keySessionExpiry = 'sessn_expiry_ts';
static const _keyLastSessionEnd = 'sessn_last_end_ts';
static const _keyDailyLimitSec = 'sessn_daily_limit_sec';
static const _keyPerSessionSec = 'sessn_per_session_sec';
static const _keyCooldownSec = 'sessn_cooldown_sec';
// ── App-session keys ───────────────────────────────────────
static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyDailyOpenCount = 'app_open_count';
SharedPreferences? _prefs;
// ── Reel-session runtime ───────────────────────────────────
bool _isSessionActive = false;
DateTime? _sessionExpiry;
int _dailyUsedSeconds = 0;
DateTime? _lastSessionEnd;
Timer? _ticker;
// ── App-session runtime ────────────────────────────────────
DateTime? _appSessionEnd;
bool _appExtensionUsed = false;
DateTime? _lastAppSessionEnd;
bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0;
// ── Settings defaults ──────────────────────────────────────
int _dailyLimitSeconds = 30 * 60; // 30 min
int _perSessionSeconds = 5 * 60; // 5 min
int _cooldownSeconds = 15 * 60; // 15 min cooldown between reel sessions
// ── Public getters — Reel session ─────────────────────────
bool get isSessionActive => _isSessionActive;
int get remainingSessionSeconds {
if (!_isSessionActive || _sessionExpiry == null) return 0;
final diff = _sessionExpiry!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
int get dailyUsedSeconds => _dailyUsedSeconds;
int get dailyLimitSeconds => _dailyLimitSeconds;
int get dailyRemainingSeconds {
final rem = _dailyLimitSeconds - _dailyUsedSeconds;
return rem > 0 ? rem : 0;
}
bool get isDailyLimitExhausted => dailyRemainingSeconds <= 0;
bool get isCooldownActive {
if (_lastSessionEnd == null) return false;
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
return elapsed < _cooldownSeconds;
}
int get cooldownRemainingSeconds {
if (!isCooldownActive || _lastSessionEnd == null) return 0;
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
final rem = _cooldownSeconds - elapsed;
return rem > 0 ? rem : 0;
}
int get perSessionSeconds => _perSessionSeconds;
int get cooldownSeconds => _cooldownSeconds;
// ── Public getters — App session ──────────────────────────
/// Whether the user has an active app session right now.
bool get isAppSessionActive {
if (_appSessionEnd == null) return false;
return DateTime.now().isBefore(_appSessionEnd!);
}
/// Seconds left in the current app session.
int get appSessionRemainingSeconds {
if (_appSessionEnd == null) return 0;
final diff = _appSessionEnd!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
/// True when the app session has expired and user has not yet acted.
bool get isAppSessionExpired => _appSessionExpiredFlag;
/// Whether the 10-min extension has been used.
bool get canExtendAppSession => !_appExtensionUsed;
/// Seconds remaining in the app-open cooldown.
int get appOpenCooldownRemainingSeconds {
if (_lastAppSessionEnd == null) return 0;
final elapsed = DateTime.now().difference(_lastAppSessionEnd!).inSeconds;
final rem = _cooldownSeconds - elapsed;
return rem > 0 ? rem : 0;
}
/// True if the app-open cooldown is still active.
bool get isAppOpenCooldownActive {
if (_lastAppSessionEnd == null) return false;
return appOpenCooldownRemainingSeconds > 0;
}
/// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount;
// ── Initialization ─────────────────────────────────────────
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _resetDailyIfNeeded();
_loadPersisted();
_startTicker();
_incrementOpenCount();
}
Future<void> _resetDailyIfNeeded() async {
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
final stored = _prefs!.getString(_keyDailyDate) ?? '';
if (stored != today) {
await _prefs!.setString(_keyDailyDate, today);
await _prefs!.setInt(_keyDailyUsedSeconds, 0);
await _prefs!.setInt(_keyDailyOpenCount, 0);
}
}
void _loadPersisted() {
_dailyUsedSeconds = _prefs!.getInt(_keyDailyUsedSeconds) ?? 0;
_dailyLimitSeconds = _prefs!.getInt(_keyDailyLimitSec) ?? 30 * 60;
_perSessionSeconds = _prefs!.getInt(_keyPerSessionSec) ?? 5 * 60;
_cooldownSeconds = _prefs!.getInt(_keyCooldownSec) ?? 15 * 60;
_dailyOpenCount = _prefs!.getInt(_keyDailyOpenCount) ?? 0;
// Reel session
final expiryMs = _prefs!.getInt(_keySessionExpiry) ?? 0;
if (expiryMs > 0) {
final expiry = DateTime.fromMillisecondsSinceEpoch(expiryMs);
if (expiry.isAfter(DateTime.now())) {
_sessionExpiry = expiry;
_isSessionActive = true;
} else {
_cleanupExpiredReelSession();
}
}
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
if (lastEndMs > 0) {
_lastSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastEndMs);
}
// App session
final appEndMs = _prefs!.getInt(_keyAppSessionEnd) ?? 0;
if (appEndMs > 0) {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
}
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) {
_lastAppSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastAppEndMs);
}
}
void _incrementOpenCount() {
_dailyOpenCount++;
_prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
bool changed = false;
// Reel session countdown
if (_isSessionActive) {
if (remainingSessionSeconds <= 0) {
_cleanupExpiredReelSession();
changed = true;
} else {
_dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) _cleanupExpiredReelSession();
changed = true;
}
}
// App session expiry check
if (_appSessionEnd != null &&
!_appSessionExpiredFlag &&
DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
if (isCooldownActive) changed = true;
if (changed) notifyListeners();
}
void _cleanupExpiredReelSession() {
_isSessionActive = false;
_sessionExpiry = null;
_lastSessionEnd = DateTime.now();
_prefs?.setInt(_keySessionExpiry, 0);
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
}
// ── Reel session API ───────────────────────────────────────
bool startSession(int minutes) {
if (isDailyLimitExhausted) return false;
if (isCooldownActive) return false;
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true;
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
}
void endSession() {
if (!_isSessionActive) return;
_cleanupExpiredReelSession();
notifyListeners();
}
void accrueSeconds(int seconds) {
_dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp(
0,
_dailyLimitSeconds,
);
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
notifyListeners();
}
// ── App session API ────────────────────────────────────────
/// Start an app session of [minutes] (160).
void startAppSession(int minutes) {
final end = DateTime.now().add(Duration(minutes: minutes));
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
notifyListeners();
}
/// Extend the app session by 10 minutes. Only works once.
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
_appSessionEnd = base.add(const Duration(minutes: 10));
_appExtensionUsed = true;
_appSessionExpiredFlag = false;
_prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, true);
notifyListeners();
return true;
}
/// Called when the user closes the app voluntarily or after extension denial.
void endAppSession() {
_lastAppSessionEnd = DateTime.now();
_appSessionEnd = null;
_appSessionExpiredFlag = false;
_prefs?.setInt(
_keyLastAppSessEnd,
_lastAppSessionEnd!.millisecondsSinceEpoch,
);
_prefs?.setInt(_keyAppSessionEnd, 0);
notifyListeners();
}
// ── Settings mutations ─────────────────────────────────────
Future<void> setDailyLimitMinutes(int minutes) async {
_dailyLimitSeconds = minutes * 60;
await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
Future<void> setPerSessionMinutes(int minutes) async {
_perSessionSeconds = minutes * 60;
await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds);
notifyListeners();
}
Future<void> setCooldownMinutes(int minutes) async {
_cooldownSeconds = minutes * 60;
await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds);
notifyListeners();
}
Future<void> resetDailyCounter() async {
_dailyUsedSeconds = 0;
await _prefs?.setInt(_keyDailyUsedSeconds, 0);
if (_isSessionActive) endSession();
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}