mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-10 04:58:35 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
/// Generates all CSS and JavaScript injection strings for the WebView.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Instagram's own bottom nav bar is hidden via both CSS and a periodic JS
|
||||
/// removal loop, since SPA re-renders can outpace MutationObserver.
|
||||
/// - Reel elements are hidden/blurred based on settings/session state.
|
||||
/// - A MutationObserver keeps re-applying the rules after SPA re-renders.
|
||||
/// - App-install banners are auto-dismissed.
|
||||
class InjectionController {
|
||||
/// iOS Safari user-agent — reduces login friction with Instagram.
|
||||
static const String iOSUserAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Version/17.5 Mobile/15E148 Safari/604.1';
|
||||
|
||||
// ── CSS injection ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Robust CSS that hides Instagram's native bottom nav bar.
|
||||
/// Covers all known selector patterns including dynamic class names.
|
||||
static const String _hideInstagramNavCSS = '''
|
||||
/* ── Instagram bottom navigation bar — hide completely ── */
|
||||
/* Role-based selectors */
|
||||
div[role="tablist"],
|
||||
nav[role="navigation"],
|
||||
/* Fixed-position bottom bar */
|
||||
div[style*="position: fixed"][style*="bottom"],
|
||||
div[style*="position:fixed"][style*="bottom"],
|
||||
/* Instagram legacy class names */
|
||||
._acbl, ._aa4b, ._aahi, ._ab8s,
|
||||
/* Section nav elements */
|
||||
section nav,
|
||||
/* Any nav inside the main app shell */
|
||||
#react-root nav,
|
||||
/* The outer wrapper of the bottom bar (PWA/mobile web) */
|
||||
[class*="x1n2onr6"][class*="x1vjfegm"] > nav,
|
||||
/* Catch-all: any fixed bottom element containing nav links */
|
||||
footer nav,
|
||||
div[class*="bottom"] nav {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* Ensure the body doesn't add bottom padding for the nav */
|
||||
body, #react-root, main {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// CSS to hide Reel-related elements everywhere (feed, profile, search).
|
||||
/// Used when session is NOT active.
|
||||
static const String _hideReelsCSS = '''
|
||||
/* Hide reel thumbnails and links */
|
||||
a[href*="/reel/"],
|
||||
a[href*="/reels"],
|
||||
[aria-label*="Reel"],
|
||||
[aria-label*="Reels"],
|
||||
div[data-media-type="2"],
|
||||
/* Profile grid reel filter tabs */
|
||||
[aria-label="Reels"],
|
||||
/* Reel indicators on feed thumbnails */
|
||||
svg[aria-label="Reels"],
|
||||
/* Video/reel chips in feed */
|
||||
[class*="reel"],
|
||||
[class*="Reel"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// CSS to blur Explore feed posts/reels (keeps stories visible).
|
||||
static const String _blurExploreCSS = '''
|
||||
/* Blur Explore grid posts and reel cards (not stories row) */
|
||||
main[role="main"] section > div > div:not(:first-child) a img,
|
||||
main[role="main"] section > div > div:not(:first-child) video,
|
||||
main[role="main"] section > div > div:not(:first-child) [class*="x6s0dn4"],
|
||||
main[role="main"] article img,
|
||||
main[role="main"] article video,
|
||||
/* Explore page grid */
|
||||
._aagv img,
|
||||
._aagv video {
|
||||
filter: blur(12px) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* Overlay to block tapping blurred content */
|
||||
._aagv::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
._aagv {
|
||||
position: relative !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Auto-dismiss "Open in App" banner that Instagram shows in mobile browsers.
|
||||
static const String _dismissAppBannerJS = '''
|
||||
(function dismissBanners() {
|
||||
const selectors = [
|
||||
'[id*="app-banner"]',
|
||||
'[class*="app-banner"]',
|
||||
'[data-testid*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]',
|
||||
'div[role="dialog"][aria-label*="App"]',
|
||||
];
|
||||
selectors.forEach(sel => {
|
||||
document.querySelectorAll(sel).forEach(el => el.remove());
|
||||
});
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Periodic remover: every 500ms force-removes the bottom nav.
|
||||
/// Complements the MutationObserver for sites that rebuild DOM faster.
|
||||
static const String _periodicNavRemoverJS = '''
|
||||
(function periodicNavRemove() {
|
||||
function removeNav() {
|
||||
// Target all fixed-bottom elements that could be the nav bar
|
||||
document.querySelectorAll([
|
||||
'div[role="tablist"]',
|
||||
'nav[role="navigation"]',
|
||||
'._acbl', '._aa4b', '._aahi', '._ab8s',
|
||||
'section nav',
|
||||
'footer nav'
|
||||
].join(',')).forEach(function(el) {
|
||||
el.style.cssText += ';display:none!important;height:0!important;overflow:hidden!important;';
|
||||
});
|
||||
// Also hide any element that is fixed at the bottom and contains nav links
|
||||
document.querySelectorAll('div[style]').forEach(function(el) {
|
||||
const s = el.style;
|
||||
if ((s.position === 'fixed' || s.position === 'sticky') &&
|
||||
(s.bottom === '0px' || s.bottom === '0') &&
|
||||
el.querySelector('a,button')) {
|
||||
el.style.cssText += ';display:none!important;';
|
||||
}
|
||||
});
|
||||
}
|
||||
removeNav();
|
||||
setInterval(removeNav, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// MutationObserver that continuously re-applies CSS after SPA re-renders.
|
||||
static String _buildMutationObserver(String cssContent) =>
|
||||
'''
|
||||
(function applyFocusGramStyles() {
|
||||
const STYLE_ID = 'focusgram-injected-style';
|
||||
|
||||
function injectCSS() {
|
||||
let el = document.getElementById(STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = ${_escapeJsString(cssContent)};
|
||||
}
|
||||
|
||||
injectCSS();
|
||||
|
||||
const observer = new MutationObserver(function() {
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
injectCSS();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
})();
|
||||
''';
|
||||
|
||||
static String _escapeJsString(String s) {
|
||||
// Wrap in JS template literal backticks; escape any internal backticks.
|
||||
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
// ── Navigation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// JS that soft-navigates Instagram's SPA without a full page reload.
|
||||
/// [path] should start with / e.g. '/direct/inbox/'.
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
const target = ${_escapeJsString(path)};
|
||||
// Try React Router / Instagram SPA navigation first (pushState trick)
|
||||
if (window.location.pathname !== target) {
|
||||
window.location.href = target;
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// JS to click Instagram's native "create post" button.
|
||||
static const String clickCreateButtonJS = '''
|
||||
(function() {
|
||||
const btn = document.querySelector(
|
||||
'[aria-label="New post"], [aria-label="Create"], svg[aria-label="New post"]'
|
||||
);
|
||||
if (btn) {
|
||||
btn.closest('a, button') ? btn.closest('a, button').click() : btn.click();
|
||||
} else {
|
||||
// Fallback: navigate to home first, create will open as modal
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// JS to get the currently logged-in user's username.
|
||||
static const String getLoggedInUsernameJS = '''
|
||||
(function() {
|
||||
try {
|
||||
// Try shared data approach
|
||||
const scripts = Array.from(document.querySelectorAll('script[type="application/json"]'));
|
||||
for (const s of scripts) {
|
||||
try {
|
||||
const d = JSON.parse(s.textContent);
|
||||
if (d && d.config && d.config.viewer && d.config.viewer.username) {
|
||||
return d.config.viewer.username;
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
// Try window additionalDataLoaded
|
||||
if (window.__additionalDataLoaded) {
|
||||
const keys = Object.keys(window.__additionalDataLoaded || {});
|
||||
for (const k of keys) {
|
||||
const v = window.__additionalDataLoaded[k];
|
||||
if (v && v.data && v.data.user && v.data.user.username) {
|
||||
return v.data.user.username;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: try profile anchor in nav
|
||||
const profileLink = document.querySelector('a[href][aria-label*="rofile"]');
|
||||
if (profileLink) {
|
||||
const href = profileLink.getAttribute('href');
|
||||
if (href) {
|
||||
const parts = href.replace(/^[/]/, "").split("/");
|
||||
if (parts[0] && parts[0].length > 0) return parts[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch(e) { return null; }
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Reel scroll-lock ────────────────────────────────────────────────────────
|
||||
|
||||
/// JS that prevents the user from scrolling to a different reel.
|
||||
/// Intercepts history changes — if a /reel/ URL changes, navigate back.
|
||||
static String reelScrollLockJS(String canonicalUrl) {
|
||||
final escapedUrl = _escapeJsString(canonicalUrl);
|
||||
return '''
|
||||
(function lockReel() {
|
||||
const LOCKED_URL = $escapedUrl;
|
||||
function extractReelId(url) {
|
||||
const m = url.match(/\\/reel\\/([^\\/\\?#]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
const lockedId = extractReelId(LOCKED_URL);
|
||||
if (!lockedId) return;
|
||||
|
||||
// Override pushState and replaceState
|
||||
const _pushState = history.pushState.bind(history);
|
||||
const _replaceState = history.replaceState.bind(history);
|
||||
|
||||
function checkAndRevert(newUrl) {
|
||||
const newId = extractReelId(newUrl || window.location.href);
|
||||
if (newId && newId !== lockedId) {
|
||||
// Different reel — go back to ours
|
||||
setTimeout(function() {
|
||||
window.location.replace(LOCKED_URL);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
history.pushState = function(state, title, url) {
|
||||
_pushState(state, title, url);
|
||||
checkAndRevert(url);
|
||||
};
|
||||
history.replaceState = function(state, title, url) {
|
||||
_replaceState(state, title, url);
|
||||
checkAndRevert(url);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function() {
|
||||
checkAndRevert(window.location.href);
|
||||
});
|
||||
|
||||
// Also disable vertical swipe gestures that drive reel-to-reel
|
||||
let startY = 0;
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (Math.abs(dy) > 20) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
/// JS to disable swipe-to-next behavior inside the isolated Reel player.
|
||||
static const String disableReelSwipeJS = '''
|
||||
(function disableSwipeNavigation() {
|
||||
let startX = 0;
|
||||
document.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, {passive: true});
|
||||
document.addEventListener('touchmove', e => {
|
||||
const dx = Math.abs(e.touches[0].clientX - startX);
|
||||
if (dx > 30) e.preventDefault();
|
||||
}, {passive: false});
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full injection JS to run on every page load.
|
||||
static String buildInjectionJS({
|
||||
required bool sessionActive,
|
||||
required bool blurExplore,
|
||||
}) {
|
||||
final StringBuffer css = StringBuffer();
|
||||
css.write(_hideInstagramNavCSS);
|
||||
if (!sessionActive) css.write(_hideReelsCSS);
|
||||
if (blurExplore) css.write(_blurExploreCSS);
|
||||
|
||||
return '''
|
||||
${_buildMutationObserver(css.toString())}
|
||||
$_periodicNavRemoverJS
|
||||
$_dismissAppBannerJS
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/// Determines whether a navigation request should be blocked.
|
||||
///
|
||||
/// Rules:
|
||||
/// - /reels/* and /reel/* are blocked unless [sessionActive] is true OR
|
||||
/// [isDmReelException] is true (single DM reel open).
|
||||
/// - /explore/ is allowed (but explore content is blurred via CSS).
|
||||
/// - Only instagram.com domains are allowed (blocks external redirects).
|
||||
class NavigationGuard {
|
||||
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
|
||||
|
||||
static const _blockedPathPrefixes = ['/reels', '/reel/'];
|
||||
|
||||
/// Returns a [BlockDecision] for the given [url].
|
||||
static BlockDecision evaluate({
|
||||
required String url,
|
||||
required bool sessionActive,
|
||||
required bool isDmReelException,
|
||||
}) {
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(url);
|
||||
} catch (_) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Allow non-HTTP schemes (about:blank, data:, etc.)
|
||||
if (!uri.scheme.startsWith('http')) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Block non-Instagram domains (prevents phishing redirects)
|
||||
final host = uri.host.toLowerCase();
|
||||
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
|
||||
return BlockDecision(
|
||||
blocked: true,
|
||||
reason: 'External domain blocked: $host',
|
||||
);
|
||||
}
|
||||
|
||||
// Check reel/reels path
|
||||
final path = uri.path.toLowerCase();
|
||||
final isReelUrl = _blockedPathPrefixes.any((p) => path.startsWith(p));
|
||||
|
||||
if (isReelUrl) {
|
||||
if (sessionActive || isDmReelException) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
return BlockDecision(
|
||||
blocked: true,
|
||||
reason: 'Reel navigation blocked — no active session',
|
||||
);
|
||||
}
|
||||
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
/// Returns true if the URL looks like a Reel link from a DM.
|
||||
static bool isDmReelLink(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final path = uri.path.toLowerCase();
|
||||
return path.startsWith('/reel/') || path.startsWith('/reels/');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a canonical single-reel URL from a DM reel link.
|
||||
/// Strips query params that might trigger Reels feed.
|
||||
static String? canonicalizeDmReelUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
// Keep only the reel path, strip all query parameters
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'www.instagram.com',
|
||||
path: uri.path,
|
||||
).toString();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BlockDecision {
|
||||
final bool blocked;
|
||||
final String? reason;
|
||||
const BlockDecision({required this.blocked, required this.reason});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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] (1–60).
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores and retrieves all user-configurable app settings.
|
||||
class SettingsService extends ChangeNotifier {
|
||||
static const _keyBlurExplore = 'set_blur_explore';
|
||||
static const _keyBlurReels = 'set_blur_reels';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool _blurExplore = true; // Default: blur explore feed posts/reels
|
||||
bool _blurReels = false; // If false: hide reels in feed (after session ends)
|
||||
bool _requireLongPress = true; // Long-press FAB to start session
|
||||
bool _showBreathGate = true; // Show breathing gate on every open
|
||||
|
||||
bool get blurExplore => _blurExplore;
|
||||
bool get blurReels => _blurReels;
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
|
||||
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_blurExplore = v;
|
||||
await _prefs?.setBool(_keyBlurExplore, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurReels(bool v) async {
|
||||
_blurReels = v;
|
||||
await _prefs?.setBool(_keyBlurReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setRequireLongPress(bool v) async {
|
||||
_requireLongPress = v;
|
||||
await _prefs?.setBool(_keyRequireLongPress, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowBreathGate(bool v) async {
|
||||
_showBreathGate = v;
|
||||
await _prefs?.setBool(_keyShowBreathGate, v);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user