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:
Ujwal
2026-03-04 10:48:14 +05:45
commit 7bb472d212
92 changed files with 14740 additions and 0 deletions
+13
View File
@@ -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);
}
+109
View File
@@ -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}
''';
}
}
+503
View File
@@ -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
}
}
}
}
+68
View File
@@ -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});
}
+168
View File
@@ -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');
}
}
}
+107
View File
@@ -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();
}
}
+600
View File
@@ -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] (160).
void startAppSession(int minutes) {
final end = DateTime.now().add(Duration(minutes: minutes));
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
notifyListeners();
}
/// Extend the app session by 10 minutes. Only works once.
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
_appSessionEnd = base.add(const Duration(minutes: 10));
_appExtensionUsed = true;
_appSessionExpiredFlag = false;
_prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, true);
notifyListeners();
return true;
}
/// Called when the user closes the app voluntarily or after extension denial.
void endAppSession() {
_lastAppSessionEnd = DateTime.now();
_appSessionEnd = null;
_appSessionExpiredFlag = false;
_prefs?.setInt(
_keyLastAppSessEnd,
_lastAppSessionEnd!.millisecondsSinceEpoch,
);
_prefs?.setInt(_keyAppSessionEnd, 0);
notifyListeners();
}
// ── Settings mutations ─────────────────────────────────────
Future<void> setDailyLimitMinutes(int minutes) async {
_dailyLimitSeconds = minutes * 60;
await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
Future<void> setPerSessionMinutes(int minutes) async {
_perSessionSeconds = minutes * 60;
await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds);
notifyListeners();
}
Future<void> setCooldownMinutes(int minutes) async {
_cooldownSeconds = minutes * 60;
await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds);
notifyListeners();
}
Future<void> setScheduleEnabled(bool v) async {
_scheduleEnabled = v;
await _prefs?.setBool(_keyScheduleEnabled, v);
notifyListeners();
}
Future<void> setScheduleTime({
required int startH,
required int startM,
required int endH,
required int endM,
}) async {
_schedEndHour = endH;
_schedEndMin = endM;
// Update the first schedule for compatibility? Or just replace all?
// Let's replace all schedules with this one if this method is called.
_schedules = [
FocusSchedule(
startHour: startH,
startMinute: startM,
endHour: endH,
endMinute: endM,
),
];
await _prefs?.setInt(_keyScheduleStartHour, startH);
await _prefs?.setInt(_keyScheduleStartMin, startM);
await _prefs?.setInt(_keyScheduleEndHour, endH);
await _prefs?.setInt(_keyScheduleEndMin, endM);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> _saveSchedulesToPrefs() async {
final json = jsonEncode(_schedules.map((s) => s.toJson()).toList());
await _prefs?.setString(_keySchedulesJson, json);
}
Future<void> addSchedule(FocusSchedule s) async {
_schedules.add(s);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> removeScheduleAt(int index) async {
if (index >= 0 && index < _schedules.length) {
_schedules.removeAt(index);
await _saveSchedulesToPrefs();
notifyListeners();
}
}
Future<void> updateScheduleAt(int index, FocusSchedule s) async {
if (index >= 0 && index < _schedules.length) {
_schedules[index] = s;
await _saveSchedulesToPrefs();
notifyListeners();
}
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}
+484
View File
@@ -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();
}
}