Files
FocusGram-Android/lib/services/session_manager.dart
Ujwal 7bb472d212 What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
2026-03-04 10:48:14 +05:45

601 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
class FocusSchedule {
final int startHour;
final int startMinute;
final int endHour;
final int endMinute;
FocusSchedule({
required this.startHour,
required this.startMinute,
required this.endHour,
required this.endMinute,
});
Map<String, dynamic> toJson() => {
'startH': startHour,
'startM': startMinute,
'endH': endHour,
'endM': endMinute,
};
factory FocusSchedule.fromJson(Map<String, dynamic> json) => FocusSchedule(
startHour: json['startH'] as int,
startMinute: json['startM'] as int,
endHour: json['endH'] as int,
endMinute: json['endM'] as int,
);
}
/// 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';
static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h';
static const _keyScheduleStartMin = 'sched_start_m';
static const _keyScheduleEndHour = 'sched_end_h';
static const _keyScheduleEndMin = 'sched_end_m';
static const _keySchedulesJson = 'sched_list_json';
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;
// ── Scheduled Blocking runtime ─────────────────────────────
bool _scheduleEnabled = false;
int _schedStartHour = 22; // Default 10 PM
int _schedStartMin = 0;
int _schedEndHour = 7;
int _schedEndMin = 0;
List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false;
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0;
int _cachedRemainingAppSessionSeconds = 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;
// If not in foreground, the clock "freezes" visually too (or we could shift the expiry)
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;
DateTime? get lastSessionEnd => _lastSessionEnd;
// ── 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;
// ── Scheduled Blocking Getters ─────────────────────────────
bool get scheduleEnabled => _scheduleEnabled;
int get schedStartHour => _schedStartHour;
int get schedStartMin => _schedStartMin;
int get schedEndHour => _schedEndHour;
int get schedEndMin => _schedEndMin;
List<FocusSchedule> get schedules => _schedules;
bool get isScheduledBlockActive {
if (!_scheduleEnabled) return false;
final now = DateTime.now();
final currentTime = now.hour * 60 + now.minute;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
if (startTime < endTime) {
// Simple range (e.g., 9:00 to 17:00)
if (currentTime >= startTime && currentTime < endTime) return true;
} else {
// Over-midnight range (e.g., 22:00 to 07:00)
if (currentTime >= startTime || currentTime < endTime) return true;
}
}
return false;
}
String? get activeScheduleText {
if (!isScheduledBlockActive) return null;
final now = DateTime.now();
final currentTime = now.hour * 60 + now.minute;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
bool active = false;
if (startTime < endTime) {
if (currentTime >= startTime && currentTime < endTime) active = true;
} else {
if (currentTime >= startTime || currentTime < endTime) active = true;
}
if (active) {
return '${formatTime12h(s.startHour, s.startMinute)} to ${formatTime12h(s.endHour, s.endMinute)}';
}
}
return null;
}
String formatTime12h(int h, int m) {
var hour = h % 12;
if (hour == 0) hour = 12;
final period = h >= 12 ? 'PM' : 'AM';
final min = m.toString().padLeft(2, '0');
return '$hour:$min $period';
}
// ── Initialization ─────────────────────────────────────────
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _resetDailyIfNeeded();
_loadPersisted();
_lastScheduleState = isScheduledBlockActive;
_startTicker();
_incrementOpenCount();
}
void setAppForeground(bool v) {
if (_isInForeground == v) return;
_isInForeground = v;
if (v) {
// Returning to foreground: resume sessions by shifting expiry
final now = DateTime.now();
if (_isSessionActive) {
_sessionExpiry = now.add(
Duration(seconds: _cachedRemainingSessionSeconds),
);
}
if (_appSessionEnd != null) {
_appSessionEnd = now.add(
Duration(seconds: _cachedRemainingAppSessionSeconds),
);
}
} else {
// Entering background: cache remaining time
_cachedRemainingSessionSeconds = remainingSessionSeconds;
_cachedRemainingAppSessionSeconds = appSessionRemainingSeconds;
}
notifyListeners();
}
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 {
// Don't show notification for expired sessions from previous app session
_cleanupExpiredReelSession(showNotification: false);
}
}
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);
}
_scheduleEnabled = _prefs!.getBool(_keyScheduleEnabled) ?? false;
_schedStartHour = _prefs!.getInt(_keyScheduleStartHour) ?? 22;
_schedStartMin = _prefs!.getInt(_keyScheduleStartMin) ?? 0;
_schedEndHour = _prefs!.getInt(_keyScheduleEndHour) ?? 7;
_schedEndMin = _prefs!.getInt(_keyScheduleEndMin) ?? 0;
final schedJson = _prefs!.getString(_keySchedulesJson);
if (schedJson != null) {
final List decode = jsonDecode(schedJson);
_schedules = decode.map((m) => FocusSchedule.fromJson(m)).toList();
} else {
// Migrate old single schedule if it exists
_schedules = [
FocusSchedule(
startHour: _schedStartHour,
startMinute: _schedStartMin,
endHour: _schedEndHour,
endMinute: _schedEndMin,
),
];
_saveSchedulesToPrefs();
}
}
void _incrementOpenCount() {
_dailyOpenCount++;
_prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
if (!_isInForeground) return; // Freeze everything when in background
bool changed = false;
// Reel session countdown
if (_isSessionActive) {
// Recalculate expiry every tick to "pause" it while backgrounded:
// We don't change _sessionExpiry, but we increment _dailyUsedSeconds.
// If we want it to actually pause, we should probably store "remaining seconds"
// and update expiry ONLY when in foreground.
if (remainingSessionSeconds <= 0) {
// Only cleanup if session was actually active and has expired naturally
_cleanupExpiredReelSession(showNotification: true);
changed = true;
} else {
_dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) {
_cleanupExpiredReelSession(showNotification: true);
}
changed = true;
}
}
// App session expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
}
if (isCooldownActive) {
changed = true;
} else if (appOpenCooldownRemainingSeconds <= 0 &&
_lastAppSessionEnd != null) {
// Just expired
changed = true;
}
// Schedule check
final sched = isScheduledBlockActive;
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
changed = true;
// Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true;
NotificationService().showNotification(
id: 1001,
title: 'FocusGram Schedule Active',
body: 'Instagram is blocked according to your schedule.',
);
} else if (!sched) {
_scheduleNotificationShown = false;
}
}
if (changed) notifyListeners();
}
void _cleanupExpiredReelSession({bool showNotification = true}) {
// Only show notification if we haven't already shown one for this session
// and the user has enabled session end notifications
// The showNotification parameter should be false when cleaning up on app startup
// (i.e., when loading an expired session from a previous app session)
if (showNotification && !_sessionEndNotificationShown) {
_sessionEndNotificationShown = true;
// Check if user wants session end notifications
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
if (notifySessionEnd) {
NotificationService().showNotification(
id: 999,
title: 'Session Ended',
body: 'Your Reel session has expired. Time to focus!',
);
}
}
_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;
_sessionEndNotificationShown = false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
}
void endSession() {
if (!_isSessionActive) return;
// Don't show notification when user manually ends the session
_cleanupExpiredReelSession(showNotification: false);
notifyListeners();
}
void accrueSeconds(int seconds) {
_dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp(
0,
_dailyLimitSeconds,
);
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) {
// Daily limit exhausted - show notification
_cleanupExpiredReelSession(showNotification: true);
}
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> setScheduleEnabled(bool v) async {
_scheduleEnabled = v;
await _prefs?.setBool(_keyScheduleEnabled, v);
notifyListeners();
}
Future<void> setScheduleTime({
required int startH,
required int startM,
required int endH,
required int endM,
}) async {
_schedEndHour = endH;
_schedEndMin = endM;
// Update the first schedule for compatibility? Or just replace all?
// Let's replace all schedules with this one if this method is called.
_schedules = [
FocusSchedule(
startHour: startH,
startMinute: startM,
endHour: endH,
endMinute: endM,
),
];
await _prefs?.setInt(_keyScheduleStartHour, startH);
await _prefs?.setInt(_keyScheduleStartMin, startM);
await _prefs?.setInt(_keyScheduleEndHour, endH);
await _prefs?.setInt(_keyScheduleEndMin, endM);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> _saveSchedulesToPrefs() async {
final json = jsonEncode(_schedules.map((s) => s.toJson()).toList());
await _prefs?.setString(_keySchedulesJson, json);
}
Future<void> addSchedule(FocusSchedule s) async {
_schedules.add(s);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> removeScheduleAt(int index) async {
if (index >= 0 && index < _schedules.length) {
_schedules.removeAt(index);
await _saveSchedulesToPrefs();
notifyListeners();
}
}
Future<void> updateScheduleAt(int index, FocusSchedule s) async {
if (index >= 0 && index < _schedules.length) {
_schedules[index] = s;
await _saveSchedulesToPrefs();
notifyListeners();
}
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}