mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-21 23:16:48 +02:00
What's new
- Reordered Settings Page. - Added "Click to Unblur" for posts. - Added Persistent Notification - Improved Grayscale Scheduling. and more.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Lightweight global router for cross-widget navigation signals.
|
||||
/// Used to allow the Settings page to trigger WebView navigations without
|
||||
/// requiring a BuildContext reference to MainWebViewPage.
|
||||
class FocusGramRouter {
|
||||
FocusGramRouter._();
|
||||
|
||||
/// When this value is non-null, [MainWebViewPage] will load the URL
|
||||
/// in the WebView and clear this value. Settings page sets this to
|
||||
/// trigger in-app navigation (e.g. Instagram Settings).
|
||||
static final pendingUrl = ValueNotifier<String?>(null);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ============================================================================
|
||||
// FocusGram — InjectionController
|
||||
// ============================================================================
|
||||
|
||||
import '../scripts/core_injection.dart' as scripts;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
|
||||
class InjectionController {
|
||||
static const String iOSUserAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
static const String reelsMutationObserverJS =
|
||||
scripts.kReelsMutationObserverJS;
|
||||
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
|
||||
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
|
||||
|
||||
static String _buildMutationObserver(String cssContent) =>
|
||||
'''
|
||||
(function fgApplyStyles() {
|
||||
const ID = 'focusgram-style';
|
||||
function inject() {
|
||||
let el = document.getElementById(ID);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = ID;
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.textContent = ${_escapeJsString(cssContent)};
|
||||
}
|
||||
inject();
|
||||
new MutationObserver(() => { if (!document.getElementById(ID)) inject(); })
|
||||
.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
''';
|
||||
|
||||
static String _escapeJsString(String s) {
|
||||
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
const t = ${_escapeJsString(path)};
|
||||
if (window.location.pathname !== t) window.location.href = t;
|
||||
})();
|
||||
''';
|
||||
|
||||
static String buildSessionStateJS(bool active) =>
|
||||
'window.__focusgramSessionActive = $active;';
|
||||
|
||||
static String buildInjectionJS({
|
||||
required bool sessionActive,
|
||||
required bool blurExplore,
|
||||
required bool blurReels,
|
||||
required bool tapToUnblur,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
// hideStoriesBar parameter removed per user request
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
|
||||
if (!sessionActive) {
|
||||
// Hide reel feed content when no session active
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
}
|
||||
|
||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||
// Previously it was inside that block alongside display:none on the parent —
|
||||
// you cannot blur children of a display:none element, making it dead code.
|
||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||
|
||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
// Stories hiding removed per user request
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
window.__fgTapToUnblur = $tapToUnblur;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
${scripts.kDismissAppBannerJS}
|
||||
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
||||
${scripts.kReelsMutationObserverJS}
|
||||
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
|
||||
${scripts.kLinkSanitizationJS}
|
||||
${scripts.kThemeDetectorJS}
|
||||
${scripts.kBadgeMonitorJS}
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
import '../scripts/content_disabling.dart' as content_disabling;
|
||||
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// WARNING: Do not add any network interception logic ("ghost mode") here.
|
||||
// All scripts in this file must be limited to UI behaviour, navigation helpers,
|
||||
// and local-only features that do not modify data sent to Meta's servers.
|
||||
|
||||
// ── CSS payloads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
const String kGlobalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
const String kBlurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native (only when disabled).
|
||||
const String kDisableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
const String kHideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const String kDismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kStrictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kTrackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('UrlChange', p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kThemeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
function getTheme() {
|
||||
try {
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (rgb && rgb.length >= 3) {
|
||||
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch(_) {}
|
||||
return 'dark';
|
||||
}
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kReelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
if (window.__fgDisableReelsEntirely === true) return 'disabled';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLocked() { return lockMode() !== null; }
|
||||
|
||||
function allowInteractionTarget(t) {
|
||||
if (!t || !t.closest) return false;
|
||||
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
|
||||
if (t.closest(MODAL_SEL)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
let __fgOrigHtmlOverflow = null;
|
||||
let __fgOrigBodyOverflow = null;
|
||||
|
||||
function applyOverflowLock() {
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
}
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (document.body) document.body.style.overflow = 'hidden';
|
||||
} else if (__fgOrigHtmlOverflow !== null) {
|
||||
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
|
||||
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
|
||||
__fgOrigHtmlOverflow = null;
|
||||
__fgOrigBodyOverflow = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
applyOverflowLock();
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) window.__fgDmReelAlreadyLoaded = true;
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; }
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
applyOverflowLock();
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kBadgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
const startedAt = Date.now();
|
||||
let initialised = false;
|
||||
let lastDmCount = 0, lastNotifCount = 0, lastTitleUnread = 0;
|
||||
|
||||
function parseBadgeCount(el) {
|
||||
if (!el) return 0;
|
||||
try {
|
||||
const raw = (el.innerText || el.textContent || '').trim();
|
||||
const n = parseInt(raw, 10);
|
||||
return isNaN(n) ? 1 : n;
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
function check() {
|
||||
try {
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'a[href*="/direct/inbox/"] ._a9-v',
|
||||
].join(','));
|
||||
const currentDmCount = parseBadgeCount(dmBadge);
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]',
|
||||
].join(','));
|
||||
const currentNotifCount = parseBadgeCount(notifBadge);
|
||||
|
||||
if (!initialised) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; initialised = true; return;
|
||||
}
|
||||
if (Date.now() - startedAt < 6000) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; return;
|
||||
}
|
||||
if (currentDmCount > lastDmCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
|
||||
} else if (currentNotifCount > lastNotifCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
|
||||
}
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 1000);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kNotificationBridgeJS = '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const startedAt = Date.now();
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
if (Date.now() - startedAt < 6000) return new _N(title, opts);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramNotificationChannel',
|
||||
title + (opts && opts.body ? ': ' + opts.body : ''),
|
||||
);
|
||||
}
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kLinkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.flutter_inappwebview && u) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramShareChannel',
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }),
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
final SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
});
|
||||
|
||||
void setSettingsService(SettingsService settingsService) {
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// Runs all post-load JavaScript injections based on current settings.
|
||||
Future<void> runAllPostLoadInjections(String url) async {
|
||||
if (_settingsService == null) return;
|
||||
|
||||
final settings = _settingsService!;
|
||||
final sessionActive = sessionManager.isSessionActive;
|
||||
|
||||
// Get settings values
|
||||
// Minimal mode controls all blocking - when enabled, it forces blur and disables
|
||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||
final tapToUnblur = settings.tapToUnblur;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
// Stories hiding functionality removed per user request
|
||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
||||
// These are now only controllable via minimal mode submenu
|
||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final hideExploreTab = disableExploreEntirely;
|
||||
final hideReelsTab = disableReelsEntirely;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
blurExplore: blurExplore,
|
||||
blurReels: false, // Blur reels feature removed
|
||||
tapToUnblur: blurExplore && tapToUnblur,
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: false, // Feature removed
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
// hideStoriesBar removed per user request
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Stories hiding functionality removed per user request
|
||||
// No stories overlay injection needed
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Determines whether a navigation request should be blocked.
|
||||
///
|
||||
/// Rules:
|
||||
/// - instagram.com/reels (and /reels/) = BLOCKED — this is the mindless feed tab
|
||||
/// - instagram.com/reel/SHORTCODE/ = ALLOWED — a specific reel (e.g. from a DM)
|
||||
/// - /explore/ is allowed (explore content is blurred via CSS instead)
|
||||
/// - Only instagram.com domains are allowed (blocks external redirects)
|
||||
class NavigationGuard {
|
||||
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
|
||||
|
||||
/// Regex matching the Reels FEED root — NOT individual reels.
|
||||
/// The `(/|\?|$)` suffix ensures query params (e.g. ?fg=blocked) still match.
|
||||
static final _reelsFeedRegex = RegExp(
|
||||
r'instagram\.com/reels(/|\?|$)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Regex matching a specific reel (e.g. /reel/ABC123/).
|
||||
static final _specificReelRegex = RegExp(
|
||||
r'instagram\.com/reel/[^/?#]+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Returns a [BlockDecision] for the given [url].
|
||||
static BlockDecision evaluate({required String url}) {
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(url);
|
||||
} catch (_) {
|
||||
return const BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Allow non-HTTP schemes (about:blank, data:, etc.)
|
||||
if (!uri.scheme.startsWith('http')) {
|
||||
return const BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Block non-Instagram domains (prevents phishing/external redirects)
|
||||
final host = uri.host.toLowerCase();
|
||||
if (!_allowedHosts.any((h) => host == h)) {
|
||||
return BlockDecision(
|
||||
blocked: true,
|
||||
reason: 'External domain blocked: $host',
|
||||
);
|
||||
}
|
||||
|
||||
// Block ONLY the Reels feed tab root (/reels, /reels/)
|
||||
// but allow specific reels (/reel/SHORTCODE/) opened from DMs
|
||||
if (_reelsFeedRegex.hasMatch(url)) {
|
||||
return const BlockDecision(
|
||||
blocked: true,
|
||||
reason:
|
||||
'Reels feed is disabled — open a specific reel from DMs instead',
|
||||
);
|
||||
}
|
||||
|
||||
return const BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
/// True if the URL is a specific individual reel (from a DM share).
|
||||
static bool isSpecificReel(String url) => _specificReelRegex.hasMatch(url);
|
||||
}
|
||||
|
||||
class BlockDecision {
|
||||
final bool blocked;
|
||||
final String? reason;
|
||||
const BlockDecision({required this.blocked, required this.reason});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> init() async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
);
|
||||
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsIOS,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.initialize(
|
||||
settings: initializationSettings,
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
// Handle notification tap
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
|
||||
Future<void> _requestIOSPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
} catch (e) {
|
||||
debugPrint('iOS permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestAndroidPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
} catch (e) {
|
||||
debugPrint('Android permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails =
|
||||
AndroidNotificationDetails(
|
||||
'focusgram_channel',
|
||||
'FocusGram Notifications',
|
||||
channelDescription: 'Notifications for FocusGram sessions and alerts',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails platformDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
try {
|
||||
await _notificationsPlugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a persistent (ongoing) notification that cannot be dismissed by the user
|
||||
Future<void> showPersistentNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails =
|
||||
AndroidNotificationDetails(
|
||||
'focusgram_persistent_channel',
|
||||
'FocusGram Persistent',
|
||||
channelDescription: 'Persistent notification while using FocusGram',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
showWhen: true,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const NotificationDetails platformDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
try {
|
||||
await _notificationsPlugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a persistent notification
|
||||
Future<void> cancelPersistentNotification({required int id}) async {
|
||||
try {
|
||||
await _notificationsPlugin.cancel(id: id);
|
||||
} catch (e) {
|
||||
debugPrint('Cancel persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels all notifications
|
||||
Future<void> cancelAllNotifications() async {
|
||||
try {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
} catch (e) {
|
||||
debugPrint('Cancel all notifications error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tracks total in-app screen time per day.
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
class ScreenTimeService extends ChangeNotifier {
|
||||
static const String prefKey = 'screen_time_data';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
Map<String, int> _secondsByDate = {};
|
||||
Timer? _ticker;
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final raw = _prefs?.getString(prefKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_secondsByDate = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Prune entries older than 30 days
|
||||
final now = DateTime.now();
|
||||
final cutoff = now.subtract(const Duration(days: 30));
|
||||
_secondsByDate.removeWhere((key, value) {
|
||||
try {
|
||||
final d = DateTime.parse(key);
|
||||
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _todayKey() {
|
||||
final now = DateTime.now();
|
||||
return '${now.year.toString().padLeft(4, '0')}-'
|
||||
'${now.month.toString().padLeft(2, '0')}-'
|
||||
'${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void startTracking() {
|
||||
if (_tracking) return;
|
||||
_tracking = true;
|
||||
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!_tracking) return;
|
||||
final key = _todayKey();
|
||||
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
|
||||
// Persist every 10 seconds to reduce writes.
|
||||
if (_secondsByDate[key]! % 10 == 0) {
|
||||
_save();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
if (!_tracking) return;
|
||||
_tracking = false;
|
||||
_save();
|
||||
}
|
||||
|
||||
Future<void> resetAll() async {
|
||||
_secondsByDate.clear();
|
||||
await _prefs?.remove(prefKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
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] (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> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import 'dart:convert';
|
||||
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 _keyTapToUnblur = 'set_tap_to_unblur';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
||||
static const _keyEnabledTabs = 'set_enabled_tabs';
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
static const _keyIsFirstRun = 'set_is_first_run';
|
||||
|
||||
// Focus / playback
|
||||
static const _keyBlockAutoplay = 'block_autoplay';
|
||||
|
||||
// Grayscale mode - now supports multiple schedules
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Minimal mode
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
// Minimal mode state tracking for smart restore
|
||||
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
|
||||
|
||||
// Reels History
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
|
||||
// Privacy keys
|
||||
static const _keySanitizeLinks = 'set_sanitize_links';
|
||||
static const _keyNotifyDMs = 'set_notify_dms';
|
||||
static const _keyNotifyActivity = 'set_notify_activity';
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
static const _keyNotifyPersistent = 'set_notify_persistent';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool _blurExplore = true;
|
||||
bool _blurReels = false;
|
||||
bool _tapToUnblur = true;
|
||||
bool _requireLongPress = true;
|
||||
bool _showBreathGate = true;
|
||||
bool _requireWordChallenge = true;
|
||||
bool _enableTextSelection = false;
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
bool _blockAutoplay = true;
|
||||
|
||||
bool _grayscaleEnabled = false;
|
||||
|
||||
// Grayscale schedules - list of {enabled, startTime, endTime}
|
||||
// startTime and endTime are in format "HH:MM"
|
||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
||||
|
||||
bool _hideSponsoredPosts = false;
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideShopTab = false;
|
||||
|
||||
// These are now controlled internally by minimal mode
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
// Tracking for smart restore
|
||||
bool _prevDisableReels = false;
|
||||
bool _prevDisableExplore = false;
|
||||
bool _prevBlurExplore = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
// Privacy defaults - notifications OFF by default
|
||||
bool _sanitizeLinks = true;
|
||||
bool _notifyDMs = false;
|
||||
bool _notifyActivity = false;
|
||||
bool _notifySessionEnd = false;
|
||||
bool _notifyPersistent = false;
|
||||
|
||||
List<String> _enabledTabs = [
|
||||
'Home',
|
||||
'Search',
|
||||
'Reels',
|
||||
'Messages',
|
||||
'Profile',
|
||||
];
|
||||
bool _isFirstRun = true;
|
||||
|
||||
bool get blurExplore => _blurExplore;
|
||||
bool get blurReels => _blurReels;
|
||||
bool get tapToUnblur => _tapToUnblur;
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
bool get requireWordChallenge => _requireWordChallenge;
|
||||
bool get enableTextSelection => _enableTextSelection;
|
||||
bool get showInstaSettings => _showInstaSettings;
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
bool get blockAutoplay => _blockAutoplay;
|
||||
bool get notifyDMs => _notifyDMs;
|
||||
bool get notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
bool get notifyPersistent => _notifyPersistent;
|
||||
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
||||
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
bool get hideShopTab => _hideShopTab;
|
||||
|
||||
// These are now controlled by minimal mode only
|
||||
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
|
||||
/// True if grayscale should currently be applied, considering the manual
|
||||
/// toggle and the optional schedules.
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (_grayscaleSchedules.isEmpty) return false;
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
|
||||
for (final schedule in _grayscaleSchedules) {
|
||||
if (schedule['enabled'] != true) continue;
|
||||
|
||||
try {
|
||||
final startParts = (schedule['startTime'] as String).split(':');
|
||||
final endParts = (schedule['endTime'] as String).split(':');
|
||||
|
||||
if (startParts.length != 2 || endParts.length != 2) continue;
|
||||
|
||||
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
||||
|
||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
||||
if (endMinutes < startMinutes) {
|
||||
// Overnight: active if current time is >= start OR < end
|
||||
if (currentMinutes >= startMinutes || currentMinutes < endMinutes) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Same day: active if current time is between start and end
|
||||
if (currentMinutes >= startMinutes && currentMinutes < endMinutes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Privacy getters
|
||||
bool get sanitizeLinks => _sanitizeLinks;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
|
||||
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
|
||||
_tapToUnblur = _prefs!.getBool(_keyTapToUnblur) ?? true;
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||
|
||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||
|
||||
// Load grayscale schedules
|
||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
||||
if (schedulesJson != null) {
|
||||
try {
|
||||
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
||||
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
|
||||
);
|
||||
} catch (_) {
|
||||
_grayscaleSchedules = [];
|
||||
}
|
||||
}
|
||||
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
// Load minimal mode
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
// Load previous states for smart restore
|
||||
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
||||
|
||||
// These are now internal states, not user-facing settings
|
||||
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
|
||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||
|
||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
|
||||
_notifyPersistent = _prefs!.getBool(_keyNotifyPersistent) ?? false;
|
||||
|
||||
_enabledTabs =
|
||||
(_prefs!.getStringList(_keyEnabledTabs) ??
|
||||
['Home', 'Search', 'Reels', 'Messages', 'Profile'])
|
||||
..remove('Create');
|
||||
if (!_enabledTabs.contains('Messages') && _enabledTabs.length < 5) {
|
||||
// Migration: add Messages if missing
|
||||
_enabledTabs.insert(3, 'Messages');
|
||||
}
|
||||
_isFirstRun = _prefs!.getBool(_keyIsFirstRun) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setFirstRunCompleted() async {
|
||||
_isFirstRun = false;
|
||||
await _prefs?.setBool(_keyIsFirstRun, false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_blurExplore = v;
|
||||
// Sync blur explore with blur reels - enabling one enables the other
|
||||
if (v && !_blurReels) {
|
||||
_blurReels = true;
|
||||
await _prefs?.setBool(_keyBlurReels, true);
|
||||
}
|
||||
await _prefs?.setBool(_keyBlurExplore, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurReels(bool v) async {
|
||||
_blurReels = v;
|
||||
// Sync blur reels with blur explore - enabling one enables the other
|
||||
if (v && !_blurExplore) {
|
||||
_blurExplore = true;
|
||||
await _prefs?.setBool(_keyBlurExplore, true);
|
||||
}
|
||||
await _prefs?.setBool(_keyBlurReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setTapToUnblur(bool v) async {
|
||||
_tapToUnblur = v;
|
||||
await _prefs?.setBool(_keyTapToUnblur, 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();
|
||||
}
|
||||
|
||||
Future<void> setRequireWordChallenge(bool v) async {
|
||||
_requireWordChallenge = v;
|
||||
await _prefs?.setBool(_keyRequireWordChallenge, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setEnableTextSelection(bool v) async {
|
||||
_enableTextSelection = v;
|
||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowInstaSettings(bool v) async {
|
||||
_showInstaSettings = v;
|
||||
await _prefs?.setBool(_keyShowInstaSettings, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlockAutoplay(bool v) async {
|
||||
_blockAutoplay = v;
|
||||
await _prefs?.setBool(_keyBlockAutoplay, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleEnabled(bool v) async {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
|
||||
_grayscaleSchedules = schedules;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
||||
_grayscaleSchedules.add(schedule);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules[index] = schedule;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeGrayscaleSchedule(int index) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules.removeAt(index);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideFollowerCounts(bool v) async {
|
||||
_hideFollowerCounts = v;
|
||||
await _prefs?.setBool(_keyHideFollowerCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable reels state (used by minimal mode submenu)
|
||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable explore state (used by minimal mode submenu)
|
||||
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Smart minimal mode toggle with state preservation
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
if (v) {
|
||||
// Turning ON - save current states BEFORE enabling minimal mode
|
||||
_prevDisableReels = _disableReelsEntirely;
|
||||
_prevDisableExplore = _disableExploreEntirely;
|
||||
_prevBlurExplore = _blurExplore;
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
|
||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
||||
|
||||
// Enable all minimal mode settings
|
||||
_minimalModeEnabled = true;
|
||||
_disableReelsEntirely = true;
|
||||
_disableExploreEntirely = true;
|
||||
_blurExplore = true;
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
||||
await _prefs?.setBool(_keyBlurExplore, true);
|
||||
} else {
|
||||
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
|
||||
_minimalModeEnabled = false;
|
||||
|
||||
// Simply restore to the states that were saved BEFORE minimal mode was enabled
|
||||
_disableReelsEntirely = _prevDisableReels;
|
||||
_disableExploreEntirely = _prevDisableExplore;
|
||||
_blurExplore = _prevBlurExplore;
|
||||
|
||||
// Save the restored states
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
|
||||
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setReelsHistoryEnabled(bool v) async {
|
||||
_reelsHistoryEnabled = v;
|
||||
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDarkMode(bool dark) {
|
||||
if (_isDarkMode != dark) {
|
||||
_isDarkMode = dark;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSanitizeLinks(bool v) async {
|
||||
_sanitizeLinks = v;
|
||||
await _prefs?.setBool(_keySanitizeLinks, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyDMs(bool v) async {
|
||||
_notifyDMs = v;
|
||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyActivity(bool v) async {
|
||||
_notifyActivity = v;
|
||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyPersistent(bool v) async {
|
||||
_notifyPersistent = v;
|
||||
await _prefs?.setBool(_keyNotifyPersistent, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleTab(String tab) async {
|
||||
if (_enabledTabs.contains(tab)) {
|
||||
if (_enabledTabs.length > 1) {
|
||||
_enabledTabs.remove(tab);
|
||||
}
|
||||
} else {
|
||||
_enabledTabs.add(tab);
|
||||
}
|
||||
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> reorderTab(int oldIndex, int newIndex) async {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final String item = _enabledTabs.removeAt(oldIndex);
|
||||
_enabledTabs.insert(newIndex, item);
|
||||
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user