diff --git a/.fdroid.yml b/.fdroid.yml
deleted file mode 100644
index a345f17..0000000
--- a/.fdroid.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-Categories:
- - Connectivity
- - Social Network
-License: AGPL-3.0-only
-AuthorName: Ujwal Chapagain
-AuthorEmail: notujwal@proton.me
-SourceCode: https://github.com/Ujwal223/FocusGram
-IssueTracker: https://github.com/Ujwal223/FocusGram/issues
-Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
-
-AutoName: FocusGram
-
-RepoType: git
-Repo: https://github.com/Ujwal223/FocusGram
-
-Builds:
- - versionName: 1.0.0
- versionCode: 3
- commit: v1.0.0
- output: build/app/outputs/flutter-apk/app-release.apk
- srclibs:
- - flutter@stable
- prebuild:
- - flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
- - '[[ $flutterVersion ]]'
- - git -C $$flutter$$ checkout -f $flutterVersion
- - export PUB_CACHE=$(pwd)/.pub-cache
- - .flutter/bin/flutter config --no-analytics
- - .flutter/bin/flutter pub get
- scanignore:
- - .flutter/bin/cache
- scandelete:
- - .flutter
- - .pub-cache
- build:
- - export PUB_CACHE=$(pwd)/.pub-cache
- - .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
-
-AutoUpdateMode: Version
-UpdateCheckMode: Tags
-VercodeOperation:
- - '%c * 10 + 1'
-UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
-CurrentVersion: 1.0.0
-CurrentVersionCode: 3
diff --git a/.gitignore b/.gitignore
index 80df92f..8fec584 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,9 @@ migrate_working_dir/
PRD.md
.agents/
TODO.md
+v2/FOCUSGRAM_V2_PLAN.md
+v2/FocusGram_Feed_Filtering_Reference.docx
+.codex
# IntelliJ related
*.iml
@@ -27,7 +30,6 @@ TODO.md
#.vscode/
RELEASE_GUIDE.md
android/key.properties
-android/fdroid-config.properties
android/app/*.jks
upload-keystore.jks
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d29021..9353b22 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
+analyzer:
+ exclude:
+ - v2/**
+
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
diff --git a/android/NOTICE b/android/NOTICE
new file mode 100644
index 0000000..06a9081
--- /dev/null
+++ b/android/NOTICE
@@ -0,0 +1,190 @@
+
+ Copyright (c) 2005-2014, The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 54f5680..701b94c 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/assets/scripts/ad_blocker_dom.js b/assets/scripts/ad_blocker_dom.js
new file mode 100644
index 0000000..2d6cb7b
--- /dev/null
+++ b/assets/scripts/ad_blocker_dom.js
@@ -0,0 +1,61 @@
+/**
+ * FocusGram DOM Ad Blocker (Fallback)
+ *
+ * DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
+ *
+ * This script provides DOM-based ad removal as a FALLBACK for ads that slip through
+ * GraphQL filtering. It's not reliable because Instagram has already rendered the content.
+ *
+ * Injected at DOCUMENT_END
+ * Removes sponsored/posts/tracking elements from the DOM.
+ */
+(function () {
+ 'use strict';
+
+ const AD_SIGNALS = [
+ 'Sponsored',
+ 'paid partnership',
+ 'Promoted',
+ ];
+
+ const textMatchesSignal = (txt) => {
+ if (!txt) return false;
+ const t = txt.trim().toLowerCase();
+ return AD_SIGNALS.some((s) => t === s.toLowerCase());
+ };
+
+ const removeSponsoredArticles = () => {
+ try {
+ // aria-label routes (best-effort; localization may break)
+ document.querySelectorAll('a[aria-label]').forEach((a) => {
+ const aria = a.getAttribute('aria-label') || '';
+ if (textMatchesSignal(aria)) {
+ const article = a.closest('article');
+ if (article) article.remove();
+ }
+ });
+
+ // Text-based removal inside feed articles (best-effort)
+ document.querySelectorAll('article').forEach((article) => {
+ const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
+ let node;
+ while ((node = walker.nextNode())) {
+ const txt = node.nodeValue;
+ if (textMatchesSignal(txt)) {
+ article.remove();
+ break;
+ }
+ }
+ });
+
+ // Suggested content is intentionally left alone. Removing suggested
+ // units after Instagram has virtualized the feed can snap the viewport
+ // back to the top on some accounts.
+ } catch (_) {}
+ };
+
+ const observer = new MutationObserver(() => removeSponsoredArticles());
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ removeSponsoredArticles();
+})();
diff --git a/assets/scripts/autoplay_blocker.js b/assets/scripts/autoplay_blocker.js
new file mode 100644
index 0000000..d171be5
--- /dev/null
+++ b/assets/scripts/autoplay_blocker.js
@@ -0,0 +1,129 @@
+/**
+ * FocusGram Autoplay Blocker
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ * Prevents video autoplay by:
+ * 1. Blocking play() calls on video elements
+ * 2. Disabling autoplay attribute
+ * 3. Removing preload attributes
+ */
+(function () {
+ 'use strict';
+
+ // This script is only registered when the setting is enabled, so default ON.
+ window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
+ ? window.__fgBlockAutoplay : true;
+ const ALLOW_KEY = '__fgUserStartedPlayback';
+ let userGestureUntil = 0;
+
+ function isReelRoute() {
+ const path = window.location.pathname || '';
+ return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
+ }
+
+ function isUserGestureActive() {
+ return Date.now() < userGestureUntil;
+ }
+
+ function markUserGesture(target) {
+ userGestureUntil = Date.now() + 1200;
+ try {
+ let video = target && target.closest ? target.closest('video') : null;
+ if (!video && target && target.querySelector) video = target.querySelector('video');
+ if (video) video[ALLOW_KEY] = true;
+ } catch (_) {}
+ }
+
+ document.addEventListener('pointerdown', function (event) {
+ markUserGesture(event.target);
+ }, true);
+ document.addEventListener('touchstart', function (event) {
+ markUserGesture(event.target);
+ }, true);
+ document.addEventListener('click', function (event) {
+ markUserGesture(event.target);
+ }, true);
+
+ // Override HTMLMediaElement.play() to check our flag
+ const _play = HTMLMediaElement.prototype.play;
+ HTMLMediaElement.prototype.play = function () {
+ if (
+ window.__fgBlockAutoplay &&
+ !isReelRoute() &&
+ this[ALLOW_KEY] !== true &&
+ !isUserGestureActive()
+ ) {
+ // Return a resolved promise to avoid breaking Instagram's code
+ try { this.pause(); } catch (_) {}
+ return Promise.resolve();
+ }
+ return _play.call(this);
+ };
+
+ // Override autoplay property setter
+ const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
+ const _originalAutoplaySetter = _videoDescriptor.set;
+
+ Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
+ set: function (value) {
+ if (window.__fgBlockAutoplay && value) {
+ // Silently ignore autoplay attempts when blocking is enabled
+ return;
+ }
+ if (_originalAutoplaySetter) {
+ _originalAutoplaySetter.call(this, value);
+ }
+ },
+ get: function () {
+ if (_videoDescriptor.get) {
+ return _videoDescriptor.get.call(this);
+ }
+ return this.getAttribute('autoplay') !== null;
+ },
+ enumerable: _videoDescriptor.enumerable,
+ configurable: true,
+ });
+
+ // On page load and SPA navigation, scan for video elements and remove autoplay
+ const removeAutoplayFromVideos = () => {
+ document.querySelectorAll('video, [role="video"]').forEach(el => {
+ if (window.__fgBlockAutoplay && !isReelRoute() && el[ALLOW_KEY] !== true) {
+ el.autoplay = false;
+ el.removeAttribute('autoplay');
+ el.removeAttribute('preload');
+ try { el.preload = 'none'; } catch (_) {}
+ if (el.paused === false) {
+ el.pause();
+ }
+ }
+ });
+ };
+
+ // Run on load and when document changes
+ removeAutoplayFromVideos();
+
+ if (!window.__fgAutoplayObserver) {
+ let _timer = null;
+ window.__fgAutoplayObserver = new MutationObserver(() => {
+ clearTimeout(_timer);
+ _timer = setTimeout(removeAutoplayFromVideos, 500);
+ });
+ window.__fgAutoplayObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // Allow Flutter to toggle
+ window.__fgSetBlockAutoplay = function (enabled) {
+ window.__fgBlockAutoplay = !!enabled;
+ if (enabled) {
+ removeAutoplayFromVideos();
+ }
+ };
+
+ document.addEventListener('play', function (event) {
+ if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
+ event.target[ALLOW_KEY] = true;
+ }
+ }, true);
+})();
diff --git a/assets/scripts/content_hider.js b/assets/scripts/content_hider.js
new file mode 100644
index 0000000..0e8f718
--- /dev/null
+++ b/assets/scripts/content_hider.js
@@ -0,0 +1,304 @@
+/**
+ * FocusGram Content Hider
+ * Toggleable visibility for: stories tray, feed posts, reels, suggested content.
+ * Flutter controls via window.__fgContent.*
+ * Injected at DOCUMENT_END.
+ *
+ * Key fixes applied:
+ * - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
+ * - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
+ * - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
+ * - Stories tray detection strengthened for fresh SPA navigations
+ * - Suggested posts detection uses multiple text-node matching strategies
+ */
+(function () {
+ 'use strict';
+
+ if (window.__fgContent && window.__fgContent.__focusgramReady) {
+ return;
+ }
+
+ const STYLE_ID = 'fg-content-hider';
+ let hideStories = false;
+ let hidePosts = false;
+ let hideSuggested = false;
+ let hideReels = false;
+
+ // ─── CSS rules ─────────────────────────────────────────────────────────────
+
+ function buildCSS() {
+ const selectors = [];
+
+ if (hideStories) {
+ selectors.push(
+ '[role="list"]:has([aria-label*="tory"])',
+ '[role="listbox"]:has([aria-label*="tory"])',
+ '[role="menu"] > ul',
+ 'section > div > div:first-child [style*="overflow"]',
+ '[role="list"] [style*="overflow"]',
+ );
+ }
+
+ if (hidePosts) {
+ selectors.push(
+ 'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
+ 'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
+ );
+ }
+
+ // hideReels CSS is intentionally NOT added here.
+ // We use DOM removal instead (see removeReels()) so that room is never left
+ // blank in the feed, and Instagram's infinite-scroll can prove scroll height.
+
+ return selectors.length
+ ? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
+ : '';
+ }
+
+ function applyCSS() {
+ if (document.body) {
+ document.body.setAttribute('data-fg-path', window.location.pathname || '/');
+ }
+ let style = document.getElementById(STYLE_ID);
+ if (!style) {
+ style = document.createElement('style');
+ style.id = STYLE_ID;
+ document.head.appendChild(style);
+ }
+ style.textContent = buildCSS();
+ }
+
+ // ─── Story tray JS ─────────────────────────────────────────────────────────
+
+ function hideStoryTray() {
+ if (!hideStories) return;
+
+ // Strategy 1:
children of a named list or menu
+ document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
+ try {
+ const items = ul.querySelectorAll('li, button, a');
+ if (items.length < 2) return;
+ ul.style.setProperty('display', 'none', 'important');
+ } catch (_) {}
+ });
+
+ // Strategy 2: horizontally scrolling container with circle items
+ document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
+ try {
+ if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
+ const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
+ if (cands.length < 2) return;
+ const s0 = window.getComputedStyle(cands[0]);
+ if (s0.width && parseFloat(s0.width) <= 90) {
+ c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
+ }
+ } catch (_) {}
+ });
+ }
+
+ // ─── Suggested posts ───────────────────────────────────────────────────────
+
+ function removeSuggested() {
+ if (!hideSuggested) return;
+
+ var SIGNALS = [
+ 'suggested for you',
+ 'suggested posts',
+ 'suggested reels',
+ 'suggested',
+ 'because you watched',
+ 'because you follow',
+ 'you might like',
+ 'posts you might like',
+ 'accounts you might like',
+ 'recommendations',
+ ];
+
+ function norm(s) {
+ return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+ }
+
+ function hasSignal(s) {
+ var t = norm(s);
+ if (!t) return false;
+ return SIGNALS.some(function (signal) {
+ if (signal === 'suggested') return t === signal;
+ return t.indexOf(signal) >= 0;
+ });
+ }
+
+ function hideContainer(from) {
+ var parent = from;
+ for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
+ var role = parent.getAttribute && parent.getAttribute('role');
+ var tag = parent.tagName;
+ var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
+ if (
+ tag === 'ARTICLE' ||
+ tag === 'SECTION' ||
+ role === 'listitem' ||
+ (hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
+ ) {
+ parent.style.setProperty('display', 'none', 'important');
+ parent.setAttribute('data-fg-hidden-suggested', '1');
+ return true;
+ }
+ parent = parent.parentElement;
+ }
+ return false;
+ }
+
+ document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
+ try {
+ if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
+ var ownLabel = node.getAttribute('aria-label');
+ if (hasSignal(ownLabel)) { hideContainer(node); return; }
+ var text = norm(node.innerText || node.textContent || '');
+ if (
+ text.indexOf('suggested for you') >= 0 ||
+ text.indexOf('suggested posts') >= 0 ||
+ text.indexOf('suggested reels') >= 0 ||
+ text.indexOf('because you watched') >= 0 ||
+ text.indexOf('because you follow') >= 0
+ ) {
+ hideContainer(node);
+ }
+ } catch (_) {}
+ });
+
+ document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
+ try {
+ if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
+ hideContainer(el);
+ }
+ } catch (_) {}
+ });
+ }
+
+ // ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
+ // display:none keeps the element in the DOM, so Instagram's virtual-scroll still
+ // reserves the slot → blank gaps. Removing the article from the DOM collapses the
+ // gap cleanly and lets the feed flow naturally.
+ function removeReels() {
+ if (!hideReels) return;
+
+ var toRemove = [];
+ document.querySelectorAll('article').forEach(function (el) {
+ try {
+ // Fast path: check for a reel-signal attribute first
+ var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
+ if (mt === '2') { toRemove.push(el); return; }
+
+ // Fallback: text-node scan for /reels/ markers
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ var n;
+ while ((n = walker.nextNode())) {
+ if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
+ toRemove.push(el); break;
+ }
+ }
+ } catch (_) {}
+ });
+
+ toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
+ }
+
+ // ─── Public API ────────────────────────────────────────────────────────────
+
+ window.__fgContent = {
+ __focusgramReady: true,
+ setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
+ setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
+ setHideSuggested: function (val) {
+ hideSuggested = !!val;
+ applyCSS();
+ if (val) removeSuggested();
+ },
+ setHideReels: function (val) {
+ hideReels = !!val;
+ applyCSS();
+ if (val) removeReels();
+ },
+ applyAll: function (flags) {
+ hideStories = !!flags.stories;
+ hidePosts = !!flags.posts;
+ hideReels = !!flags.reels;
+ hideSuggested = !!flags.suggested;
+ applyCSS();
+ if (hideSuggested) removeSuggested();
+ if (hideStories) hideStoryTray();
+ if (hideReels) removeReels();
+ },
+ };
+
+ // ─── SPA heartbeat ─────────────────────────────────────────────────────────
+ // pushState/replaceState don't fire any DOM event we can listen for.
+ // Hook the methods themselves so we know a navigation happened, then debounce
+ // re-apply. This also catches the case where the MutationObserver was on `body`
+ // and that node got replaced by Instagram's SPA re-render.
+
+ function scheduleReapply() {
+ clearTimeout(window.__fg_applyTimer);
+ window.__fg_applyTimer = setTimeout(function () {
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+ }, 250);
+ }
+
+ var _origPush = history.pushState;
+ var _origReplace = history.replaceState;
+
+ history.pushState = function () {
+ _origPush.apply(this, arguments);
+ scheduleReapply();
+ };
+
+ history.replaceState = function () {
+ _origReplace.apply(this, arguments);
+ scheduleReapply();
+ };
+
+ // Reinforce on popstate too (user hits back/forward)
+ window.addEventListener('popstate', scheduleReapply, { passive: true });
+ // For pushState on the same URL (rare but possible) – poll path briefly
+ window.addEventListener('pageshow', scheduleReapply, { passive: true });
+ window.addEventListener('focus', scheduleReapply, { passive: true });
+
+ // ─── MutationObserver ───────────────────────────────────────────────────────
+ // Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
+ // re-applies everything on each cycle. Does NOT guard on a per-element timer
+ // that would never re-fire after the body is replaced by SPA re-render.
+
+ if (!window.__fgContentObserver) {
+ window.__fgContentObserver = new MutationObserver(function () {
+ clearTimeout(window.__fg_moTimer);
+ window.__fg_moTimer = setTimeout(function () {
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+ }, 300);
+ });
+
+ // `document.documentElement` survives SPA navigations (body gets replaced
+ // but stays). Observing it catches both subtree mutations and, via
+ // the SPA heartbeat above, re-applies after pushState.
+ window.__fgContentObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // ─── Initial run ────────────────────────────────────────────────────────────
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+
+ // Signal ready — Flutter will call applyAll() with stored prefs
+ if (window.ContentChannel) {
+ window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
+ }
+})();
diff --git a/assets/scripts/fetch_interceptor.js b/assets/scripts/fetch_interceptor.js
new file mode 100644
index 0000000..8853356
--- /dev/null
+++ b/assets/scripts/fetch_interceptor.js
@@ -0,0 +1,315 @@
+/**
+ * FocusGram Unified Feed Filter via Fetch Interception
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ *
+ * This script intercepts GraphQL fetch calls and filters feed content based on:
+ * - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
+ * - Sponsored posts (ad_action_link, ad_header_style)
+ * - Suggested posts (is_suggested, is_suggested_for_you, __typename)
+ * - Videos/Reels (is_video, media_type, clips_metadata)
+ * - Autoplay blocking (video autoplay prevention)
+ */
+(function () {
+ 'use strict';
+
+ // Configuration flags (set by Flutter via prefs)
+ window.__fgFilterConfig = {
+ blockAds: false,
+ blockSponsored: false,
+ blockSuggested: false,
+ blockVideos: false,
+ blockAutoplay: false,
+ blockGraphQLQueryWhenFeedPosts: false,
+ };
+
+ const textHasAdSignal = (value) => {
+ const s = String(value || '').toLowerCase();
+ return (
+ s === 'sponsored' ||
+ s.includes('"sponsored"') ||
+ s.includes('paid partnership') ||
+ s.includes('promoted') ||
+ s.includes('ad_id') ||
+ s.includes('ad_tracking') ||
+ s.includes('sponsor_tags')
+ );
+ };
+
+ // Helper: Check if a node is an ad
+ const isAdNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+ const typename = String(node.__typename || '');
+ const adText = JSON.stringify({
+ organic_tracking_token: node.organic_tracking_token,
+ sponsor_tags: node.sponsor_tags,
+ social_context: node.social_context,
+ title: node.title,
+ header: node.header,
+ label: node.label,
+ overlay_text: node.overlay_text,
+ });
+
+ return !!(
+ node.is_ad ||
+ node.is_paid_partnership ||
+ node.sponsor_tags ||
+ node.ad_tracking_token ||
+ node.ad_action_link ||
+ node.ad_id ||
+ node.ad_impression_token ||
+ node.ad_metadata ||
+ node.commerciality_status === 'commercial' ||
+ (node.product_type && node.product_type === 'ad') ||
+ (node.ad_header_style && node.ad_header_style !== 'none') ||
+ typename === 'GraphAdStory' ||
+ typename.includes('Ad') ||
+ textHasAdSignal(adText)
+ );
+ };
+
+ // Helper: Check if a node is sponsored
+ const isSponsoredNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ return !!(
+ node.is_paid_partnership ||
+ node.sponsor_tags ||
+ (node.ad_action_link && node.ad_action_link.href) ||
+ (node.ad_header_style && node.ad_header_style !== 'none') ||
+ textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
+ );
+ };
+
+ // Helper: Check if a node is suggested content
+ const isSuggestedNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+ const typename = String(node.__typename || '');
+ const reason = JSON.stringify({
+ reason: node.suggested_reason,
+ social_context: node.social_context,
+ title: node.title,
+ header: node.header,
+ label: node.label,
+ }).toLowerCase();
+
+ return !!(
+ node.is_suggested ||
+ node.is_suggested_for_you ||
+ node.is_recommendation ||
+ node.suggested_users ||
+ node.suggested_media ||
+ node.suggested_content ||
+ node.recommendation_source ||
+ typename.includes('Suggested') ||
+ typename.includes('Recommendation') ||
+ reason.includes('suggested') ||
+ reason.includes('recommend')
+ );
+ };
+
+ // Helper: Check if a node is a video/reel
+ const isVideoNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ return !!(
+ node.is_video ||
+ (node.media_type === 2) ||
+ node.clips_metadata ||
+ (node.__typename && (
+ node.__typename.includes('Clips') ||
+ node.__typename.includes('Video')
+ ))
+ );
+ };
+
+ const isFeedMediaNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+ return !!(
+ node.pk ||
+ node.id ||
+ node.code ||
+ node.media_type ||
+ node.image_versions2 ||
+ node.video_versions ||
+ node.carousel_media ||
+ node.__typename?.includes('Media') ||
+ node.__typename?.includes('Timeline')
+ );
+ };
+
+ // Helper: Check for media in carousel
+ const hasVideoInCarousel = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ if (node.media_type === 8) {
+ const edges = node.edge_sidecar_to_children?.edges || [];
+ return edges.some(edge => isVideoNode(edge.node));
+ }
+ return false;
+ };
+
+ // Main filter function for feed nodes
+ const shouldFilterNode = (node) => {
+ const config = window.__fgFilterConfig;
+
+ if (!node || typeof node !== 'object') return false;
+
+ if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
+ return true;
+ }
+
+ // Check ads
+ if (config.blockAds && isAdNode(node)) {
+ return true;
+ }
+
+ // Check sponsored (separate from ads)
+ if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
+ return true;
+ }
+
+ // Check suggested content
+ if (config.blockSuggested && isSuggestedNode(node)) {
+ return true;
+ }
+
+ // Check videos/reels
+ if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
+ return true;
+ }
+
+ return false;
+ };
+
+ // Recursively filter GraphQL response edges
+ const filterEdges = (edges, path = []) => {
+ if (!Array.isArray(edges)) return edges;
+
+ return edges.filter(edge => {
+ if (!edge || !edge.node) return true;
+ const node = edge.node;
+
+ // Keep the edge if it doesn't match any filter
+ if (!shouldFilterNode(node)) return true;
+
+ // Log filtered content for debugging
+ if (window.__fgDebugFilter) {
+ const type = node.__typename || 'Unknown';
+ console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
+ }
+
+ return false;
+ });
+ };
+
+ // Recursively walk GraphQL response and filter edges
+ const walkAndFilter = (obj, visited = new Set()) => {
+ if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
+ visited.add(obj);
+
+ // Handle arrays
+ if (Array.isArray(obj)) {
+ obj.forEach(item => walkAndFilter(item, visited));
+ return;
+ }
+
+ // Check for edges array (common GraphQL pattern)
+ if (obj.edges && Array.isArray(obj.edges)) {
+ obj.edges = filterEdges(obj.edges);
+ }
+
+ // Recurse into children
+ for (const key in obj) {
+ if (obj.hasOwnProperty(key) && key !== '__typename') {
+ const val = obj[key];
+ if (val && typeof val === 'object') {
+ walkAndFilter(val, visited);
+ }
+ }
+ }
+ };
+
+ // Override fetch
+ const _fetch = window.fetch.bind(window);
+
+ window.fetch = async function (input, init) {
+ const url = typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input?.url ?? '';
+
+ // Call original fetch
+ let response = await _fetch(input, init);
+
+ // Only intercept GraphQL feed queries
+ if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) {
+ return response;
+ }
+
+ // Clone response to read body
+ const cloned = response.clone();
+
+ try {
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.includes('application/json')) {
+ return response;
+ }
+
+ const data = await cloned.json();
+
+ // Filter the response data
+ walkAndFilter(data);
+
+ // Return modified response
+ return new Response(JSON.stringify(data), {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
+ } catch (e) {
+ // On error, return original response
+ return response;
+ }
+ };
+
+ // Preserve native function appearance
+ Object.defineProperty(window, 'fetch', {
+ value: window.fetch,
+ writable: true,
+ configurable: true,
+ });
+ window.fetch.toString = () => 'function fetch() { [native code] }';
+
+ const _xhrOpen = XMLHttpRequest.prototype.open;
+ const _xhrSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method, url) {
+ this.__fgUrl = typeof url === 'string' ? url : String(url || '');
+ return _xhrOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function () {
+ if (
+ window.__fgFilterConfig.blockVideos &&
+ this.__fgUrl &&
+ (this.__fgUrl.includes('/api/v1/clips/') ||
+ this.__fgUrl.includes('/api/v1/discover/'))
+ ) {
+ try { this.abort(); } catch (_) {}
+ return;
+ }
+ return _xhrSend.apply(this, arguments);
+ };
+
+ // Allow Flutter to update config flags
+ window.__fgSetFilterConfig = function (config) {
+ if (typeof config === 'object') {
+ Object.assign(window.__fgFilterConfig, config);
+ if (window.__fgDebugFilter) {
+ console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
+ }
+ }
+ };
+
+ // Enable debug logging
+ window.__fgDebugFilter = false;
+})();
diff --git a/assets/scripts/ghost_mode.js b/assets/scripts/ghost_mode.js
new file mode 100644
index 0000000..2b78da6
--- /dev/null
+++ b/assets/scripts/ghost_mode.js
@@ -0,0 +1,179 @@
+/**
+ * FocusGram Ghost Mode
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ * Blocks story-seen, message-seen, and online-presence signals.
+ */
+(function () {
+ 'use strict';
+
+ // ─── Seen API patterns ────────────────────────────────────────────────────
+ const SEEN_PATTERNS = [
+ /\/api\/v1\/media\/[\w-]+\/seen\//,
+ /\/api\/v1\/stories\/reel\/seen\//,
+ /\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
+ /\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
+ /\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
+ ];
+
+ // ─── Activity patterns (like, comment) — intercepted for local history ────
+ const ACTIVITY_PATTERNS = [
+ /\/api\/v1\/web\/likes\/[\w-]+\/like\//,
+ /\/api\/v1\/web\/comments\/add\//,
+ /\/api\/v1\/friendships\/[\w-]+\/follow\//,
+ ];
+
+ const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
+ const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
+
+ const fakeOkResponse = () =>
+ new Response(JSON.stringify({ status: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ // ─── Fetch override ───────────────────────────────────────────────────────
+ const _fetch = window.fetch.bind(window);
+
+ const patchedFetch = async function (input, init) {
+ const url =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input?.url ?? '';
+
+ // Block seen
+ if (isSeen(url)) {
+ if (window.GhostChannel) {
+ window.GhostChannel.postMessage(
+ JSON.stringify({ type: 'seen_blocked', url })
+ );
+ }
+ return fakeOkResponse();
+ }
+
+ // Intercept activity for local history
+ if (isActivity(url) && window.ActivityChannel) {
+ const body = init?.body;
+ const bodyText =
+ body instanceof URLSearchParams
+ ? body.toString()
+ : typeof body === 'string'
+ ? body
+ : '';
+ window.ActivityChannel.postMessage(
+ JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
+ );
+ }
+
+ return _fetch(input, init);
+ };
+
+ // Disguise as native
+ Object.defineProperty(window, 'fetch', {
+ value: patchedFetch,
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+ window.fetch.toString = () => 'function fetch() { [native code] }';
+ window.fetch[Symbol.toStringTag] = 'fetch';
+
+ // ─── XMLHttpRequest override ──────────────────────────────────────────────
+ const _XHROpen = XMLHttpRequest.prototype.open;
+ const _XHRSend = XMLHttpRequest.prototype.send;
+
+ XMLHttpRequest.prototype.open = function (method, url, ...args) {
+ this._fg_url = url ?? '';
+ this._fg_method = (method ?? '').toUpperCase();
+ return _XHROpen.call(this, method, url, ...args);
+ };
+
+ XMLHttpRequest.prototype.send = function (body) {
+ if (this._fg_url && isSeen(this._fg_url)) {
+ // Fire readyState 4 with fake success without actually sending
+ const self = this;
+ setTimeout(() => {
+ Object.defineProperty(self, 'readyState', { get: () => 4 });
+ Object.defineProperty(self, 'status', { get: () => 200 });
+ Object.defineProperty(self, 'responseText', {
+ get: () => '{"status":"ok"}',
+ });
+ Object.defineProperty(self, 'response', {
+ get: () => '{"status":"ok"}',
+ });
+ self.dispatchEvent(new Event('readystatechange'));
+ self.dispatchEvent(new Event('load'));
+ }, 10);
+ return;
+ }
+ return _XHRSend.call(this, body);
+ };
+
+ // ─── WebSocket intercept (message-seen via WS) ────────────────────────────
+ const _WS = window.WebSocket;
+
+ function PatchedWebSocket(url, protocols) {
+ const ws = protocols ? new _WS(url, protocols) : new _WS(url);
+ const _send = ws.send.bind(ws);
+
+ ws.send = function (data) {
+ if (typeof data === 'string') {
+ // IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
+ try {
+ const parsed = JSON.parse(data);
+ if (
+ parsed?.op === '4' ||
+ parsed?.op === 'seen' ||
+ (parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
+ ) {
+ return; // drop
+ }
+ } catch (_) {}
+ // Text-based seen signal check
+ if (data.includes('"seen"') && data.includes('"thread_id"')) {
+ return;
+ }
+ }
+ return _send(data);
+ };
+
+ return ws;
+ }
+
+ // Preserve WebSocket prototype chain so IG's ws checks pass
+ PatchedWebSocket.prototype = _WS.prototype;
+ PatchedWebSocket.CONNECTING = _WS.CONNECTING;
+ PatchedWebSocket.OPEN = _WS.OPEN;
+ PatchedWebSocket.CLOSING = _WS.CLOSING;
+ PatchedWebSocket.CLOSED = _WS.CLOSED;
+ window.WebSocket = PatchedWebSocket;
+
+ // ─── Visibility trick — hide "Active Now" ────────────────────────────────
+ // Only applied if user enables online-status hiding
+ // Wrapped in a named fn so Flutter can call it:
+ // controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
+ window.__fgEnableOnlineHide = function () {
+ Object.defineProperty(document, 'visibilityState', {
+ get: () => 'hidden',
+ configurable: true,
+ });
+ Object.defineProperty(document, 'hidden', {
+ get: () => true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ };
+
+ window.__fgDisableOnlineHide = function () {
+ // Restore by deleting the overrides (falls back to native getter)
+ delete document.visibilityState;
+ delete document.hidden;
+ document.dispatchEvent(new Event('visibilitychange'));
+ };
+
+ // Signal to Flutter that ghost mode JS is active
+ if (window.GhostChannel) {
+ window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
+ }
+})();
diff --git a/assets/scripts/theme_detector.js b/assets/scripts/theme_detector.js
new file mode 100644
index 0000000..69d23eb
--- /dev/null
+++ b/assets/scripts/theme_detector.js
@@ -0,0 +1,47 @@
+/**
+ * FocusGram Theme Detector
+ * Reads light/dark theme from page and bridges to Flutter.
+ * Injected at DOCUMENT_END.
+ */
+(function () {
+ 'use strict';
+
+ (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();
+ })();
+})();
diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt
deleted file mode 100644
index 45e93cb..0000000
--- a/fastlane/metadata/android/en-US/changelogs/1.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-Initial open-source release of FocusGram.
-- Complete Reels and Explore hiding.
-- Timed Reel sessions and daily limits.
-- Isolated DM Reel player.
-- Privacy-first: No Firebase or trackers.
diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt
deleted file mode 100644
index a0b9d0b..0000000
--- a/fastlane/metadata/android/en-US/changelogs/2.txt
+++ /dev/null
@@ -1 +0,0 @@
-Same as1st version. just version pump
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt
deleted file mode 100644
index 4f1c995..0000000
--- a/fastlane/metadata/android/en-US/changelogs/3.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-New: Reels History, Minimal Mode, Disable Reels/Explore toggles, Autoplay Blocker, Screen Time Dashboard, Grayscale Mode, 8 content hide toggles.
-
-Fixes: DM keyboard bug, Reels scroll lag.
-
-Performance: Native nav bar, background preloading, skeleton screen, haptic feedback, smooth scrolling.
-
-F-Droid: Removed all Google dependencies. No Play Services in APK.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/4.txt b/fastlane/metadata/android/en-US/changelogs/4.txt
deleted file mode 100644
index 62f4ec9..0000000
--- a/fastlane/metadata/android/en-US/changelogs/4.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-What's new
-- Reordered Settings Page.
-- Added "Click to Unblur" for posts.
-- Added Persistent Notification
-- Improved Grayscale Scheduling.
-and more.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
deleted file mode 100644
index ecb34fa..0000000
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-FocusGram is a free and open-source Android app designed to help you regain control over your social media usage. It wraps the mobile version of Instagram in a secure WebView and injects features to minimize distractions.
-
-Features:
-- **Focus Mode**: Blur explore posts and hide reel buttons.
-- **Guardrails**: Set daily usage limits and session cooldowns.
-- **Mindfulness**: A mandatory breathing exercise before entering the app.
-- **Privacy First**: No analytics, no tracking, and no proprietary Google Play Services requirements.
-- **Hybrid Composition**: Optimized WebView performance for smooth scrolling.
-
-FocusGram is NOT an Instagram mod. It uses the standard web version of Instagram and applies client-side CSS and JS modifications only.
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
deleted file mode 100644
index 285db2e..0000000
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ /dev/null
@@ -1 +0,0 @@
-A digital wellness wrapper for Instagram.
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
deleted file mode 100644
index f2affd7..0000000
--- a/fastlane/metadata/android/en-US/title.txt
+++ /dev/null
@@ -1 +0,0 @@
-FocusGram
diff --git a/lib/focus_settings.dart b/lib/focus_settings.dart
new file mode 100644
index 0000000..6e99e28
--- /dev/null
+++ b/lib/focus_settings.dart
@@ -0,0 +1,17 @@
+class FocusSettings {
+ final bool ghostMode; // hide read receipts
+ final bool noAds; // strip ads and sponsored posts
+ final bool noStories; // hide story tray
+ final bool noReels; // hide reels tab
+ final bool noAutoplay; // stop videos autoplaying
+ final bool noDMs; // block direct messages
+
+ const FocusSettings({
+ this.ghostMode = false,
+ this.noAds = false,
+ this.noStories = false,
+ this.noReels = false,
+ this.noAutoplay = false,
+ this.noDMs = false,
+ });
+}
\ No newline at end of file
diff --git a/lib/main.dart b/lib/main.dart
index 172555d..38fe0c5 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -145,6 +145,7 @@ class _InitialRouteHandlerState extends State {
// Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
+ durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true),
);
}
diff --git a/lib/screens/app_session_picker.dart b/lib/screens/app_session_picker.dart
index 2490140..34493a6 100644
--- a/lib/screens/app_session_picker.dart
+++ b/lib/screens/app_session_picker.dart
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State {
55,
60,
];
- int _selectedIndex = 2; // default: 15 min
+ int _selectedIndex = 0; // default: 5 min unless a previous choice exists
+ late final FixedExtentScrollController _scrollController;
+
+ @override
+ void initState() {
+ super.initState();
+ final lastMinutes = context.read().lastAppSessionMinutes;
+ final lastIndex = _minuteOptions.indexOf(lastMinutes);
+ _selectedIndex = lastIndex >= 0 ? lastIndex : 0;
+ _scrollController = FixedExtentScrollController(
+ initialItem: _selectedIndex,
+ );
+ }
+
+ @override
+ void dispose() {
+ _scrollController.dispose();
+ super.dispose();
+ }
@override
Widget build(BuildContext context) {
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State {
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
+ controller: _scrollController,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
- controller: FixedExtentScrollController(
- initialItem: _selectedIndex,
- ),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
diff --git a/lib/screens/breath_gate_screen.dart b/lib/screens/breath_gate_screen.dart
index 6acf5eb..83b1bb3 100644
--- a/lib/screens/breath_gate_screen.dart
+++ b/lib/screens/breath_gate_screen.dart
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'dart:async';
-/// A mindfulness screen shown before the app opens.
-/// Forces the user to take a deep 10-second breath.
+/// A mindfulness screen shown before Instagram opens.
class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish;
+ final int durationSeconds;
- const BreathGateScreen({super.key, required this.onFinish});
+ const BreathGateScreen({
+ super.key,
+ required this.onFinish,
+ this.durationSeconds = 10,
+ });
@override
State createState() => _BreathGateScreenState();
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation _scaleAnimation;
- int _secondsRemaining = 10;
+ late int _secondsRemaining;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
+ _secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
- // 10-second breathing animation: 5s in, 5s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
- 'Are you sure you want to open FocusGram?',
+ 'Are you sure you want to open Instagram?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State
borderRadius: BorderRadius.circular(25),
),
),
- child: const Text('Continue to FocusGram'),
+ child: const Text('Continue to Instagram'),
),
),
],
diff --git a/lib/screens/extras_settings_page.dart b/lib/screens/extras_settings_page.dart
new file mode 100644
index 0000000..7bb9998
--- /dev/null
+++ b/lib/screens/extras_settings_page.dart
@@ -0,0 +1,122 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
+import '../services/settings_service.dart';
+
+class ExtrasSettingsPage extends StatelessWidget {
+ const ExtrasSettingsPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final settings = context.watch();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text(
+ 'Extras',
+ style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
+ ),
+ centerTitle: true,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ),
+ body: ListView(
+ children: [
+ const _SectionHeader(title: 'MEDIA'),
+ _SwitchTile(
+ title: 'Download Media (Feed + Reels)',
+ subtitle: 'Adds a download icon on posts and reels',
+ value: settings.videoDownloadEnabled,
+ onChanged: (v) async {
+ await settings.setVideoDownloadEnabled(v);
+ HapticFeedback.selectionClick();
+ },
+ ),
+
+ const _SectionHeader(title: 'FOCUS'),
+ _SwitchTile(
+ title: 'GHOST MODE',
+ subtitle: 'Hide seen indicator / read receipts',
+ value: settings.ghostMode,
+ onChanged: (v) async {
+ await settings.setGhostMode(v);
+ HapticFeedback.selectionClick();
+ },
+ ),
+
+ const _SectionHeader(title: 'FOCUSGRAM V2'),
+ _SwitchTile(
+ title: 'Ad Blocker',
+ subtitle: 'Removes ads and sponsored posts',
+ value: settings.v2AdBlockerDomEnabled,
+ onChanged: (v) async {
+ await settings.setV2AdBlockerDomEnabled(v);
+ HapticFeedback.selectionClick();
+ },
+ ),
+ _SwitchTile(
+ title: 'Block Suggested Posts',
+ subtitle: 'Removes Suggested for you and recommendation units',
+ value: settings.contentSuggested,
+ onChanged: (v) async {
+ await settings.setContentSuggestedEnabled(v);
+ HapticFeedback.selectionClick();
+ },
+ ),
+
+ const SizedBox(height: 40),
+ ],
+ ),
+ );
+ }
+}
+
+class _SwitchTile extends StatelessWidget {
+ final String title;
+ final String? subtitle;
+ final bool value;
+ final ValueChanged onChanged;
+
+ const _SwitchTile({
+ required this.title,
+ this.subtitle,
+ required this.value,
+ required this.onChanged,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return SwitchListTile(
+ title: Text(title, style: const TextStyle(fontSize: 15)),
+ subtitle: subtitle != null
+ ? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
+ : null,
+ value: value,
+ onChanged: onChanged,
+ );
+ }
+}
+
+class _SectionHeader extends StatelessWidget {
+ final String title;
+ const _SectionHeader({required this.title});
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
+ child: Text(
+ title,
+ style: const TextStyle(
+ color: Colors.grey,
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.2,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/guardrails_page.dart b/lib/screens/guardrails_page.dart
index e3c86af..947a883 100644
--- a/lib/screens/guardrails_page.dart
+++ b/lib/screens/guardrails_page.dart
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State {
Future Function() action,
) async {
if (sm.isScheduledBlockActive) {
- final ok = await DisciplineChallenge.show(context, count: 35);
+ final settings = context.read();
+ final ok = await DisciplineChallenge.show(
+ context,
+ count: settings.resolvedWordChallengeCount(),
+ );
if (!context.mounted || !ok) return;
}
await action();
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton(
onPressed: () async {
final sm = context.read();
- int wordCount = 15;
+ final settings = context.read();
+ int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) {
diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart
index d93d23c..e7d3f8d 100644
--- a/lib/screens/main_webview_page.dart
+++ b/lib/screens/main_webview_page.dart
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:collection';
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -10,11 +12,7 @@ import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/injection_manager.dart';
-import '../scripts/autoplay_blocker.dart';
import '../scripts/native_feel.dart';
-import '../scripts/haptic_bridge.dart';
-import '../scripts/spa_navigation_monitor.dart';
-import '../scripts/content_disabling.dart';
import '../services/screen_time_service.dart';
import '../services/navigation_guard.dart';
import '../services/focusgram_router.dart';
@@ -25,6 +23,48 @@ import '../utils/discipline_challenge.dart';
import 'settings_page.dart';
import '../features/loading/skeleton_screen.dart';
import '../features/preloader/instagram_preloader.dart';
+import '../v2_integration/script_engine_v2_overlay.dart';
+import '../v2_integration/script_registry_v2_overlay.dart';
+import '../scripts/focus_scripts.dart';
+import '../focus_settings.dart';
+import 'package:http/http.dart' as http;
+
+import '../services/adblock/adblock_content_blocker_loader.dart';
+
+/// Core validator/dispatcher for the JS → Flutter bridge:
+/// `window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON)`
+Future handleFocusGramMediaDownload({
+ required String raw,
+ required Future Function(Uri uri) launch,
+}) async {
+ try {
+ final payload = jsonDecode(raw) as Map;
+
+ final url = payload['url'] as String?;
+ if (url == null || url.isEmpty) return false;
+
+ final uri = Uri.tryParse(url);
+ if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
+ return false;
+ }
+
+ // Best-effort origin allow-list (Instagram/CDN). Kept permissive to avoid
+ // breaking legitimate downloads while still blocking obvious abuse.
+ final host = uri.host.toLowerCase();
+ final looksInstagramCdn =
+ host.contains('cdninstagram.com') ||
+ host.contains('fbcdn.net') ||
+ host.contains('instagram.com');
+
+ if (!looksInstagramCdn) return false;
+
+ await launch(uri);
+ return true;
+ } catch (_) {
+ // Best-effort only; never crash UI.
+ return false;
+ }
+}
class MainWebViewPage extends StatefulWidget {
const MainWebViewPage({super.key});
@@ -35,9 +75,15 @@ class MainWebViewPage extends StatefulWidget {
class _MainWebViewPageState extends State
with WidgetsBindingObserver {
+ static const String _donationPopupShownKey = 'donation_popup_shown_once';
+ static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
+
InAppWebViewController? _controller;
+
+ AdblockContentBlockerData? _adblockData;
late final PullToRefreshController _pullToRefreshController;
InjectionManager? _injectionManager;
+ ScriptEngineV2Overlay? _v2Engine;
final GlobalKey<_EdgePanelState> _edgePanelKey = GlobalKey<_EdgePanelState>();
bool _showSkeleton =
true; // true from the start so skeleton covers black Scaffold before WebView first paints
@@ -49,10 +95,12 @@ class _MainWebViewPageState extends State
bool _lastSessionActive = false;
String _currentUrl = 'https://www.instagram.com/';
bool _hasError = false;
-bool _reelsBlockedOverlay = false;
+ bool _reelsBlockedOverlay = false;
bool _exploreBlockedOverlay = false;
bool _isPreloaded = false;
bool _minimalModeBannerDismissed = false;
+ bool _isInDirectThread = false;
+ bool _dmThreadCdnBlockArmed = false;
DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0);
SkeletonType _skeletonType = SkeletonType.generic;
@@ -79,16 +127,34 @@ bool _reelsBlockedOverlay = false;
// Check for updates on launch
context.read().checkForUpdates();
+ unawaited(_loadAdblockerData());
+
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read().addListener(_onSessionChanged);
context.read().addListener(_onSettingsChanged);
+ context.read().addListener(_onScreenTimeChanged);
+ context.read().startTracking();
_lastSessionActive = context.read().isSessionActive;
// Initialise structural snapshots so first change is detected correctly
final settings = context.read();
_lastMinimalMode = settings.minimalModeEnabled;
_lastDisableReels = settings.disableReelsEntirely;
_lastDisableExplore = settings.disableExploreEntirely;
+ _lastBlockHomeFeedScroll = settings.blockHomeFeedScroll;
_lastBlockAutoplay = settings.blockAutoplay;
+ _lastGhostMode = settings.ghostMode;
+ _lastNoAds = settings.noAds;
+ _lastNoStories = settings.noStories;
+ _lastNoReels = settings.noReels;
+ _lastNoAutoplay = settings.noAutoplay;
+ _lastNoDMs = settings.noDMs;
+ _lastV2GhostModeEnabled = settings.ghostMode;
+ _lastV2AdBlockerDomEnabled = settings.v2AdBlockerDomEnabled;
+ _lastV2ContentHiderEnabled = settings.v2ContentHiderEnabled;
+ _lastV2FetchInterceptorEnabled = _shouldEnableFetchInterceptor(settings);
+ _lastV2AutoplayBlockerEnabled = settings.blockAutoplay;
+ _lastAdblockToggleValue = settings.v2AdBlockerDomEnabled;
+ _onScreenTimeChanged();
});
FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged);
@@ -102,6 +168,51 @@ bool _reelsBlockedOverlay = false;
}
}
+ bool _shouldEnableFetchInterceptor(SettingsService settings) {
+ return settings.ghostMode ||
+ settings.noAds ||
+ settings.v2AdBlockerDomEnabled ||
+ settings.noReels ||
+ settings.hideSuggestedPosts ||
+ (settings.v2ContentHiderEnabled &&
+ (settings.contentPosts ||
+ settings.contentReels ||
+ settings.contentSuggested));
+ }
+
+ Future _onScreenTimeChanged() async {
+ if (!mounted) return;
+ if (context.read().totalSeconds < 300) return;
+
+ final prefs = await SharedPreferences.getInstance();
+ if (prefs.getBool(_donationPopupShownKey) ?? false) return;
+ await prefs.setBool(_donationPopupShownKey, true);
+ if (!mounted) return;
+
+ await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Support FocusGram'),
+ content: const Text(
+ 'Please donate to support the development of this project.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Not now'),
+ ),
+ FilledButton(
+ onPressed: () {
+ Navigator.pop(context);
+ launchUrl(_donateUri, mode: LaunchMode.externalApplication);
+ },
+ child: const Text('Donate'),
+ ),
+ ],
+ ),
+ );
+ }
+
/// Sets the isolated reel player flag in the WebView so the scroll-lock
/// knows it should block swipe-to-next-reel.
Future _setIsolatedPlayer(bool active) async {
@@ -141,16 +252,102 @@ bool _reelsBlockedOverlay = false;
bool _lastMinimalMode = false;
bool _lastDisableReels = false;
bool _lastDisableExplore = false;
+ bool _lastBlockHomeFeedScroll = false;
bool _lastBlockAutoplay = false;
+ bool _lastGhostMode = false;
+ bool _lastNoAds = false;
+ bool _lastNoStories = false;
+ bool _lastNoReels = false;
+ bool _lastNoAutoplay = false;
+ bool _lastNoDMs = false;
+ bool _lastV2GhostModeEnabled = false;
+ bool _lastV2AdBlockerDomEnabled = false;
+ bool _lastV2ContentHiderEnabled = false;
+ bool _lastV2FetchInterceptorEnabled = false;
+ bool _lastV2AutoplayBlockerEnabled = false;
+
+ // Tracks v2 adblock toggle to know when to reload WebView for ContentBlocker changes
+ bool _lastAdblockToggleValue = false;
void _onSettingsChanged() {
if (!mounted) return;
final settings = context.read();
+ // If adblock toggle flipped, rebuild WebView so `contentBlockers` applies.
+ // IMPORTANT: do NOT early-return, otherwise we skip the v2 overlay prefs sync
+ // (which is what enables the DOM fallback script + other v2 toggles).
+ if (_lastAdblockToggleValue != settings.v2AdBlockerDomEnabled) {
+ _lastAdblockToggleValue = settings.v2AdBlockerDomEnabled;
+ _adblockData = null;
+ _loadAdblockerData();
+ _controller?.reload();
+ }
+
+ // 0. V2 overlay sync (prefs must be updated before toggling)
+ unawaited(() async {
+ final prefs = await SharedPreferences.getInstance();
+ if (_v2Engine != null) {
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.ghostMode.name}_enabled',
+ settings.ghostMode,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.adBlockerDom.name}_enabled',
+ settings.v2AdBlockerDomEnabled,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.contentHider.name}_enabled',
+ settings.v2ContentHiderEnabled,
+ );
+
+ final bool fetchInterceptorEnabled = _shouldEnableFetchInterceptor(
+ settings,
+ );
+ final bool autoplayBlockerEnabled = settings.blockAutoplay;
+
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.fetchInterceptor.name}_enabled',
+ fetchInterceptorEnabled,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.autoplayBlocker.name}_enabled',
+ autoplayBlockerEnabled,
+ );
+
+ await prefs.setBool('content_stories', settings.contentStories);
+ await prefs.setBool('content_posts', settings.contentPosts);
+ await prefs.setBool('content_reels', settings.contentReels);
+ await prefs.setBool('content_suggested', settings.contentSuggested);
+
+ final shouldReloadV2 =
+ _lastV2GhostModeEnabled != settings.ghostMode ||
+ _lastV2AdBlockerDomEnabled != settings.v2AdBlockerDomEnabled ||
+ _lastV2ContentHiderEnabled != settings.v2ContentHiderEnabled ||
+ _lastV2FetchInterceptorEnabled != fetchInterceptorEnabled ||
+ _lastV2AutoplayBlockerEnabled != autoplayBlockerEnabled;
+
+ _lastV2GhostModeEnabled = settings.ghostMode;
+ _lastV2AdBlockerDomEnabled = settings.v2AdBlockerDomEnabled;
+ _lastV2ContentHiderEnabled = settings.v2ContentHiderEnabled;
+ _lastV2FetchInterceptorEnabled = fetchInterceptorEnabled;
+ _lastV2AutoplayBlockerEnabled = autoplayBlockerEnabled;
+
+ if (shouldReloadV2) {
+ _reloadDebounce?.cancel();
+ _reloadDebounce = Timer(const Duration(milliseconds: 600), () {
+ if (mounted) _controller?.reload();
+ });
+ } else {
+ await _v2Engine?.injectDocumentEndScripts();
+ }
+ }
+ }());
+
// 1. Apply all cosmetic changes immediately via injection
if (_controller != null) {
_controller!.evaluateJavascript(
- source: 'window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur};',
+ source:
+ 'window.__fgSetBlockAutoplay?.(${settings.blockAutoplay}); window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur};',
);
}
if (_controller != null && _injectionManager != null) {
@@ -166,12 +363,26 @@ bool _reelsBlockedOverlay = false;
settings.minimalModeEnabled != _lastMinimalMode ||
settings.disableReelsEntirely != _lastDisableReels ||
settings.disableExploreEntirely != _lastDisableExplore ||
- settings.blockAutoplay != _lastBlockAutoplay;
+ settings.blockHomeFeedScroll != _lastBlockHomeFeedScroll ||
+ settings.blockAutoplay != _lastBlockAutoplay ||
+ settings.ghostMode != _lastGhostMode ||
+ settings.noAds != _lastNoAds ||
+ settings.noStories != _lastNoStories ||
+ settings.noReels != _lastNoReels ||
+ settings.noAutoplay != _lastNoAutoplay ||
+ settings.noDMs != _lastNoDMs;
_lastMinimalMode = settings.minimalModeEnabled;
_lastDisableReels = settings.disableReelsEntirely;
_lastDisableExplore = settings.disableExploreEntirely;
+ _lastBlockHomeFeedScroll = settings.blockHomeFeedScroll;
_lastBlockAutoplay = settings.blockAutoplay;
+ _lastGhostMode = settings.ghostMode;
+ _lastNoAds = settings.noAds;
+ _lastNoStories = settings.noStories;
+ _lastNoReels = settings.noReels;
+ _lastNoAutoplay = settings.noAutoplay;
+ _lastNoDMs = settings.noDMs;
if (structuralChange && _controller != null) {
// Debounce: if user toggles rapidly, only reload once they stop
@@ -191,6 +402,8 @@ bool _reelsBlockedOverlay = false;
FocusGramRouter.pendingUrl.removeListener(_onPendingUrlChanged);
context.read().removeListener(_onSessionChanged);
context.read().removeListener(_onSettingsChanged);
+ context.read().removeListener(_onScreenTimeChanged);
+ context.read().stopTracking();
super.dispose();
}
@@ -200,7 +413,7 @@ bool _reelsBlockedOverlay = false;
final sm = context.read();
final screenTime = context.read();
final settings = context.read();
-
+
if (state == AppLifecycleState.resumed) {
sm.setAppForeground(true);
screenTime.startTracking();
@@ -211,12 +424,12 @@ bool _reelsBlockedOverlay = false;
state == AppLifecycleState.detached) {
sm.setAppForeground(false);
screenTime.stopTracking();
-
+
// Show persistent notification when schedules are active (if enabled)
if (settings.notifyPersistent) {
final isScheduleActive = sm.isScheduledBlockActive;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
-
+
if (isScheduleActive) {
NotificationService().showPersistentNotification(
id: 5001,
@@ -282,7 +495,9 @@ bool _reelsBlockedOverlay = false;
},
child: AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(20),
+ ),
title: const Text(
'Session Complete ✓',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
@@ -342,6 +557,44 @@ bool _reelsBlockedOverlay = false;
);
}
+ Future _loadAdblockerData() async {
+ final settings = context.read();
+ final prefs = await SharedPreferences.getInstance();
+ final previousHosts = _adblockData?.blockedHosts;
+
+ final loader = AdblockContentBlockerLoader();
+ final data = await loader.loadOrUpdateIfNeeded(
+ enabled: settings.v2AdBlockerDomEnabled,
+ prefs: prefs,
+ );
+
+ if (mounted) {
+ setState(() => _adblockData = data);
+ if (settings.v2AdBlockerDomEnabled &&
+ data.blockedHosts.isNotEmpty &&
+ _controller != null &&
+ (previousHosts == null ||
+ !setEquals(previousHosts, data.blockedHosts))) {
+ unawaited(_controller?.reload());
+ }
+ }
+ return data;
+ }
+
+ bool _isBlockedByAdblockHostList(WebUri uri, Set? blockedHosts) {
+ if (blockedHosts == null || blockedHosts.isEmpty) return false;
+
+ var host = uri.host.toLowerCase();
+ if (blockedHosts.contains(host)) return true;
+
+ while (true) {
+ final dot = host.indexOf('.');
+ if (dot < 0 || dot == host.length - 1) return false;
+ host = host.substring(dot + 1);
+ if (blockedHosts.contains(host)) return true;
+ }
+ }
+
void _initWebView() {
// Preloader disabled — keepAlive WebView silently fails when app cold-starts,
// leaving _isPreloaded = true with no content, causing permanent black screen.
@@ -383,10 +636,37 @@ bool _reelsBlockedOverlay = false;
return '$m:$s';
}
+ static bool _isHomeFeedUrl(String url) {
+ final uri = Uri.tryParse(url);
+ if (uri == null) return url == '/' || url.isEmpty;
+ final path = uri.path.isEmpty ? '/' : uri.path;
+ return uri.host.contains('instagram.com') && path == '/';
+ }
+
+ static bool _isDirectThreadUrl(String url) {
+ final path = Uri.tryParse(url)?.path ?? url;
+ return RegExp(r'^/direct/t/[^/]+/?$').hasMatch(path);
+ }
+
+ static bool _isFktmInstagramCdn(String url) {
+ final host = Uri.tryParse(url)?.host.toLowerCase() ?? '';
+ return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host);
+ }
+
+ void _syncDirectThreadState(String url) {
+ final active = _isDirectThreadUrl(url);
+ if (_isInDirectThread == active) return;
+ _isInDirectThread = active;
+ _dmThreadCdnBlockArmed = false;
+ }
+
Future _showReelSessionPicker() async {
final settings = context.read();
if (settings.requireWordChallenge) {
- final passed = await DisciplineChallenge.show(context);
+ final passed = await DisciplineChallenge.show(
+ context,
+ count: settings.resolvedWordChallengeCount(),
+ );
if (!passed || !mounted) return;
}
_showReelSessionPickerBottomSheet();
@@ -426,9 +706,13 @@ bool _reelsBlockedOverlay = false;
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
+ _buildReelSessionTile(1),
+ _buildReelSessionTile(3),
_buildReelSessionTile(5),
_buildReelSessionTile(10),
_buildReelSessionTile(15),
+ _buildReelSessionTile(20),
+ _buildReelSessionTile(30),
const SizedBox(height: 40),
TextButton(
onPressed: () => Navigator.pop(context),
@@ -481,6 +765,10 @@ bool _reelsBlockedOverlay = false;
await _controller?.goBack();
return;
}
+ if (_isHomeFeedUrl(_currentUrl)) {
+ SystemNavigator.pop();
+ return;
+ }
final didNavigate =
await (_controller
?.evaluateJavascript(
@@ -591,6 +879,7 @@ bool _reelsBlockedOverlay = false;
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
+ thirdPartyCookiesEnabled: false,
hardwareAcceleration: true,
// FIX 2: Set to false so the WebView renders
// its own opaque background. When true + black
@@ -603,48 +892,143 @@ bool _reelsBlockedOverlay = false;
allowsInlineMediaPlayback: true,
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
+ contentBlockers:
+ _adblockData?.contentBlockers ?? const [],
),
initialUserScripts: UnmodifiableListView([
- UserScript(
- source:
- 'window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur}; window.__focusgramSessionActive = ${sm.isSessionActive};',
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- UserScript(
- source: kAutoplayBlockerJS,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- UserScript(
- source: kSpaNavigationMonitorScript,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- UserScript(
- source: kNativeFeelingScript,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- if (settings.disableReelsEntirely)
- UserScript(
- source: kDisableReelsEntirelyCssScript,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
+ ...const [],
+ ...buildUserScripts(
+ FocusSettings(
+ ghostMode: settings.ghostMode,
+ noAds: settings.noAds,
+ noStories: settings.noStories,
+ noReels: settings.noReels,
+ noAutoplay: settings.noAutoplay,
+ noDMs: settings.noDMs,
),
- if (settings.disableExploreEntirely)
- UserScript(
- source: kDisableExploreEntirelyCssScript,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- UserScript(
- source: kHapticBridgeScript,
- injectionTime:
- UserScriptInjectionTime.AT_DOCUMENT_START,
),
]),
pullToRefreshController: _pullToRefreshController,
+ shouldInterceptRequest: (controller, request) async {
+ final url = request.url.toString();
+ final referrer =
+ request.headers?['Referer'] ??
+ request.headers?['referer'];
+ if (referrer != null &&
+ _isDirectThreadUrl(referrer)) {
+ _syncDirectThreadState(referrer);
+ }
+
+ if (_isInDirectThread &&
+ _isFktmInstagramCdn(url)) {
+ if (_dmThreadCdnBlockArmed) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+ _dmThreadCdnBlockArmed = true;
+ }
+
+ // Strict/high-priority domain blocking from uBlock-style lists.
+ final adblockHosts = _adblockData?.blockedHosts;
+ if (_isBlockedByAdblockHostList(
+ request.url,
+ adblockHosts,
+ )) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+
+ // Block trackers + paid pixel iframes (hardcoded safety)
+ const blockedDomains = [
+ 'fbsbx.com/paid_ads_pixel',
+ 'fbsbx.com/paid_ads',
+ 'facebook.com/tr',
+ 'instagram.com/paid_ads',
+ 'analytics.facebook.com',
+ 'facebook.com/tracking',
+ ];
+ if (blockedDomains.any(
+ (d) => url.contains(d),
+ )) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+
+ // Also block any IG paid-pixel iframe HTML documents
+ if (url.contains('/paid_ads_pixel/iframe/') ||
+ url.contains('/generete_pixels/')) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+
+ // Block Reels API
+ if (settings.noReels &&
+ (url.contains('/api/v1/clips/') ||
+ url.contains('/api/v1/discover/'))) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+
+ // Block DMs API
+ if (settings.noDMs &&
+ (url.contains('edge-chat.instagram.com') ||
+ url.contains('/api/v1/direct_v2/'))) {
+ return WebResourceResponse(
+ data: Uint8List(0),
+ );
+ }
+
+ // Strip ads from feed
+ if (settings.noAds &&
+ url.contains(
+ 'instagram.com/graphql/query',
+ )) {
+ try {
+ final res = await http.post(
+ Uri.parse(url),
+ headers: Map.from(
+ request.headers ?? {},
+ ),
+ );
+
+ final json = jsonDecode(res.body);
+ final connection =
+ json['data']?['xdt_api__v1__feed__timeline__connection'];
+
+ if (connection != null &&
+ connection['edges'] is List) {
+ final edges = connection['edges'] as List;
+ edges.removeWhere((e) {
+ final node = e['node'];
+ if (node == null) return false;
+ return node['ad'] != null ||
+ node['explore_story'] != null ||
+ node['media']?['inventory_source'] ==
+ 'mixed_unconnected';
+ });
+ }
+
+ return WebResourceResponse(
+ data: Uint8List.fromList(
+ utf8.encode(jsonEncode(json)),
+ ),
+ headers: res.headers,
+ statusCode: 200,
+ contentType: 'application/json',
+ );
+ } catch (e) {
+ // if anything fails, pass through original request unmodified
+ return null;
+ }
+ }
+
+ return null;
+ },
onWebViewCreated: (controller) async {
_controller = controller;
@@ -664,6 +1048,61 @@ bool _reelsBlockedOverlay = false;
_registerJavaScriptHandlers(controller);
+ // ── FocusGram v2 overlay initial sync ───────────────
+ // ScriptEngineV2Overlay reads enabled state from prefs keys:
+ // fg_v2_{scriptName}_enabled
+ // Set them BEFORE DOCUMENT_START scripts are injected.
+ // V2 overlay toggles:
+ // - ghost_mode: user FocusGram "ghostMode" controls it
+ // - others: keep using existing v2 toggles
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.ghostMode.name}_enabled',
+ settingsService.ghostMode,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.adBlockerDom.name}_enabled',
+ settingsService.v2AdBlockerDomEnabled,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.contentHider.name}_enabled',
+ settingsService.v2ContentHiderEnabled,
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.fetchInterceptor.name}_enabled',
+ _shouldEnableFetchInterceptor(
+ settingsService,
+ ),
+ );
+ await prefs.setBool(
+ 'fg_v2_${V2OverlayScriptId.autoplayBlocker.name}_enabled',
+ settingsService.blockAutoplay,
+ );
+
+ // Content hider flags consumed by v2/content_hider.js
+ await prefs.setBool(
+ 'content_stories',
+ settingsService.contentStories,
+ );
+ await prefs.setBool(
+ 'content_posts',
+ settingsService.contentPosts,
+ );
+ await prefs.setBool(
+ 'content_reels',
+ settingsService.contentReels,
+ );
+ await prefs.setBool(
+ 'content_suggested',
+ settingsService.contentSuggested,
+ );
+
+ // Phase 1 V2 overlay engine (theme + best-effort ad DOM cleanup)
+ _v2Engine = ScriptEngineV2Overlay(
+ controller: controller,
+ prefs: prefs,
+ );
+ await _v2Engine!.initDocumentStartScripts();
+
// Start safety timeout — clears loading state
// if onLoadStop never fires (e.g. network stall).
_resetLoadingTimeout();
@@ -671,6 +1110,7 @@ bool _reelsBlockedOverlay = false;
onLoadStart: (controller, url) {
if (!mounted) return;
final u = url?.toString() ?? '';
+ _syncDirectThreadState(u);
final lower = u.toLowerCase();
final isOnboardingUrl =
lower.contains('/accounts/login') ||
@@ -698,6 +1138,7 @@ bool _reelsBlockedOverlay = false;
_loadingTimeout?.cancel();
final current = url?.toString() ?? '';
+ _syncDirectThreadState(current);
setState(() {
_isLoading = false;
_currentUrl = current;
@@ -707,6 +1148,9 @@ bool _reelsBlockedOverlay = false;
await _injectionManager
?.runAllPostLoadInjections(current);
+ // Phase 1 V2 overlay DOM scripts
+ await _v2Engine?.injectDocumentEndScripts();
+
await controller.evaluateJavascript(
source:
InjectionController.notificationBridgeJS,
@@ -737,6 +1181,7 @@ bool _reelsBlockedOverlay = false;
final uri = navigationAction.request.url;
final appSettings = context
.read();
+ _syncDirectThreadState(url);
final disableReels =
appSettings.disableReelsEntirely;
@@ -774,7 +1219,9 @@ bool _reelsBlockedOverlay = false;
if (disableExplore && isExploreUrl(url)) {
// Show overlay immediately without navigating away
- setState(() => _exploreBlockedOverlay = true);
+ setState(
+ () => _exploreBlockedOverlay = true,
+ );
// Don't go back - just block the navigation
return NavigationActionPolicy.CANCEL;
}
@@ -899,7 +1346,7 @@ bool _reelsBlockedOverlay = false;
right: 0,
child: const _InstagramGradientProgressBar(),
),
-_EdgePanel(key: _edgePanelKey),
+ _EdgePanel(key: _edgePanelKey),
if (_exploreBlockedOverlay)
Positioned.fill(
@@ -944,10 +1391,7 @@ _EdgePanel(key: _edgePanelKey),
Text(
'You can re-enable Explore in Settings > Focus.',
textAlign: TextAlign.center,
- style: TextStyle(
- color: textSub,
- fontSize: 12,
- ),
+ style: TextStyle(color: textSub, fontSize: 12),
),
const SizedBox(height: 20),
TextButton(
@@ -1237,6 +1681,47 @@ _EdgePanel(key: _edgePanelKey),
},
);
+ controller.addJavaScriptHandler(
+ handlerName: 'FocusGramMediaDownload',
+ callback: (args) async {
+ if (!mounted) return null;
+
+ final raw = (args.isNotEmpty ? args[0] : '') as String;
+
+ // We still want to show a tailored snackbar message, but the heavy
+ // JSON + security validation is delegated to the pure helper.
+ String type = 'video';
+ try {
+ final payload = jsonDecode(raw) as Map;
+ type = (payload['type'] as String? ?? 'video').toString();
+ } catch (_) {
+ // If payload isn't parseable, helper will reject anyway.
+ }
+
+ final ok = await handleFocusGramMediaDownload(
+ raw: raw,
+ launch: (uri) => launchUrl(uri, mode: LaunchMode.externalApplication),
+ );
+
+ if (!mounted) return null;
+
+ if (!ok) return null;
+
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ type == 'photo'
+ ? 'Opening photo download…'
+ : 'Opening video download…',
+ ),
+ behavior: SnackBarBehavior.floating,
+ margin: const EdgeInsets.fromLTRB(16, 0, 16, 20),
+ ),
+ );
+ return null;
+ }, // closes callback
+ ); // closes addJavaScriptHandler
+
controller.addJavaScriptHandler(
handlerName: 'FocusGramThemeChannel',
callback: (args) {
@@ -1257,7 +1742,12 @@ _EdgePanel(key: _edgePanelKey),
handlerName: 'UrlChange',
callback: (args) async {
final url = (args.isNotEmpty ? args[0] : '') as String? ?? '';
+ _syncDirectThreadState(url);
await _injectionManager?.runAllPostLoadInjections(url);
+
+ // Phase 1 V2 overlay re-inject on SPA route changes
+ await _v2Engine?.injectDocumentEndScripts();
+
if (!mounted) return;
setState(() {
_currentUrl = url;
@@ -1267,9 +1757,12 @@ _EdgePanel(key: _edgePanelKey),
_isLoading = false;
});
- final settings = context.read();
- final disableReels = settings.disableReelsEntirely;
- final disableExplore = settings.disableExploreEntirely;
+ final disableReels = context
+ .read()
+ .disableReelsEntirely;
+ final disableExplore = context
+ .read()
+ .disableExploreEntirely;
final path = Uri.tryParse(url)?.path ?? url;
final isReels = path.startsWith('/reels') || path.startsWith('/reel/');
@@ -1374,22 +1867,37 @@ class _EdgePanelState extends State<_EdgePanel> {
@override
Widget build(BuildContext context) {
final sm = context.watch();
- final int remaining = sm.remainingSessionSeconds;
- final double progress = sm.perSessionSeconds > 0
- ? (remaining / sm.perSessionSeconds).clamp(0.0, 1.0)
- : 0;
- Color barColor = progress < 0.2
- ? Colors.redAccent
- : (progress < 0.5 ? Colors.yellowAccent : Colors.blueAccent);
-
final settings = context.watch();
final isDark = settings.isDarkMode;
- final reelsHardDisabled =
- settings.disableReelsEntirely;
- final panelBg = isDark ? const Color(0xFF121212) : Colors.white;
- final textDim = isDark ? Colors.white70 : Colors.black87;
- final textSub = isDark ? Colors.white30 : Colors.black38;
+ final reelsHardDisabled = settings.disableReelsEntirely;
+ final panelBg = isDark ? const Color(0xFF111214) : Colors.white;
+ final textMain = isDark ? Colors.white : Colors.black87;
+ final textSub = isDark ? Colors.white60 : Colors.black54;
final border = isDark ? Colors.white12 : Colors.black12;
+ final canStart =
+ !reelsHardDisabled &&
+ !sm.isSessionActive &&
+ !sm.isCooldownActive &&
+ sm.dailyRemainingSeconds > 0;
+ final statusColor = reelsHardDisabled
+ ? Colors.redAccent
+ : sm.isSessionActive
+ ? Colors.greenAccent
+ : sm.isCooldownActive
+ ? Colors.orangeAccent
+ : Colors.blueAccent;
+ final statusText = reelsHardDisabled
+ ? 'Reels blocked'
+ : sm.isSessionActive
+ ? 'Session active'
+ : sm.isCooldownActive
+ ? 'Cooldown'
+ : sm.dailyRemainingSeconds <= 0
+ ? 'Daily limit reached'
+ : 'Ready';
+ final sessionProgress = sm.isSessionActive && sm.perSessionSeconds > 0
+ ? (sm.remainingSessionSeconds / sm.perSessionSeconds).clamp(0.0, 1.0)
+ : 0.0;
return Stack(
children: [
@@ -1404,164 +1912,247 @@ class _EdgePanelState extends State<_EdgePanel> {
),
),
AnimatedPositioned(
- duration: const Duration(milliseconds: 350),
- curve: Curves.easeOutQuart,
- left: _isExpanded ? 0 : -220,
- top: MediaQuery.of(context).size.height * 0.25 + 30,
+ duration: const Duration(milliseconds: 260),
+ curve: Curves.easeOutCubic,
+ right: _isExpanded ? 12 : -328,
+ top: MediaQuery.of(context).padding.top + 72,
child: Container(
- width: 210,
- padding: const EdgeInsets.all(24),
+ width: 316,
+ constraints: BoxConstraints(
+ maxHeight:
+ MediaQuery.of(context).size.height -
+ MediaQuery.of(context).padding.top -
+ 96,
+ ),
+ padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: panelBg.withValues(alpha: 0.98),
- borderRadius: const BorderRadius.only(
- topRight: Radius.circular(28),
- bottomRight: Radius.circular(28),
- ),
+ borderRadius: BorderRadius.circular(20),
border: Border.all(color: border, width: 0.5),
boxShadow: [
BoxShadow(
color: (isDark ? Colors.black : Colors.black12).withValues(
alpha: 0.3,
),
- blurRadius: 30,
- spreadRadius: 5,
+ blurRadius: 24,
+ offset: const Offset(0, 12),
),
],
),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Text(
- 'FOCUS CONTROL',
- style: TextStyle(
- color: Colors.blueAccent,
- fontSize: 10,
- fontWeight: FontWeight.bold,
- letterSpacing: 1.5,
- ),
- ),
- IconButton(
- icon: Icon(
- Icons.chevron_left_rounded,
- color: textDim,
- size: 28,
- ),
- onPressed: _toggleExpansion,
- padding: EdgeInsets.zero,
- constraints: const BoxConstraints(),
- ),
- ],
- ),
- const SizedBox(height: 32),
- Text(
- 'REEL SESSION',
- style: TextStyle(
- color: textSub,
- fontSize: 11,
- letterSpacing: 1,
- ),
- ),
- const SizedBox(height: 8),
- Text(
- sm.isSessionActive
- ? _formatTime(sm.remainingSessionSeconds)
- : 'Off',
- style: TextStyle(
- color: barColor,
- fontSize: 40,
- fontWeight: FontWeight.w200,
- letterSpacing: 2,
- ),
- ),
- const SizedBox(height: 20),
- _buildStatRow(
- 'REEL QUOTA',
- '${sm.dailyRemainingSeconds ~/ 60}m Left',
- Icons.timer_outlined,
- isDark: isDark,
- ),
- _buildStatRow(
- 'AUTO-CLOSE',
- _formatTime(sm.appSessionRemainingSeconds),
- Icons.hourglass_empty_rounded,
- isDark: isDark,
- ),
- _buildStatRow(
- 'COOLDOWN',
- sm.isCooldownActive
- ? _formatTime(sm.cooldownRemainingSeconds)
- : 'Off',
- Icons.coffee_rounded,
- isWarning: sm.isCooldownActive,
- isDark: isDark,
- ),
- if (!reelsHardDisabled &&
- !sm.isSessionActive &&
- sm.dailyRemainingSeconds > 0) ...[
- const SizedBox(height: 12),
- SizedBox(
- width: double.infinity,
- child: ElevatedButton(
- onPressed: () {
- context
- .findAncestorStateOfType<_MainWebViewPageState>()
- ?._showReelSessionPicker();
- },
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.blueAccent,
- foregroundColor: Colors.white,
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ width: 38,
+ height: 38,
+ decoration: BoxDecoration(
+ color: statusColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
+ child: Icon(Icons.timer_outlined, color: statusColor),
),
- child: const Text(
- 'Start Session',
- style: TextStyle(
- fontSize: 13,
- fontWeight: FontWeight.bold,
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Focus Control',
+ style: TextStyle(
+ color: textMain,
+ fontSize: 17,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ statusText,
+ style: TextStyle(
+ color: statusColor,
+ fontSize: 12,
+ ),
+ ),
+ ],
),
),
+ IconButton(
+ tooltip: 'Close',
+ icon: Icon(
+ Icons.close_rounded,
+ color: textSub,
+ size: 22,
+ ),
+ onPressed: _toggleExpansion,
+ ),
+ ],
+ ),
+ const SizedBox(height: 18),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: (isDark ? Colors.white : Colors.black).withValues(
+ alpha: 0.05,
+ ),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Reel session',
+ style: TextStyle(color: textSub, fontSize: 12),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ sm.isSessionActive
+ ? _formatTime(sm.remainingSessionSeconds)
+ : 'Not running',
+ style: TextStyle(
+ color: sm.isSessionActive ? statusColor : textMain,
+ fontSize: sm.isSessionActive ? 38 : 24,
+ fontWeight: FontWeight.w700,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ ),
+ const SizedBox(height: 12),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(999),
+ child: LinearProgressIndicator(
+ value: sm.isSessionActive ? sessionProgress : 0,
+ minHeight: 6,
+ backgroundColor: isDark
+ ? Colors.white10
+ : Colors.black12,
+ valueColor: AlwaysStoppedAnimation(
+ statusColor,
+ ),
+ ),
+ ),
+ ],
),
),
- ] else if (reelsHardDisabled) ...[
+ const SizedBox(height: 14),
+ Row(
+ children: [
+ Expanded(
+ child: _buildStatCard(
+ 'Quota',
+ '${sm.dailyRemainingSeconds ~/ 60}m',
+ Icons.hourglass_bottom_rounded,
+ isDark: isDark,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: _buildStatCard(
+ 'Auto-close app',
+ sm.appSessionRemainingSeconds > 0
+ ? _formatTime(sm.appSessionRemainingSeconds)
+ : 'Off',
+ Icons.lock_clock_rounded,
+ isDark: isDark,
+ ),
+ ),
+ ],
+ ),
const SizedBox(height: 10),
- Text(
- 'Reels disabled in settings',
- style: TextStyle(color: textSub, fontSize: 11),
+ _buildStatusRow(
+ icon: Icons.local_cafe_outlined,
+ label: 'Cooldown',
+ value: sm.isCooldownActive
+ ? _formatTime(sm.cooldownRemainingSeconds)
+ : 'Inactive',
+ color: sm.isCooldownActive ? Colors.orangeAccent : textSub,
+ isDark: isDark,
+ ),
+ _buildStatusRow(
+ icon: Icons.block_rounded,
+ label: 'Hard block',
+ value: reelsHardDisabled ? 'On' : 'Off',
+ color: reelsHardDisabled ? Colors.redAccent : textSub,
+ isDark: isDark,
+ ),
+ const SizedBox(height: 16),
+ if (sm.isSessionActive)
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton.icon(
+ onPressed: () {
+ context.read().endSession();
+ HapticFeedback.mediumImpact();
+ },
+ icon: const Icon(Icons.stop_circle_outlined, size: 18),
+ label: const Text('End Session'),
+ style: FilledButton.styleFrom(
+ backgroundColor: Colors.redAccent,
+ foregroundColor: Colors.white,
+ ),
+ ),
+ )
+ else
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton.icon(
+ onPressed: canStart
+ ? () {
+ _toggleExpansion();
+ context
+ .findAncestorStateOfType<
+ _MainWebViewPageState
+ >()
+ ?._showReelSessionPicker();
+ }
+ : null,
+ icon: const Icon(Icons.play_arrow_rounded, size: 20),
+ label: const Text('Start Session'),
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (!canStart && !sm.isSessionActive)
+ Text(
+ reelsHardDisabled
+ ? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
+ : sm.isCooldownActive
+ ? 'A cooldown is active before the next Reel session.'
+ : 'Your daily Reel quota is used up.',
+ style: TextStyle(
+ color: textSub,
+ fontSize: 12,
+ height: 1.35,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Divider(color: border),
+ ListTile(
+ onTap: () {
+ _toggleExpansion();
+ context
+ .findAncestorStateOfType<_MainWebViewPageState>()
+ ?._signOut();
+ },
+ leading: const Icon(
+ Icons.logout_rounded,
+ color: Colors.redAccent,
+ size: 20,
+ ),
+ title: const Text(
+ 'Switch Account',
+ style: TextStyle(
+ color: Colors.redAccent,
+ fontSize: 13,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ dense: true,
+ contentPadding: EdgeInsets.zero,
),
],
- const SizedBox(height: 32),
- Divider(color: isDark ? Colors.white10 : Colors.black12),
- const SizedBox(height: 8),
- ListTile(
- onTap: () {
- _toggleExpansion();
- context
- .findAncestorStateOfType<_MainWebViewPageState>()
- ?._signOut();
- },
- leading: const Icon(
- Icons.logout_rounded,
- color: Colors.redAccent,
- size: 20,
- ),
- title: const Text(
- 'Switch Account',
- style: TextStyle(
- color: Colors.redAccent,
- fontSize: 13,
- fontWeight: FontWeight.bold,
- ),
- ),
- dense: true,
- contentPadding: EdgeInsets.zero,
- ),
- ],
+ ),
),
),
),
@@ -1569,61 +2160,66 @@ class _EdgePanelState extends State<_EdgePanel> {
);
}
- Widget _buildStatRow(
+ Widget _buildStatCard(
String label,
String value,
IconData icon, {
- bool isWarning = false,
bool isDark = true,
}) {
final textMain = isDark ? Colors.white : Colors.black;
- final textSub = isDark ? Colors.white38 : Colors.black38;
- return Padding(
- padding: const EdgeInsets.only(bottom: 20),
- child: Row(
+ final textSub = isDark ? Colors.white54 : Colors.black54;
+ return Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: isWarning
- ? Colors.redAccent.withValues(alpha: 0.1)
- : (isDark ? Colors.white : Colors.black).withValues(
- alpha: 0.05,
- ),
- borderRadius: BorderRadius.circular(10),
- ),
- child: Icon(
- icon,
- color: isWarning
- ? Colors.redAccent
- : (isDark ? Colors.white70 : Colors.black54),
- size: 16,
+ Icon(icon, color: textSub, size: 18),
+ const SizedBox(height: 8),
+ Text(label, style: TextStyle(color: textSub, fontSize: 11)),
+ const SizedBox(height: 2),
+ Text(
+ value,
+ style: TextStyle(
+ color: textMain,
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ fontFeatures: const [FontFeature.tabularFigures()],
),
),
- const SizedBox(width: 16),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- label,
- style: TextStyle(
- color: textSub,
- fontSize: 9,
- fontWeight: FontWeight.bold,
- letterSpacing: 1,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- value,
- style: TextStyle(
- color: isWarning ? Colors.redAccent : textMain,
- fontSize: 18,
- fontWeight: FontWeight.w600,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStatusRow({
+ required IconData icon,
+ required String label,
+ required String value,
+ required Color color,
+ bool isDark = true,
+ }) {
+ final textMain = isDark ? Colors.white : Colors.black87;
+ final textSub = isDark ? Colors.white54 : Colors.black54;
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 6),
+ child: Row(
+ children: [
+ Icon(icon, color: color, size: 18),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(label, style: TextStyle(color: textSub, fontSize: 13)),
+ ),
+ Text(
+ value,
+ style: TextStyle(
+ color: color == textSub ? textMain : color,
+ fontWeight: FontWeight.w600,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
),
],
),
diff --git a/lib/screens/onboarding_page.dart b/lib/screens/onboarding_page.dart
index 7eb495d..1896724 100644
--- a/lib/screens/onboarding_page.dart
+++ b/lib/screens/onboarding_page.dart
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State {
final PageController _pageController = PageController();
int _currentPage = 0;
- // Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
+ // Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5;
static const int _kBlurPage = 3;
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State {
final List slides = [
// ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide(
- icon: Icons.auto_awesome,
- color: Colors.blue,
+ icon: Icons.auto_awesome_rounded,
+ color: const Color(0xFF4F8DFF),
title: 'Welcome to FocusGram',
description:
- 'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
+ 'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
),
- // ── Page 1: Session Management ───────────────────────────────────────
+ // ── Page 1: Focus controls ───────────────────────────────────────────
_StaticSlide(
- icon: Icons.timer,
- color: Colors.orange,
- title: 'Session Management',
+ icon: Icons.timer_outlined,
+ color: const Color(0xFFFFB74D),
+ title: 'Time With Intent',
description:
- 'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
+ 'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
),
// ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide(
- icon: Icons.link,
- color: Colors.cyan,
+ icon: Icons.link_rounded,
+ color: const Color(0xFF35C2D6),
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State {
// ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide(
- icon: Icons.notifications_active,
- color: Colors.green,
- title: 'Stay Notified',
+ icon: Icons.notifications_active_outlined,
+ color: const Color(0xFF5DD18A),
+ title: 'Useful Alerts Only',
description:
- 'We need notification permissions to alert you when your session is over or a new message arrives.',
+ 'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
isPermissionPage: true,
permission: Permission.notification,
),
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State {
),
),
),
- const SizedBox(height: 32),
+ const SizedBox(height: 28),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State {
final isBlur = _currentPage == _kBlurPage;
String label;
- if (isLast) {
- label = 'Get Started';
+ if (isNotif) {
+ label = 'Allow & Start';
} else if (isLink) {
label = 'Configure';
- } else if (isNotif) {
- label = 'Allow Notifications';
} else if (isBlur) {
label = 'Save & Continue';
+ } else if (isLast) {
+ label = 'Get Started';
} else {
label = 'Next';
}
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State {
);
} else if (isNotif) {
await Permission.notification.request();
- await NotificationService().init();
+ await NotificationService()
+ .requestPermissionsNow();
}
if (!context.mounted) return;
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State {
// Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1)
TextButton(
- onPressed: () => _finish(context),
+ onPressed: () {
+ if (_currentPage == _kNotifPage) {
+ _finish(context);
+ } else {
+ _pageController.animateToPage(
+ _kTotalPages - 1,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ }
+ },
child: const Text(
- 'Skip',
+ 'Skip setup',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
),
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
- padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
+ padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Icon(icon, size: 120, color: color),
- const SizedBox(height: 48),
+ Container(
+ width: 112,
+ height: 112,
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(28),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
+ ),
+ child: Icon(icon, size: 54, color: color),
+ ),
+ const SizedBox(height: 36),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
- fontSize: 32,
+ fontSize: 30,
fontWeight: FontWeight.bold,
),
),
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
- fontSize: 18,
+ fontSize: 16,
height: 1.5,
),
),
+ if (isPermissionPage || isAppSettingsPage) ...[
+ const SizedBox(height: 24),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.06),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white10),
+ ),
+ child: Text(
+ isPermissionPage
+ ? 'Permission is optional and can be changed later.'
+ : 'This opens Android settings; return here when done.',
+ textAlign: TextAlign.center,
+ style: const TextStyle(color: Colors.white54, fontSize: 12),
+ ),
+ ),
+ ],
],
),
);
diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart
index 1dcc789..a95c8c9 100644
--- a/lib/screens/reel_player_overlay.dart
+++ b/lib/screens/reel_player_overlay.dart
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State {
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
+ blockHomeFeedScroll: false,
),
);
},
diff --git a/lib/screens/session_modal.dart b/lib/screens/session_modal.dart
index 25e9aa8..b7382d1 100644
--- a/lib/screens/session_modal.dart
+++ b/lib/screens/session_modal.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
+import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget {
@@ -63,23 +64,22 @@ class _SessionModalState extends State {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [1, 5, 10, 15].map((m) {
- return Expanded(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4.0),
- child: ElevatedButton(
- onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
- ? null
- : () => _start(m),
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.white12,
- foregroundColor: Colors.white,
- padding: const EdgeInsets.symmetric(vertical: 12),
- ),
- child: Text('${m}m'),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [1, 3, 5, 10, 15, 20, 30].map((m) {
+ return SizedBox(
+ width: 72,
+ child: ElevatedButton(
+ onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
+ ? null
+ : () => _start(m),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.white12,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 12),
),
+ child: Text('${m}m'),
),
);
}).toList(),
@@ -92,8 +92,8 @@ class _SessionModalState extends State {
Slider(
value: _customMinutes,
min: 1,
- max: 30,
- divisions: 29,
+ max: 60,
+ divisions: 59,
label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v),
),
@@ -126,10 +126,15 @@ class _SessionModalState extends State {
void _start(int minutes) async {
final sm = context.read();
+ final settings = context.read();
- // Always require word challenge for reel sessions (User request)
- final success = await DisciplineChallenge.show(context);
- if (!success) return;
+ if (settings.requireWordChallenge) {
+ final success = await DisciplineChallenge.show(
+ context,
+ count: settings.resolvedWordChallengeCount(),
+ );
+ if (!success) return;
+ }
if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context);
diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart
index fe77f14..78db905 100644
--- a/lib/screens/settings_page.dart
+++ b/lib/screens/settings_page.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -8,6 +9,7 @@ import '../services/settings_service.dart';
import '../services/focusgram_router.dart';
import '../features/screen_time/screen_time_screen.dart';
import 'guardrails_page.dart';
+import 'extras_settings_page.dart';
// ─── Main Settings Page ───────────────────────────────────────────────────────
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
),
body: ListView(
children: [
+ const _DonateTile(),
_buildStatsRow(sm),
const _SectionHeader(title: 'FOCUS & BLOCKING'),
@@ -63,6 +66,19 @@ class SettingsPage extends StatelessWidget {
),
),
+ const _SectionHeader(title: 'EXTRAS'),
+ _SubmoduleTile(
+ icon: Icons.download_rounded,
+ iconColor: Colors.orangeAccent,
+ title: 'Extras',
+ subtitle: 'Download media, Ghost Mode, Ad Blocker',
+ enabled: true,
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
+ ),
+ ),
+
const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile(
icon: Icons.palette_outlined,
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
body: ListView(
children: [
const _SectionHeader(title: 'BLOCKING'),
+
Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
color: Colors.redAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
- child: const Icon(Icons.shield_rounded, color: Colors.redAccent, size: 20),
+ child: const Icon(
+ Icons.shield_rounded,
+ color: Colors.redAccent,
+ size: 20,
+ ),
),
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
subtitle: Text(
- settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure',
- style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey),
+ settings.minimalModeEnabled
+ ? 'Enabled - tap to customize'
+ : 'Disabled - tap to configure',
+ style: TextStyle(
+ fontSize: 12,
+ color: settings.minimalModeEnabled
+ ? Colors.greenAccent
+ : Colors.grey,
+ ),
),
trailing: Switch(
value: settings.minimalModeEnabled,
@@ -307,22 +335,49 @@ class FocusSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick();
},
),
- onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())),
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
+ ),
),
const _SectionHeader(title: 'FRICTION'),
_SwitchTile(
title: 'Mindfulness Gate',
- subtitle: 'Breath screen before opening Instagram',
+ subtitle: '${settings.breathGateSeconds}s before opening Instagram',
value: settings.showBreathGate,
onChanged: (v) => settings.setShowBreathGate(v),
),
+ if (settings.showBreathGate)
+ _NumberEditTile(
+ title: 'Gate Duration',
+ label: '${settings.breathGateSeconds} seconds',
+ initialValue: settings.breathGateSeconds,
+ min: 3,
+ max: 60,
+ suffix: 'seconds',
+ onSubmitted: (v) => settings.setBreathGateSeconds(v),
+ ),
_SwitchTile(
- title: 'Strict Mode (Word Challenge)',
- subtitle: 'Must type a phrase before starting a Reel session',
+ title: 'Typing Challenge',
+ subtitle: settings.wordChallengeCount == 0
+ ? 'Random: 10-35 words'
+ : '${settings.wordChallengeCount} words',
value: settings.requireWordChallenge,
onChanged: (v) => settings.setRequireWordChallenge(v),
),
+ if (settings.requireWordChallenge)
+ _ChoiceTile(
+ title: 'Typing Words',
+ value: settings.wordChallengeCount,
+ label: settings.wordChallengeCount == 0
+ ? 'Random (10-35)'
+ : '${settings.wordChallengeCount} words',
+ options: const [20, 25, 30, 35, 0],
+ optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words',
+ onSelected: (v) => settings.setWordChallengeCount(v),
+ ),
+
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Block Autoplay Videos',
@@ -348,6 +403,48 @@ class FocusSettingsPage extends StatelessWidget {
),
),
+ const _SectionHeader(title: 'FOCUSGRAM V2 OVERLAY'),
+
+ _SwitchTile(
+ title: 'Content Hider',
+ subtitle: 'Hide stories tray, feed posts, reels, suggested content',
+ value: settings.v2ContentHiderEnabled,
+ onChanged: (v) => settings.setV2ContentHiderEnabled(v),
+ ),
+
+ if (settings.v2ContentHiderEnabled)
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Column(
+ children: [
+ _SwitchTile(
+ title: 'Hide Stories Tray',
+ subtitle: 'Story bubbles row',
+ value: settings.contentStories,
+ onChanged: (v) => settings.setContentStoriesEnabled(v),
+ ),
+ _SwitchTile(
+ title: 'Hide Feed Posts',
+ subtitle: 'Home feed posts',
+ value: settings.contentPosts,
+ onChanged: (v) => settings.setContentPostsEnabled(v),
+ ),
+ _SwitchTile(
+ title: 'Hide Reels (Feed)',
+ subtitle: 'Reels shown in the feed',
+ value: settings.contentReels,
+ onChanged: (v) => settings.setContentReelsEnabled(v),
+ ),
+ _SwitchTile(
+ title: 'Hide Suggested Content',
+ subtitle: 'Suggested posts and recommendation units',
+ value: settings.contentSuggested,
+ onChanged: (v) => settings.setContentSuggestedEnabled(v),
+ ),
+ ],
+ ),
+ ),
+
const SizedBox(height: 40),
],
),
@@ -355,6 +452,50 @@ class FocusSettingsPage extends StatelessWidget {
}
}
+class _DonateTile extends StatelessWidget {
+ const _DonateTile();
+
+ static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
+ decoration: BoxDecoration(
+ color: Colors.pinkAccent.withValues(alpha: 0.10),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)),
+ ),
+ child: ListTile(
+ leading: Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: Colors.pinkAccent.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.favorite_rounded,
+ color: Colors.pinkAccent,
+ size: 20,
+ ),
+ ),
+ title: const Text(
+ 'Please donate to support the development of this project.',
+ style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
+ ),
+ subtitle: const Text(
+ 'Your support keeps FocusGram free and maintained.',
+ style: TextStyle(fontSize: 12),
+ ),
+ trailing: const Icon(Icons.open_in_new, size: 14),
+ onTap: () =>
+ launchUrl(_donateUri, mode: LaunchMode.externalApplication),
+ ),
+ );
+ }
+}
+
// ─── Minimal Mode Submenu ─────────────────────────────────────────────────────
class MinimalModeSubmenuPage extends StatefulWidget {
@@ -368,6 +509,7 @@ class _MinimalModeSubmenuPageState extends State {
late bool _blurExplore;
late bool _disableReelsEntirely;
late bool _disableExploreEntirely;
+ late bool _blockHomeFeedScroll;
@override
void initState() {
@@ -376,26 +518,51 @@ class _MinimalModeSubmenuPageState extends State {
_blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely;
+ _blockHomeFeedScroll = settings.blockHomeFeedScroll;
}
- void _updateSetting(String key, bool value) {
+ Future _updateSetting(String key, bool value) async {
final settings = context.read();
setState(() {
switch (key) {
case 'blurExplore':
_blurExplore = value;
- settings.setBlurExplore(value);
break;
case 'disableReelsEntirely':
_disableReelsEntirely = value;
- settings.setDisableReelsEntirelyInternal(value);
break;
case 'disableExploreEntirely':
_disableExploreEntirely = value;
- settings.setDisableExploreEntirelyInternal(value);
+ break;
+ case 'blockHomeFeedScroll':
+ _blockHomeFeedScroll = value;
break;
}
});
+
+ switch (key) {
+ case 'blurExplore':
+ await settings.setBlurExplore(value);
+ break;
+ case 'disableReelsEntirely':
+ await settings.setDisableReelsEntirelyInternal(value);
+ break;
+ case 'disableExploreEntirely':
+ await settings.setDisableExploreEntirelyInternal(value);
+ break;
+ case 'blockHomeFeedScroll':
+ await settings.setBlockHomeFeedScrollInternal(value);
+ break;
+ }
+
+ if (!mounted) return;
+ final latest = context.read();
+ setState(() {
+ _blurExplore = latest.blurExplore;
+ _disableReelsEntirely = latest.disableReelsEntirely;
+ _disableExploreEntirely = latest.disableExploreEntirely;
+ _blockHomeFeedScroll = latest.blockHomeFeedScroll;
+ });
HapticFeedback.selectionClick();
}
@@ -406,6 +573,7 @@ class _MinimalModeSubmenuPageState extends State {
_blurExplore = true;
_disableReelsEntirely = true;
_disableExploreEntirely = true;
+ _blockHomeFeedScroll = true;
});
HapticFeedback.mediumImpact();
}
@@ -418,6 +586,7 @@ class _MinimalModeSubmenuPageState extends State {
_blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely;
+ _blockHomeFeedScroll = settings.blockHomeFeedScroll;
});
HapticFeedback.mediumImpact();
}
@@ -437,61 +606,88 @@ class _MinimalModeSubmenuPageState extends State {
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
- colors: isMinimalModeEnabled
- ? [Colors.redAccent.withValues(alpha: 0.2), Colors.red.withValues(alpha: 0.1)]
- : [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
+ colors: isMinimalModeEnabled
+ ? [
+ Colors.redAccent.withValues(alpha: 0.2),
+ Colors.red.withValues(alpha: 0.1),
+ ]
+ : [
+ Colors.grey.withValues(alpha: 0.1),
+ Colors.grey.withValues(alpha: 0.05),
+ ],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
- color: isMinimalModeEnabled ? Colors.redAccent.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.2),
+ color: isMinimalModeEnabled
+ ? Colors.redAccent.withValues(alpha: 0.3)
+ : Colors.grey.withValues(alpha: 0.2),
),
),
child: Column(
children: [
Icon(
- isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined,
+ isMinimalModeEnabled
+ ? Icons.shield_rounded
+ : Icons.shield_outlined,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
size: 48,
),
const SizedBox(height: 12),
Text(
- isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled',
+ isMinimalModeEnabled
+ ? 'Minimal Mode Active'
+ : 'Minimal Mode Disabled',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
- color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
+ color: isMinimalModeEnabled
+ ? Colors.redAccent
+ : Colors.grey,
),
),
const SizedBox(height: 8),
Text(
- isMinimalModeEnabled
+ isMinimalModeEnabled
? 'Distractions are blocked. Customize which features stay enabled below.'
: 'Turn on to block all distractions at once, or customize individual settings below.',
textAlign: TextAlign.center,
- style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.black54),
+ style: TextStyle(
+ fontSize: 13,
+ color: isDark ? Colors.white54 : Colors.black54,
+ ),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
- onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode,
+ onPressed: isMinimalModeEnabled
+ ? _turnOffMinimalMode
+ : _turnOnMinimalMode,
style: ElevatedButton.styleFrom(
- backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent,
+ backgroundColor: isMinimalModeEnabled
+ ? Colors.grey
+ : Colors.redAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ child: Text(
+ isMinimalModeEnabled
+ ? 'Turn Off Minimal Mode'
+ : 'Turn On Minimal Mode',
),
- child: Text(isMinimalModeEnabled ? 'Turn Off Minimal Mode' : 'Turn On Minimal Mode'),
),
),
],
),
),
-
+
const _SectionHeader(title: 'CUSTOMIZE SETTINGS'),
-
+
Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -502,7 +698,11 @@ class _MinimalModeSubmenuPageState extends State {
),
child: const Row(
children: [
- Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent),
+ Icon(
+ Icons.touch_app_rounded,
+ size: 14,
+ color: Colors.blueAccent,
+ ),
SizedBox(width: 8),
Expanded(
child: Text(
@@ -513,13 +713,19 @@ class _MinimalModeSubmenuPageState extends State {
],
),
),
-
+
_SwitchTile(
title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped',
value: _blurExplore,
onChanged: (v) => _updateSetting('blurExplore', v),
),
+ _SwitchTile(
+ title: 'Block Home Feed Scroll',
+ subtitle: 'Freeze vertical scrolling on the home feed only',
+ value: _blockHomeFeedScroll,
+ onChanged: (v) => _updateSetting('blockHomeFeedScroll', v),
+ ),
_SwitchTile(
title: 'Disable Reels Entirely',
subtitle: 'Block all Reels with no session option',
@@ -532,7 +738,7 @@ class _MinimalModeSubmenuPageState extends State {
value: _disableExploreEntirely,
onChanged: (v) => _updateSetting('disableExploreEntirely', v),
),
-
+
const SizedBox(height: 40),
],
),
@@ -550,43 +756,52 @@ class AppearancePage extends StatefulWidget {
}
class _AppearancePageState extends State {
- Future _addSchedule(BuildContext context, SettingsService settings) async {
+ Future _addSchedule(
+ BuildContext context,
+ SettingsService settings,
+ ) async {
TimeOfDay? startTime = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 21, minute: 0),
helpText: 'Select start time',
);
-
+
if (startTime == null || !context.mounted) return;
-
+
TimeOfDay? endTime = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 6, minute: 0),
helpText: 'Select end time',
);
-
+
if (endTime == null || !context.mounted) return;
-
+
final newSchedule = {
'enabled': true,
- 'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
- 'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
+ 'startTime':
+ '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
+ 'endTime':
+ '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
};
-
+
await settings.addGrayscaleSchedule(newSchedule);
}
- Future _editSchedule(BuildContext context, SettingsService settings, int index) async {
+ Future _editSchedule(
+ BuildContext context,
+ SettingsService settings,
+ int index,
+ ) async {
final schedules = settings.grayscaleSchedules;
if (index >= schedules.length) return;
-
+
final current = schedules[index];
final startParts = (current['startTime'] as String).split(':');
final endParts = (current['endTime'] as String).split(':');
-
+
// Capture context before async gap
final capturedContext = context;
-
+
TimeOfDay? startTime = await showTimePicker(
context: capturedContext,
initialTime: TimeOfDay(
@@ -595,9 +810,9 @@ class _AppearancePageState extends State {
),
helpText: 'Select start time',
);
-
+
if (startTime == null || !capturedContext.mounted) return;
-
+
TimeOfDay? endTime = await showTimePicker(
context: capturedContext,
initialTime: TimeOfDay(
@@ -606,27 +821,31 @@ class _AppearancePageState extends State {
),
helpText: 'Select end time',
);
-
+
if (endTime == null || !capturedContext.mounted) return;
-
+
final updatedSchedule = {
...current,
- 'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
- 'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
+ 'startTime':
+ '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
+ 'endTime':
+ '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
};
-
+
await settings.updateGrayscaleSchedule(index, updatedSchedule);
}
Future _toggleSchedule(SettingsService settings, int index) async {
- final schedules = List>.from(settings.grayscaleSchedules);
+ final schedules = List>.from(
+ settings.grayscaleSchedules,
+ );
if (index >= schedules.length) return;
-
+
schedules[index] = {
...schedules[index],
'enabled': !(schedules[index]['enabled'] as bool),
};
-
+
await settings.setGrayscaleSchedules(schedules);
}
@@ -648,7 +867,7 @@ class _AppearancePageState extends State {
],
),
);
-
+
if (confirmed == true) {
await settings.removeGrayscaleSchedule(index);
}
@@ -669,7 +888,8 @@ class _AppearancePageState extends State {
const _SectionHeader(title: 'DISPLAY'),
_SwitchTile(
title: 'Grayscale Mode',
- subtitle: 'Makes Instagram black & white — reduces dopamine response',
+ subtitle:
+ 'Makes Instagram black & white — reduces dopamine response',
value: settings.grayscaleEnabled,
onChanged: (v) => settings.setGrayscaleEnabled(v),
),
@@ -687,7 +907,7 @@ class _AppearancePageState extends State {
style: TextStyle(fontSize: 12, height: 1.5),
),
),
-
+
// Status indicator
if (settings.grayscaleSchedules.isNotEmpty)
Padding(
@@ -695,26 +915,38 @@ class _AppearancePageState extends State {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
- color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1),
+ color: settings.isGrayscaleActiveNow
+ ? Colors.green.withValues(alpha: 0.1)
+ : Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
- border: Border.all(color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.3) : Colors.orange.withValues(alpha: 0.3)),
+ border: Border.all(
+ color: settings.isGrayscaleActiveNow
+ ? Colors.green.withValues(alpha: 0.3)
+ : Colors.orange.withValues(alpha: 0.3),
+ ),
),
child: Row(
children: [
Icon(
- settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule,
- color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent,
- size: 20
+ settings.isGrayscaleActiveNow
+ ? Icons.check_circle
+ : Icons.schedule,
+ color: settings.isGrayscaleActiveNow
+ ? Colors.greenAccent
+ : Colors.orangeAccent,
+ size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
- settings.isGrayscaleActiveNow
- ? 'Grayscale is active now'
+ settings.isGrayscaleActiveNow
+ ? 'Grayscale is active now'
: 'Grayscale is currently inactive',
style: TextStyle(
- fontSize: 13,
- color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent
+ fontSize: 13,
+ color: settings.isGrayscaleActiveNow
+ ? Colors.greenAccent
+ : Colors.orangeAccent,
),
),
),
@@ -722,7 +954,7 @@ class _AppearancePageState extends State {
),
),
),
-
+
// Schedule list
...List.generate(settings.grayscaleSchedules.length, (index) {
final schedule = settings.grayscaleSchedules[index];
@@ -732,11 +964,14 @@ class _AppearancePageState extends State {
width: 36,
height: 36,
decoration: BoxDecoration(
- color: (isEnabled ? Colors.purpleAccent : Colors.grey).withValues(alpha: 0.12),
+ color: (isEnabled ? Colors.purpleAccent : Colors.grey)
+ .withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
- isEnabled ? Icons.play_circle_outline : Icons.pause_circle_outline,
+ isEnabled
+ ? Icons.play_circle_outline
+ : Icons.pause_circle_outline,
color: isEnabled ? Colors.purpleAccent : Colors.grey,
size: 20,
),
@@ -750,7 +985,10 @@ class _AppearancePageState extends State {
),
subtitle: Text(
isEnabled ? 'Active' : 'Disabled',
- style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
+ style: TextStyle(
+ fontSize: 12,
+ color: isDark ? Colors.white54 : Colors.black45,
+ ),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -795,7 +1033,7 @@ class _AppearancePageState extends State {
onTap: () => _editSchedule(context, settings, index),
);
}),
-
+
// Add schedule button
ListTile(
leading: Container(
@@ -805,16 +1043,26 @@ class _AppearancePageState extends State {
color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
- child: const Icon(Icons.add_circle_outline, color: Colors.green, size: 20),
+ child: const Icon(
+ Icons.add_circle_outline,
+ color: Colors.green,
+ size: 20,
+ ),
+ ),
+ title: const Text(
+ 'Add Schedule',
+ style: TextStyle(color: Colors.green),
),
- title: const Text('Add Schedule', style: TextStyle(color: Colors.green)),
subtitle: Text(
'Add a new grayscale schedule',
- style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
+ style: TextStyle(
+ fontSize: 12,
+ color: isDark ? Colors.white54 : Colors.black45,
+ ),
),
onTap: () => _addSchedule(context, settings),
),
-
+
const SizedBox(height: 40),
],
),
@@ -966,15 +1214,9 @@ class _SwitchTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SwitchListTile(
- title: Text(
- title,
- style: const TextStyle(fontSize: 15),
- ),
+ title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
- ? Text(
- subtitle ?? '',
- style: const TextStyle(fontSize: 12),
- )
+ ? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
@@ -982,6 +1224,131 @@ class _SwitchTile extends StatelessWidget {
}
}
+class _ChoiceTile extends StatelessWidget {
+ final String title;
+ final T value;
+ final String label;
+ final List options;
+ final String Function(T value) optionLabel;
+ final ValueChanged onSelected;
+
+ const _ChoiceTile({
+ required this.title,
+ required this.value,
+ required this.label,
+ required this.options,
+ required this.optionLabel,
+ required this.onSelected,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ title: Text(title, style: const TextStyle(fontSize: 15)),
+ subtitle: Text(label, style: const TextStyle(fontSize: 12)),
+ trailing: PopupMenuButton(
+ initialValue: value,
+ onSelected: onSelected,
+ itemBuilder: (context) => options
+ .map(
+ (option) => PopupMenuItem(
+ value: option,
+ child: Text(optionLabel(option)),
+ ),
+ )
+ .toList(),
+ child: const Icon(Icons.expand_more_rounded, size: 22),
+ ),
+ onTap: () async {
+ final selected = await showModalBottomSheet(
+ context: context,
+ builder: (context) => SafeArea(
+ child: ListView(
+ shrinkWrap: true,
+ children: options
+ .map(
+ (option) => ListTile(
+ title: Text(optionLabel(option)),
+ trailing: option == value
+ ? const Icon(Icons.check_rounded)
+ : null,
+ onTap: () => Navigator.pop(context, option),
+ ),
+ )
+ .toList(),
+ ),
+ ),
+ );
+ if (selected != null) onSelected(selected);
+ },
+ );
+ }
+}
+
+class _NumberEditTile extends StatelessWidget {
+ final String title;
+ final String label;
+ final int initialValue;
+ final int min;
+ final int max;
+ final String suffix;
+ final ValueChanged onSubmitted;
+
+ const _NumberEditTile({
+ required this.title,
+ required this.label,
+ required this.initialValue,
+ required this.min,
+ required this.max,
+ required this.suffix,
+ required this.onSubmitted,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ title: Text(title, style: const TextStyle(fontSize: 15)),
+ subtitle: Text(label, style: const TextStyle(fontSize: 12)),
+ trailing: const Icon(Icons.edit_outlined, size: 20),
+ onTap: () async {
+ final controller = TextEditingController(text: '$initialValue');
+ final result = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text(title),
+ content: TextField(
+ controller: controller,
+ autofocus: true,
+ keyboardType: TextInputType.number,
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
+ decoration: InputDecoration(
+ suffixText: suffix,
+ helperText: '$min-$max $suffix',
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () {
+ final parsed = int.tryParse(controller.text.trim());
+ if (parsed == null) return;
+ Navigator.pop(dialogContext, parsed.clamp(min, max).toInt());
+ },
+ child: const Text('Save'),
+ ),
+ ],
+ ),
+ );
+ controller.dispose();
+ if (result != null) onSubmitted(result);
+ },
+ );
+ }
+}
+
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
diff --git a/lib/scripts/core_injection.dart b/lib/scripts/core_injection.dart
index 0f59904..1c7f56c 100644
--- a/lib/scripts/core_injection.dart
+++ b/lib/scripts/core_injection.dart
@@ -277,13 +277,15 @@ const String kReelsMutationObserverJS = r'''
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 with reel present
+ // Lock DM reels to prevent swipe-to-next, and optionally lock the home
+ // feed as a separate Minimal Mode control.
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
- // Only lock scroll when reel element is actually present on the page
- if (window.__fgDisableReelsEntirely === true &&
- !!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
+ if (window.__fgBlockHomeFeedScroll === true &&
+ (window.location.pathname === '/' || window.location.pathname === '')) {
+ return 'home_feed';
+ }
return null;
}
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
try {
const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL);
- // Apply lock for dm_reel or disabled modes when reel is present
- if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
+ if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
diff --git a/lib/scripts/focus_scripts.dart b/lib/scripts/focus_scripts.dart
new file mode 100644
index 0000000..e417cfe
--- /dev/null
+++ b/lib/scripts/focus_scripts.dart
@@ -0,0 +1,95 @@
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+import '../focus_settings.dart';
+
+// Ghost Mode
+const String ghostModeJS = '''
+const _WS = window.WebSocket;
+window.WebSocket = function(url, protocols) {
+ if (url.includes('edge-chat.instagram.com') ||
+ url.includes('gateway.instagram.com')) {
+ return {
+ send: ()=>{}, close: ()=>{},
+ readyState: 1,
+ addEventListener: ()=>{},
+ removeEventListener: ()=>{},
+ };
+ }
+ return new _WS(url, protocols);
+};
+window.WebSocket.prototype = _WS.prototype;
+''';
+
+// No Story Tray
+const String hideStoryTrayJS = '''
+const style = document.createElement('style');
+style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
+document.head.appendChild(style);
+''';
+
+// No Autoplay
+const String noAutoplayJS = '''
+document.addEventListener('play', function(e) {
+ if (e.target.tagName === 'VIDEO') {
+ e.target.pause();
+ }
+}, true);
+''';
+
+// No Reels / Explore
+const String hideReelsJS = '''
+const hideReels = () => {
+ // nav bar reels icon
+ document.querySelectorAll('a[href="/reels/"]').forEach(el => {
+ el.closest('div')?.style.setProperty('display', 'none', 'important');
+ });
+ // explore page
+ document.querySelectorAll('a[href="/explore/"]').forEach(el => {
+ el.closest('div')?.style.setProperty('display', 'none', 'important');
+ });
+};
+
+new MutationObserver(hideReels).observe(document.body, {
+ childList: true,
+ subtree: true
+});
+
+hideReels();
+''';
+
+// No DMs
+const String hideDMsJS = '''
+const style = document.createElement('style');
+style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
+document.head.appendChild(style);
+''';
+
+List buildUserScripts(FocusSettings settings) {
+ final startScripts = [];
+ final endScripts = [];
+
+ // AT_DOCUMENT_START scripts
+ if (settings.ghostMode) startScripts.add(ghostModeJS);
+ if (settings.noAutoplay) startScripts.add(noAutoplayJS);
+
+ // AT_DOCUMENT_END scripts
+ if (settings.noStories) endScripts.add(hideStoryTrayJS);
+ if (settings.noReels) endScripts.add(hideReelsJS);
+ if (settings.noDMs) endScripts.add(hideDMsJS);
+
+ final scripts = [];
+ if (startScripts.isNotEmpty) {
+ scripts.add(UserScript(
+ source: startScripts.join('\n'),
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
+ forMainFrameOnly: false,
+ ));
+ }
+ if (endScripts.isNotEmpty) {
+ scripts.add(UserScript(
+ source: endScripts.join('\n'),
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
+ forMainFrameOnly: true,
+ ));
+ }
+ return scripts;
+}
\ No newline at end of file
diff --git a/lib/scripts/video_downloader.dart b/lib/scripts/video_downloader.dart
new file mode 100644
index 0000000..4ebb7b6
--- /dev/null
+++ b/lib/scripts/video_downloader.dart
@@ -0,0 +1,355 @@
+/// Best-effort Instagram media downloader UI.
+///
+/// The script only exposes URLs already rendered in the WebView. It cannot
+/// decrypt or fetch media that Instagram has not loaded, but it covers visible
+/// feed posts, reels, profile avatars, and DM visual/video messages.
+const String kVideoDownloadJS = r'''
+(function() {
+ 'use strict';
+
+ if (window.__fgMediaDownloadRunning) return;
+ window.__fgMediaDownloadRunning = true;
+
+ const BTN_ATTR = 'data-fg-download-btn';
+ const URL_ATTR = 'data-fg-download-url';
+ const TYPE_ATTR = 'data-fg-download-type';
+ const MAX_PER_PASS = 60;
+
+ function text(value) {
+ try { return (value || '').toString(); } catch (_) { return ''; }
+ }
+
+ function isHttp(value) {
+ const s = text(value);
+ return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
+ }
+
+ function cleanUrl(value) {
+ const s = text(value).trim();
+ if (!isHttp(s)) return null;
+ return s.replace(/&/g, '&');
+ }
+
+ function bestFromSrcset(srcset) {
+ const raw = text(srcset);
+ if (!raw) return null;
+ let best = null;
+ let bestScore = -1;
+ raw.split(',').forEach(function(part) {
+ const bits = part.trim().split(/\s+/);
+ const url = cleanUrl(bits[0]);
+ if (!url) return;
+ const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
+ if (score >= bestScore) {
+ bestScore = score;
+ best = url;
+ }
+ });
+ return best;
+ }
+
+ function backgroundUrl(el) {
+ try {
+ const bg = window.getComputedStyle(el).backgroundImage || '';
+ const match = bg.match(/url\(["']?(.*?)["']?\)/);
+ return match ? cleanUrl(match[1]) : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function urlFromJsonishAttribute(el) {
+ const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
+ for (let i = 0; i < attrs.length; i++) {
+ const value = text(el.getAttribute && el.getAttribute(attrs[i]));
+ const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
+ if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
+ }
+ return null;
+ }
+
+ function mediaUrl(el) {
+ if (!el) return null;
+ const tag = text(el.tagName).toLowerCase();
+ if (tag === 'video') {
+ return cleanUrl(el.currentSrc || el.src) ||
+ cleanUrl(el.getAttribute('src')) ||
+ cleanUrl(el.getAttribute('poster')) ||
+ firstSource(el);
+ }
+ if (tag === 'img') {
+ return cleanUrl(el.currentSrc || el.src) ||
+ bestFromSrcset(el.getAttribute('srcset')) ||
+ cleanUrl(el.getAttribute('src'));
+ }
+ return backgroundUrl(el) || urlFromJsonishAttribute(el);
+ }
+
+ function firstSource(video) {
+ try {
+ const sources = video.querySelectorAll('source');
+ for (let i = 0; i < sources.length; i++) {
+ const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
+ if (url) return url;
+ }
+ } catch (_) {}
+ return null;
+ }
+
+ function typeFrom(el, url) {
+ const tag = text(el && el.tagName).toLowerCase();
+ const u = text(url).toLowerCase();
+ if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
+ return 'video';
+ }
+ return 'photo';
+ }
+
+ function looksLikeAvatar(el) {
+ try {
+ const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
+ if (!img) return false;
+ const alt = text(img.getAttribute('alt')).toLowerCase();
+ const r = img.getBoundingClientRect();
+ const rounded =
+ window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
+ parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
+ return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function mediaScore(item) {
+ try {
+ const r = item.el.getBoundingClientRect();
+ let score = Math.max(0, r.width) * Math.max(0, r.height);
+ if (item.type === 'video') score += 10000000;
+ if (looksLikeAvatar(item.el)) score -= 10000000;
+ if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
+ return score;
+ } catch (_) {
+ return 0;
+ }
+ }
+
+ function filename(type) {
+ const ext = type === 'video' ? 'mp4' : 'jpg';
+ return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
+ }
+
+ function inView(el) {
+ try {
+ const r = el.getBoundingClientRect();
+ return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function icon() {
+ return ' ';
+ }
+
+ function sendDownload(url, type) {
+ try {
+ if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
+ window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
+ type: type,
+ url: url,
+ filename: filename(type),
+ }));
+ } catch (_) {}
+ }
+
+ function makeButton(url, type, mode) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.setAttribute(BTN_ATTR, '1');
+ btn.setAttribute(URL_ATTR, url);
+ btn.setAttribute(TYPE_ATTR, type);
+ btn.setAttribute('aria-label', 'Download media');
+ btn.innerHTML = icon();
+ btn.style.cssText = [
+ 'position:absolute',
+ 'z-index:2147483647',
+ 'width:34px',
+ 'height:34px',
+ 'border-radius:10px',
+ 'border:1px solid rgba(255,255,255,.18)',
+ 'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
+ 'color:rgba(255,255,255,.94)',
+ 'display:flex',
+ 'align-items:center',
+ 'justify-content:center',
+ 'padding:0',
+ 'cursor:pointer',
+ 'pointer-events:auto',
+ 'backdrop-filter:blur(8px)',
+ '-webkit-backdrop-filter:blur(8px)',
+ ].join(';');
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
+ }, true);
+ return btn;
+ }
+
+ function ensureRelative(container) {
+ try {
+ const pos = window.getComputedStyle(container).position;
+ if (!pos || pos === 'static') container.style.position = 'relative';
+ } catch (_) {}
+ }
+
+ function placeNearSave(article, url, type) {
+ const ref = article.querySelector([
+ 'button[aria-label*="Save" i]',
+ 'button[aria-label*="Bookmark" i]',
+ 'svg[aria-label*="Save" i]',
+ 'svg[aria-label*="Bookmark" i]',
+ 'a[href*="/save"]',
+ ].join(','));
+ if (!ref) return false;
+
+ const target = ref.closest('button,a,div') || ref;
+ const bar = target.parentElement || article;
+ if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
+
+ const btn = makeButton(url, type, 'inline');
+ btn.style.position = 'relative';
+ btn.style.inset = 'auto';
+ btn.style.marginLeft = '8px';
+ btn.style.color = 'currentColor';
+ btn.style.border = '0';
+ btn.style.backdropFilter = 'none';
+ btn.style.webkitBackdropFilter = 'none';
+ try {
+ target.insertAdjacentElement('afterend', btn);
+ return true;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ function placeOverlay(container, url, type, where) {
+ if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
+ ensureRelative(container);
+ const btn = makeButton(url, type, 'overlay');
+ if (where === 'reel') {
+ btn.style.top = '12px';
+ btn.style.right = '12px';
+ } else if (where === 'profile') {
+ btn.style.top = '8px';
+ btn.style.right = '8px';
+ } else {
+ btn.style.right = '10px';
+ btn.style.bottom = '10px';
+ }
+ container.appendChild(btn);
+ return true;
+ }
+
+ function visibleMedia(root) {
+ return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
+ .filter(inView)
+ .map(function(el) {
+ const url = mediaUrl(el);
+ return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
+ })
+ .filter(Boolean);
+ }
+
+ function handleFeed() {
+ let added = 0;
+ document.querySelectorAll('article').forEach(function(article) {
+ if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
+ const media = visibleMedia(article)
+ .filter(function(item) { return !looksLikeAvatar(item.el); })
+ .sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
+ if (!media) return;
+ if (placeNearSave(article, media.url, media.type) ||
+ placeOverlay(article, media.url, media.type, 'feed')) {
+ added++;
+ }
+ });
+ return added;
+ }
+
+ function handleReels() {
+ let added = 0;
+ visibleMedia(document).forEach(function(media) {
+ if (added >= MAX_PER_PASS) return;
+ const container =
+ media.el.closest('[class*="ReelsVideoPlayer"]') ||
+ media.el.closest('article') ||
+ media.el.closest('[role="presentation"]') ||
+ media.el.parentElement;
+ if (placeOverlay(container, media.url, media.type, 'reel')) added++;
+ });
+ return added;
+ }
+
+ function handleDirect() {
+ let added = 0;
+ visibleMedia(document).forEach(function(media) {
+ if (added >= MAX_PER_PASS) return;
+ const bubble =
+ media.el.closest('[role="button"]') ||
+ media.el.closest('div[style*="max-width"]') ||
+ media.el.closest('article') ||
+ media.el.parentElement;
+ if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
+ });
+ return added;
+ }
+
+ function handleProfile() {
+ let added = 0;
+ const path = window.location.pathname || '/';
+ if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
+ document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
+ if (added >= 4 || !inView(img)) return;
+ const url = mediaUrl(img);
+ if (!url) return;
+ const r = img.getBoundingClientRect();
+ if (r.width < 56 && r.height < 56) return;
+ const container = img.closest('div') || img.parentElement;
+ if (placeOverlay(container, url, 'photo', 'profile')) added++;
+ });
+ return added;
+ }
+
+ function pass() {
+ try {
+ const path = window.location.pathname || '/';
+ if (path.indexOf('/direct') === 0) {
+ handleDirect();
+ } else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
+ handleReels();
+ } else {
+ handleFeed();
+ handleProfile();
+ }
+ } catch (_) {}
+ }
+
+ let timer = null;
+ function schedule() {
+ clearTimeout(timer);
+ timer = setTimeout(pass, 220);
+ }
+
+ new MutationObserver(schedule).observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['src', 'srcset', 'style'],
+ });
+ window.addEventListener('scroll', schedule, { passive: true });
+ window.addEventListener('resize', schedule, { passive: true });
+ window.addEventListener('focus', schedule, { passive: true });
+ pass();
+})();
+''';
diff --git a/lib/services/adblock/adblock_content_blocker_loader.dart b/lib/services/adblock/adblock_content_blocker_loader.dart
new file mode 100644
index 0000000..ccb7546
--- /dev/null
+++ b/lib/services/adblock/adblock_content_blocker_loader.dart
@@ -0,0 +1,430 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+import 'package:http/http.dart' as http;
+import 'package:shared_preferences/shared_preferences.dart';
+
+class AdblockContentBlockerData {
+ final List contentBlockers;
+ final Set blockedHosts;
+ final String sourceTag;
+
+ const AdblockContentBlockerData({
+ required this.contentBlockers,
+ required this.blockedHosts,
+ required this.sourceTag,
+ });
+
+ Map toJson() => {
+ 'sourceTag': sourceTag,
+ 'hosts': blockedHosts.toList(),
+ // We can’t safely serialize ContentBlocker objects; rebuild from hosts.
+ // contentBlockers will always be regenerated from hosts when restoring.
+ };
+
+ static AdblockContentBlockerData fromJson(Map json) {
+ final hosts =
+ (json['hosts'] as List?)?.whereType().toSet() ?? {};
+ return AdblockContentBlockerData(
+ contentBlockers: hosts
+ .map(
+ (h) => ContentBlocker(
+ trigger: ContentBlockerTrigger(
+ urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
+ ),
+ action: ContentBlockerAction(
+ type: ContentBlockerActionType.BLOCK,
+ ),
+ ),
+ )
+ .toList(growable: false),
+ blockedHosts: hosts,
+ sourceTag: (json['sourceTag'] as String?) ?? 'cached',
+ );
+ }
+}
+
+class AdblockContentBlockerLoader {
+ // Cache keys
+ static const _keyCache = 'adblock_cb_cache_v2';
+ static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
+ static const _keySourceCache = 'adblock_source_cache_v1';
+
+ static const _maxContentBlockerRules = 5000;
+
+ // Raw GitHub sources, intentionally split by repository sections so the app
+ // follows upstream changes without depending on third-party packaged mirrors.
+ static const _sources = <_SourceSpec>[
+ // uBlock Origin built-in Annoyances family:
+ // https://github.com/uBlockOrigin/uAssets/tree/master/filters
+ _SourceSpec(
+ tag: 'ublock_annoyances',
+ url:
+ 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
+ ),
+ _SourceSpec(
+ tag: 'ublock_annoyances_cookies',
+ url:
+ 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
+ ),
+ _SourceSpec(
+ tag: 'ublock_annoyances_others',
+ url:
+ 'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
+ ),
+
+ // EasyList network-blocking sections:
+ // https://github.com/easylist/easylist/tree/master/easylist
+ _SourceSpec(
+ tag: 'easylist_adservers',
+ url:
+ 'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
+ ),
+ _SourceSpec(
+ tag: 'easylist_general_block',
+ url:
+ 'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
+ ),
+ _SourceSpec(
+ tag: 'easylist_specific_block',
+ url:
+ 'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
+ ),
+ _SourceSpec(
+ tag: 'easylist_thirdparty',
+ url:
+ 'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
+ ),
+
+ // AdGuard BaseFilter network-blocking sections:
+ // https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
+ _SourceSpec(
+ tag: 'adguard_base_adservers',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
+ ),
+ _SourceSpec(
+ tag: 'adguard_base_adservers_firstparty',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
+ ),
+ _SourceSpec(
+ tag: 'adguard_base_antiadblock',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
+ ),
+ _SourceSpec(
+ tag: 'adguard_base_cryptominers',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
+ ),
+ _SourceSpec(
+ tag: 'adguard_base_general_url',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
+ ),
+ _SourceSpec(
+ tag: 'adguard_base_specific',
+ url:
+ 'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
+ ),
+ ];
+
+ Future loadOrUpdateIfNeeded({
+ required bool enabled,
+ required SharedPreferences prefs,
+ int timeoutMs = 8000,
+ }) async {
+ if (!enabled) {
+ return const AdblockContentBlockerData(
+ contentBlockers: [],
+ blockedHosts: {},
+ sourceTag: 'disabled',
+ );
+ }
+
+ final cachedData = _readCachedData(prefs);
+ final sourceCache = _readSourceCache(prefs);
+
+ final fetchResults = await _fetchAllSources(
+ cache: sourceCache,
+ timeoutMs: timeoutMs,
+ );
+
+ if (fetchResults.isEmpty && cachedData != null) {
+ return cachedData;
+ }
+
+ final sourceEntries = {...sourceCache};
+ for (final result in fetchResults) {
+ sourceEntries[result.tag] = result.source;
+ }
+
+ final hosts = sourceEntries.values
+ .expand((source) => source.hosts)
+ .where(_isValidHostname)
+ .toSet();
+
+ if (hosts.isEmpty && cachedData != null) {
+ return cachedData;
+ }
+
+ final data = _buildData(
+ hosts: hosts,
+ sourceTag: fetchResults.any((r) => r.changed)
+ ? 'updated-github'
+ : 'validated-github-cache',
+ );
+
+ await prefs.setString(_keyCache, jsonEncode(data.toJson()));
+ await prefs.setString(
+ _keySourceCache,
+ jsonEncode({
+ for (final entry in sourceEntries.entries) entry.key: entry.value,
+ }),
+ );
+ await prefs.setInt(
+ _keyCacheUpdatedAt,
+ DateTime.now().millisecondsSinceEpoch,
+ );
+
+ return data;
+ }
+
+ AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
+ final cached = prefs.getString(_keyCache);
+ if (cached == null) return null;
+ try {
+ final decoded = jsonDecode(cached) as Map;
+ return AdblockContentBlockerData.fromJson(decoded);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Map _readSourceCache(SharedPreferences prefs) {
+ final cached = prefs.getString(_keySourceCache);
+ if (cached == null) return {};
+ try {
+ final decoded = jsonDecode(cached) as Map;
+ return decoded.map((tag, value) {
+ return MapEntry(
+ tag,
+ _CachedSource.fromJson(value as Map),
+ );
+ });
+ } catch (_) {
+ return {};
+ }
+ }
+
+ AdblockContentBlockerData _buildData({
+ required Set hosts,
+ required String sourceTag,
+ }) {
+ final sortedHosts = hosts.toList(growable: false)..sort();
+ final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
+
+ return AdblockContentBlockerData(
+ contentBlockers: cappedHosts
+ .map(
+ (h) => ContentBlocker(
+ trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
+ action: ContentBlockerAction(
+ type: ContentBlockerActionType.BLOCK,
+ ),
+ ),
+ )
+ .toList(growable: false),
+ blockedHosts: cappedHosts,
+ sourceTag: sourceTag,
+ );
+ }
+
+ Future> _fetchAllSources({
+ required Map cache,
+ required int timeoutMs,
+ }) async {
+ final client = http.Client();
+ try {
+ final timeout = Duration(milliseconds: timeoutMs);
+ return Future.wait(
+ _sources.map(
+ (source) => _fetchSource(
+ client: client,
+ source: source,
+ cached: cache[source.tag],
+ timeout: timeout,
+ ),
+ ),
+ ).then((results) => results.whereType<_FetchedSource>().toList());
+ } finally {
+ client.close();
+ }
+ }
+
+ Future<_FetchedSource?> _fetchSource({
+ required http.Client client,
+ required _SourceSpec source,
+ required _CachedSource? cached,
+ required Duration timeout,
+ }) async {
+ try {
+ final headers = {
+ if (cached?.etag != null) 'If-None-Match': cached!.etag!,
+ if (cached?.lastModified != null)
+ 'If-Modified-Since': cached!.lastModified!,
+ 'User-Agent': 'FocusGram-AdblockListUpdater',
+ };
+
+ final res = await client
+ .get(Uri.parse(source.url), headers: headers)
+ .timeout(timeout);
+
+ if (res.statusCode == 304 && cached != null) {
+ return _FetchedSource(tag: source.tag, source: cached, changed: false);
+ }
+
+ if (res.statusCode != 200 || res.body.isEmpty) return null;
+
+ return _FetchedSource(
+ tag: source.tag,
+ source: _CachedSource(
+ url: source.url,
+ etag: res.headers['etag'],
+ lastModified: res.headers['last-modified'],
+ hosts: parseHostsFromFilterText(res.body),
+ ),
+ changed: true,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
+ /// syntax forms:
+ /// - ||example.com^
+ /// - ||example.com/
+ /// - ||example.com
+ ///
+ /// We ignore all element-hiding/cosmetic rules and $ options.
+ @visibleForTesting
+ static Set parseHostsFromFilterText(String raw) {
+ final hosts = {};
+
+ for (final line in raw.split('\n')) {
+ final l = line.trim();
+ if (l.isEmpty) continue;
+ if (l.startsWith('!')) continue;
+ if (l.startsWith('@@')) continue;
+
+ // Skip comments / metadata
+ if (l.startsWith('[')) continue;
+
+ // Skip cosmetic element-hiding rules
+ if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
+ continue;
+ }
+
+ // uBlock-style host anchors
+ if (l.startsWith('||')) {
+ final body = l.substring(2);
+
+ // Drop anything after a separator like '^', '/', '?', ' ' (conservative)
+ // e.g. "example.com^" -> "example.com"
+ // e.g. "example.com/" -> "example.com"
+ // e.g. "example.com^$third-party" -> "example.com"
+ final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
+
+ String host = body;
+ for (final sc in stopChars) {
+ final idx = host.indexOf(sc);
+ if (idx >= 0) host = host.substring(0, idx);
+ }
+
+ host = host.trim();
+
+ // Remove leading/trailing dots
+ host = host
+ .replaceAll(RegExp(r'^\.+'), '')
+ .replaceAll(RegExp(r'\.+$'), '');
+
+ if (host.isEmpty) continue;
+ if (host.contains('*') || host.contains(',')) continue;
+
+ final normalized = host.toLowerCase();
+ if (!_isValidHostname(normalized)) continue;
+
+ hosts.add(normalized);
+ }
+ }
+
+ return hosts;
+ }
+
+ static String _urlFilterForHost(String host) {
+ final escaped = RegExp.escape(host);
+ return r'^https?://([^/?#]+\.)?'
+ '$escaped'
+ r'([/?#:].*)?$';
+ }
+
+ static bool _isValidHostname(String host) {
+ if (!host.contains('.')) return false;
+ if (host.length > 255) return false;
+ if (host.startsWith('.') || host.endsWith('.')) return false;
+ if (host.contains('..')) return false;
+ return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
+ }
+}
+
+class _SourceSpec {
+ final String tag;
+ final String url;
+
+ const _SourceSpec({required this.tag, required this.url});
+}
+
+class _FetchedSource {
+ final String tag;
+ final _CachedSource source;
+ final bool changed;
+
+ _FetchedSource({
+ required this.tag,
+ required this.source,
+ required this.changed,
+ });
+}
+
+class _CachedSource {
+ final String url;
+ final String? etag;
+ final String? lastModified;
+ final Set hosts;
+
+ const _CachedSource({
+ required this.url,
+ required this.etag,
+ required this.lastModified,
+ required this.hosts,
+ });
+
+ factory _CachedSource.fromJson(Map json) {
+ return _CachedSource(
+ url: (json['url'] as String?) ?? '',
+ etag: json['etag'] as String?,
+ lastModified: json['lastModified'] as String?,
+ hosts: (json['hosts'] as List?)?.whereType().toSet() ?? {},
+ );
+ }
+
+ Map toJson() => {
+ 'url': url,
+ 'etag': etag,
+ 'lastModified': lastModified,
+ 'hosts': hosts.toList(growable: false)..sort(),
+ };
+}
diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart
index 4ef2dfd..c09ebe8 100644
--- a/lib/services/injection_controller.dart
+++ b/lib/services/injection_controller.dart
@@ -57,15 +57,15 @@ class InjectionController {
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 hideSuggestedPosts,
+ required bool hideSponsoredPosts,
required bool hideLikeCounts,
required bool hideFollowerCounts,
- // hideStoriesBar parameter removed per user request
required bool hideExploreTab,
required bool hideReelsTab,
required bool hideShopTab,
required bool disableReelsEntirely,
+ required bool blockHomeFeedScroll,
}) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
@@ -75,18 +75,12 @@ class InjectionController {
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);
@@ -94,6 +88,7 @@ class InjectionController {
return '''
${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely;
+ window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())}
diff --git a/lib/services/injection_manager.dart b/lib/services/injection_manager.dart
index e7c0c91..8d7f965 100644
--- a/lib/services/injection_manager.dart
+++ b/lib/services/injection_manager.dart
@@ -6,6 +6,7 @@ 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;
+import '../scripts/video_downloader.dart' as video_downloader;
// Core JS and CSS payloads injected into the Instagram WebView.
//
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
// ── InjectionManager class ─────────────────────────────────────────────────
-class InjectionManager {
+abstract class JsEvaluator {
+ Future evaluateJavascript({required String source});
+}
+
+class _WebViewJsEvaluator implements JsEvaluator {
final InAppWebViewController controller;
+ _WebViewJsEvaluator(this.controller);
+
+ @override
+ Future evaluateJavascript({required String source}) {
+ return controller.evaluateJavascript(source: source);
+ }
+}
+
+class InjectionManager {
+ final JsEvaluator _jsEvaluator;
final SharedPreferences prefs;
final SessionManager sessionManager;
SettingsService? _settingsService;
InjectionManager({
- required this.controller,
+ required InAppWebViewController controller,
required this.prefs,
required this.sessionManager,
- });
+ JsEvaluator? jsEvaluator,
+ }) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
+
+ InjectionManager.forTest({
+ required JsEvaluator jsEvaluator,
+ required this.prefs,
+ required this.sessionManager,
+ }) : _jsEvaluator = jsEvaluator;
void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService;
@@ -415,18 +437,19 @@ class InjectionManager {
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection;
- final hideSponsoredPosts = settings.hideSponsoredPosts;
+
+ // Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
+ final hideSuggestedPosts = false;
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 blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab;
- final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive,
@@ -434,33 +457,35 @@ class InjectionManager {
blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection,
- hideSuggestedPosts: false, // Feature removed
- hideSponsoredPosts: hideSponsoredPosts,
+ hideSuggestedPosts: hideSuggestedPosts,
+ hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts,
- // hideStoriesBar removed per user request
hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely,
+ blockHomeFeedScroll: blockHomeFeedScroll,
);
try {
- await controller.evaluateJavascript(source: injectionJS);
+ await _jsEvaluator.evaluateJavascript(source: injectionJS);
} catch (e) {
// Silently handle injection errors
}
// Inject grayscale when active, remove when not active
- if (isGrayscaleActive) {
+ if (settings.isGrayscaleActiveNow) {
try {
- await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
+ await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) {
// Silently handle injection errors
}
} else {
try {
- await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
+ await _jsEvaluator.evaluateJavascript(
+ source: grayscale.kGrayscaleOffJS,
+ );
} catch (e) {
// Silently handle injection errors
}
@@ -469,7 +494,9 @@ class InjectionManager {
// Inject hide like counts JS when enabled
if (hideLikeCounts) {
try {
- await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
+ await _jsEvaluator.evaluateJavascript(
+ source: ui_hider.kHideLikeCountsJS,
+ );
} catch (e) {
// Silently handle injection errors
}
@@ -478,11 +505,11 @@ class InjectionManager {
// Stories hiding functionality removed per user request
// No stories overlay injection needed
- // Inject hide sponsored posts JS when enabled
- if (hideSponsoredPosts) {
+ // Inject video downloader UI when enabled
+ if (settings.videoDownloadEnabled) {
try {
- await controller.evaluateJavascript(
- source: ui_hider.kHideSponsoredPostsJS,
+ await _jsEvaluator.evaluateJavascript(
+ source: video_downloader.kVideoDownloadJS,
);
} catch (e) {
// Silently handle injection errors
@@ -492,7 +519,7 @@ class InjectionManager {
// Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) {
try {
- await controller.evaluateJavascript(
+ await _jsEvaluator.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS,
);
} catch (e) {
diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart
index 97196db..78da206 100644
--- a/lib/services/notification_service.dart
+++ b/lib/services/notification_service.dart
@@ -9,16 +9,16 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
- Future init() async {
+ Future init({bool requestPermissions = false}) async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
- requestAlertPermission: true,
- requestBadgePermission: true,
- requestSoundPermission: true,
+ requestAlertPermission: requestPermissions,
+ requestBadgePermission: requestPermissions,
+ requestSoundPermission: requestPermissions,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
@@ -37,7 +37,12 @@ class NotificationService {
},
);
- // Request permissions after initialization
+ if (requestPermissions) {
+ await requestPermissionsNow();
+ }
+ }
+
+ Future requestPermissionsNow() async {
await _requestIOSPermissions();
await _requestAndroidPermissions();
}
diff --git a/lib/services/screen_time_service.dart b/lib/services/screen_time_service.dart
index 615f161..a45aebc 100644
--- a/lib/services/screen_time_service.dart
+++ b/lib/services/screen_time_service.dart
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
///
/// Storage format (in SharedPreferences, key `screen_time_data`):
/// {
-/// "2026-02-26": 3420, // seconds
-/// "2026-02-25": 1800
+/// "2026-05-26": 3420, // seconds
+/// "2026-05-25": 1800
/// }
///
/// All data stays on-device only.
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
bool _tracking = false;
Map get secondsByDate => Map.unmodifiable(_secondsByDate);
+ int get totalSeconds =>
+ _secondsByDate.values.fold(0, (total, seconds) => total + seconds);
Future init() async {
_prefs = await SharedPreferences.getInstance();
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
- _secondsByDate = decoded.map(
- (k, v) => MapEntry(k, (v as num).toInt()),
- );
+ _secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
}
} catch (_) {
_secondsByDate = {};
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
super.dispose();
}
}
-
diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart
index 2e4b64e..147fb5b 100644
--- a/lib/services/session_manager.dart
+++ b/lib/services/session_manager.dart
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
+ static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h';
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0;
+ int _lastAppSessionMinutes = 5;
// ── Scheduled Blocking runtime ─────────────────────────────
bool _scheduleEnabled = false;
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0;
List _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 _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;
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount;
+ int get lastAppSessionMinutes => _lastAppSessionMinutes;
// ── Scheduled Blocking Getters ─────────────────────────────
bool get scheduleEnabled => _scheduleEnabled;
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
}
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
+ _lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) {
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
}
}
- // App session expiry check
+ // App session countdown / expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
- changed = true;
}
+ changed = true;
}
if (isCooldownActive) {
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
changed = true;
-
+
// Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true;
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
// (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;
-
+ final notifySessionEnd =
+ _prefs?.getBool('set_notify_session_end') ?? false;
+
if (notifySessionEnd) {
NotificationService().showNotification(
id: 999,
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
);
}
}
-
+
_isSessionActive = false;
_sessionExpiry = null;
_lastSessionEnd = DateTime.now();
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true;
- _sessionEndNotificationShown = false; // Reset notification flag for new session
+ _sessionEndNotificationShown =
+ false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
+ _lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
+ _prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners();
}
diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart
index 39acc96..ab7e4f1 100644
--- a/lib/services/settings_service.dart
+++ b/lib/services/settings_service.dart
@@ -2,14 +2,18 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'notification_service.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 _keyRequireLongPress = 'set_require_long_press';
+ static const _keyBreathGateSeconds = 'breath_gate_seconds';
+ static const _keyWordChallengeCount = 'word_challenge_count';
static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings';
@@ -18,23 +22,42 @@ class SettingsService extends ChangeNotifier {
// Focus / playback
static const _keyBlockAutoplay = 'block_autoplay';
+ // Extras (Phase 2)
+ static const _keyVideoDownloadEnabled = 'video_download_enabled';
+ static const _keyHideSuggestedPosts = 'hide_suggested_posts';
+
+ // ── FocusGram v2 overlay toggles ─────────────────────────────────────────
+ static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
+ static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
+ static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
+
+ // Content hider flags (consumed by v2/content_hider.js via prefs keys)
+ static const _keyContentStories = 'content_stories';
+ static const _keyContentPosts = 'content_posts';
+ static const _keyContentReels = 'content_reels';
+ static const _keyContentSuggested = 'content_suggested';
+
// 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';
+ static const _keyMinimalModePrevDisableReels =
+ 'minimal_mode_prev_disable_reels';
+ static const _keyMinimalModePrevDisableExplore =
+ 'minimal_mode_prev_disable_explore';
+ static const _keyMinimalModePrevBlurExplore =
+ 'minimal_mode_prev_blur_explore';
+ static const _keyMinimalModePrevBlockHomeFeedScroll =
+ 'minimal_mode_prev_block_home_feed_scroll';
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
@@ -46,6 +69,14 @@ class SettingsService extends ChangeNotifier {
static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent';
+ // Focus mode settings
+ static const _keyGhostMode = 'ghost_mode';
+ static const _keyNoAds = 'no_ads';
+ static const _keyNoStories = 'no_stories';
+ static const _keyNoReels = 'no_reels';
+ static const _keyNoAutoplay = 'no_autoplay';
+ static const _keyNoDMs = 'no_dms';
+
SharedPreferences? _prefs;
bool _blurExplore = true;
@@ -54,19 +85,33 @@ class SettingsService extends ChangeNotifier {
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
+ int _breathGateSeconds = 10;
+ int _wordChallengeCount = 30;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true;
+ bool _videoDownloadEnabled = false;
+ bool _hideSuggestedPosts = false;
+
+ // ── FocusGram v2 overlay toggles ─────────────────────────────────────────
+ bool _v2GhostModeEnabled = false;
+ bool _v2AdBlockerDomEnabled = false;
+ bool _v2ContentHiderEnabled = false;
+
+ // Content hider flags (consumed by v2/content_hider.js via prefs keys)
+ bool _contentStories = false;
+ bool _contentPosts = false;
+ bool _contentReels = false;
+ bool _contentSuggested = false;
+
+ // Grayscale mode - now supports multiple schedules
bool _grayscaleEnabled = false;
-
- // Grayscale schedules - list of {enabled, startTime, endTime}
- // startTime and endTime are in format "HH:MM"
List> _grayscaleSchedules = [];
- bool _hideSponsoredPosts = false;
+ // Content filtering / UI hiding
bool _hideLikeCounts = false;
bool _hideFollowerCounts = false;
bool _hideShopTab = false;
@@ -74,12 +119,14 @@ class SettingsService extends ChangeNotifier {
// These are now controlled internally by minimal mode
bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false;
+ bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false;
// Tracking for smart restore
bool _prevDisableReels = false;
bool _prevDisableExplore = false;
bool _prevBlurExplore = false;
+ bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true;
@@ -90,6 +137,14 @@ class SettingsService extends ChangeNotifier {
bool _notifySessionEnd = false;
bool _notifyPersistent = false;
+ // Focus mode settings
+ bool _ghostMode = false;
+ bool _noAds = false;
+ bool _noStories = false;
+ bool _noReels = false;
+ bool _noAutoplay = false;
+ bool _noDMs = false;
+
List _enabledTabs = [
'Home',
'Search',
@@ -105,12 +160,28 @@ class SettingsService extends ChangeNotifier {
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
+ int get breathGateSeconds => _breathGateSeconds;
+ int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings;
List get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay;
+
+ // Extras (Phase 2)
+ bool get videoDownloadEnabled => _videoDownloadEnabled;
+ bool get hideSuggestedPosts => _hideSuggestedPosts;
+
+ // ── FocusGram v2 overlay toggles ─────────────────────────────────────────
+ bool get v2GhostModeEnabled => _v2GhostModeEnabled;
+ bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
+ bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
+
+ bool get contentStories => _contentStories;
+ bool get contentPosts => _contentPosts;
+ bool get contentReels => _contentReels;
+ bool get contentSuggested => _contentSuggested;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd;
@@ -119,14 +190,22 @@ class SettingsService extends ChangeNotifier {
bool get grayscaleEnabled => _grayscaleEnabled;
List> get grayscaleSchedules => _grayscaleSchedules;
- bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab;
+ // Focus mode settings
+ bool get ghostMode => _ghostMode;
+ bool get noAds => _noAds;
+ bool get noStories => _noStories;
+ bool get noReels => _noReels;
+ bool get noAutoplay => _noAutoplay;
+ bool get noDMs => _noDMs;
+
// These are now controlled by minimal mode only
- bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
- bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
+ bool get disableReelsEntirely => _disableReelsEntirely;
+ bool get disableExploreEntirely => _disableExploreEntirely;
+ bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
@@ -136,22 +215,23 @@ class SettingsService extends ChangeNotifier {
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 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
@@ -182,43 +262,80 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
+ _breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
+ .clamp(3, 60)
+ .toInt();
+ _wordChallengeCount = _normaliseWordChallengeCount(
+ _prefs!.getInt(_keyWordChallengeCount) ?? 30,
+ );
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
- _grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
-
+ // Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
+ _videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
+ _hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
+
+ // ── FocusGram v2 overlay toggles ─────────────────────────────────────────
+ _v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
+ _v2AdBlockerDomEnabled =
+ _prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
+ _v2ContentHiderEnabled =
+ _prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
+
+ _contentStories = _prefs!.getBool(_keyContentStories) ?? false;
+ _contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
+ _contentReels = _prefs!.getBool(_keyContentReels) ?? false;
+ _contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
+ _hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
+
// Load grayscale schedules
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
_grayscaleSchedules = List>.from(
- (jsonDecode(schedulesJson) as List).map((e) => Map.from(e))
+ (jsonDecode(schedulesJson) as List).map(
+ (e) => Map.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;
+ _prevDisableReels =
+ _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
+ _prevDisableExplore =
+ _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
+ _prevBlockHomeFeedScroll =
+ _prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? 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;
+ _disableReelsEntirely =
+ _prefs!.getBool('internal_disable_reels_entirely') ?? false;
+ _disableExploreEntirely =
+ _prefs!.getBool('internal_disable_explore_entirely') ?? false;
+ _blockHomeFeedScroll =
+ _prefs!.getBool('internal_block_home_feed_scroll') ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
+ // Focus mode settings
+ _ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
+ _noAds = _prefs!.getBool(_keyNoAds) ?? false;
+ _noStories = _prefs!.getBool(_keyNoStories) ?? false;
+ _noReels = _prefs!.getBool(_keyNoReels) ?? false;
+ _noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
+ _noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
+
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
@@ -245,12 +362,12 @@ class SettingsService extends ChangeNotifier {
Future 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);
+
+ if (_minimalModeEnabled) {
+ await _checkAndAutoDisableMinimalMode();
+ }
+
notifyListeners();
}
@@ -289,6 +406,30 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
+ Future setBreathGateSeconds(int seconds) async {
+ _breathGateSeconds = seconds.clamp(3, 60).toInt();
+ await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
+ notifyListeners();
+ }
+
+ Future setWordChallengeCount(int count) async {
+ _wordChallengeCount = _normaliseWordChallengeCount(count);
+ await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
+ notifyListeners();
+ }
+
+ int resolvedWordChallengeCount() {
+ if (_wordChallengeCount != 0) return _wordChallengeCount;
+ final now = DateTime.now().microsecondsSinceEpoch;
+ return 10 + (now % 26);
+ }
+
+ static int _normaliseWordChallengeCount(int count) {
+ if (count == 0) return 0;
+ const allowed = [20, 25, 30, 35];
+ return allowed.contains(count) ? count : 30;
+ }
+
Future setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -307,13 +448,29 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
+ // ── Extras (Phase 2) ──────────────────────────────────────────────────────
+
+ Future setVideoDownloadEnabled(bool v) async {
+ _videoDownloadEnabled = v;
+ await _prefs?.setBool(_keyVideoDownloadEnabled, v);
+ notifyListeners();
+ }
+
+ Future setHideSuggestedPosts(bool v) async {
+ _hideSuggestedPosts = v;
+ await _prefs?.setBool(_keyHideSuggestedPosts, v);
+ notifyListeners();
+ }
+
Future setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners();
}
- Future setGrayscaleSchedules(List> schedules) async {
+ Future setGrayscaleSchedules(
+ List> schedules,
+ ) async {
_grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners();
@@ -321,14 +478,23 @@ class SettingsService extends ChangeNotifier {
Future addGrayscaleSchedule(Map schedule) async {
_grayscaleSchedules.add(schedule);
- await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
+ await _prefs?.setString(
+ _keyGrayscaleSchedules,
+ jsonEncode(_grayscaleSchedules),
+ );
notifyListeners();
}
- Future updateGrayscaleSchedule(int index, Map schedule) async {
+ Future updateGrayscaleSchedule(
+ int index,
+ Map schedule,
+ ) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules[index] = schedule;
- await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
+ await _prefs?.setString(
+ _keyGrayscaleSchedules,
+ jsonEncode(_grayscaleSchedules),
+ );
notifyListeners();
}
}
@@ -336,20 +502,76 @@ class SettingsService extends ChangeNotifier {
Future removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index);
- await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
+ await _prefs?.setString(
+ _keyGrayscaleSchedules,
+ jsonEncode(_grayscaleSchedules),
+ );
notifyListeners();
}
}
- Future setHideSponsoredPosts(bool v) async {
- _hideSponsoredPosts = v;
- await _prefs?.setBool(_keyHideSponsoredPosts, v);
+ Future setHideShopTab(bool v) async {
+ _hideShopTab = v;
+ await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
- Future setHideLikeCounts(bool v) async {
- _hideLikeCounts = v;
- await _prefs?.setBool(_keyHideLikeCounts, v);
+ // ── FocusGram v2 overlay setters ──────────────────────────────────────────
+ Future setV2GhostModeEnabled(bool v) async {
+ _v2GhostModeEnabled = v;
+ await _prefs?.setBool(_keyV2GhostModeEnabled, v);
+ notifyListeners();
+ }
+
+ Future setV2AdBlockerDomEnabled(bool v) async {
+ _v2AdBlockerDomEnabled = v;
+ await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
+ notifyListeners();
+ }
+
+ Future setV2ContentHiderEnabled(bool v) async {
+ _v2ContentHiderEnabled = v;
+ await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
+ notifyListeners();
+ }
+
+ Future setContentStoriesEnabled(bool v) async {
+ if (v && !_v2ContentHiderEnabled) {
+ _v2ContentHiderEnabled = true;
+ await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
+ }
+ _contentStories = v;
+ await _prefs?.setBool(_keyContentStories, v);
+ notifyListeners();
+ }
+
+ Future setContentPostsEnabled(bool v) async {
+ if (v && !_v2ContentHiderEnabled) {
+ _v2ContentHiderEnabled = true;
+ await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
+ }
+ _contentPosts = v;
+ await _prefs?.setBool(_keyContentPosts, v);
+ notifyListeners();
+ }
+
+ Future setContentReelsEnabled(bool v) async {
+ if (v && !_v2ContentHiderEnabled) {
+ _v2ContentHiderEnabled = true;
+ await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
+ }
+ _contentReels = v;
+ await _prefs?.setBool(_keyContentReels, v);
+ notifyListeners();
+ }
+
+ Future setContentSuggestedEnabled(bool v) async {
+ if (v && !_v2ContentHiderEnabled) {
+ _v2ContentHiderEnabled = true;
+ await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
+ }
+ _contentSuggested = v;
+ await _prefs?.setBool(_keyContentSuggested, v);
notifyListeners();
}
@@ -359,62 +581,138 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
- Future setHideShopTab(bool v) async {
- _hideShopTab = v;
- await _prefs?.setBool(_keyHideShopTab, v);
- notifyListeners();
- }
-
/// Setter for internal disable reels state (used by minimal mode submenu)
+ /// Auto-disables minimal mode if all features are turned off
Future setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v);
+
+ // Check if minimal mode should auto-disable
+ await _checkAndAutoDisableMinimalMode();
+
notifyListeners();
}
/// Setter for internal disable explore state (used by minimal mode submenu)
+ /// Auto-disables minimal mode if all features are turned off
Future setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v);
+
+ // Check if minimal mode should auto-disable
+ await _checkAndAutoDisableMinimalMode();
+
notifyListeners();
}
+ /// Setter for home feed scroll blocking state (used by minimal mode submenu).
+ Future setBlockHomeFeedScrollInternal(bool v) async {
+ _blockHomeFeedScroll = v;
+ await _prefs?.setBool('internal_block_home_feed_scroll', v);
+
+ await _checkAndAutoDisableMinimalMode();
+
+ notifyListeners();
+ }
+
+ /// Helper: Auto-disable minimal mode if all its features are disabled
+ /// This ensures minimal mode auto-turns-off when user disables all sub-features
+ ///
+ /// NOTE: We must check the RAW state variables here, NOT the public getters
+ /// (disableReelsEntirely/disableExploreEntirely), because those getters
+ /// unconditionally return true when _minimalModeEnabled is true, which would
+ /// make the "all disabled" condition impossible to reach.
+ Future _checkAndAutoDisableMinimalMode() async {
+ if (!_minimalModeEnabled) return;
+
+ // Check the RAW saved state, not the getters
+ final rawReels =
+ _prefs?.getBool('internal_disable_reels_entirely') ??
+ _disableReelsEntirely;
+ final rawExplore =
+ _prefs?.getBool('internal_disable_explore_entirely') ??
+ _disableExploreEntirely;
+
+ final rawHomeFeedScroll =
+ _prefs?.getBool('internal_block_home_feed_scroll') ??
+ _blockHomeFeedScroll;
+
+ final allDisabled =
+ !rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
+
+ if (allDisabled) {
+ _minimalModeEnabled = false;
+ await _prefs?.setBool(_keyMinimalModeEnabled, false);
+ }
+ }
+
/// Smart minimal mode toggle with state preservation
Future setMinimalModeEnabled(bool v) async {
if (v) {
- // Turning ON - save current states BEFORE enabling minimal mode
+ // ── Turning ON ──────────────────────────────────────────────────────────
+ // Save current pre-minimal-mode states so we can restore them later
_prevDisableReels = _disableReelsEntirely;
_prevDisableExplore = _disableExploreEntirely;
_prevBlurExplore = _blurExplore;
-
+ _prevBlockHomeFeedScroll = _blockHomeFeedScroll;
+
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
- await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
+ await _prefs?.setBool(
+ _keyMinimalModePrevDisableExplore,
+ _prevDisableExplore,
+ );
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
-
- // Enable all minimal mode settings
+ await _prefs?.setBool(
+ _keyMinimalModePrevBlockHomeFeedScroll,
+ _prevBlockHomeFeedScroll,
+ );
+
_minimalModeEnabled = true;
_disableReelsEntirely = true;
_disableExploreEntirely = true;
- _blurExplore = true;
-
+ _blockHomeFeedScroll = true;
+ _blurExplore = true; // blurExplore is controlled by minimal mode while ON
+
await _prefs?.setBool(_keyMinimalModeEnabled, true);
await _prefs?.setBool('internal_disable_reels_entirely', true);
await _prefs?.setBool('internal_disable_explore_entirely', true);
+ await _prefs?.setBool('internal_block_home_feed_scroll', true);
await _prefs?.setBool(_keyBlurExplore, true);
} else {
- // Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
+ // ── Turning OFF ─────────────────────────────────────────────────────────
+ // Restore states that were saved BEFORE minimal mode was enabled.
+ // _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
_minimalModeEnabled = false;
-
- // Simply restore to the states that were saved BEFORE minimal mode was enabled
_disableReelsEntirely = _prevDisableReels;
_disableExploreEntirely = _prevDisableExplore;
+ _blockHomeFeedScroll = _prevBlockHomeFeedScroll;
+ // For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
+ // to the saved prefs value (covers the case where no prev was saved).
_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(
+ 'internal_disable_reels_entirely',
+ _disableReelsEntirely,
+ );
+ await _prefs?.setBool(
+ 'internal_disable_explore_entirely',
+ _disableExploreEntirely,
+ );
+ await _prefs?.setBool(
+ 'internal_block_home_feed_scroll',
+ _blockHomeFeedScroll,
+ );
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
+
+ // After restoring, check whether the user had ALL minimal features OFF
+ // already — if so, minimal mode should stay off (no-op).
+ if (!_disableReelsEntirely &&
+ !_disableExploreEntirely &&
+ !_blockHomeFeedScroll &&
+ !_blurExplore) {
+ // All features are off — minimal mode correctly stays off. No action needed.
+ }
}
notifyListeners();
}
@@ -441,24 +739,69 @@ class SettingsService extends ChangeNotifier {
Future setNotifyDMs(bool v) async {
_notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v);
+ if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future setNotifyActivity(bool v) async {
_notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v);
+ if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future setNotifySessionEnd(bool v) async {
_notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v);
+ if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future setNotifyPersistent(bool v) async {
_notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v);
+ if (v) {
+ await NotificationService().requestPermissionsNow();
+ } else {
+ await NotificationService().cancelPersistentNotification(id: 5001);
+ }
+ notifyListeners();
+ }
+
+ // ── Focus mode settings ──────────────────────────────────────────────────────
+ Future setGhostMode(bool v) async {
+ _ghostMode = v;
+ await _prefs?.setBool(_keyGhostMode, v);
+ notifyListeners();
+ }
+
+ Future setNoAds(bool v) async {
+ _noAds = v;
+ await _prefs?.setBool(_keyNoAds, v);
+ notifyListeners();
+ }
+
+ Future setNoStories(bool v) async {
+ _noStories = v;
+ await _prefs?.setBool(_keyNoStories, v);
+ notifyListeners();
+ }
+
+ Future setNoReels(bool v) async {
+ _noReels = v;
+ await _prefs?.setBool(_keyNoReels, v);
+ notifyListeners();
+ }
+
+ Future setNoAutoplay(bool v) async {
+ _noAutoplay = v;
+ await _prefs?.setBool(_keyNoAutoplay, v);
+ notifyListeners();
+ }
+
+ Future setNoDMs(bool v) async {
+ _noDMs = v;
+ await _prefs?.setBool(_keyNoDMs, v);
notifyListeners();
}
diff --git a/lib/utils/discipline_challenge.dart b/lib/utils/discipline_challenge.dart
index 9ddf824..8acf0ce 100644
--- a/lib/utils/discipline_challenge.dart
+++ b/lib/utils/discipline_challenge.dart
@@ -517,7 +517,7 @@ class DisciplineChallenge {
];
/// Shows the word challenge dialog. Returns true if successful.
- static Future show(BuildContext context, {int count = 15}) async {
+ static Future show(BuildContext context, {int count = 30}) async {
final list = List.from(_words)..shuffle();
final challenge = list.take(count).join(' ');
final controller = TextEditingController();
diff --git a/lib/v2_integration/script_engine_v2_overlay.dart b/lib/v2_integration/script_engine_v2_overlay.dart
new file mode 100644
index 0000000..0390e6b
--- /dev/null
+++ b/lib/v2_integration/script_engine_v2_overlay.dart
@@ -0,0 +1,141 @@
+import 'dart:convert';
+
+import 'package:flutter/services.dart';
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'script_registry_v2_overlay.dart';
+
+class ScriptEngineV2Overlay {
+ final InAppWebViewController controller;
+ final SharedPreferences prefs;
+
+ final Map _cache = {};
+
+ ScriptEngineV2Overlay({required this.controller, required this.prefs});
+
+ Future initDocumentStartScripts() async {
+ for (final s in V2OverlayScriptRegistry.all) {
+ final enabled = _getEnabled(s.id);
+ s.enabled = enabled;
+
+ if (!enabled) continue;
+
+ if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
+ final code = await _load(s.assetPath);
+ if (code == null) continue;
+
+ await controller.addUserScript(
+ userScript: UserScript(
+ source: code,
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
+ allowedOriginRules: {'https://www.instagram.com'},
+ ),
+ );
+ }
+ }
+ }
+
+ Future injectDocumentEndScripts() async {
+ for (final s in V2OverlayScriptRegistry.all) {
+ final enabled = _getEnabled(s.id);
+ s.enabled = enabled;
+ if (!enabled) continue;
+
+ if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
+ final code = await _load(s.assetPath);
+ if (code == null) continue;
+ try {
+ await controller.evaluateJavascript(source: code);
+ } catch (_) {
+ // Best-effort injection; never crash UI.
+ }
+ }
+ }
+
+ await _pushContentFlagsIfNeeded();
+ }
+
+ Future toggle(V2OverlayScriptId id, bool enabled) async {
+ await prefs.setBool(_enabledKey(id), enabled);
+
+ // For DOCUMENT_START scripts, require reload for clean removal.
+ if (V2OverlayScriptRegistry.byId(id).injectionTime ==
+ UserScriptInjectionTime.AT_DOCUMENT_START) {
+ await controller.reload();
+ return;
+ }
+
+ // For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
+ await controller.reload();
+ }
+
+ bool _getEnabled(V2OverlayScriptId id) {
+ return prefs.getBool(_enabledKey(id)) ??
+ (id == V2OverlayScriptId.themeDetector);
+ }
+
+ String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
+
+ Future _pushContentFlagsIfNeeded() async {
+ final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
+
+ final contentFlags = {
+ 'stories': prefs.getBool('content_stories') ?? false,
+ 'posts': prefs.getBool('content_posts') ?? false,
+ 'reels': prefs.getBool('content_reels') ?? false,
+ 'suggested': prefs.getBool('content_suggested') ?? false,
+ };
+
+ // Apply DOM content hider flags
+ if (contentScriptEnabled) {
+ await controller.evaluateJavascript(
+ source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
+ );
+ }
+
+ // Also push network filter flags used by fetch_interceptor.js
+ // so toggles actually affect request/response behavior.
+ final noAds =
+ (prefs.getBool('no_ads') ?? false) ||
+ (prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
+ final blockFeedPosts = contentFlags['posts'] ?? false;
+ final blockSuggested = contentFlags['suggested'] ?? false;
+ final blockReels = contentFlags['reels'] ?? false;
+ final blockAutoplay =
+ prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
+
+ await controller.evaluateJavascript(
+ source:
+ 'window.__fgSetFilterConfig?.(${jsonEncode({
+ // Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
+ 'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
+
+ // Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
+ 'blockAds': noAds,
+ 'blockSponsored': noAds,
+
+ 'blockSuggested': blockSuggested,
+
+ // Keep video blocking controlled by existing toggles if desired.
+ 'blockVideos': blockReels,
+ 'blockAutoplay': blockAutoplay,
+ })});',
+ );
+
+ await controller.evaluateJavascript(
+ source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
+ );
+ }
+
+ Future _load(String assetPath) async {
+ if (_cache.containsKey(assetPath)) return _cache[assetPath];
+ try {
+ final code = await rootBundle.loadString(assetPath);
+ _cache[assetPath] = code;
+ return code;
+ } catch (_) {
+ return null;
+ }
+ }
+}
diff --git a/lib/v2_integration/script_registry_v2_overlay.dart b/lib/v2_integration/script_registry_v2_overlay.dart
new file mode 100644
index 0000000..261efa7
--- /dev/null
+++ b/lib/v2_integration/script_registry_v2_overlay.dart
@@ -0,0 +1,77 @@
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+
+enum V2OverlayScriptId {
+ ghostMode,
+ themeDetector,
+ adBlockerDom,
+ contentHider,
+ fetchInterceptor,
+ autoplayBlocker,
+}
+
+class V2OverlayInstaScript {
+ final V2OverlayScriptId id;
+ final String name;
+ final String assetPath;
+ final UserScriptInjectionTime injectionTime;
+ bool enabled;
+
+ V2OverlayInstaScript({
+ required this.id,
+ required this.name,
+ required this.assetPath,
+ this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
+ this.enabled = false,
+ });
+}
+
+class V2OverlayScriptRegistry {
+ static final List all = [
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.ghostMode,
+ name: 'ghost_mode',
+ assetPath: 'assets/scripts/ghost_mode.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
+ enabled: false,
+ ),
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.themeDetector,
+ name: 'theme_detector',
+ assetPath: 'assets/scripts/theme_detector.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
+ enabled: true,
+ ),
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.adBlockerDom,
+ name: 'ad_blocker_dom',
+ assetPath: 'assets/scripts/ad_blocker_dom.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
+ enabled: false,
+ ),
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.contentHider,
+ name: 'content_hider',
+ assetPath: 'assets/scripts/content_hider.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
+ enabled: false,
+ ),
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.fetchInterceptor,
+ name: 'fetch_interceptor',
+ assetPath: 'assets/scripts/fetch_interceptor.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
+ enabled: false,
+ ),
+ V2OverlayInstaScript(
+ id: V2OverlayScriptId.autoplayBlocker,
+ name: 'autoplay_blocker',
+ assetPath: 'assets/scripts/autoplay_blocker.js',
+ injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
+ enabled: false,
+ ),
+ ];
+
+ static V2OverlayInstaScript byId(V2OverlayScriptId id) {
+ return all.firstWhere((s) => s.id == id);
+ }
+}
diff --git a/metadata/com.ujwal.focusgram.yml b/metadata/com.ujwal.focusgram.yml
deleted file mode 100644
index a345f17..0000000
--- a/metadata/com.ujwal.focusgram.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-Categories:
- - Connectivity
- - Social Network
-License: AGPL-3.0-only
-AuthorName: Ujwal Chapagain
-AuthorEmail: notujwal@proton.me
-SourceCode: https://github.com/Ujwal223/FocusGram
-IssueTracker: https://github.com/Ujwal223/FocusGram/issues
-Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
-
-AutoName: FocusGram
-
-RepoType: git
-Repo: https://github.com/Ujwal223/FocusGram
-
-Builds:
- - versionName: 1.0.0
- versionCode: 3
- commit: v1.0.0
- output: build/app/outputs/flutter-apk/app-release.apk
- srclibs:
- - flutter@stable
- prebuild:
- - flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
- - '[[ $flutterVersion ]]'
- - git -C $$flutter$$ checkout -f $flutterVersion
- - export PUB_CACHE=$(pwd)/.pub-cache
- - .flutter/bin/flutter config --no-analytics
- - .flutter/bin/flutter pub get
- scanignore:
- - .flutter/bin/cache
- scandelete:
- - .flutter
- - .pub-cache
- build:
- - export PUB_CACHE=$(pwd)/.pub-cache
- - .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
-
-AutoUpdateMode: Version
-UpdateCheckMode: Tags
-VercodeOperation:
- - '%c * 10 + 1'
-UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
-CurrentVersion: 1.0.0
-CurrentVersionCode: 3
diff --git a/pubspec.lock b/pubspec.lock
index e458d07..ec180c5 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -37,10 +37,10 @@ packages:
dependency: "direct main"
description:
name: app_settings
- sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
+ sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
url: "https://pub.dev"
source: hosted
- version: "6.1.1"
+ version: "7.0.0"
archive:
dependency: transitive
description:
@@ -213,10 +213,10 @@ packages:
dependency: "direct main"
description:
name: fl_chart
- sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
+ sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
url: "https://pub.dev"
source: hosted
- version: "0.69.2"
+ version: "0.71.0"
flutter:
dependency: "direct main"
description: flutter
@@ -290,10 +290,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
- sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
- version: "0.13.1"
+ version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -372,10 +372,10 @@ packages:
dependency: "direct main"
description:
name: google_fonts
- sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
+ sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
url: "https://pub.dev"
source: hosted
- version: "8.0.2"
+ version: "8.1.0"
gtk:
dependency: transitive
description:
@@ -596,10 +596,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
- sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
+ sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
- version: "8.3.1"
+ version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
@@ -668,18 +668,18 @@ packages:
dependency: "direct main"
description:
name: permission_handler
- sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
+ sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
- version: "12.0.1"
+ version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
- sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
+ sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
- version: "13.0.1"
+ version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
@@ -764,10 +764,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
- sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
- version: "2.5.4"
+ version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index be27856..ab7b7bd 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -11,11 +11,11 @@ dependencies:
flutter:
sdk: flutter
- # WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
+ # WebView engine
flutter_inappwebview: ^6.1.5
# Local key-value persistence — latest stable
- shared_preferences: ^2.5.4
+ shared_preferences: ^2.5.5
# Date/time formatting for daily resets — latest stable
intl: ^0.20.2
@@ -28,26 +28,26 @@ dependencies:
# URL launcher for About page links — latest stable
url_launcher: ^6.3.2
- package_info_plus: ^8.1.2
+ package_info_plus: ^9.0.0
# Handling Instagram deep links — latest stable
- app_links: ^6.3.2
+ app_links: ^6.4.1
# Open system settings — latest stable
- app_settings: ^6.1.1
- google_fonts: ^8.0.2
- http: ^1.3.0
- permission_handler: ^12.0.1
+ app_settings: ^7.0.0
+ google_fonts: ^8.1.0
+ http: ^1.6.0
+ permission_handler: ^11.4.0
# Image/file picker for story uploads on Android
- image_picker: ^1.1.2
+ image_picker: ^1.2.0
flutter_windowmanager_plus: ^1.0.1
# Charts for on-device screen time dashboard (MIT)
- fl_chart: ^0.69.0
+ fl_chart: ^0.71.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
- flutter_launcher_icons: ^0.13.1
+ flutter_launcher_icons: ^0.14.4
flutter:
uses-material-design: true
@@ -55,6 +55,12 @@ flutter:
assets:
- assets/images/focusgram.png
- assets/images/focusgram.ico
+ - assets/scripts/ghost_mode.js
+ - assets/scripts/ad_blocker_dom.js
+ - assets/scripts/content_hider.js
+ - assets/scripts/theme_detector.js
+ - assets/scripts/fetch_interceptor.js
+ - assets/scripts/autoplay_blocker.js
flutter_launcher_icons:
android: true
diff --git a/test/screens/main_webview_page_media_download_test.dart b/test/screens/main_webview_page_media_download_test.dart
new file mode 100644
index 0000000..864aacd
--- /dev/null
+++ b/test/screens/main_webview_page_media_download_test.dart
@@ -0,0 +1,63 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:focusgram/screens/main_webview_page.dart';
+
+void main() {
+ group('handleFocusGramMediaDownload', () {
+ test('rejects non-http(s) schemes', () async {
+ final launched = [];
+ final ok = await handleFocusGramMediaDownload(
+ raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
+ launch: (uri) async => launched.add(uri),
+ );
+
+ expect(ok, isFalse);
+ expect(launched, isEmpty);
+ });
+
+ test('accepts http(s) instagram-like hosts and calls launcher', () async {
+ final launched = [];
+ final ok = await handleFocusGramMediaDownload(
+ raw: '{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
+ launch: (uri) async => launched.add(uri),
+ );
+
+ expect(ok, isTrue);
+ expect(launched, hasLength(1));
+ expect(launched.first.scheme, 'https');
+ expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
+ });
+
+ test('rejects non-instagram hosts even if http(s)', () async {
+ final launched = [];
+ final ok = await handleFocusGramMediaDownload(
+ raw: '{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
+ launch: (uri) async => launched.add(uri),
+ );
+
+ expect(ok, isFalse);
+ expect(launched, isEmpty);
+ });
+
+ test('rejects malformed JSON safely', () async {
+ final launched = [];
+ final ok = await handleFocusGramMediaDownload(
+ raw: '{not json',
+ launch: (uri) async => launched.add(uri),
+ );
+
+ expect(ok, isFalse);
+ expect(launched, isEmpty);
+ });
+
+ test('rejects missing url field', () async {
+ final launched = [];
+ final ok = await handleFocusGramMediaDownload(
+ raw: '{"type":"video","filename":"x"}',
+ launch: (uri) async => launched.add(uri),
+ );
+
+ expect(ok, isFalse);
+ expect(launched, isEmpty);
+ });
+ });
+}
diff --git a/test/services/adblocker_test.dart b/test/services/adblocker_test.dart
new file mode 100644
index 0000000..6c53456
--- /dev/null
+++ b/test/services/adblocker_test.dart
@@ -0,0 +1,90 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'package:focusgram/services/injection_manager.dart';
+import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
+import 'package:focusgram/services/session_manager.dart';
+import 'package:focusgram/services/settings_service.dart';
+
+class _FakeJsEvaluator implements JsEvaluator {
+ final List sources = [];
+
+ @override
+ Future evaluateJavascript({required String source}) async {
+ sources.add(source);
+ }
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ });
+
+ test(
+ 'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
+ () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setV2AdBlockerDomEnabled(true);
+
+ expect(settings.v2AdBlockerDomEnabled, isTrue);
+
+ mgr.setSettingsService(settings);
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ // Verify that sponsored posts JS injection is NOT triggered by InjectionManager
+ // (it's handled by the V2 DOM Ad Blocker engine instead)
+ final sponsoredPostsInjected = fakeEval.sources.any(
+ (s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
+ );
+
+ expect(
+ sponsoredPostsInjected,
+ isFalse,
+ reason:
+ 'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
+ );
+ },
+ );
+
+ test(
+ 'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
+ () {
+ final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
+! comment
+[Adblock Plus 2.0]
+||ads.example.com^
+||tracker.example.net/path.js\$third-party
+@@||allowed.example.com^
+example.com##.sponsored
+||wild*.example.com^
+||bad,domain.example^
+||sub.adguard.example.org^\$script,third-party
+''');
+
+ expect(
+ hosts,
+ containsAll({
+ 'ads.example.com',
+ 'tracker.example.net',
+ 'sub.adguard.example.org',
+ }),
+ );
+ expect(hosts, isNot(contains('allowed.example.com')));
+ expect(hosts, isNot(contains('wild*.example.com')));
+ expect(hosts, isNot(contains('bad,domain.example')));
+ },
+ );
+}
diff --git a/test/services/injection_manager_test.dart b/test/services/injection_manager_test.dart
new file mode 100644
index 0000000..5c4fccc
--- /dev/null
+++ b/test/services/injection_manager_test.dart
@@ -0,0 +1,155 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:focusgram/services/injection_manager.dart';
+import 'package:focusgram/services/session_manager.dart';
+import 'package:focusgram/services/settings_service.dart';
+
+class _FakeJsEvaluator implements JsEvaluator {
+ final List sources = [];
+
+ @override
+ Future evaluateJavascript({required String source}) async {
+ sources.add(source);
+ }
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ });
+
+ test(
+ 'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
+ () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setHideSuggestedPosts(true);
+
+ mgr.setSettingsService(settings);
+
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
+ expect(any, isFalse);
+ },
+ );
+
+ test(
+ 'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
+ () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setHideSuggestedPosts(false);
+
+ mgr.setSettingsService(settings);
+
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
+ expect(any, isFalse);
+ },
+ );
+
+ test(
+ 'injects video downloader JS only when settings.videoDownloadEnabled=true',
+ () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setVideoDownloadEnabled(true);
+
+ mgr.setSettingsService(settings);
+
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ final any = fakeEval.sources.any(
+ (s) => s.contains('__fgMediaDownloadRunning'),
+ );
+ expect(any, isTrue);
+ },
+ );
+
+ test(
+ 'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
+ () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setVideoDownloadEnabled(false);
+
+ mgr.setSettingsService(settings);
+
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ final any = fakeEval.sources.any(
+ (s) => s.contains('__fgMediaDownloadRunning'),
+ );
+ expect(any, isFalse);
+ },
+ );
+
+ test('injects home feed scroll lock flag when enabled', () async {
+ final prefs = await SharedPreferences.getInstance();
+ final sm = SessionManager();
+ final fakeEval = _FakeJsEvaluator();
+
+ final mgr = InjectionManager.forTest(
+ jsEvaluator: fakeEval,
+ prefs: prefs,
+ sessionManager: sm,
+ );
+
+ final settings = SettingsService();
+ await settings.init();
+ await settings.setBlockHomeFeedScrollInternal(true);
+
+ mgr.setSettingsService(settings);
+
+ await mgr.runAllPostLoadInjections('https://www.instagram.com/');
+
+ final any = fakeEval.sources.any(
+ (s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
+ );
+ expect(any, isTrue);
+ });
+}
diff --git a/test/services/reels_blocker_test.dart b/test/services/reels_blocker_test.dart
new file mode 100644
index 0000000..4500a11
--- /dev/null
+++ b/test/services/reels_blocker_test.dart
@@ -0,0 +1,56 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:focusgram/services/injection_controller.dart';
+
+void main() {
+ group('InjectionController reels blocker', () {
+ test('includes strict reels blocker JS when sessionActive=false', () {
+ final js = InjectionController.buildInjectionJS(
+ sessionActive: false,
+ blurExplore: false,
+ blurReels: false,
+ tapToUnblur: false,
+ enableTextSelection: false,
+ hideSuggestedPosts: false,
+ hideSponsoredPosts: false,
+ hideLikeCounts: false,
+ hideFollowerCounts: false,
+ hideExploreTab: false,
+ hideReelsTab: false,
+ hideShopTab: false,
+ disableReelsEntirely: false,
+ blockHomeFeedScroll: false,
+ );
+
+ expect(js, contains('window.__fgReelsBlockPatched'));
+ expect(js, contains("window.location.href = '/reels/?fg=blocked';"));
+ });
+
+ test(
+ 'does NOT include strict reels blocker JS when sessionActive=true',
+ () {
+ final js = InjectionController.buildInjectionJS(
+ sessionActive: true,
+ blurExplore: false,
+ blurReels: false,
+ tapToUnblur: false,
+ enableTextSelection: false,
+ hideSuggestedPosts: false,
+ hideSponsoredPosts: false,
+ hideLikeCounts: false,
+ hideFollowerCounts: false,
+ hideExploreTab: false,
+ hideReelsTab: false,
+ hideShopTab: false,
+ disableReelsEntirely: false,
+ blockHomeFeedScroll: false,
+ );
+
+ expect(js, isNot(contains('window.__fgReelsBlockPatched')));
+ expect(
+ js,
+ isNot(contains("window.location.href = '/reels/?fg=blocked';")),
+ );
+ },
+ );
+ });
+}
diff --git a/test/services/screen_time_service_test.dart b/test/services/screen_time_service_test.dart
new file mode 100644
index 0000000..b59219c
--- /dev/null
+++ b/test/services/screen_time_service_test.dart
@@ -0,0 +1,66 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:focusgram/services/screen_time_service.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ });
+
+ test('init loads persisted secondsByDate', () async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(
+ ScreenTimeService.prefKey,
+ '{"2026-01-01": 42, "2026-01-02": 7}',
+ );
+
+ final s = ScreenTimeService();
+ await s.init();
+
+ expect(s.secondsByDate['2026-01-01'], 42);
+ expect(s.secondsByDate['2026-01-02'], 7);
+ });
+
+ test('resetAll clears stored data and in-memory map', () async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(ScreenTimeService.prefKey, '{"2026-01-01": 42}');
+
+ final s = ScreenTimeService();
+ await s.init();
+ expect(s.secondsByDate.isNotEmpty, isTrue);
+
+ await s.resetAll();
+ expect(s.secondsByDate, isEmpty);
+
+ final raw = prefs.getString(ScreenTimeService.prefKey);
+ expect(raw, isNull);
+ });
+
+ test('startTracking increments today seconds and stopTracking persists', () async {
+ final s = ScreenTimeService();
+ await s.init();
+
+ final beforeTodayKey = DateTime.now();
+ final todayKey =
+ '${beforeTodayKey.year.toString().padLeft(4, '0')}-'
+ '${beforeTodayKey.month.toString().padLeft(2, '0')}-'
+ '${beforeTodayKey.day.toString().padLeft(2, '0')}';
+
+ s.startTracking();
+
+ // Wait ~2 seconds (test is unit-ish; still acceptable).
+ await Future.delayed(const Duration(seconds: 2));
+
+ s.stopTracking();
+
+ expect(s.secondsByDate[todayKey], isNotNull);
+ expect(s.secondsByDate[todayKey]!, greaterThanOrEqualTo(2));
+
+ final prefs = await SharedPreferences.getInstance();
+ final stored = prefs.getString(ScreenTimeService.prefKey);
+ expect(stored, isNotNull);
+ expect(stored, contains(todayKey));
+ });
+}
diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart
new file mode 100644
index 0000000..b091887
--- /dev/null
+++ b/test/services/settings_service_test.dart
@@ -0,0 +1,105 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:focusgram/services/settings_service.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ SharedPreferences.setMockInitialValues({});
+ });
+
+ group('SettingsService — Phase 2 Extras', () {
+ test('defaults are OFF for video download/hide suggested', () async {
+ final s = SettingsService();
+ await s.init();
+
+ expect(s.videoDownloadEnabled, isFalse);
+ expect(s.hideSuggestedPosts, isFalse);
+ });
+
+ test('setVideoDownloadEnabled persists', () async {
+ final s = SettingsService();
+ await s.init();
+
+ await s.setVideoDownloadEnabled(true);
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(s.videoDownloadEnabled, isTrue);
+ expect(prefs.getBool('video_download_enabled'), isTrue);
+ });
+
+ test('setHideSuggestedPosts persists', () async {
+ final s = SettingsService();
+ await s.init();
+
+ await s.setHideSuggestedPosts(true);
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(s.hideSuggestedPosts, isTrue);
+ expect(prefs.getBool('hide_suggested_posts'), isTrue);
+ });
+ });
+
+ group('SettingsService — minimal mode', () {
+ test(
+ 'home feed scroll can be disabled while minimal mode stays on',
+ () async {
+ final s = SettingsService();
+ await s.init();
+
+ await s.setMinimalModeEnabled(true);
+ await s.setBlockHomeFeedScrollInternal(false);
+
+ expect(s.minimalModeEnabled, isTrue);
+ expect(s.blockHomeFeedScroll, isFalse);
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(prefs.getBool('internal_block_home_feed_scroll'), isFalse);
+ expect(prefs.getBool('minimal_mode_enabled'), isTrue);
+ },
+ );
+
+ test(
+ 'minimal mode turns off when all child features are disabled',
+ () async {
+ final s = SettingsService();
+ await s.init();
+
+ await s.setMinimalModeEnabled(true);
+ await s.setBlurExplore(false);
+ await s.setBlockHomeFeedScrollInternal(false);
+ await s.setDisableReelsEntirelyInternal(false);
+ await s.setDisableExploreEntirelyInternal(false);
+
+ expect(s.minimalModeEnabled, isFalse);
+ expect(s.blurExplore, isFalse);
+ expect(s.blockHomeFeedScroll, isFalse);
+ expect(s.disableReelsEntirely, isFalse);
+ expect(s.disableExploreEntirely, isFalse);
+ },
+ );
+ });
+
+ group('SettingsService — v2 filtering split', () {
+ test(
+ 'ad blocker and suggested posts toggles persist independently',
+ () async {
+ final s = SettingsService();
+ await s.init();
+
+ await s.setV2AdBlockerDomEnabled(true);
+ await s.setContentSuggestedEnabled(true);
+ await s.setV2AdBlockerDomEnabled(false);
+
+ expect(s.v2AdBlockerDomEnabled, isFalse);
+ expect(s.contentSuggested, isTrue);
+ expect(s.v2ContentHiderEnabled, isTrue);
+
+ final prefs = await SharedPreferences.getInstance();
+ expect(prefs.getBool('v2_adblock_dom_enabled'), isFalse);
+ expect(prefs.getBool('content_suggested'), isTrue);
+ },
+ );
+ });
+}
diff --git a/v2/MainActivity.kt b/v2/MainActivity.kt
index 4e0a4c3..d7cdc8d 100644
--- a/v2/MainActivity.kt
+++ b/v2/MainActivity.kt
@@ -1,41 +1,16 @@
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
//
-// Adds:
-// 1. Platform channel for FLAG_SECURE (anti-screenshot at OS level)
-// 2. Ghost mode WebView integration notes
+// Ghost mode WebView integration notes
package com.focusgram.focusgram
import android.os.Bundle
-import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
-import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
-
- private val CHANNEL = "com.focusgram/window_flags"
-
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
-
- MethodChannel(
- flutterEngine.dartExecutor.binaryMessenger,
- CHANNEL
- ).setMethodCallHandler { call, result ->
- when (call.method) {
- "setSecure" -> {
- val secure = call.argument("secure") ?: false
- if (secure) {
- window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
- }
- result.success(null)
- }
- else -> result.notImplemented()
- }
- }
}
}
@@ -56,9 +31,6 @@ class MainActivity : FlutterActivity() {
// super.initState();
// _ghost = GhostModeService();
// _ghost.load().then((_) {
-// WidgetsBinding.instance.addPostFrameCallback((_) {
-// _ghost.applyWindowFlags(context);
-// });
// setState(() {});
// });
// }
diff --git a/v2/assets/scripts/ad_blocker_dom.js b/v2/assets/scripts/ad_blocker_dom.js
new file mode 100644
index 0000000..611518c
--- /dev/null
+++ b/v2/assets/scripts/ad_blocker_dom.js
@@ -0,0 +1,100 @@
+/**
+ * FocusGram DOM Ad Blocker
+ * Removes sponsored posts, "Suggested for you" injections, and ad elements.
+ * Uses structure-based selectors — NOT class names (those change weekly).
+ * Injected at DOCUMENT_END.
+ */
+(function () {
+ 'use strict';
+
+ // ─── Sponsored text signals (Instagram localizes these) ───────────────────
+ // We match the STRUCTURE not just English text.
+ // In IG mobile web, sponsored label appears as a or
+ // that is a direct sibling/child of the article header area.
+ const SPONSORED_TEXTS = new Set([
+ 'sponsored', // en
+ 'gesponsert', // de
+ 'patrocinado', // es/pt
+ 'sponsorisé', // fr
+ 'sponsorizzato', // it
+ 'sponsrad', // sv
+ 'sponsoreret', // da
+ 'gesponsord', // nl
+ 'рекламa', // ru
+ 'विज्ञापन', // hi
+ '广告', // zh
+ 'ad', // en short
+ ]);
+
+ const isSponsoredText = (text) =>
+ SPONSORED_TEXTS.has(text.trim().toLowerCase());
+
+ // ─── Remove a single article element ──────────────────────────────────────
+ const removeArticle = (el) => {
+ // Walk up to find the article or main feed item container
+ const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
+ target.remove();
+ };
+
+ // ─── Core ad scanner ──────────────────────────────────────────────────────
+ const scanAndRemove = () => {
+ // Strategy 1:
inside feed
+ document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
+ a.closest('article')?.remove();
+ });
+
+ // Strategy 2: Sponsored text in article spans
+ document.querySelectorAll('article').forEach((article) => {
+ const spans = article.querySelectorAll('span, div');
+ for (const span of spans) {
+ if (
+ span.children.length === 0 && // leaf node
+ isSponsoredText(span.textContent)
+ ) {
+ article.remove();
+ return;
+ }
+ }
+ });
+
+ // Strategy 3: "Suggested for you" feed injections
+ document.querySelectorAll('article, section').forEach((el) => {
+ const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
+ if (
+ firstText &&
+ (firstText.toLowerCase().startsWith('suggested') ||
+ firstText.toLowerCase().startsWith('you might') ||
+ firstText.toLowerCase() === 'posts you might like')
+ ) {
+ el.remove();
+ }
+ });
+
+ // Strategy 4: Instagram marks some ad containers with aria-label
+ document
+ .querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
+ .forEach((el) => {
+ el.closest('article')?.remove();
+ });
+
+ // Strategy 5: Tracking pixel iframes / hidden images
+ document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
+ document
+ .querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
+ .forEach((el) => el.remove());
+ };
+
+ // ─── Run on load + watch for new content ──────────────────────────────────
+ scanAndRemove();
+
+ const observer = new MutationObserver((mutations) => {
+ // Only scan if nodes were added (skip attribute/text changes)
+ const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
+ if (hasAdditions) scanAndRemove();
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+})();
diff --git a/v2/assets/scripts/autoplay_blocker.js b/v2/assets/scripts/autoplay_blocker.js
new file mode 100644
index 0000000..6911383
--- /dev/null
+++ b/v2/assets/scripts/autoplay_blocker.js
@@ -0,0 +1,83 @@
+/**
+ * FocusGram Autoplay Blocker
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ * Prevents video autoplay by:
+ * 1. Blocking play() calls on video elements
+ * 2. Disabling autoplay attribute
+ * 3. Removing preload attributes
+ */
+(function () {
+ 'use strict';
+
+ window.__fgBlockAutoplay = false;
+
+ // Override HTMLMediaElement.play() to check our flag
+ const _play = HTMLMediaElement.prototype.play;
+ HTMLMediaElement.prototype.play = function () {
+ if (window.__fgBlockAutoplay) {
+ // Return a resolved promise to avoid breaking Instagram's code
+ return Promise.resolve();
+ }
+ return _play.call(this);
+ };
+
+ // Override autoplay property setter
+ const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
+ const _originalAutoplaySetter = _videoDescriptor.set;
+
+ Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
+ set: function (value) {
+ if (window.__fgBlockAutoplay && value) {
+ // Silently ignore autoplay attempts when blocking is enabled
+ return;
+ }
+ if (_originalAutoplaySetter) {
+ _originalAutoplaySetter.call(this, value);
+ }
+ },
+ get: function () {
+ if (_videoDescriptor.get) {
+ return _videoDescriptor.get.call(this);
+ }
+ return this.getAttribute('autoplay') !== null;
+ },
+ enumerable: _videoDescriptor.enumerable,
+ configurable: true,
+ });
+
+ // On page load and SPA navigation, scan for video elements and remove autoplay
+ const removeAutoplayFromVideos = () => {
+ document.querySelectorAll('video, [role="video"]').forEach(el => {
+ if (window.__fgBlockAutoplay) {
+ el.autoplay = false;
+ el.removeAttribute('autoplay');
+ if (el.paused === false) {
+ el.pause();
+ }
+ }
+ });
+ };
+
+ // Run on load and when document changes
+ removeAutoplayFromVideos();
+
+ if (!window.__fgAutoplayObserver) {
+ let _timer = null;
+ window.__fgAutoplayObserver = new MutationObserver(() => {
+ clearTimeout(_timer);
+ _timer = setTimeout(removeAutoplayFromVideos, 500);
+ });
+ window.__fgAutoplayObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // Allow Flutter to toggle
+ window.__fgSetBlockAutoplay = function (enabled) {
+ window.__fgBlockAutoplay = !!enabled;
+ if (enabled) {
+ removeAutoplayFromVideos();
+ }
+ };
+})();
diff --git a/v2/assets/scripts/content_hider.js b/v2/assets/scripts/content_hider.js
new file mode 100644
index 0000000..0e8f718
--- /dev/null
+++ b/v2/assets/scripts/content_hider.js
@@ -0,0 +1,304 @@
+/**
+ * FocusGram Content Hider
+ * Toggleable visibility for: stories tray, feed posts, reels, suggested content.
+ * Flutter controls via window.__fgContent.*
+ * Injected at DOCUMENT_END.
+ *
+ * Key fixes applied:
+ * - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
+ * - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
+ * - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
+ * - Stories tray detection strengthened for fresh SPA navigations
+ * - Suggested posts detection uses multiple text-node matching strategies
+ */
+(function () {
+ 'use strict';
+
+ if (window.__fgContent && window.__fgContent.__focusgramReady) {
+ return;
+ }
+
+ const STYLE_ID = 'fg-content-hider';
+ let hideStories = false;
+ let hidePosts = false;
+ let hideSuggested = false;
+ let hideReels = false;
+
+ // ─── CSS rules ─────────────────────────────────────────────────────────────
+
+ function buildCSS() {
+ const selectors = [];
+
+ if (hideStories) {
+ selectors.push(
+ '[role="list"]:has([aria-label*="tory"])',
+ '[role="listbox"]:has([aria-label*="tory"])',
+ '[role="menu"] > ul',
+ 'section > div > div:first-child [style*="overflow"]',
+ '[role="list"] [style*="overflow"]',
+ );
+ }
+
+ if (hidePosts) {
+ selectors.push(
+ 'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
+ 'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
+ );
+ }
+
+ // hideReels CSS is intentionally NOT added here.
+ // We use DOM removal instead (see removeReels()) so that room is never left
+ // blank in the feed, and Instagram's infinite-scroll can prove scroll height.
+
+ return selectors.length
+ ? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
+ : '';
+ }
+
+ function applyCSS() {
+ if (document.body) {
+ document.body.setAttribute('data-fg-path', window.location.pathname || '/');
+ }
+ let style = document.getElementById(STYLE_ID);
+ if (!style) {
+ style = document.createElement('style');
+ style.id = STYLE_ID;
+ document.head.appendChild(style);
+ }
+ style.textContent = buildCSS();
+ }
+
+ // ─── Story tray JS ─────────────────────────────────────────────────────────
+
+ function hideStoryTray() {
+ if (!hideStories) return;
+
+ // Strategy 1: children of a named list or menu
+ document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
+ try {
+ const items = ul.querySelectorAll('li, button, a');
+ if (items.length < 2) return;
+ ul.style.setProperty('display', 'none', 'important');
+ } catch (_) {}
+ });
+
+ // Strategy 2: horizontally scrolling container with circle items
+ document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
+ try {
+ if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
+ const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
+ if (cands.length < 2) return;
+ const s0 = window.getComputedStyle(cands[0]);
+ if (s0.width && parseFloat(s0.width) <= 90) {
+ c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
+ }
+ } catch (_) {}
+ });
+ }
+
+ // ─── Suggested posts ───────────────────────────────────────────────────────
+
+ function removeSuggested() {
+ if (!hideSuggested) return;
+
+ var SIGNALS = [
+ 'suggested for you',
+ 'suggested posts',
+ 'suggested reels',
+ 'suggested',
+ 'because you watched',
+ 'because you follow',
+ 'you might like',
+ 'posts you might like',
+ 'accounts you might like',
+ 'recommendations',
+ ];
+
+ function norm(s) {
+ return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+ }
+
+ function hasSignal(s) {
+ var t = norm(s);
+ if (!t) return false;
+ return SIGNALS.some(function (signal) {
+ if (signal === 'suggested') return t === signal;
+ return t.indexOf(signal) >= 0;
+ });
+ }
+
+ function hideContainer(from) {
+ var parent = from;
+ for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
+ var role = parent.getAttribute && parent.getAttribute('role');
+ var tag = parent.tagName;
+ var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
+ if (
+ tag === 'ARTICLE' ||
+ tag === 'SECTION' ||
+ role === 'listitem' ||
+ (hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
+ ) {
+ parent.style.setProperty('display', 'none', 'important');
+ parent.setAttribute('data-fg-hidden-suggested', '1');
+ return true;
+ }
+ parent = parent.parentElement;
+ }
+ return false;
+ }
+
+ document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
+ try {
+ if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
+ var ownLabel = node.getAttribute('aria-label');
+ if (hasSignal(ownLabel)) { hideContainer(node); return; }
+ var text = norm(node.innerText || node.textContent || '');
+ if (
+ text.indexOf('suggested for you') >= 0 ||
+ text.indexOf('suggested posts') >= 0 ||
+ text.indexOf('suggested reels') >= 0 ||
+ text.indexOf('because you watched') >= 0 ||
+ text.indexOf('because you follow') >= 0
+ ) {
+ hideContainer(node);
+ }
+ } catch (_) {}
+ });
+
+ document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
+ try {
+ if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
+ hideContainer(el);
+ }
+ } catch (_) {}
+ });
+ }
+
+ // ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
+ // display:none keeps the element in the DOM, so Instagram's virtual-scroll still
+ // reserves the slot → blank gaps. Removing the article from the DOM collapses the
+ // gap cleanly and lets the feed flow naturally.
+ function removeReels() {
+ if (!hideReels) return;
+
+ var toRemove = [];
+ document.querySelectorAll('article').forEach(function (el) {
+ try {
+ // Fast path: check for a reel-signal attribute first
+ var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
+ if (mt === '2') { toRemove.push(el); return; }
+
+ // Fallback: text-node scan for /reels/ markers
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ var n;
+ while ((n = walker.nextNode())) {
+ if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
+ toRemove.push(el); break;
+ }
+ }
+ } catch (_) {}
+ });
+
+ toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
+ }
+
+ // ─── Public API ────────────────────────────────────────────────────────────
+
+ window.__fgContent = {
+ __focusgramReady: true,
+ setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
+ setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
+ setHideSuggested: function (val) {
+ hideSuggested = !!val;
+ applyCSS();
+ if (val) removeSuggested();
+ },
+ setHideReels: function (val) {
+ hideReels = !!val;
+ applyCSS();
+ if (val) removeReels();
+ },
+ applyAll: function (flags) {
+ hideStories = !!flags.stories;
+ hidePosts = !!flags.posts;
+ hideReels = !!flags.reels;
+ hideSuggested = !!flags.suggested;
+ applyCSS();
+ if (hideSuggested) removeSuggested();
+ if (hideStories) hideStoryTray();
+ if (hideReels) removeReels();
+ },
+ };
+
+ // ─── SPA heartbeat ─────────────────────────────────────────────────────────
+ // pushState/replaceState don't fire any DOM event we can listen for.
+ // Hook the methods themselves so we know a navigation happened, then debounce
+ // re-apply. This also catches the case where the MutationObserver was on `body`
+ // and that node got replaced by Instagram's SPA re-render.
+
+ function scheduleReapply() {
+ clearTimeout(window.__fg_applyTimer);
+ window.__fg_applyTimer = setTimeout(function () {
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+ }, 250);
+ }
+
+ var _origPush = history.pushState;
+ var _origReplace = history.replaceState;
+
+ history.pushState = function () {
+ _origPush.apply(this, arguments);
+ scheduleReapply();
+ };
+
+ history.replaceState = function () {
+ _origReplace.apply(this, arguments);
+ scheduleReapply();
+ };
+
+ // Reinforce on popstate too (user hits back/forward)
+ window.addEventListener('popstate', scheduleReapply, { passive: true });
+ // For pushState on the same URL (rare but possible) – poll path briefly
+ window.addEventListener('pageshow', scheduleReapply, { passive: true });
+ window.addEventListener('focus', scheduleReapply, { passive: true });
+
+ // ─── MutationObserver ───────────────────────────────────────────────────────
+ // Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
+ // re-applies everything on each cycle. Does NOT guard on a per-element timer
+ // that would never re-fire after the body is replaced by SPA re-render.
+
+ if (!window.__fgContentObserver) {
+ window.__fgContentObserver = new MutationObserver(function () {
+ clearTimeout(window.__fg_moTimer);
+ window.__fg_moTimer = setTimeout(function () {
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+ }, 300);
+ });
+
+ // `document.documentElement` survives SPA navigations (body gets replaced
+ // but stays). Observing it catches both subtree mutations and, via
+ // the SPA heartbeat above, re-applies after pushState.
+ window.__fgContentObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // ─── Initial run ────────────────────────────────────────────────────────────
+ applyCSS();
+ if (hideStories) hideStoryTray();
+ if (hideSuggested) removeSuggested();
+ if (hideReels) removeReels();
+
+ // Signal ready — Flutter will call applyAll() with stored prefs
+ if (window.ContentChannel) {
+ window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
+ }
+})();
diff --git a/v2/assets/scripts/fetch_interceptor.js b/v2/assets/scripts/fetch_interceptor.js
new file mode 100644
index 0000000..ee3aefe
--- /dev/null
+++ b/v2/assets/scripts/fetch_interceptor.js
@@ -0,0 +1,281 @@
+/**
+ * FocusGram Unified Feed Filter via Fetch Interception
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ *
+ * This script intercepts GraphQL fetch calls and filters feed content based on:
+ * - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
+ * - Sponsored posts (ad_action_link, ad_header_style)
+ * - Suggested posts (is_suggested, is_suggested_for_you, __typename)
+ * - Videos/Reels (is_video, media_type, clips_metadata)
+ * - Autoplay blocking (video autoplay prevention)
+ */
+(function () {
+ 'use strict';
+
+ // Configuration flags (set by Flutter via prefs)
+ window.__fgFilterConfig = {
+ blockAds: false,
+ blockSponsored: false,
+ blockSuggested: false,
+ blockVideos: false,
+ blockAutoplay: false,
+ blockGraphQLQueryWhenFeedPosts: false,
+ };
+
+ // Helper: Check if a node is an ad
+ const isAdNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ return !!(
+ node.is_ad ||
+ node.ad_action_link ||
+ node.ad_id ||
+ (node.product_type && node.product_type === 'ad') ||
+ (node.ad_header_style && node.ad_header_style !== 'none') ||
+ (node.__typename && node.__typename === 'GraphAdStory')
+ );
+ };
+
+ // Helper: Check if a node is sponsored
+ const isSponsoredNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ return !!(
+ (node.ad_action_link && node.ad_action_link.href) ||
+ (node.ad_header_style && node.ad_header_style !== 'none')
+ );
+ };
+
+ // Helper: Check if a node is suggested content
+ const isSuggestedNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+ const typename = String(node.__typename || '');
+ const reason = JSON.stringify({
+ reason: node.suggested_reason,
+ social_context: node.social_context,
+ title: node.title,
+ header: node.header,
+ label: node.label,
+ }).toLowerCase();
+
+ return !!(
+ node.is_suggested ||
+ node.is_suggested_for_you ||
+ node.is_recommendation ||
+ node.suggested_users ||
+ node.suggested_media ||
+ node.suggested_content ||
+ node.recommendation_source ||
+ typename.includes('Suggested') ||
+ typename.includes('Recommendation') ||
+ reason.includes('suggested') ||
+ reason.includes('recommend')
+ );
+ };
+
+ // Helper: Check if a node is a video/reel
+ const isVideoNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ return !!(
+ node.is_video ||
+ (node.media_type === 2) ||
+ node.clips_metadata ||
+ (node.__typename && (
+ node.__typename.includes('Clips') ||
+ node.__typename.includes('Video')
+ ))
+ );
+ };
+
+ const isFeedMediaNode = (node) => {
+ if (!node || typeof node !== 'object') return false;
+ return !!(
+ node.pk ||
+ node.id ||
+ node.code ||
+ node.media_type ||
+ node.image_versions2 ||
+ node.video_versions ||
+ node.carousel_media ||
+ node.__typename?.includes('Media') ||
+ node.__typename?.includes('Timeline')
+ );
+ };
+
+ // Helper: Check for media in carousel
+ const hasVideoInCarousel = (node) => {
+ if (!node || typeof node !== 'object') return false;
+
+ if (node.media_type === 8) {
+ const edges = node.edge_sidecar_to_children?.edges || [];
+ return edges.some(edge => isVideoNode(edge.node));
+ }
+ return false;
+ };
+
+ // Main filter function for feed nodes
+ const shouldFilterNode = (node) => {
+ const config = window.__fgFilterConfig;
+
+ if (!node || typeof node !== 'object') return false;
+
+ if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
+ return true;
+ }
+
+ // Check ads
+ if (config.blockAds && isAdNode(node)) {
+ return true;
+ }
+
+ // Check sponsored (separate from ads)
+ if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
+ return true;
+ }
+
+ // Check suggested content
+ if (config.blockSuggested && isSuggestedNode(node)) {
+ return true;
+ }
+
+ // Check videos/reels
+ if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
+ return true;
+ }
+
+ return false;
+ };
+
+ // Recursively filter GraphQL response edges
+ const filterEdges = (edges, path = []) => {
+ if (!Array.isArray(edges)) return edges;
+
+ return edges.filter(edge => {
+ if (!edge || !edge.node) return true;
+ const node = edge.node;
+
+ // Keep the edge if it doesn't match any filter
+ if (!shouldFilterNode(node)) return true;
+
+ // Log filtered content for debugging
+ if (window.__fgDebugFilter) {
+ const type = node.__typename || 'Unknown';
+ console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
+ }
+
+ return false;
+ });
+ };
+
+ // Recursively walk GraphQL response and filter edges
+ const walkAndFilter = (obj, visited = new Set()) => {
+ if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
+ visited.add(obj);
+
+ // Handle arrays
+ if (Array.isArray(obj)) {
+ obj.forEach(item => walkAndFilter(item, visited));
+ return;
+ }
+
+ // Check for edges array (common GraphQL pattern)
+ if (obj.edges && Array.isArray(obj.edges)) {
+ obj.edges = filterEdges(obj.edges);
+ }
+
+ // Recurse into children
+ for (const key in obj) {
+ if (obj.hasOwnProperty(key) && key !== '__typename') {
+ const val = obj[key];
+ if (val && typeof val === 'object') {
+ walkAndFilter(val, visited);
+ }
+ }
+ }
+ };
+
+ // Override fetch
+ const _fetch = window.fetch.bind(window);
+
+ window.fetch = async function (input, init) {
+ const url = typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input?.url ?? '';
+
+ // Call original fetch
+ let response = await _fetch(input, init);
+
+ // Only intercept GraphQL feed queries
+ if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) {
+ return response;
+ }
+
+ // Clone response to read body
+ const cloned = response.clone();
+
+ try {
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.includes('application/json')) {
+ return response;
+ }
+
+ const data = await cloned.json();
+
+ // Filter the response data
+ walkAndFilter(data);
+
+ // Return modified response
+ return new Response(JSON.stringify(data), {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
+ } catch (e) {
+ // On error, return original response
+ return response;
+ }
+ };
+
+ // Preserve native function appearance
+ Object.defineProperty(window, 'fetch', {
+ value: window.fetch,
+ writable: true,
+ configurable: true,
+ });
+ window.fetch.toString = () => 'function fetch() { [native code] }';
+
+ const _xhrOpen = XMLHttpRequest.prototype.open;
+ const _xhrSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method, url) {
+ this.__fgUrl = typeof url === 'string' ? url : String(url || '');
+ return _xhrOpen.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function () {
+ if (
+ window.__fgFilterConfig.blockVideos &&
+ this.__fgUrl &&
+ (this.__fgUrl.includes('/api/v1/clips/') ||
+ this.__fgUrl.includes('/api/v1/discover/'))
+ ) {
+ try { this.abort(); } catch (_) {}
+ return;
+ }
+ return _xhrSend.apply(this, arguments);
+ };
+
+ // Allow Flutter to update config flags
+ window.__fgSetFilterConfig = function (config) {
+ if (typeof config === 'object') {
+ Object.assign(window.__fgFilterConfig, config);
+ if (window.__fgDebugFilter) {
+ console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
+ }
+ }
+ };
+
+ // Enable debug logging
+ window.__fgDebugFilter = false;
+})();
diff --git a/v2/assets/scripts/ghost_mode.js b/v2/assets/scripts/ghost_mode.js
new file mode 100644
index 0000000..7180581
--- /dev/null
+++ b/v2/assets/scripts/ghost_mode.js
@@ -0,0 +1,207 @@
+/**
+ * FocusGram Ghost Mode
+ * Injected at DOCUMENT_START — before Instagram's JS loads.
+ * Blocks story-seen, message-seen, and online-presence signals.
+ */
+(function () {
+ 'use strict';
+
+ // ─── Seen API patterns ────────────────────────────────────────────────────
+ const SEEN_PATTERNS = [
+ /\/api\/v1\/media\/[\w-]+\/seen\//,
+ /\/api\/v1\/stories\/reel\/seen\//,
+ /\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
+ /\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
+ /\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
+ ];
+
+ // ─── Activity patterns (like, comment) — intercepted for local history ────
+ const ACTIVITY_PATTERNS = [
+ /\/api\/v1\/web\/likes\/[\w-]+\/like\//,
+ /\/api\/v1\/web\/comments\/add\//,
+ /\/api\/v1\/friendships\/[\w-]+\/follow\//,
+ ];
+
+ const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
+ const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
+
+ const fakeOkResponse = () =>
+ new Response(JSON.stringify({ status: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ // ─── Fetch override ───────────────────────────────────────────────────────
+ const _fetch = window.fetch.bind(window);
+
+ const patchedFetch = async function (input, init) {
+ const url =
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input?.url ?? '';
+
+ // Block seen
+ if (isSeen(url)) {
+ if (window.GhostChannel) {
+ window.GhostChannel.postMessage(
+ JSON.stringify({ type: 'seen_blocked', url })
+ );
+ }
+ return fakeOkResponse();
+ }
+
+ // Intercept activity for local history
+ if (isActivity(url) && window.ActivityChannel) {
+ const body = init?.body;
+ const bodyText =
+ body instanceof URLSearchParams
+ ? body.toString()
+ : typeof body === 'string'
+ ? body
+ : '';
+ window.ActivityChannel.postMessage(
+ JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
+ );
+ }
+
+ return _fetch(input, init);
+ };
+
+ // Disguise as native
+ Object.defineProperty(window, 'fetch', {
+ value: patchedFetch,
+ writable: true,
+ configurable: true,
+ enumerable: true,
+ });
+ window.fetch.toString = () => 'function fetch() { [native code] }';
+ window.fetch[Symbol.toStringTag] = 'fetch';
+
+ // ─── XMLHttpRequest override ──────────────────────────────────────────────
+ const _XHROpen = XMLHttpRequest.prototype.open;
+ const _XHRSend = XMLHttpRequest.prototype.send;
+
+ XMLHttpRequest.prototype.open = function (method, url, ...args) {
+ this._fg_url = url ?? '';
+ this._fg_method = (method ?? '').toUpperCase();
+ return _XHROpen.call(this, method, url, ...args);
+ };
+
+ XMLHttpRequest.prototype.send = function (body) {
+ if (this._fg_url && isSeen(this._fg_url)) {
+ // Fire readyState 4 with fake success without actually sending
+ const self = this;
+ setTimeout(() => {
+ Object.defineProperty(self, 'readyState', { get: () => 4 });
+ Object.defineProperty(self, 'status', { get: () => 200 });
+ Object.defineProperty(self, 'responseText', {
+ get: () => '{"status":"ok"}',
+ });
+ Object.defineProperty(self, 'response', {
+ get: () => '{"status":"ok"}',
+ });
+ self.dispatchEvent(new Event('readystatechange'));
+ self.dispatchEvent(new Event('load'));
+ }, 10);
+ return;
+ }
+ return _XHRSend.call(this, body);
+ };
+
+ // ─── WebSocket intercept (message-seen via WS) ────────────────────────────
+ // Strict WS URL blocking (ghost mode requirement)
+ // sid/cid vary per user/chat; block by endpoint prefix, not exact query.
+ const isBlockedWssUrl = (u) => {
+ if (!u) return false;
+ const urlStr = String(u);
+
+ return (
+ urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
+ urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
+ );
+ };
+
+ // Signal to other injected scripts that ghost-mode is active
+ window.__fgGhostModeActive = true;
+
+ const _WS = window.WebSocket;
+
+ function PatchedWebSocket(url, protocols) {
+ const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
+
+ // If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
+ if (isBlockedWssUrl(urlStr)) {
+ return {
+ send: () => {},
+ close: () => {},
+ readyState: 1,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ };
+ }
+
+ const ws = protocols ? new _WS(url, protocols) : new _WS(url);
+ const _send = ws.send.bind(ws);
+
+ ws.send = function (data) {
+ if (typeof data === 'string') {
+ // IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
+ try {
+ const parsed = JSON.parse(data);
+ if (
+ parsed?.op === '4' ||
+ parsed?.op === 'seen' ||
+ (parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
+ ) {
+ return; // drop
+ }
+ } catch (_) {}
+ // Text-based seen signal check
+ if (data.includes('"seen"') && data.includes('"thread_id"')) {
+ return;
+ }
+ }
+ return _send(data);
+ };
+
+ return ws;
+ }
+
+ // Preserve WebSocket prototype chain so IG's ws checks pass
+ PatchedWebSocket.prototype = _WS.prototype;
+ PatchedWebSocket.CONNECTING = _WS.CONNECTING;
+ PatchedWebSocket.OPEN = _WS.OPEN;
+ PatchedWebSocket.CLOSING = _WS.CLOSING;
+ PatchedWebSocket.CLOSED = _WS.CLOSED;
+ window.WebSocket = PatchedWebSocket;
+
+ // ─── Visibility trick — hide "Active Now" ────────────────────────────────
+ // Only applied if user enables online-status hiding
+ // Wrapped in a named fn so Flutter can call it:
+ // controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
+ window.__fgEnableOnlineHide = function () {
+ Object.defineProperty(document, 'visibilityState', {
+ get: () => 'hidden',
+ configurable: true,
+ });
+ Object.defineProperty(document, 'hidden', {
+ get: () => true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ };
+
+ window.__fgDisableOnlineHide = function () {
+ // Restore by deleting the overrides (falls back to native getter)
+ delete document.visibilityState;
+ delete document.hidden;
+ document.dispatchEvent(new Event('visibilitychange'));
+ };
+
+ // Signal to Flutter that ghost mode JS is active
+ if (window.GhostChannel) {
+ window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
+ }
+})();
diff --git a/v2/assets/scripts/theme_detector.js b/v2/assets/scripts/theme_detector.js
new file mode 100644
index 0000000..54749c3
--- /dev/null
+++ b/v2/assets/scripts/theme_detector.js
@@ -0,0 +1,89 @@
+/**
+ * FocusGram Theme Detector
+ * Reads Instagram's background + bottom nav color and reports to Flutter.
+ * Injected at DOCUMENT_END so DOM is ready.
+ */
+(function () {
+ 'use strict';
+
+ const parseRgb = (str) => {
+ // Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
+ const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!m) return null;
+ return {
+ r: parseInt(m[1]),
+ g: parseInt(m[2]),
+ b: parseInt(m[3]),
+ a: m[4] !== undefined ? parseFloat(m[4]) : 1,
+ };
+ };
+
+ const toHex = ({ r, g, b }) =>
+ '#' +
+ [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
+
+ const detectColors = () => {
+ // Background — Instagram sets it on or a root div
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
+
+ // Bottom nav — IG mobile web renders a fixed bottom bar
+ // Target by role="navigation" or position:fixed at bottom
+ let navBg = bodyBg;
+ const navCandidates = document.querySelectorAll(
+ 'nav, [role="navigation"], div[style*="bottom"]'
+ );
+ for (const el of navCandidates) {
+ const style = getComputedStyle(el);
+ if (
+ style.position === 'fixed' &&
+ parseInt(style.bottom) <= 10 &&
+ style.backgroundColor !== 'rgba(0, 0, 0, 0)'
+ ) {
+ navBg = style.backgroundColor;
+ break;
+ }
+ }
+
+ const bodyColor = parseRgb(bodyBg);
+ const navColor = parseRgb(navBg);
+
+ if (!bodyColor) return;
+
+ // Determine dark/light
+ const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
+ const isDark = luminance < 0.5;
+
+ const payload = {
+ bodyHex: toHex(bodyColor),
+ navHex: navColor ? toHex(navColor) : toHex(bodyColor),
+ isDark,
+ };
+
+ if (window.ThemeChannel) {
+ window.ThemeChannel.postMessage(JSON.stringify(payload));
+ }
+ };
+
+ // Run on load
+ detectColors();
+
+ // Watch for Instagram's dark mode toggle (adds/removes class on )
+ const observer = new MutationObserver(detectColors);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['class', 'style', 'color-scheme'],
+ });
+ observer.observe(document.body, {
+ attributes: true,
+ attributeFilter: ['class', 'style'],
+ });
+
+ // Also run after navigation (Instagram is SPA, URL changes without reload)
+ let lastUrl = location.href;
+ new MutationObserver(() => {
+ if (location.href !== lastUrl) {
+ lastUrl = location.href;
+ setTimeout(detectColors, 300); // small delay for IG to render new page
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+})();
diff --git a/v2/channel_registry.dart b/v2/channel_registry.dart
index df05b77..fc2c6ea 100644
--- a/v2/channel_registry.dart
+++ b/v2/channel_registry.dart
@@ -23,41 +23,41 @@ class ChannelRegistry {
// ─────────────────────────────────────────────────────────────────────────
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
- name: 'GhostChannel',
- onMessageReceived: (msg) {
- try {
- final data = jsonDecode(msg.message) as Map;
- if (kDebugMode) {
- debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
- }
- // In release: silent. Could surface to a debug overlay in dev builds.
- } catch (_) {}
- },
- );
+ name: 'GhostChannel',
+ onMessageReceived: (msg) {
+ try {
+ final data = jsonDecode(msg.message) as Map;
+ if (kDebugMode) {
+ debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
+ }
+ // In release: silent. Could surface to a debug overlay in dev builds.
+ } catch (_) {}
+ },
+ );
JavaScriptChannel _themeChannel() => JavaScriptChannel(
- name: 'ThemeChannel',
- onMessageReceived: (msg) {
- SystemUiManager.applyFromThemePayload(msg.message);
- },
- );
+ name: 'ThemeChannel',
+ onMessageReceived: (msg) {
+ SystemUiManager.applyFromThemePayload(msg.message);
+ },
+ );
JavaScriptChannel _contentChannel() => JavaScriptChannel(
- name: 'ContentChannel',
- onMessageReceived: (msg) {
- // 'ready' signal — engine pushes flags back via evaluateJavascript
- // handled in ScriptEngine.injectDocumentEndScripts()
- if (kDebugMode) debugPrint('[Content] ${msg.message}');
- },
- );
+ name: 'ContentChannel',
+ onMessageReceived: (msg) {
+ // 'ready' signal — engine pushes flags back via evaluateJavascript
+ // handled in ScriptEngine.injectDocumentEndScripts()
+ if (kDebugMode) debugPrint('[Content] ${msg.message}');
+ },
+ );
JavaScriptChannel _activityChannel() => JavaScriptChannel(
- name: 'ActivityChannel',
- onMessageReceived: (msg) {
- try {
- final data = jsonDecode(msg.message) as Map;
- onActivityEvent?.call(data);
- } catch (_) {}
- },
- );
+ name: 'ActivityChannel',
+ onMessageReceived: (msg) {
+ try {
+ final data = jsonDecode(msg.message) as Map;
+ onActivityEvent?.call(data);
+ } catch (_) {}
+ },
+ );
}
diff --git a/v2/ghost_mode_service.dart b/v2/ghost_mode_service.dart
index 802d257..303b11a 100644
--- a/v2/ghost_mode_service.dart
+++ b/v2/ghost_mode_service.dart
@@ -3,7 +3,7 @@
// Three-layer ghost mode:
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
-// 3. FLAG_SECURE — anti-screenshot at OS level
+// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
//
// Usage:
// final service = GhostModeService();
@@ -15,8 +15,8 @@
// shouldInterceptRequest: service.shouldInterceptRequest,
// )
//
-// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback
-// service.applyWindowFlags(context);
+// // Anti-screenshot: disabled per user request
+// // service.applyWindowFlags(context);
import 'dart:typed_data';
@@ -36,55 +36,50 @@ class GhostFeatures {
bool hideVoiceListened;
bool hideReplyImageViewed;
bool disableAnalytics;
- bool antiScreenshot;
GhostFeatures({
- this.hideStoryViews = true,
- this.hideReadReceipts = true,
- this.hideLiveJoin = true,
- this.hideTypingIndicator = true,
- this.hideVoiceListened = true,
+ this.hideStoryViews = true,
+ this.hideReadReceipts = true,
+ this.hideLiveJoin = true,
+ this.hideTypingIndicator = true,
+ this.hideVoiceListened = true,
this.hideReplyImageViewed = true,
- this.disableAnalytics = true,
- this.antiScreenshot = false, // Off by default — user must opt in
+ this.disableAnalytics = true,
});
static const _keys = {
- 'hideStoryViews': 'gm_story',
- 'hideReadReceipts': 'gm_read',
- 'hideLiveJoin': 'gm_live',
- 'hideTypingIndicator': 'gm_typing',
- 'hideVoiceListened': 'gm_voice',
+ 'hideStoryViews': 'gm_story',
+ 'hideReadReceipts': 'gm_read',
+ 'hideLiveJoin': 'gm_live',
+ 'hideTypingIndicator': 'gm_typing',
+ 'hideVoiceListened': 'gm_voice',
'hideReplyImageViewed': 'gm_reply',
- 'disableAnalytics': 'gm_analytics',
- 'antiScreenshot': 'gm_screenshot',
+ 'disableAnalytics': 'gm_analytics',
};
Future save() async {
final p = await SharedPreferences.getInstance();
await Future.wait([
- p.setBool(_keys['hideStoryViews']!, hideStoryViews),
- p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
- p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
- p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
- p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
+ p.setBool(_keys['hideStoryViews']!, hideStoryViews),
+ p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
+ p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
+ p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
+ p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
- p.setBool(_keys['disableAnalytics']!, disableAnalytics),
- p.setBool(_keys['antiScreenshot']!, antiScreenshot),
+ p.setBool(_keys['disableAnalytics']!, disableAnalytics),
]);
}
static Future load() async {
final p = await SharedPreferences.getInstance();
return GhostFeatures(
- hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
- hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
- hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
- hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
- hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
+ hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
+ hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
+ hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
+ hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
+ hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
- disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
- antiScreenshot: p.getBool(_keys['antiScreenshot']!) ?? false,
+ disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
);
}
}
@@ -110,23 +105,18 @@ final _nativeBlocklist = [
RegExp(r'/ajax/logging/'),
];
-final Uint8List _fakeOkBody = Uint8List.fromList(
- '{"status":"ok"}'.codeUnits,
-);
+final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
// ─── Main service ─────────────────────────────────────────────────────────────
class GhostModeService {
GhostFeatures features = GhostFeatures();
InAppWebViewController? _controller;
- // Platform channel for FLAG_SECURE (anti-screenshot)
- static const _channel = MethodChannel('com.focusgram/window_flags');
-
Future load() async {
features = await GhostFeatures.load();
}
- // ─── WebView setup ──────────────────────────────────────────────────────────
+ // ─── WebView setup ────────────────────────────────────────────────────────
/// Call from InAppWebView.onWebViewCreated
void onWebViewCreated(InAppWebViewController controller) {
@@ -170,34 +160,28 @@ class GhostModeService {
/// InAppWebViewSettings required for shouldInterceptRequest to fire
InAppWebViewSettings buildWebViewSettings() {
return InAppWebViewSettings(
- useShouldInterceptRequest: true, // Enable native intercept callback
+ useShouldInterceptRequest: true, // Enable native intercept callback
useShouldOverrideUrlLoading: true,
javaScriptEnabled: true,
disableDefaultErrorPage: true,
- useHybridComposition: true, // Needed for FLAG_SECURE to work
+ useHybridComposition:
+ true, // Needed for FLAG_SECURE to work (though disabled)
// Disable service worker cache that can replay seen-events offline
- cacheEnabled: false, // Start clean — optional, tradeoff vs perf
+ cacheEnabled: false, // Start clean — optional, tradeoff vs perf
);
}
// ─── Anti-screenshot ────────────────────────────────────────────────────────
+ // Anti-screenshot disabled per user request
- /// Call from initState → addPostFrameCallback
Future applyWindowFlags(BuildContext context) async {
- if (!features.antiScreenshot) return;
- try {
- await _channel.invokeMethod('setSecure', {'secure': true});
- } on MissingPluginException {
- // Platform channel not registered — use plugin fallback below
- debugPrint('[GhostMode] FLAG_SECURE: platform channel missing. '
- 'Add flutter_windowmanager or implement MainActivity channel.');
- }
+ // Anti-screenshot disabled per user request
+ return;
}
Future clearWindowFlags() async {
- try {
- await _channel.invokeMethod('setSecure', {'secure': false});
- } catch (_) {}
+ // Anti-screenshot disabled per user request
+ return;
}
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
diff --git a/v2/instagram_webview.dart b/v2/instagram_webview.dart
index e186ff5..1aab363 100644
--- a/v2/instagram_webview.dart
+++ b/v2/instagram_webview.dart
@@ -6,6 +6,7 @@ import '../injection/script_engine.dart';
import '../injection/script_registry.dart';
import '../channels/channel_registry.dart';
import '../webview/webview_config.dart';
+import '../services/ghost_mode_service.dart';
class InstagramWebView extends StatefulWidget {
const InstagramWebView({super.key});
@@ -17,9 +18,10 @@ class InstagramWebView extends StatefulWidget {
class InstagramWebViewState extends State {
InAppWebViewController? _controller;
ScriptEngine? _engine;
+ GhostModeService? _ghostMode;
bool _loading = true;
- // ── Public API — call from Settings screen ────────────────────────────────
+ // ── Public API — call from Settings screen ─────────────────────────────
Future toggleScript(ScriptId id, bool enabled) async {
await _engine?.toggle(id, enabled);
}
@@ -32,6 +34,37 @@ class InstagramWebViewState extends State {
await _engine?.setOnlineHide(enabled);
}
+ // Ghost mode controls
+ Future setGhostModeEnabled(bool enabled) async {
+ if (_ghostMode != null) {
+ _ghostMode!.features.disableAnalytics = enabled;
+ _ghostMode!.features.hideStoryViews = enabled;
+ _ghostMode!.features.hideReadReceipts = enabled;
+ _ghostMode!.features.hideLiveJoin = enabled;
+ _ghostMode!.features.hideTypingIndicator = enabled;
+ _ghostMode!.features.hideVoiceListened = enabled;
+ _ghostMode!.features.hideReplyImageViewed = enabled;
+ await _ghostMode!.features.save();
+ // Reapply settings if webview exists
+ if (_controller != null) {
+ // Force reload to apply new settings
+ await _controller!.reload();
+ }
+ }
+ }
+
+ Future setAntiScreenshot(bool enabled) async {
+ if (_ghostMode != null) {
+ _ghostMode!.features.antiScreenshot = enabled;
+ await _ghostMode!.features.save();
+ if (_ghostMode!.features.antiScreenshot) {
+ await _ghostMode!.applyWindowFlags(context);
+ } else {
+ await _ghostMode!.clearWindowFlags();
+ }
+ }
+ }
+
// ─────────────────────────────────────────────────────────────────────────
@override
@@ -40,12 +73,18 @@ class InstagramWebViewState extends State {
children: [
InAppWebView(
initialUrlRequest: WebViewConfig.initialRequest,
- initialSettings: WebViewConfig.settings,
+ initialSettings:
+ _ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
// ── ContentBlockers — merged base + EasyList rules ──────────────
contentBlockers: WebViewConfig.baseContentBlockers,
// TODO Phase 1.5: merge EasyListParser.load() here at startup
+ // ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
+ initialUserScripts: UnmodifiableListView(
+ _ghostMode?.buildUserScripts() ?? [],
+ ),
+
// ── JavaScript channels ─────────────────────────────────────────
javascriptChannels: ChannelRegistry(
onActivityEvent: (event) {
@@ -56,6 +95,12 @@ class InstagramWebViewState extends State {
onWebViewCreated: (controller) async {
_controller = controller;
+
+ // Initialize GhostModeService
+ _ghostMode = GhostModeService();
+ await _ghostMode!.load();
+
+ // Initialize existing script engine for other scripts
final prefs = await SharedPreferences.getInstance();
_engine = ScriptEngine(controller: controller, prefs: prefs);
@@ -66,6 +111,10 @@ class InstagramWebViewState extends State {
onLoadStop: (controller, url) async {
// Inject DOCUMENT_END scripts
await _engine?.injectDocumentEndScripts();
+
+ // Re-inject ghost mode scripts on SPA navigation
+ await _ghostMode?.onPageLoaded(url?.uriValue);
+
setState(() => _loading = false);
},
@@ -103,6 +152,15 @@ class InstagramWebViewState extends State {
await _engine?.injectDocumentEndScripts();
}
},
+
+ // ── Native intercept for service worker requests ────────────────
+ shouldInterceptRequest: (controller, request) async {
+ return await _ghostMode?.shouldInterceptRequest(
+ controller,
+ request,
+ ) ??
+ null;
+ },
),
// ── Subtle loading indicator ──────────────────────────────────────
diff --git a/v2/main.dart b/v2/main.dart
index 39b7db7..7e16e04 100644
--- a/v2/main.dart
+++ b/v2/main.dart
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'core/theme/system_ui_manager.dart';
import 'core/webview/instagram_webview.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
+
+ // Enable web contents debugging for ghost mode verification
+ if (kDebugMode) {
+ InAppWebViewController.setWebContentsDebuggingEnabled(true);
+ }
+
await SystemUiManager.enableEdgeToEdge();
runApp(const FocusGramApp());
}
diff --git a/v2/script_engine.dart b/v2/script_engine.dart
index b8b6733..ed90f73 100644
--- a/v2/script_engine.dart
+++ b/v2/script_engine.dart
@@ -35,12 +35,36 @@ class ScriptEngine {
);
}
}
+
+ // Initialize script configurations after scripts are loaded
+ await _initializeScriptConfigs();
+ }
+
+ // ── Initialize script configurations from saved preferences ────────────────
+ Future _initializeScriptConfigs() async {
+ // Fetch interceptor config
+ final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
+ if (fetchInterceptor.enabled) {
+ await _updateFetchInterceptorConfig();
+ }
+
+ // Autoplay blocker config
+ final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
+ if (autoplayBlocker.enabled) {
+ await _updateAutoplayBlockerConfig();
+ }
+
+ // Content hider flags
+ await _pushContentFlags();
}
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
Future injectDocumentEndScripts() async {
- for (final script in ScriptRegistry.all
- .where((s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END && s.enabled)) {
+ for (final script in ScriptRegistry.all.where(
+ (s) =>
+ s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
+ s.enabled,
+ )) {
await _inject(script);
}
// After content_hider is injected, push saved content flags
@@ -77,6 +101,9 @@ class ScriptEngine {
} else {
await _inject(script);
}
+
+ // Re-initialize configurations after toggle
+ await _initializeScriptConfigs();
}
// ── Content hider flags ────────────────────────────────────────────────────
@@ -100,15 +127,80 @@ class ScriptEngine {
);
}
+ // ── Fetch interceptor configuration ────────────────────────────────────────
+ Future setFetchInterceptorConfig({
+ bool? blockAds,
+ bool? blockSponsored,
+ bool? blockSuggested,
+ bool? blockVideos,
+ bool? blockAutoplay,
+ }) async {
+ final prefs = await SharedPreferences.getInstance();
+ final config = {
+ 'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
+ 'blockSponsored':
+ blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
+ 'blockSuggested':
+ blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
+ 'blockVideos':
+ blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
+ 'blockAutoplay':
+ blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
+ };
+
+ // Save individual prefs
+ await prefs.setBool('fetch_block_ads', config['blockAds']!);
+ await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
+ await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
+ await prefs.setBool('fetch_block_videos', config['blockVideos']!);
+ await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
+
+ // Apply to webview
+ await controller.evaluateJavascript(
+ source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
+ );
+ }
+
+ Future _updateFetchInterceptorConfig() async {
+ final prefs = await SharedPreferences.getInstance();
+ await setFetchInterceptorConfig(
+ blockAds: prefs.getBool('fetch_block_ads'),
+ blockSponsored: prefs.getBool('fetch_block_sponsored'),
+ blockSuggested: prefs.getBool('fetch_block_suggested'),
+ blockVideos: prefs.getBool('fetch_block_videos'),
+ blockAutoplay: prefs.getBool('fetch_block_autoplay'),
+ );
+ }
+
+ // ── Autoplay blocker configuration ─────────────────────────────────────────
+ Future setAutoplayBlockerEnabled(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool('autoplay_blocker_enabled', enabled);
+
+ // Apply to webview
+ await controller.evaluateJavascript(
+ source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
+ );
+ }
+
+ Future _updateAutoplayBlockerConfig() async {
+ final prefs = await SharedPreferences.getInstance();
+ await setAutoplayBlockerEnabled(
+ prefs.getBool('autoplay_blocker_enabled') ?? false,
+ );
+ }
+
// ── Online status hide ─────────────────────────────────────────────────────
Future setOnlineHide(bool enabled) async {
await prefs.setBool('ghost_online_hide', enabled);
if (enabled) {
await controller.evaluateJavascript(
- source: 'window.__fgEnableOnlineHide?.()');
+ source: 'window.__fgEnableOnlineHide?.()',
+ );
} else {
await controller.evaluateJavascript(
- source: 'window.__fgDisableOnlineHide?.()');
+ source: 'window.__fgDisableOnlineHide?.()',
+ );
}
}
diff --git a/v2/script_registry.dart b/v2/script_registry.dart
index 9cba347..866ef3b 100644
--- a/v2/script_registry.dart
+++ b/v2/script_registry.dart
@@ -3,7 +3,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum ScriptId {
ghostMode,
themeDetector,
- adBlockerDom,
contentHider,
fetchInterceptor,
autoplayBlocker,
@@ -32,18 +31,11 @@ class InstaScript {
class ScriptRegistry {
static final List all = [
// ── DOCUMENT_START — must be before IG's JS loads ──
- InstaScript(
- id: ScriptId.ghostMode,
- name: 'Ghost Mode',
- description: 'Blocks story seen, message seen, and online status signals.',
- assetPath: 'assets/scripts/ghost_mode.js',
- injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
- enabled: false,
- ),
InstaScript(
id: ScriptId.fetchInterceptor,
- name: 'Fetch Interceptor',
- description: 'Unified feed filter: blocks ads, sponsored, suggested, videos via GraphQL interception.',
+ name: 'Ad & Content Blocker',
+ description:
+ 'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
@@ -66,14 +58,6 @@ class ScriptRegistry {
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true, // always on — needed for native feel
),
- InstaScript(
- id: ScriptId.adBlockerDom,
- name: 'DOM Ad Blocker',
- description: 'Removes sponsored posts and tracking elements from feed (legacy - use Fetch Interceptor instead).',
- assetPath: 'assets/scripts/ad_blocker_dom.js',
- injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
- enabled: false,
- ),
InstaScript(
id: ScriptId.contentHider,
name: 'Content Hider',
@@ -100,6 +84,4 @@ class ScriptRegistry {
enabled: false,
),
];
-
- static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id);
}
diff --git a/v2/system_ui_manager.dart b/v2/system_ui_manager.dart
index 6e9e3ac..63d6b70 100644
--- a/v2/system_ui_manager.dart
+++ b/v2/system_ui_manager.dart
@@ -8,7 +8,8 @@ class SystemUiManager {
try {
final data = jsonDecode(jsonPayload) as Map;
final isDark = data['isDark'] as bool? ?? false;
- final bodyHex = data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
+ final bodyHex =
+ data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
final navHex = data['navHex'] as String? ?? bodyHex;
final bodyColor = _parseHex(bodyHex);
@@ -20,8 +21,9 @@ class SystemUiManager {
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: navColor,
- systemNavigationBarIconBrightness:
- isDark ? Brightness.light : Brightness.dark,
+ systemNavigationBarIconBrightness: isDark
+ ? Brightness.light
+ : Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent,
),
);
@@ -33,25 +35,29 @@ class SystemUiManager {
// ── Fallback presets ─────────────────────────────────────────────────────
static void applyLight() {
- SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
- statusBarColor: Color(0xFFFFFFFF),
- statusBarIconBrightness: Brightness.dark,
- statusBarBrightness: Brightness.light,
- systemNavigationBarColor: Color(0xFFFFFFFF),
- systemNavigationBarIconBrightness: Brightness.dark,
- systemNavigationBarDividerColor: Colors.transparent,
- ));
+ SystemChrome.setSystemUIOverlayStyle(
+ const SystemUiOverlayStyle(
+ statusBarColor: Color(0xFFFFFFFF),
+ statusBarIconBrightness: Brightness.dark,
+ statusBarBrightness: Brightness.light,
+ systemNavigationBarColor: Color(0xFFFFFFFF),
+ systemNavigationBarIconBrightness: Brightness.dark,
+ systemNavigationBarDividerColor: Colors.transparent,
+ ),
+ );
}
static void applyDark() {
- SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
- statusBarColor: Color(0xFF000000),
- statusBarIconBrightness: Brightness.light,
- statusBarBrightness: Brightness.dark,
- systemNavigationBarColor: Color(0xFF000000),
- systemNavigationBarIconBrightness: Brightness.light,
- systemNavigationBarDividerColor: Colors.transparent,
- ));
+ SystemChrome.setSystemUIOverlayStyle(
+ const SystemUiOverlayStyle(
+ statusBarColor: Color(0xFF000000),
+ statusBarIconBrightness: Brightness.light,
+ statusBarBrightness: Brightness.dark,
+ systemNavigationBarColor: Color(0xFF000000),
+ systemNavigationBarIconBrightness: Brightness.light,
+ systemNavigationBarDividerColor: Colors.transparent,
+ ),
+ );
}
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
diff --git a/v2/webview_config.dart b/v2/webview_config.dart
index 3dc7998..21ec16b 100644
--- a/v2/webview_config.dart
+++ b/v2/webview_config.dart
@@ -11,110 +11,107 @@ class WebViewConfig {
// ── Base InAppWebView settings ────────────────────────────────────────────
static InAppWebViewSettings get settings => InAppWebViewSettings(
- // Identity
- userAgent: userAgent,
+ // Identity
+ userAgent: userAgent,
- // Performance
- hardwareAcceleration: true,
- // useHybridComposition: false breaks some Android 12+ devices — keep true
- useHybridComposition: true,
- cacheEnabled: true,
- cacheMode: CacheMode.LOAD_DEFAULT,
+ // Performance
+ hardwareAcceleration: true,
+ // useHybridComposition: false breaks some Android 12+ devices — keep true
+ useHybridComposition: true,
+ cacheEnabled: true,
+ cacheMode: CacheMode.LOAD_DEFAULT,
- // Media
- mediaPlaybackRequiresUserGesture: false,
- allowsInlineMediaPlayback: true,
- allowsPictureInPictureMediaPlayback: true,
+ // Media
+ mediaPlaybackRequiresUserGesture: false,
+ allowsInlineMediaPlayback: true,
+ allowsPictureInPictureMediaPlayback: true,
- // UX — feel like native, not browser
- overScrollMode: OverScrollMode.NEVER,
- verticalScrollBarEnabled: false,
- horizontalScrollBarEnabled: false,
- supportZoom: false,
- builtInZoomControls: false,
- displayZoomControls: false,
- scrollsToTop: true,
+ // UX — feel like native, not browser
+ overScrollMode: OverScrollMode.NEVER,
+ verticalScrollBarEnabled: false,
+ horizontalScrollBarEnabled: false,
+ supportZoom: false,
+ builtInZoomControls: false,
+ displayZoomControls: false,
+ scrollsToTop: true,
- // JS & storage — IG needs all of these
- javaScriptEnabled: true,
- javaScriptCanOpenWindowsAutomatically: false,
- domStorageEnabled: true,
- databaseEnabled: true,
- allowFileAccessFromFileURLs: false,
- allowUniversalAccessFromFileURLs: false,
+ // JS & storage — IG needs all of these
+ javaScriptEnabled: true,
+ javaScriptCanOpenWindowsAutomatically: false,
+ domStorageEnabled: true,
+ databaseEnabled: true,
+ allowFileAccessFromFileURLs: false,
+ allowUniversalAccessFromFileURLs: false,
- // Compat
- mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
- safeBrowsingEnabled: false, // IG known-safe domain, no need for extra latency
+ // Compat
+ mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
+ safeBrowsingEnabled:
+ false, // IG known-safe domain, no need for extra latency
+ // Disable Chrome custom tabs popup (links open in WebView)
+ suppressesIncrementalRendering: false,
- // Disable Chrome custom tabs popup (links open in WebView)
- suppressesIncrementalRendering: false,
+ // iOS specific
+ allowsBackForwardNavigationGestures: true,
+ allowsLinkPreview: false,
+ isFraudulentWebsiteWarningEnabled: false,
- // iOS specific
- allowsBackForwardNavigationGestures: true,
- allowsLinkPreview: false,
- isFraudulentWebsiteWarningEnabled: false,
-
- // Android specific
- forceDark: ForceDark.AUTO, // respect system dark mode
- algorithmicDarkeningAllowed: true,
- );
+ // Android specific
+ forceDark: ForceDark.AUTO, // respect system dark mode
+ algorithmicDarkeningAllowed: true,
+ );
// ── ContentBlocker rules — ad network blocking ─────────────────────────
// These are baked-in rules targeting known ad/tracking domains.
// Full EasyList parsing is handled separately and merged at runtime.
// This set is always-on regardless of user toggle.
static List get baseContentBlockers => [
- // Meta ad infrastructure
- _block('.*connect\\.facebook\\.net.*'),
- _block('.*graph\\.facebook\\.com.*ads.*'),
- _block('.*an\\.facebook\\.com.*'),
+ // Meta ad infrastructure
+ _block('.*connect\\.facebook\\.net.*'),
+ _block('.*graph\\.facebook\\.com.*ads.*'),
+ _block('.*an\\.facebook\\.com.*'),
- // Google ad networks
- _block('.*doubleclick\\.net.*'),
- _block('.*googleadservices\\.com.*'),
- _block('.*googlesyndication\\.com.*'),
- _block('.*adservice\\.google\\..*'),
+ // Google ad networks
+ _block('.*doubleclick\\.net.*'),
+ _block('.*googleadservices\\.com.*'),
+ _block('.*googlesyndication\\.com.*'),
+ _block('.*adservice\\.google\\..*'),
- // Common trackers
- _block('.*scorecardresearch\\.com.*'),
- _block('.*quantserve\\.com.*'),
- _block('.*chartbeat\\.com.*'),
- _block('.*newrelic\\.com.*'),
+ // Common trackers
+ _block('.*scorecardresearch\\.com.*'),
+ _block('.*quantserve\\.com.*'),
+ _block('.*chartbeat\\.com.*'),
+ _block('.*newrelic\\.com.*'),
- // Ad servers
- _block('.*ads\\.yahoo\\.com.*'),
- _block('.*advertising\\.com.*'),
- _block('.*adnxs\\.com.*'),
- _block('.*adsrvr\\.org.*'),
- _block('.*taboola\\.com.*'),
- _block('.*outbrain\\.com.*'),
- _block('.*pubmatic\\.com.*'),
- _block('.*rubiconproject\\.com.*'),
- _block('.*openx\\.net.*'),
- _block('.*casalemedia\\.com.*'),
- _block('.*criteo\\.com.*'),
- _block('.*criteo\\.net.*'),
+ // Ad servers
+ _block('.*ads\\.yahoo\\.com.*'),
+ _block('.*advertising\\.com.*'),
+ _block('.*adnxs\\.com.*'),
+ _block('.*adsrvr\\.org.*'),
+ _block('.*taboola\\.com.*'),
+ _block('.*outbrain\\.com.*'),
+ _block('.*pubmatic\\.com.*'),
+ _block('.*rubiconproject\\.com.*'),
+ _block('.*openx\\.net.*'),
+ _block('.*casalemedia\\.com.*'),
+ _block('.*criteo\\.com.*'),
+ _block('.*criteo\\.net.*'),
- // Pixel trackers
- _block('.*pixel\\.quantserve\\.com.*'),
- _block('.*pixel\\.facebook\\.com.*'),
+ // Pixel trackers
+ _block('.*pixel\\.quantserve\\.com.*'),
+ _block('.*pixel\\.facebook\\.com.*'),
- // IG-specific ad endpoints (safe to block — don't affect core IG)
- _block('.*\\.instagram\\.com.*\\/ads\\/.*'),
- ];
+ // IG-specific ad endpoints (safe to block — don't affect core IG)
+ _block('.*\\.instagram\\.com.*\\/ads\\/.*'),
+ ];
static ContentBlocker _block(String pattern) => ContentBlocker(
- trigger: ContentBlockerTrigger(urlFilter: pattern),
- action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
- );
+ trigger: ContentBlockerTrigger(urlFilter: pattern),
+ action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
+ );
// ── URLRequest for initial load ───────────────────────────────────────────
static URLRequest get initialRequest => URLRequest(
- url: WebUri(instagramUrl),
- headers: {
- 'Accept-Language': 'en-US,en;q=0.9',
- 'DNT': '1',
- },
- );
+ url: WebUri(instagramUrl),
+ headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
+ );
}