19 Commits

Author SHA1 Message Date
Ujwal Chapagain 3d74e30360 Update release-apk.yml 2026-06-13 15:01:02 +05:45
Ujwal Chapagain 3ec122c03b Update release-apk.yml 2026-06-13 14:54:27 +05:45
Ujwal Chapagain 99a581ad01 Update release-apk.yml 2026-06-13 14:43:02 +05:45
Ujwal Chapagain 65796c8e9e SIGN the apk 2026-06-13 14:04:18 +05:45
Ujwal Chapagain 6d48682b39 Update README.md 2026-06-13 13:35:51 +05:45
Ujwal Chapagain 7cb4d62cbe update screenshot 2026-06-13 13:33:57 +05:45
Ujwal223 335f6467bc Add popup.json 2026-06-13 13:30:01 +05:45
Ujwal223 b7c8120496 Feature Pack with bug fixes for V2 2026-06-13 13:06:25 +05:45
Ujwal223 39b6545e4a Feature Pack with bug fixes for V2 2026-06-09 23:39:43 +05:45
Ujwal223 f1bd12f0bd V2 Release 2026-05-25 22:58:54 +05:45
Ujwal223 7d13ad64f1 Refactor GitHub Actions workflow and add scripts for versioning and release notes extraction 2026-05-25 22:53:53 +05:45
Ujwal Chapagain 5f86441675 Update GitHub Actions to use latest versions 2026-05-25 22:39:48 +05:45
Ujwal223 842dc70829 V2 Release 2026-05-25 22:12:38 +05:45
Ujwal223 2d33dcb889 Progress SAve- downloader,blur,ghost mode(Partially) works 2026-05-25 18:00:57 +05:45
Ujwal223 4f63e784ac JUst SAving Progress, i might fuck up 2026-05-23 11:56:23 +05:45
Ujwal Chapagain a504c51ac5 fix shields.io badge "invalid" 2026-03-05 11:02:22 +05:45
Ujwal Chapagain 5fafb9f142 Update README.md 2026-03-05 06:55:40 +05:45
Ujwal Chapagain d2a0294ab3 Removed Few things from ReadME as they were mentioned to be included for upcoming release. 2026-03-04 18:24:34 +05:45
Ujwal Chapagain 2e3823cdf7 Add Buy Me MoMo in Readme 2026-03-04 17:59:51 +05:45
127 changed files with 16027 additions and 958 deletions
-45
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
import os, re
from pathlib import Path
version = os.environ["VERSION"]
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
pattern = rf"(?ms)^##\s+FocusGram\s+{re.escape(version)}\s*$.*?(?=^##\s+|\Z)"
m = re.search(pattern, text)
if not m:
raise SystemExit(f"Could not find changelog section for version {version}")
Path("release_notes.md").write_text(m.group(0).strip() + "\n", encoding="utf-8")
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
import re
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
m = re.search(r"^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", text, re.M)
if not m:
raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.1.0")
print(m.group(1))
-54
View File
@@ -1,54 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '15 14 * * 5'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: manual
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.38.7'
channel: 'stable'
cache: true
- name: Install dependencies
run: flutter pub get
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Build Android (for CodeQL)
run: flutter build apk --debug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+164
View File
@@ -0,0 +1,164 @@
name: Build APK and Create GitHub Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
required: false
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Java 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install required Android SDK packages
shell: bash
run: |
set -euo pipefail
sdkmanager \
"platform-tools" \
"platforms;android-35" \
"build-tools;34.0.0" \
"build-tools;35.0.0"
- name: Get dependencies
run: flutter pub get
- name: Resolve version and tag
id: meta
shell: bash
run: |
set -euo pipefail
INPUT_VERSION="${{ github.event.inputs.version }}"
if [[ -n "${INPUT_VERSION}" ]]; then
VERSION="${INPUT_VERSION#v}"
else
VERSION="$(python3 .github/scripts/get_version.py)"
fi
TAG="v${VERSION}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Extract release notes from CHANGELOG.md
shell: bash
env:
VERSION: ${{ steps.meta.outputs.version }}
run: python3 .github/scripts/get_notes.py
- name: Decode Android keystore
shell: bash
run: |
set -euo pipefail
mkdir -p android/app
# tr -d strips any newlines/spaces introduced when the secret was stored
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" \
| tr -d '[:space:]' \
| base64 --decode > android/app/upload-keystore.jks
chmod 600 android/app/upload-keystore.jks
echo "Keystore written: $(wc -c < android/app/upload-keystore.jks) bytes"
- name: Create Android key.properties
shell: bash
env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -euo pipefail
# Trim any accidental whitespace/newlines from secret values
KS_PASS="$(printf '%s' "${KEYSTORE_PASSWORD}" | tr -d '[:space:]')"
K_PASS="$(printf '%s' "${KEY_PASSWORD}" | tr -d '[:space:]')"
K_ALIAS="$(printf '%s' "${KEY_ALIAS}" | tr -d '[:space:]')"
# Absolute path prevents Gradle from misresolving a relative storeFile
KEYSTORE_PATH="${GITHUB_WORKSPACE}/android/app/upload-keystore.jks"
{
printf 'storePassword=%s\n' "${KS_PASS}"
printf 'keyPassword=%s\n' "${K_PASS}"
printf 'keyAlias=%s\n' "${K_ALIAS}"
printf 'storeFile=%s\n' "${KEYSTORE_PATH}"
} > android/key.properties
- name: Verify keystore
shell: bash
env:
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
set -euo pipefail
KS_PASS="$(printf '%s' "${KEYSTORE_PASSWORD}" | tr -d '[:space:]')"
echo "=== Keystore file ==="
ls -lh android/app/upload-keystore.jks
file android/app/upload-keystore.jks
echo ""
echo "=== key.properties keys (values hidden) ==="
cut -d'=' -f1 android/key.properties
echo ""
echo "=== Keystore verification via keytool ==="
keytool -list \
-keystore android/app/upload-keystore.jks \
-storepass "${KS_PASS}" \
2>&1 | grep -vE "^(Warning|$)"
- name: Build release APK
run: flutter build apk --release
- name: Rename APK
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: focusgram-apk-${{ steps.meta.outputs.tag }}
path: build/app/outputs/flutter-apk/focusgram-release.apk
if-no-files-found: error
- name: Create Git tag
shell: bash
env:
TAG: ${{ steps.meta.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "Tag already exists on remote: ${TAG}"
exit 1
fi
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: FocusGram ${{ steps.meta.outputs.tag }}
body_path: release_notes.md
files: build/app/outputs/flutter-apk/focusgram-release.apk
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-12
View File
@@ -1,12 +0,0 @@
name: release
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.38.7'
+3 -3
View File
@@ -12,8 +12,10 @@
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
PRD.md PRD.md
.reasonix/
.agents/ .agents/
TODO.md
# IntelliJ related # IntelliJ related
*.iml *.iml
@@ -25,9 +27,7 @@ TODO.md
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
# is commented out by default. # is commented out by default.
#.vscode/ #.vscode/
RELEASE_GUIDE.md
android/key.properties android/key.properties
android/fdroid-config.properties
android/app/*.jks android/app/*.jks
upload-keystore.jks upload-keystore.jks
+17 -8
View File
@@ -1,13 +1,22 @@
## FocusGram 1.0.0 ## FocusGram 2.1.0
### What's new ### What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts. - NEW: Startup Page - choose which page to launch on app launch.
- Added Persistent Notification - NEW: App lock and DM Lock.
- Improved Grayscale Scheduling. - NEW: Bait me button in Focus Control.
- NEW: Interactive Level based system for unlocking features.
- NEW: Effort Friction Mode.
- NEW: Strict and fully working Ghost Mode.
### Bug fixes ### Bug fixes
- Fixed a Bug Where Reels Werent playing despite Reels Sessions being ON.
- Fixed a Bug Where Session End Popup could be just dismissed and app ran Normally despite session already ended. - Fixed: Greyscale mode used to turn off when app was restarted.
- Fixed: Images in posts containing multiple images werent getting unblurred when tapped.
- Fixed: You could send message as "Ghost" in GHost mode (Ghost's cant talk with real people 🤪).
- Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs.
- Fixed: Download media button (rarely) opened random media rather than desired one.
- Fixed: Reel Session could be started despite quota being finished.
- Perfomance Optimizations - Perfomance Optimizations
- Other Minor Changes. - A lof of other Minor fixes.
+47 -48
View File
@@ -7,11 +7,11 @@
**Use social media on your terms.** **Use social media on your terms.**
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE)
[![Flutter](https://img.shields.io/badge/Flutter-3.38-blue?logo=flutter&logoColor=white)](https://flutter.dev) [![Version](https://img.shields.io/badge/version-2.1.0-white)](https://flutter.dev)
[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=300)](https://github.com/ujwal223/focusgram/releases) [![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=30)](https://github.com/ujwal223/focusgram/releases)
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'> <a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram'> <img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram on Uptodown'>
</a> </a>
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues/new) [Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues/new)
@@ -20,12 +20,15 @@
--- ---
Most people don't want to quit Instagram. They want to check their messages, post a story, and leave without losing an hour to Reels they never meant to watch. Most people don't want to completely quit Instagram but control its usage (i.e They want to check their messages, post a story, and leave) without losing many hours to Reels and distracting content they never meant to watch.
FocusGram is an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use. FocusGram is an Android-only app that loads the Instagram website with the distracting parts removed and with Extra features. No private APIs. No data collection. Just a cleaner way to use a platform you already use.
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" /> > FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo 👉👈.
>
> [![Buy Me a Momo](https://img.shields.io/badge/-%F0%9F%A5%9F%20Buy%20Me%20a%20Momo-FF6B35?style=for-the-badge&labelColor=1a1a1a)](https://buymemomo.com/ujwal)
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/assets/images/app-demo.png" />
--- ---
@@ -33,29 +36,32 @@ FocusGram is an Android app that loads the Instagram website with the distractin
**Focus tools** **Focus tools**
- Block Reels entirely, or allow them in timed sessions (115 min) with daily limits and cooldowns - Block Reels entirely, or allow them in timed sessions (130 min) with daily limits and cooldowns
- Autoplay blocker — videos won't play until you tap them - Minimal Mode strips everything down to Feed and DMs
- Minimal Mode — strips everything down to Feed and DMs - Hide ALL feed posts entirely.
**Content filtering** **Content filtering**
- Hide the Explore tab, Reels tab, or Shop tab individually - Hide the Explore tab or Reels tab individually
- Disable Explore and suggested content entirely - Disable Explore and blur posts, videos on feed entirely
- Disable Reels Entirely - Click to unblur feed posts
- Disable Reels entirely
- Disable scrolling of home feed
**Habit tools** **Habit tools**
- Screen Time Dashboard daily usage, 7-day chart, weekly average - Screen Time Dashboard: daily usage, 7-day chart, weekly average
- Grayscale Mode reduces the visual pull of colour; can be scheduled by time of day - Grayscale Mode: reduces the visual pull of colour; can be scheduled by time of day
- Session intentions optionally set a reason before opening the app - Session intentions: optionally set a reason before opening the app
- Reel & App Quota: Allocate only certain time for reels and/or instagram
**The app itself** **Other Features**
- Feels (almost) like a native app, not a browser.
- No blank loading screen — content loads in the background before you get there
- Instant updates via pull-to-refresh
- Dark mode follows your system
- Lock the app and/or your private messages.
- See other's message without sending seen indicator*
- Choose which page to launch when app is opened.
- Choose pause time before opening app (mindfulness gate).
- Save media on your local device.
--- ---
## Installation ## Installation
@@ -66,35 +72,36 @@ FocusGram is an Android app that loads the Instagram website with the distractin
3. Open the file and allow "Install from unknown sources" if prompted 3. Open the file and allow "Install from unknown sources" if prompted
### Uptodown ### Uptodown
Go to the [Focusgram - Uptodown](https://focusgram.en.uptodown.com/android) page<br> 1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page
2. Click "Get the Latest Version"<br> 2. Click "Get the Latest Version"
3. Click "Download"<br> 3. Click "Download"
3. Open the file and allow "Install from unknown sources" if prompted 4. Open the file and allow "Install from unknown sources" if prompted
--- ---
## Privacy ## Privacy
FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView your login goes directly to Meta's servers, the same as any mobile browser. FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView and your login goes directly to Meta's servers, the same as any mobile browser.
Our app has:
- No analytics - No analytics
- No crash reporting - No crash reporting
- No third-party SDKs - No third-party SDKs
- No Logging
- No data leaves your device - No data leaves your device
- All settings and history are stored locally using Android's standard storage APIs
--- ---
## Frequently asked questions ## Frequently asked questions
**Will this get my account banned?** **Will this get my account banned?**<br>
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics. Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials.
**Is this a mod of Instagram's app?** **Is this a mod of Instagram's app?**<br>
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code. No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
**Why is it free?** **How do i support this project?**<br>
Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0. You can support this project by donating here: [Donate](https://buymemomo.com/ujwal)
--- ---
@@ -112,8 +119,7 @@ Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](htt
- JDK 17 (Eclipse Adoptium 17.0.17+) - JDK 17 (Eclipse Adoptium 17.0.17+)
### Build ### Build
``` ```bash
bash
flutter pub get flutter pub get
flutter build apk --release flutter build apk --release
``` ```
@@ -124,17 +130,8 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
- CSS injection (element hiding, grayscale, scroll behaviour) - CSS injection (element hiding, grayscale, scroll behaviour)
- URL interception via NavigationDelegate (Reels blocking, Explore blocking) - URL interception via NavigationDelegate (Reels blocking, Explore blocking)
Nothing is modified server-side. The app never reads, intercepts, or stores Instagram content beyond what is explicitly listed (Reel URL, title, and thumbnail URL for the local history feature).
### Permissions
| Permission | Reason |
|---|---|
| `INTERNET` | Load instagram.com |
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
| `WAKE_LOCK` | Keep device awake during active Focus sessions |
| `FOREGROUND_SERVICE` | Run background service for session tracking |
### Stack ### Stack
| | | | | |
|---|---| |---|---|
| Framework | Flutter (Dart) | | Framework | Flutter (Dart) |
@@ -150,11 +147,11 @@ Nothing is modified server-side. The app never reads, intercepts, or stores Inst
FocusGram is an independent, free, and open-source productivity tool licensed under AGPL-3.0. It is not affiliated with, endorsed by, or associated with Meta Platforms, Inc. or Instagram in any way. FocusGram is an independent, free, and open-source productivity tool licensed under AGPL-3.0. It is not affiliated with, endorsed by, or associated with Meta Platforms, Inc. or Instagram in any way.
**How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com` the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers. **How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com`; the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers.
**What we do not do:** **What we do not do:**
- Use Instagram's or Meta's private APIs - Use/Alter Instagram's or Meta's private APIs
- Intercept, read, log, or store user credentials, session data, or any content - Intercept, read, log, or store user credentials, session data, or any sensitive content
- Modify any server-side Meta or Instagram services - Modify any server-side Meta or Instagram services
- Scrape, harvest, or collect any user data - Scrape, harvest, or collect any user data
- Claim ownership of any Meta or Instagram trademarks, logos, or intellectual property — any branding visible within the app is served directly from `instagram.com` and remains the property of Meta Platforms, Inc. - Claim ownership of any Meta or Instagram trademarks, logos, or intellectual property — any branding visible within the app is served directly from `instagram.com` and remains the property of Meta Platforms, Inc.
@@ -167,6 +164,8 @@ For legal concerns, contact `notujwal@proton.me` before taking any other action.
## License ## License
Copyright © 2025 Ujwal Chapagain Copyright © 2025-2026 Ujwal Chapagain
Licensed under the [GNU Affero General Public License v3.0](LICENSE). You are free to use, modify, and distribute this software under the same terms. Licensed under the [GNU Affero General Public License v3.0](LICENSE). You are free to use, modify, and distribute this software under the same terms.
FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) under AGPL-3.0, Thanks for Reading README.
+4
View File
@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- v2/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+190
View File
@@ -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
+3 -5
View File
@@ -45,7 +45,7 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 4
versionName = "1.1.0" versionName = "2.1.0"
} }
buildTypes { buildTypes {
@@ -63,11 +63,9 @@ android {
} }
} }
// Narrow exclusions to only the specific modules that cause conflicts,
// not entire Google/Firebase groups (which would block AdMob & Firebase).
configurations.all { configurations.all {
exclude(group = "com.google.android.gms")
exclude(group = "com.google.firebase")
exclude(group = "com.google.android.datatransport")
exclude(group = "com.google.android.play")
exclude(group = "com.google.android.play", module = "core") exclude(group = "com.google.android.play", module = "core")
exclude(group = "com.google.android.play", module = "core-common") exclude(group = "com.google.android.play", module = "core-common")
} }
+2
View File
@@ -61,6 +61,8 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<queries> <queries>
File diff suppressed because one or more lines are too long
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+10
View File
@@ -0,0 +1,10 @@
{
"show": false,
"id": "popup_005",
"header": "FOCUSGRAM UPDATE DISCONTINUITION!!",
"body": "Due to NO Support from community, it is being difficult for me to maintain this project, due to which, Next coming update might be the LAST update for FocusGram. THANKS FOR UNDERSTANDING! ",
"max_shows": 6,
"button_text": "OKAY..."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

+61
View File
@@ -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();
})();
+129
View File
@@ -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);
})();
+304
View File
@@ -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: <ul> 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 <html> 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' }));
}
})();
+315
View File
@@ -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') && !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;
})();
+199
View File
@@ -0,0 +1,199 @@
/**
* FocusGram Ghost Mode (V2 Overlay)
* Injected at DOCUMENT_START before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*
* Uses _prev chain pattern: each section saves the PREVIOUS fetch/XHR
* before overriding, so they compose rather than conflict.
*/
(function () {
'use strict';
// ─── First-interaction DM gate ──────────────────────────────────────────
// On /direct/*, first click blocks all api/graphql (inbox loads first).
window.__fgDirectApiBlocked = false;
document.addEventListener('click', function() {
if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
}, true);
document.addEventListener('touchstart', function() {
if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
}, true);
var _prevD = window.location.pathname.indexOf('/direct/') === 0;
setInterval(function() {
var now = window.location.pathname.indexOf('/direct/') === 0;
if (now !== _prevD) { _prevD = now; window.__fgDirectApiBlocked = false; }
}, 300);
function _blockIfNeeded(url) {
return window.__fgDirectApiBlocked &&
window.location.pathname.indexOf('/direct/') === 0 &&
url.indexOf('/api/graphql') !== -1;
}
// ─── SEEN + ACTIVITY 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\//,
];
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));
// ─── Fetch override — chains with whatever was there ──────────────────────
const _prevFetch = window.fetch;
window.fetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// DM first-interaction gate
if (_blockIfNeeded(url)) {
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
// Seen pattern block
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'seen_blocked', url }));
}
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
// Activity interceptor 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 _prevFetch(input, init);
};
window.fetch.toString = () => 'function fetch() { [native code] }';
// ─── XHR override — chains ──────────────────────────────────────────────
const _prevOpen = XMLHttpRequest.prototype.open;
const _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _prevOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
const url = this._fg_url || '';
// DM first-interaction gate
if (_blockIfNeeded(url)) {
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"}' });
['readystatechange', 'load'].forEach(function(t) {
try { self.dispatchEvent(new Event(t)); } catch(e) {}
});
}, 5);
return;
}
// Seen pattern block
if (url && isSeen(url)) {
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"}' });
['readystatechange', 'load'].forEach(function(t) {
try { self.dispatchEvent(new Event(t)); } catch(e) {}
});
}, 5);
return;
}
return _prevSend.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') {
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
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" ──────────────────────────────
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 () {
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' }));
}
})();
+47
View File
@@ -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();
})();
})();
@@ -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.
@@ -1 +0,0 @@
Same as1st version. just version pump
@@ -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.
@@ -1,6 +0,0 @@
What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
@@ -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.
@@ -1 +0,0 @@
A digital wellness wrapper for Instagram.
@@ -1 +0,0 @@
FocusGram
+6 -6
View File
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( SizedBox(
height: 80, height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: 6, itemCount: 6,
itemBuilder: (context, index) => Padding( itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 56, width: 56,
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
Container( Container(
width: 32, width: 32,
height: 8, height: 6,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(3),
), ),
), ),
], ],
+7 -13
View File
@@ -35,12 +35,11 @@ class NativeBottomNav extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
final bgColor = final bgColor = theme.colorScheme.surface.withValues(
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98); alpha: isDark ? 0.95 : 0.98,
final iconColorInactive = );
isDark ? Colors.white70 : Colors.black54; final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
final iconColorActive = final iconColorActive = theme.colorScheme.primary;
theme.colorScheme.primary;
final tabs = <_NavItem>[ final tabs = <_NavItem>[
_NavItem( _NavItem(
@@ -103,8 +102,7 @@ class NativeBottomNav extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) { children: tabs.map((item) {
final color = final color = item.active ? iconColorActive : iconColorInactive;
item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35; final opacity = item.enabled ? 1.0 : 0.35;
return Expanded( return Expanded(
@@ -129,10 +127,7 @@ class NativeBottomNav extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
item.label, item.label,
style: TextStyle( style: TextStyle(fontSize: 10, color: color),
fontSize: 10,
color: color,
),
), ),
], ],
), ),
@@ -164,4 +159,3 @@ class _NavItem {
required this.enabled, required this.enabled,
}); });
} }
@@ -2,9 +2,9 @@ import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart'; import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart'; import '../../scripts/native_feel.dart';
import '../../scripts/focus_scripts.dart';
class InstagramPreloader { class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView; static HeadlessInAppWebView? _headlessWebView;
@@ -13,13 +13,11 @@ class InstagramPreloader {
static bool isReady = false; static bool isReady = false;
static Future<void> start(String userAgent) async { static Future<void> start(String userAgent) async {
if (_headlessWebView != null) return; // don't start twice if (_headlessWebView != null) return;
_headlessWebView = HeadlessInAppWebView( _headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive, keepAlive: keepAlive,
initialUrlRequest: URLRequest( initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
url: WebUri('https://www.instagram.com/'),
),
initialSettings: InAppWebViewSettings( initialSettings: InAppWebViewSettings(
userAgent: userAgent, userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true, mediaPlaybackRequiresUserGesture: true,
@@ -33,12 +31,10 @@ class InstagramPreloader {
safeBrowsingEnabled: false, safeBrowsingEnabled: false,
), ),
initialUserScripts: UnmodifiableListView([ initialUserScripts: UnmodifiableListView([
// DM Ghost comprehensive blocking, gated by window.__fgFullDmGhost flag.
// it should have worked, but sadly it didnt
UserScript( UserScript(
source: 'window.__fgBlockAutoplay = true;', source: kFullDmGhostJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
), ),
UserScript( UserScript(
@@ -49,6 +45,7 @@ class InstagramPreloader {
source: kNativeFeelingScript, source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
), ),
// ReelMetadataExtractor removed reel history feature deleted
]), ]),
onWebViewCreated: (c) { onWebViewCreated: (c) {
controller = c; controller = c;
@@ -69,4 +66,3 @@ class InstagramPreloader {
isReady = false; isReady = false;
} }
} }
@@ -8,6 +8,8 @@ class ReelsHistoryEntry {
final String title; final String title;
final String thumbnailUrl; final String thumbnailUrl;
final DateTime visitedAt; final DateTime visitedAt;
final int durationSeconds; // How long the session lasted
final int adsWatchedInSession; // How many ads watched during this session
const ReelsHistoryEntry({ const ReelsHistoryEntry({
required this.id, required this.id,
@@ -15,6 +17,8 @@ class ReelsHistoryEntry {
required this.title, required this.title,
required this.thumbnailUrl, required this.thumbnailUrl,
required this.visitedAt, required this.visitedAt,
this.durationSeconds = 0,
this.adsWatchedInSession = 0,
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -23,6 +27,8 @@ class ReelsHistoryEntry {
'title': title, 'title': title,
'thumbnailUrl': thumbnailUrl, 'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(), 'visitedAt': visitedAt.toUtc().toIso8601String(),
'durationSeconds': durationSeconds,
'adsWatchedInSession': adsWatchedInSession,
}; };
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) { static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
@@ -31,8 +37,11 @@ class ReelsHistoryEntry {
url: (json['url'] as String?) ?? '', url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel', title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '', thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ?? visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(), DateTime.now().toUtc(),
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
adsWatchedInSession: (json['adsWatchedInSession'] as num?)?.toInt() ?? 0,
); );
} }
} }
@@ -70,6 +79,8 @@ class ReelsHistoryService {
required String url, required String url,
required String title, required String title,
required String thumbnailUrl, required String thumbnailUrl,
int durationSeconds = 0,
int adsWatchedInSession = 0,
}) async { }) async {
if (url.isEmpty) return; if (url.isEmpty) return;
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
@@ -88,6 +99,8 @@ class ReelsHistoryService {
title: title.isEmpty ? 'Instagram Reel' : title, title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl, thumbnailUrl: thumbnailUrl,
visitedAt: now, visitedAt: now,
durationSeconds: durationSeconds,
adsWatchedInSession: adsWatchedInSession,
); );
final updated = [entry, ...entries]; final updated = [entry, ...entries];
@@ -103,6 +116,47 @@ class ReelsHistoryService {
await _save(entries); await _save(entries);
} }
/// Get average reels watched per day in the last 7 days.
Future<double> getWeeklyAverageReels() async {
final entries = await getEntries();
if (entries.isEmpty) return 0;
final now = DateTime.now();
final sevenDaysAgo = now.subtract(const Duration(days: 7));
final recent = entries
.where((e) => e.visitedAt.isAfter(sevenDaysAgo))
.toList();
if (recent.isEmpty) return 0;
return recent.length / 7.0;
}
/// Get reel counts grouped by day (for the level system).
Future<Map<String, int>> getDailyReelCounts({int days = 30}) async {
final entries = await getEntries();
final now = DateTime.now();
final cutoff = now.subtract(Duration(days: days));
final recent = entries.where((e) => e.visitedAt.isAfter(cutoff)).toList();
final Map<String, int> counts = {};
for (final entry in recent) {
final dayKey =
'${entry.visitedAt.year}-'
'${entry.visitedAt.month.toString().padLeft(2, '0')}-'
'${entry.visitedAt.day.toString().padLeft(2, '0')}';
counts[dayKey] = (counts[dayKey] ?? 0) + 1;
}
return counts;
}
/// Get total reels watched in the last [days] days.
Future<int> getRecentReelCount({int days = 7}) async {
final entries = await getEntries();
final now = DateTime.now();
final cutoff = now.subtract(Duration(days: days));
return entries.where((e) => e.visitedAt.isAfter(cutoff)).length;
}
Future<void> clearAll() async { Future<void> clearAll() async {
final prefs = await _getPrefs(); final prefs = await _getPrefs();
await prefs.remove(_prefsKey); await prefs.remove(_prefsKey);
@@ -114,4 +168,3 @@ class ReelsHistoryService {
await prefs.setString(_prefsKey, jsonEncode(jsonList)); await prefs.setString(_prefsKey, jsonEncode(jsonList));
} }
} }
@@ -32,10 +32,7 @@ class _UpdateBannerState extends State<UpdateBanner> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
color: colorScheme.outlineVariant,
width: 0.5,
),
), ),
), ),
child: Column( child: Column(
@@ -121,10 +118,11 @@ class _UpdateBannerState extends State<UpdateBanner> {
text = text.replaceAll(RegExp(r'#{1,6}\s'), ''); text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1'); text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1'); text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
text = text = text.replaceAll(
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text RegExp(r'\[([^\]]+)\]\([^)]+\)'),
r'\1',
); // links -> text
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1'); text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
return text.trim(); return text.trim();
} }
} }
@@ -56,8 +56,9 @@ class UpdateCheckerService extends ChangeNotifier {
return; return;
} }
final cleanVersion = final cleanVersion = gitVersionTag.startsWith('v')
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag; ? gitVersionTag.substring(1)
: gitVersionTag;
var trimmed = body.trim(); var trimmed = body.trim();
if (trimmed.length > 1500) { if (trimmed.length > 1500) {
@@ -73,7 +74,7 @@ class UpdateCheckerService extends ChangeNotifier {
_isDismissed = false; _isDismissed = false;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Update check failed: $e'); // debugPrint('Update check failed: $e');
} }
} }
+17
View File
@@ -0,0 +1,17 @@
class FocusSettings {
final bool ghostMode; // DM ghost blocks seen/DM signals comprehensively
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 = true,
this.noStories = false,
this.noReels = false,
this.noAutoplay = false,
this.noDMs = false,
});
}
+80 -5
View File
@@ -4,11 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:hive_flutter/hive_flutter.dart';
// google_mobile_ads removed switched to Adsterra only
import 'services/session_manager.dart'; import 'services/session_manager.dart';
import 'services/settings_service.dart'; import 'services/settings_service.dart';
import 'services/screen_time_service.dart'; import 'services/screen_time_service.dart';
import 'services/focusgram_router.dart'; import 'services/focusgram_router.dart';
import 'services/injection_controller.dart'; import 'services/injection_controller.dart';
import 'services/credit_store.dart';
import 'services/bait_engine.dart';
import 'services/app_lock_service.dart';
import 'services/level_service.dart';
import 'services/snapshot_service.dart';
import 'screens/app_lock_screen.dart';
import 'screens/onboarding_page.dart'; import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart'; import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart'; import 'screens/breath_gate_screen.dart';
@@ -17,6 +25,7 @@ import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'features/update_checker/update_checker_service.dart'; import 'features/update_checker/update_checker_service.dart';
import 'features/preloader/instagram_preloader.dart'; import 'features/preloader/instagram_preloader.dart';
import 'widgets/remote_popup_handler.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -27,23 +36,40 @@ void main() async {
DeviceOrientation.portraitDown, DeviceOrientation.portraitDown,
]); ]);
// Initialise storage & SDKs
await Hive.initFlutter();
final creditStore = CreditStore();
final baitEngine = BaitEngine();
final levelService = LevelService();
final appLockService = AppLockService();
final snapshotService = SnapshotService();
final sessionManager = SessionManager(); final sessionManager = SessionManager();
final settingsService = SettingsService(); final settingsService = SettingsService();
final screenTimeService = ScreenTimeService(); final screenTimeService = ScreenTimeService();
final updateChecker = UpdateCheckerService(); final updateChecker = UpdateCheckerService();
await creditStore.init();
await baitEngine.init();
await appLockService.init();
await levelService.init();
await snapshotService.init();
await sessionManager.init(); await sessionManager.init();
await settingsService.init(); await settingsService.init();
await screenTimeService.init(); await screenTimeService.init();
await NotificationService().init(); await NotificationService().init(requestPermissions: true);
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider.value(value: sessionManager), ChangeNotifierProvider.value(value: sessionManager),
ChangeNotifierProvider.value(value: settingsService), ChangeNotifierProvider.value(value: settingsService),
ChangeNotifierProvider.value(value: screenTimeService), ChangeNotifierProvider.value(value: screenTimeService),
ChangeNotifierProvider.value(value: creditStore),
ChangeNotifierProvider.value(value: baitEngine),
ChangeNotifierProvider.value(value: levelService),
ChangeNotifierProvider.value(value: appLockService),
ChangeNotifierProvider.value(value: snapshotService),
ChangeNotifierProvider.value(value: updateChecker), ChangeNotifierProvider.value(value: updateChecker),
], ],
child: const FocusGramApp(), child: const FocusGramApp(),
@@ -97,30 +123,67 @@ class InitialRouteHandler extends StatefulWidget {
State<InitialRouteHandler> createState() => _InitialRouteHandlerState(); State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
} }
class _InitialRouteHandlerState extends State<InitialRouteHandler> { class _InitialRouteHandlerState extends State<InitialRouteHandler>
with WidgetsBindingObserver {
bool _breathCompleted = false; bool _breathCompleted = false;
bool _appSessionStarted = false; bool _appSessionStarted = false;
bool _onboardingCompleted = false; bool _onboardingCompleted = false;
bool _lockScreenDismissed = false;
late AppLinks _appLinks; late AppLinks _appLinks;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_appLinks = AppLinks(); _appLinks = AppLinks();
_initDeepLinks(); _initDeepLinks();
WidgetsBinding.instance.addPostFrameCallback((_) {
RemotePopupHandler.checkAndShow(context);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final appLock = context.read<AppLockService>();
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
appLock.onBackgrounded();
} else if (state == AppLifecycleState.resumed) {
if (appLock.shouldLockOnResume) {
appLock.onLockScreenShown();
_showLockScreen();
}
}
}
Future<void> _showLockScreen() async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)),
);
if (result == true && mounted) {
setState(() => _lockScreenDismissed = true);
}
} }
Future<void> _initDeepLinks() async { Future<void> _initDeepLinks() async {
// 1. Handle background links while app is running // 1. Handle background links while app is running
_appLinks.uriLinkStream.listen((uri) { _appLinks.uriLinkStream.listen((uri) {
debugPrint('Incoming Deep Link: $uri'); // debugPrint('Incoming Deep Link: $uri');
FocusGramRouter.pendingUrl.value = uri.toString(); FocusGramRouter.pendingUrl.value = uri.toString();
}); });
// 2. Handle the initial link that opened the app // 2. Handle the initial link that opened the app
final initialUri = await _appLinks.getInitialLink(); final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) { if (initialUri != null) {
debugPrint('Initial Deep Link: $initialUri'); // debugPrint('Initial Deep Link: $initialUri');
FocusGramRouter.pendingUrl.value = initialUri.toString(); FocusGramRouter.pendingUrl.value = initialUri.toString();
} }
} }
@@ -129,6 +192,17 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sm = context.watch<SessionManager>(); final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>(); final settings = context.watch<SettingsService>();
final appLock = context.watch<AppLockService>();
// Step 0: App-wide lock (shows before everything, once per cold start)
if (appLock.needsUnlockOnStart && !_lockScreenDismissed) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!appLock.isShowingLock) {
appLock.onLockScreenShown();
_showLockScreen();
}
});
}
// Step 1: Onboarding // Step 1: Onboarding
if (settings.isFirstRun && !_onboardingCompleted) { if (settings.isFirstRun && !_onboardingCompleted) {
@@ -145,6 +219,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
// Step 3: Breath gate // Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) { if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen( return BreathGateScreen(
durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true), onFinish: () => setState(() => _breathCompleted = true),
); );
} }
+320
View File
@@ -0,0 +1,320 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
/// Full-screen ad page. User MUST click the ad to earn the reward.
///
/// Flow:
/// 1. Ad loads in WebView for 20s
/// 2. User taps the ad opens in external browser via url_launcher
/// 3. Timer continues counting to 20s regardless
/// 4. After 20s, "Continue & Earn Reward" button unlocks if BOTH ads clicked
/// 5. If ads not clicked within time, a Retry button appears to reload
const String _kAdHtml = '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; background:#111; display:flex; flex-direction:column; align-items:center; justify-content:space-around; }
.ad-slot { width:100%; text-align:center; }
</style>
</head>
<body>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 1</div>
<script async="async" data-cfasync="false" src="https://pl18364273.effectivecpmnetwork.com/e8a9b107824c939fb63d96c218c1336a/invoke.js"></script>
<div id="container-e8a9b107824c939fb63d96c218c1336a"></div>
</div>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 2</div>
<script>
atOptions = {'key':'99233324430f9128f2b01c30b6eebc20','format':'iframe','height':250,'width':300,'params':{}};
</script>
<script src="https://www.highperformanceformat.com/99233324430f9128f2b01c30b6eebc20/invoke.js"></script>
</div>
</body>
</html>
''';
class AdsterraAdScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const AdsterraAdScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 20,
});
@override
State<AdsterraAdScreen> createState() => _AdsterraAdScreenState();
}
class _AdsterraAdScreenState extends State<AdsterraAdScreen> {
int _elapsed = 0;
Timer? _timer;
int _adsClicked = 0; // count of ad clicks (need 2 for reward)
bool _retrying = false;
InAppWebViewController? _webController;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _elapsed++);
});
}
Future<void> _retry() async {
setState(() {
_retrying = true;
_elapsed = 0;
_adsClicked = 0;
});
_startTimer();
try {
await _webController?.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
} catch (_) {}
if (mounted) setState(() => _retrying = false);
}
@override
Widget build(BuildContext context) {
final timerDone = _elapsed >= widget.requiredSeconds;
final bothClicked = _adsClicked >= 2;
final done = timerDone && bothClicked;
// When timer expired but ads not clicked, wait a bit then allow skip
final canSkip = timerDone && !bothClicked;
String statusText;
Color statusColor;
if (bothClicked && timerDone) {
statusText = 'Ready!';
statusColor = Colors.greenAccent;
} else if (bothClicked) {
statusText = 'Both ads clicked! Waiting for timer…';
statusColor = Colors.greenAccent;
} else {
statusText = 'Tap BOTH ads below to earn XP ($_adsClicked/2)';
statusColor = Colors.white.withValues(alpha: 0.4);
}
String buttonText;
bool buttonEnabled;
VoidCallback? buttonAction;
if (done) {
buttonText = 'Continue & Earn Reward';
buttonEnabled = true;
buttonAction = () => Navigator.pop(context, true);
} else if (timerDone && !bothClicked) {
buttonText = 'Tap both ads to continue';
buttonEnabled = false;
buttonAction = null;
} else {
final remaining = widget.requiredSeconds - _elapsed;
buttonText = 'Wait ${remaining > 0 ? remaining : 0}s';
buttonEnabled = false;
buttonAction = null;
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Top bar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.videocam, color: Colors.white54, size: 18),
const SizedBox(width: 8),
const Text(
'Sponsored',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
const Spacer(),
Text(
'${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white54,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (_elapsed / widget.requiredSeconds).clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
done ? Colors.greenAccent : Colors.blueAccent,
),
),
),
// Hint text
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
statusText,
style: TextStyle(color: statusColor, fontSize: 11),
),
),
// Ad WebView
Expanded(
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
useHybridComposition: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
),
onWebViewCreated: (c) async {
_webController = c;
await c.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
onLoadStop: (_, url) {
// ad loaded
},
shouldOverrideUrlLoading: (controller, nav) async {
final url = nav.request.url?.toString() ?? '';
if (url.isNotEmpty &&
!url.contains('adsterra.com') &&
!url.startsWith('about:')) {
if (_adsClicked < 2) _adsClicked++;
if (mounted) setState(() {});
await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
),
// Button area
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: buttonEnabled ? buttonAction : null,
style: ElevatedButton.styleFrom(
backgroundColor: done
? Colors.greenAccent
: Colors.grey,
foregroundColor: done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
size: 22,
),
label: Text(
buttonText,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
// Retry / Skip buttons when timer done but ads not clicked
if (canSkip && !_retrying) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: _retry,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(alpha: 0.4),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text(
'Retry — Reload Ads',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Skip (no reward)',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 13,
),
),
),
],
if (_retrying)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orangeAccent,
),
),
),
],
),
),
],
),
),
);
}
}
+311
View File
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// The lock screen shown when FocusGram is locked.
///
/// Supports PIN entry with optional scrambled keypad.
/// [forAppWide] controls which PIN to verify: true = app-wide, false = messages.
/// [title] lets the screen show context (e.g. "Messages Locked").
class AppLockScreen extends StatefulWidget {
final bool forAppWide;
final String? title;
final String? subtitle;
const AppLockScreen({
super.key,
this.forAppWide = true,
this.title,
this.subtitle,
});
@override
State<AppLockScreen> createState() => _AppLockScreenState();
}
class _AppLockScreenState extends State<AppLockScreen> {
String _enteredPin = '';
bool _showError = false;
String _errorMsg = '';
bool _isVerifying = false;
List<int> _scrambledDigits = [];
@override
void initState() {
super.initState();
_refreshScrambled();
}
void _refreshScrambled() {
setState(() {
_scrambledDigits = context.read<AppLockService>().getScrambledDigits();
});
}
@override
Widget build(BuildContext context) {
final appLock = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.white,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue.withValues(alpha: 0.1),
),
child: const Icon(
Icons.lock_outline,
color: Colors.blueAccent,
size: 32,
),
),
const SizedBox(height: 20),
// Title
Text(
widget.title ?? 'FocusGram is Locked',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
widget.subtitle ?? 'Enter your PIN to unlock',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const SizedBox(height: 32),
// PIN dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final filled = i < _enteredPin.length;
return Container(
width: 16,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? Colors.blueAccent
: (isDark ? Colors.white24 : Colors.black12),
),
);
}),
),
// Error text
if (_showError)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
_errorMsg,
style: const TextStyle(color: Colors.redAccent, fontSize: 13),
),
),
if (_isVerifying)
const Padding(
padding: EdgeInsets.only(top: 16),
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Keypad
_buildKeypad(appLock),
],
),
),
);
}
Widget _buildKeypad(AppLockService appLock) {
final useScrambled = appLock.scrambleKeypad;
// Build digit labels
final digitLabels = useScrambled
? _scrambledDigits.map((d) => d.toString()).toList()
: List.generate(10, (i) => i.toString());
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Column(
children: [
// Row 1: 1 2 3
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[1],
onTap: () => _onDigit(digitLabels[1]),
),
_KeypadButton(
label: digitLabels[2],
onTap: () => _onDigit(digitLabels[2]),
),
_KeypadButton(
label: digitLabels[3],
onTap: () => _onDigit(digitLabels[3]),
),
],
),
// Row 2: 4 5 6
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[4],
onTap: () => _onDigit(digitLabels[4]),
),
_KeypadButton(
label: digitLabels[5],
onTap: () => _onDigit(digitLabels[5]),
),
_KeypadButton(
label: digitLabels[6],
onTap: () => _onDigit(digitLabels[6]),
),
],
),
// Row 3: 7 8 9
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[7],
onTap: () => _onDigit(digitLabels[7]),
),
_KeypadButton(
label: digitLabels[8],
onTap: () => _onDigit(digitLabels[8]),
),
_KeypadButton(
label: digitLabels[9],
onTap: () => _onDigit(digitLabels[9]),
),
],
),
// Row 4: delete 0 scramble-refresh
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(label: '', onTap: _onDelete, isFunction: true),
_KeypadButton(
label: digitLabels[0],
onTap: () => _onDigit(digitLabels[0]),
),
if (useScrambled)
_KeypadButton(
label: '',
onTap: _refreshScrambled,
isFunction: true,
)
else
const SizedBox(width: 72), // Placeholder
],
),
],
),
);
}
void _onDigit(String digit) {
if (_enteredPin.length >= 4) return;
setState(() {
_enteredPin += digit;
_showError = false;
});
if (_enteredPin.length == 4) {
_verifyPin();
}
}
void _onDelete() {
if (_enteredPin.isEmpty) return;
setState(
() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1),
);
}
Future<void> _verifyPin() async {
setState(() => _isVerifying = true);
final appLock = context.read<AppLockService>();
final valid = await appLock.verifyPin(
_enteredPin,
forAppWide: widget.forAppWide,
);
if (!mounted) return;
if (valid) {
HapticFeedback.heavyImpact();
appLock.onUnlocked();
Navigator.of(context).pop(true);
} else {
setState(() {
_showError = true;
_errorMsg = 'Wrong PIN. Try again.';
_enteredPin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
}
}
}
class _KeypadButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool isFunction;
const _KeypadButton({
required this.label,
required this.onTap,
this.isFunction = false,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return SizedBox(
width: 72,
height: 72,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(36),
onTap: onTap,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: isFunction ? 28 : 24,
fontWeight: FontWeight.w500,
color: isFunction
? Colors.blueAccent
: (isDark ? Colors.white : Colors.black87),
),
),
),
),
),
);
}
}
+225
View File
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
import 'app_lock_setup_screen.dart';
/// App Lock settings two independent lock modes (app-wide + messages tab),
/// each with their own toggle, all backed by a single PIN.
class AppLockSettingsPage extends StatefulWidget {
const AppLockSettingsPage({super.key});
@override
State<AppLockSettingsPage> createState() => _AppLockSettingsPageState();
}
class _AppLockSettingsPageState extends State<AppLockSettingsPage> {
Future<bool> _ensurePin() async {
final appLock = context.read<AppLockService>();
if (appLock.hasPin) return true;
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
return ok == true;
}
@override
Widget build(BuildContext context) {
final a = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final anythingOn = a.lockAppWide || a.lockMessages;
return Scaffold(
appBar: AppBar(
title: const Text(
'App Lock',
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: [
// Status card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: anythingOn
? [
Colors.blueAccent.withValues(alpha: 0.15),
Colors.blue.withValues(alpha: 0.05),
]
: [
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: anythingOn
? Colors.blueAccent.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Column(
children: [
Icon(
anythingOn ? Icons.lock_rounded : Icons.lock_open_rounded,
color: anythingOn ? Colors.blueAccent : Colors.grey,
size: 48,
),
const SizedBox(height: 12),
Text(
anythingOn ? 'Lock Active' : 'No Lock',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: anythingOn ? Colors.blueAccent : Colors.grey,
),
),
const SizedBox(height: 6),
Text(
_statusText(a),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const _SectionHeader(title: 'LOCK MODES'),
// App-wide lock
SwitchListTile(
title: const Text('Lock Entire App'),
subtitle: const Text('Require PIN when opening FocusGram.'),
value: a.lockAppWide,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockAppWide(v);
HapticFeedback.selectionClick();
},
),
// Messages tab lock
SwitchListTile(
title: const Text('Lock Messages Tab'),
subtitle: const Text(
'Require PIN to open Instagram Direct Messages',
),
value: a.lockMessages,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockMessages(v);
HapticFeedback.selectionClick();
},
),
// PIN & extras
if (a.hasPin) ...[
const _SectionHeader(title: 'PIN & SECURITY'),
ListTile(
title: const Text('Change PIN'),
subtitle: const Text('Set a new 4-digit code'),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
onTap: () async {
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
if (ok == true && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('PIN updated')));
}
},
),
SwitchListTile(
title: const Text('Scrambled Keypad'),
subtitle: const Text('Shuffle digits on the lock screen'),
value: a.scrambleKeypad,
onChanged: (v) async {
await a.setScrambleKeypad(v);
HapticFeedback.selectionClick();
},
),
// Biometrics option removed
],
// Hint if no PIN
if (!a.hasPin)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Colors.blueAccent,
),
SizedBox(width: 8),
Expanded(
child: Text(
'Enable any lock mode above to set up your PIN.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 40),
],
),
);
}
String _statusText(AppLockService a) {
if (!a.hasPin) return 'Set a PIN to enable any lock mode.';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages tab');
if (parts.isEmpty) return 'Both modes are off — enable one above.';
return '${parts.join(' + ')} lock is active.';
}
}
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,
),
),
);
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// First-time setup screen for App Lock.
/// User enters PIN twice, then optionally enables biometrics.
class AppLockSetupScreen extends StatefulWidget {
const AppLockSetupScreen({super.key});
@override
State<AppLockSetupScreen> createState() => _AppLockSetupScreenState();
}
class _AppLockSetupScreenState extends State<AppLockSetupScreen> {
final _pinController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePin = true;
bool _obscureConfirm = true;
String? _error;
@override
void dispose() {
_pinController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Set App Lock PIN'), centerTitle: true),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text(
'Choose a 4-digit PIN to lock FocusGram.',
style: TextStyle(fontSize: 15, height: 1.5),
),
const SizedBox(height: 32),
// PIN field
TextField(
controller: _pinController,
obscureText: _obscurePin,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Enter PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscurePin ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscurePin = !_obscurePin),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: 16),
// Confirm PIN field
TextField(
controller: _confirmController,
obscureText: _obscureConfirm,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Confirm PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
// Error
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_error!,
style: const TextStyle(color: Colors.redAccent),
),
),
const Spacer(),
// Save button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _savePin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Enable App Lock',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Future<void> _savePin() async {
final pin = _pinController.text.trim();
final confirm = _confirmController.text.trim();
if (pin.length != 4) {
setState(() => _error = 'PIN must be exactly 4 digits.');
return;
}
if (pin != confirm) {
setState(() => _error = 'PINs do not match.');
return;
}
if (pin == pin.split('').toSet().join('') && pin.length == 4) {
// Allow any 4-digit PIN
}
final appLock = context.read<AppLockService>();
// Set both PINs to the same value for simplicity
await appLock.setPin(pin, forAppWide: true);
await appLock.setPin(pin, forAppWide: false);
HapticFeedback.heavyImpact();
if (mounted) {
Navigator.pop(context, true);
}
}
}
+20 -4
View File
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
55, 55,
60, 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<SessionManager>().lastAppSessionMinutes;
final lastIndex = _minuteOptions.indexOf(lastMinutes);
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
_scrollController = FixedExtentScrollController(
initialItem: _selectedIndex,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
perspective: 0.003, perspective: 0.003,
squeeze: 1.1, squeeze: 1.1,
diameterRatio: 2.5, diameterRatio: 2.5,
controller: _scrollController,
onSelectedItemChanged: (i) { onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i); setState(() => _selectedIndex = i);
}, },
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate( childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) { children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex; final isSelected = entry.key == _selectedIndex;
+266
View File
@@ -0,0 +1,266 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import '../services/session_manager.dart';
/// The Bait Me button widget.
///
/// Shows a gamble-themed button that triggers random outcomes.
/// Gated behind Level 3. Cooldown prevents spam.
class BaitMeButton extends StatefulWidget {
const BaitMeButton({super.key});
@override
State<BaitMeButton> createState() => _BaitMeButtonState();
}
class _BaitMeButtonState extends State<BaitMeButton>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final baitEngine = context.read<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) {
return const SizedBox.shrink();
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// The button
SizedBox(
width: 48,
height: 48,
child: Stack(
children: [
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning
? _spinAnimation.value * 2 * pi * 3
: 0,
child: child,
);
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: baitEngine.isOnCooldown
? Colors.grey.withValues(alpha: 0.3)
: Colors.purpleAccent.withValues(alpha: 0.2),
border: Border.all(
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
width: 2,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: baitEngine.isOnCooldown ? null : _onBaitMe,
child: Center(
child: Icon(
Icons.casino_rounded,
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
size: 22,
),
),
),
),
),
),
// Cooldown badge
if (baitEngine.isOnCooldown)
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${baitEngine.cooldownRemainingMinutes}m',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
const SizedBox(height: 2),
Text(
'Bait Me',
style: TextStyle(
fontSize: 9,
color: isDark ? Colors.white60 : Colors.black54,
),
),
],
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() {
_isSpinning = true;
});
_spinController.forward(from: 0);
// Wait for spin animation
await Future.delayed(const Duration(milliseconds: 1200));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
// Wire callbacks
baitEngine.onAddMinutes = (minutes) {
creditStore.addBonusMinutes(minutes);
HapticFeedback.heavyImpact();
};
baitEngine.onResetSession = () {
creditStore.resetBalances();
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onReduceSessionTime = (minutes) {
// Deduct from reel credits
for (var i = 0; i < minutes; i++) {
creditStore.drainReelsMinute();
}
HapticFeedback.heavyImpact();
};
baitEngine.onIncreaseCooldown = (minutes) {
// Increase cooldown by adding to the last session end time
// Session manager handles cooldown via _lastSessionEnd
HapticFeedback.heavyImpact();
};
baitEngine.onEndReelSession = () {
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onEndAppSession = () {
sessionManager.endAppSession();
HapticFeedback.heavyImpact();
};
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
};
// Activate
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
});
// Show result dialog
_showOutcomeDialog(context, outcome);
}
void _showOutcomeDialog(BuildContext context, BaitOutcome outcome) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
BaitEngine.outcomeLabel(outcome),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: outcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 12),
Text(
BaitEngine.outcomeSubtext(outcome),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white70 : Colors.black87,
height: 1.4,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
// import '../services/level_service.dart'; // unused
import '../services/session_manager.dart';
/// Full-screen Bait Me page with big spin animation.
class BaitMeFullScreen extends StatefulWidget {
const BaitMeFullScreen({super.key});
@override
State<BaitMeFullScreen> createState() => _BaitMeFullScreenState();
}
class _BaitMeFullScreenState extends State<BaitMeFullScreen>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
bool _done = false;
BaitOutcome? _lastOutcome;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Spacer(),
// Title
Text(
_done ? '🎲 Result!' : '🎲 Bait Me',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_done
? BaitEngine.outcomeSubtext(
_lastOutcome ?? BaitOutcome.addTenMinutes,
)
: 'Tap the button to test your luck!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 15,
),
),
const Spacer(),
// Spinning icon
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning
? _spinAnimation.value * 2 * pi * 5
: 0,
child: child,
);
},
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _done
? Colors.green.withValues(alpha: 0.15)
: Colors.purpleAccent.withValues(alpha: 0.15),
border: Border.all(
color: _done ? Colors.greenAccent : Colors.purpleAccent,
width: 3,
),
),
child: Center(
child: Icon(
_done ? Icons.check_circle : Icons.casino_rounded,
color: _done ? Colors.greenAccent : Colors.purpleAccent,
size: 56,
),
),
),
),
const Spacer(),
// Outcome description
if (_done && _lastOutcome != null)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
BaitEngine.outcomeLabel(_lastOutcome!),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _lastOutcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 8),
Text(
BaitEngine.outcomeSubtext(_lastOutcome!),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 14,
height: 1.4,
),
),
],
),
),
const Spacer(flex: 2),
// Big button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isSpinning ? null : _onBaitMe,
style: ElevatedButton.styleFrom(
backgroundColor: _done
? Colors.greenAccent
: Colors.purpleAccent,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
),
icon: Icon(
_isSpinning
? Icons.hourglass_top
: _done
? Icons.check_circle
: Icons.casino_rounded,
size: 24,
),
label: Text(
_isSpinning
? 'Rolling…'
: _done
? 'Done — Close'
: '🎲 Spin the Wheel!',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
),
if (!_done)
Padding(
padding: const EdgeInsets.only(top: 12),
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Not now',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
),
),
),
),
const Spacer(),
],
),
),
),
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() => _isSpinning = true);
_spinController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 1800));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
baitEngine.onAddMinutes = (m) => creditStore.addBonusMinutes(m);
baitEngine.onResetSession = () => creditStore.resetBalances();
baitEngine.onReduceSessionTime = (m) {
for (var i = 0; i < m; i++) {
creditStore.drainReelsMinute();
}
};
baitEngine.onEndReelSession = () => sessionManager.endSession();
baitEngine.onEndAppSession = () => sessionManager.endAppSession();
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
};
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
_done = true;
_lastOutcome = outcome;
});
HapticFeedback.heavyImpact();
}
}
+11 -7
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
/// A mindfulness screen shown before the app opens. /// A mindfulness screen shown before Instagram opens.
/// Forces the user to take a deep 10-second breath.
class BreathGateScreen extends StatefulWidget { class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish; final VoidCallback onFinish;
final int durationSeconds;
const BreathGateScreen({super.key, required this.onFinish}); const BreathGateScreen({
super.key,
required this.onFinish,
this.durationSeconds = 10,
});
@override @override
State<BreathGateScreen> createState() => _BreathGateScreenState(); State<BreathGateScreen> createState() => _BreathGateScreenState();
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
int _secondsRemaining = 10; late int _secondsRemaining;
Timer? _timer; Timer? _timer;
bool _canContinue = false; bool _canContinue = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text( const Text(
'Are you sure you want to open FocusGram?', 'Are you sure you want to open Instagram?',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
), ),
), ),
child: const Text('Continue to FocusGram'), child: const Text('Continue to Instagram'),
), ),
), ),
], ],
+342
View File
@@ -0,0 +1,342 @@
/*import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
/// A hidden debug menu for development & testing.
///
/// Access: tap the app version in settings 7 times.
/// Allows manually setting XP/level to test feature gating.
class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key});
@override
State<DebugMenuScreen> createState() => _DebugMenuScreenState();
}
class _DebugMenuScreenState extends State<DebugMenuScreen> {
int _customLevel = 1;
int _customXp = 0;
@override
void initState() {
super.initState();
final levelService = context.read<LevelService>();
_customLevel = levelService.level;
_customXp = levelService.xp;
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Debug Menu',
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(
padding: const EdgeInsets.all(16),
children: [
// Current state
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.amber.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, color: Colors.amber, size: 20),
const SizedBox(width: 8),
const Text(
'Developer Tools',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const SizedBox(height: 12),
Text(
'Current: Level ${levelService.level} · ${levelService.xp} XP',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 4),
Text(
'Progress: ${(levelService.levelProgress * 100).toStringAsFixed(0)}% to next level',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const SizedBox(height: 24),
// Manual level setter
const Text(
'SET LEVEL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 12),
// Quick level buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(5, (i) {
final lvl = i + 1;
final selected = _customLevel == lvl;
return ElevatedButton(
onPressed: () => setState(() => _customLevel = lvl),
style: ElevatedButton.styleFrom(
backgroundColor: selected ? Colors.blueAccent : null,
foregroundColor: selected ? Colors.white : null,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
child: Text('Level $lvl'),
);
}),
),
const SizedBox(height: 16),
// Set XP field
const Text(
'SET XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'XP Amount',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
controller: TextEditingController(text: '$_customXp'),
onChanged: (v) {
_customXp = int.tryParse(v) ?? 0;
},
),
const SizedBox(height: 20),
// Apply button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _applyDebugSettings(levelService),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.warning_amber_rounded, size: 20),
label: const Text(
'Apply Debug Settings',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 32),
// Feature unlock preview
const Text(
'FEATURE UNLOCK STATUS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = _customLevel >= feature.requiredLevel;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 13,
color: unlocked ? null : Colors.grey,
),
),
),
Text(
'Lv ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 32),
const SizedBox(height: 40),
// Danger zone
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.dangerous_outlined, color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text(
'Danger Zone',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _resetAllData(levelService),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
icon: const Icon(Icons.delete_forever, size: 18),
label: const Text('Reset All Level Data'),
),
),
],
),
),
],
),
);
}
Future<void> _applyDebugSettings(LevelService levelService) async {
HapticFeedback.heavyImpact();
// Use reflection-like approach: set the private fields via a method
// Since LevelService doesn't expose a raw setter, we provide one here.
await _forceSetLevel(levelService, _customLevel, _customXp);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Set to Level $_customLevel with $_customXp XP'),
backgroundColor: Colors.amber.shade800,
),
);
}
}
Future<void> _forceSetLevel(LevelService levelService, int level, int xp) async {
// The LevelService stores data in Hive (local only).
// We bypass the normal XP system by writing directly to cache.
await levelService.debugSetLevel(level, xp);
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) setState(() {});
}
Future<void> _resetAllData(LevelService levelService) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset All Level Data?'),
content: const Text(
'This will reset your level, XP, and all history to defaults. '
'This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true && mounted) {
await levelService.debugReset();
if (mounted) {
setState(() {
_customLevel = 1;
_customXp = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Level data reset')),
);
}
}
}
}
*/
+325
View File
@@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import 'timer_fallback_screen.dart';
import '../widgets/native_ad_banner.dart';
/// Shown before a reel or Instagram session when credits are zero
/// and Effort Friction Mode is enabled.
///
/// Fallback chain: Adsterra Social Bar (WebView) Timer fallback.
class EffortFrictionGate extends StatefulWidget {
final String sessionType; // 'reels' or 'insta'
final VoidCallback onProceed;
final VoidCallback? onCancel;
const EffortFrictionGate({
super.key,
required this.sessionType,
required this.onProceed,
this.onCancel,
});
@override
State<EffortFrictionGate> createState() => _EffortFrictionGateState();
}
class _EffortFrictionGateState extends State<EffortFrictionGate> {
bool _isWorking = false;
String _status = '';
@override
Widget build(BuildContext context) {
final creditStore = context.watch<CreditStore>();
final isReels = widget.sessionType == 'reels';
final credits = isReels
? creditStore.reelsMinutes
: creditStore.instaMinutes;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Colors.orange.shade800, Colors.orange.shade500],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.3),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 28),
Text(
isReels ? 'Earn Reels Time' : 'Earn Instagram Time',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Watch a short ad to earn ${CreditStore.minutesPerAd} minutes '
'of ${isReels ? 'reel' : 'Instagram'} time.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 32),
// Credit balance display
if (credits > 0)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.green.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.access_time,
color: Colors.greenAccent,
size: 20,
),
const SizedBox(width: 8),
Text(
'You have $credits min remaining',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 20),
// Status message
if (_status.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: Colors.blueAccent,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_status,
style: const TextStyle(
color: Colors.blueAccent,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 12),
// Watch ad button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: _isWorking ? null : _startFallbackChain,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: _isWorking
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.play_arrow_rounded, size: 22),
label: Text(
_isWorking
? 'Working…'
: 'Watch Ad (+${CreditStore.minutesPerAd} min)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
// Proceed button
if (credits > 0)
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: widget.onProceed,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Proceed with earned time'),
),
),
const SizedBox(height: 16),
// Cancel
TextButton(
onPressed: widget.onCancel ?? () => Navigator.pop(context),
child: Text(
credits > 0 ? 'Skip for now' : 'Not now',
style: TextStyle(color: Colors.white.withValues(alpha: 0.4)),
),
),
const Spacer(flex: 1),
Text(
'Ads by Adsterra',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.15),
fontSize: 10,
),
),
const SizedBox(height: 4),
// Native banner ad at bottom
const NativeAdBanner(height: 50),
const SizedBox(height: 8),
],
),
),
),
);
}
// Fallback Chain
Future<void> _startFallbackChain() async {
setState(() => _isWorking = true);
// Tier 1: Adsterra ad (full-screen WebView)
setState(() => _status = '');
if (mounted) {
final adsterraResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => AdsterraAdScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (adsterraResult == true && mounted) {
_grantReward();
setState(() {
_isWorking = false;
_status = '';
});
return;
}
if (!mounted) return;
}
// Tier 2: Timer fallback (always works)
setState(() => _status = 'Using timer fallback…');
if (mounted) {
final timerResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => TimerFallbackScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (timerResult == true && mounted) {
_grantReward();
}
}
if (mounted) {
setState(() {
_isWorking = false;
_status = '';
});
}
}
void _grantReward() {
final creditStore = context.read<CreditStore>();
final levelService = context.read<LevelService>();
if (widget.sessionType == 'reels') {
creditStore.addReelsMinutes();
} else {
creditStore.addInstaMinutes();
}
levelService.addXpForAd();
HapticFeedback.heavyImpact();
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
import 'ghost_mode_submenu_page.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
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: 'STARTUP'),
_LaunchPagePicker(settings: settings),
const SizedBox(height: 8),
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'),
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: settings.ghostMode
? Colors.purple.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.visibility_off_rounded,
color: settings.ghostMode ? Colors.purpleAccent : Colors.grey,
size: 20,
),
),
title: const Text('Ghost Mode', style: TextStyle(fontSize: 15)),
subtitle: Text(
_ghostSubtitle(settings),
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const GhostModeSubmenuPage()),
),
),
const SizedBox(height: 40),
],
),
);
}
}
String _ghostSubtitle(SettingsService s) {
if (s.ghostMode) return 'DM Ghost active — works inside chat only';
return 'Tap to configure ghost modes';
}
class _LaunchPagePicker extends StatelessWidget {
final SettingsService settings;
const _LaunchPagePicker({required this.settings});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final options = ['home', 'following', 'favorites', 'direct'];
final labels = {
'home': 'Home Feed',
'following': 'Following',
'favorites': 'Favorites',
'direct': 'Direct Messages',
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: settings.startupPage,
decoration: const InputDecoration(
labelText: 'Launch Page',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: options
.map(
(p) => DropdownMenuItem(
value: p,
child: Text(
labels[p] ?? p,
style: const TextStyle(fontSize: 14),
),
),
)
.toList(),
onChanged: (v) {
if (v != null) settings.setStartupPage(v);
HapticFeedback.selectionClick();
},
),
const SizedBox(height: 6),
Text(
'Choose which page opens when you launch Focusgram.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white38 : Colors.black38,
),
),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> 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,
),
),
);
}
}
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
/// Ghost Mode submenu tap "Ghost Mode" in Extras to open this.
/// Single mode: DM Ghost (comprehensive seen-signal blocking).
class GhostModeSubmenuPage extends StatelessWidget {
const GhostModeSubmenuPage({super.key});
@override
Widget build(BuildContext context) {
final s = context.watch<SettingsService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Ghost Mode',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// DM Ghost
_GhostCard(
icon: Icons.visibility_off_rounded,
title: 'DM Ghost',
subtitle: 'Read messages without the person knowing (works inside chat interface — first entry only)',
value: s.ghostMode,
warning:
'When DM Ghost is enabled, you can\'t send messages or react to any, you can just receive messages. You can turn ghost mode off anytime from topbar button.',
onChanged: (v) => s.setGhostMode(v),
isDark: isDark,
danger: true,
),
const SizedBox(height: 24),
const SizedBox(height: 40),
],
),
);
}
}
class _GhostCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final String warning;
final ValueChanged<bool> onChanged;
final bool isDark;
final bool danger;
const _GhostCard({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
required this.warning,
required this.onChanged,
required this.isDark,
this.danger = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.08 : 0.03),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.25 : 0.1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: value
? (danger ? Colors.redAccent : Colors.blueAccent)
: Colors.grey,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: value
? (danger ? Colors.redAccent : null)
: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
Switch(
value: value,
activeThumbColor: danger ? Colors.redAccent : null,
onChanged: onChanged,
),
],
),
if (value)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (danger ? Colors.red : Colors.amber).withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
danger ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14,
color: danger ? Colors.redAccent : Colors.amber,
),
const SizedBox(width: 6),
Expanded(
child: Text(
warning,
style: TextStyle(
fontSize: 11,
color: danger
? Colors.redAccent
: Colors.amber.shade800,
),
),
),
],
),
),
),
],
),
);
}
}
+88 -3
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/session_manager.dart'; import '../services/session_manager.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import '../utils/discipline_challenge.dart'; import '../utils/discipline_challenge.dart';
class GuardrailsPage extends StatefulWidget { class GuardrailsPage extends StatefulWidget {
@@ -18,7 +20,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> Function() action, Future<void> Function() action,
) async { ) async {
if (sm.isScheduledBlockActive) { if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35); final settings = context.read<SettingsService>();
final ok = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!context.mounted || !ok) return; if (!context.mounted || !ok) return;
} }
await action(); await action();
@@ -109,6 +115,10 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
), ),
), ),
), ),
// If quota used up, show earn page instead of slider
if (sm.dailyRemainingSeconds <= 0)
_buildQuotaExhaustedTile(context, sm)
else
_buildFrictionSliderTile( _buildFrictionSliderTile(
context: context, context: context,
sm: sm, sm: sm,
@@ -121,7 +131,16 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60), isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText: warningText:
'Increasing your limit makes it easier to scroll. Are you sure?', 'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()), onConfirmed: (v) async {
// XP penalty for increasing limit
final increase = (v.toInt() - (sm.dailyLimitSeconds ~/ 60));
if (increase > 0) {
// context.read<LevelService>().grantDebugXp(
// -increase * 5, 'Penalty: increased reel limit',
// );
}
await sm.setDailyLimitMinutes(v.toInt());
},
), ),
_buildFrictionSliderTile( _buildFrictionSliderTile(
context: context, context: context,
@@ -221,6 +240,71 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
); );
} }
Widget _buildQuotaExhaustedTile(BuildContext context, SessionManager sm) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.orange.withValues(alpha: 0.2)),
),
child: Column(
children: [
const Icon(
Icons.hourglass_empty,
color: Colors.orangeAccent,
size: 36,
),
const SizedBox(height: 8),
const Text(
'Daily Reel Quota Used Up',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
const Text(
'Watch an ad to earn 3 more minutes of reel time.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _earnQuota(context, sm),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text('Watch Ad (+3 min reels)'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
);
}
Future<void> _earnQuota(BuildContext context, SessionManager sm) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (result == true && context.mounted) {
sm.increaseDailyLimit(3);
context.read<LevelService>().addXpForAd();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('+3 min reel quota earned!')),
);
}
}
Widget _buildFrictionSliderTile({ Widget _buildFrictionSliderTile({
required BuildContext context, required BuildContext context,
required SessionManager sm, required SessionManager sm,
@@ -321,7 +405,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
int wordCount = 15; final settings = context.read<SettingsService>();
int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words // If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') && if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) { sm.dailyRemainingSeconds <= 0) {
+537
View File
@@ -0,0 +1,537 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
import '../services/settings_service.dart';
import '../services/credit_store.dart';
import 'adsterra_ad_screen.dart';
/// Displays current level, XP progress, and locked/preview features.
class LevelPanelScreen extends StatelessWidget {
const LevelPanelScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text(
'Your Journey',
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(
padding: const EdgeInsets.all(16),
children: [
// Level Header Card
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _levelColors(levelService.level, isDark),
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _levelColors(
levelService.level,
isDark,
)[0].withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
// Level badge
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.2),
border: Border.all(
color: Colors.white.withValues(alpha: 0.4),
width: 3,
),
),
child: Center(
child: Text(
'${levelService.level}',
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
Text(
_levelTitle(levelService.level),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// XP progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: levelService.levelProgress,
minHeight: 8,
backgroundColor: Colors.white.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'${levelService.xp} / ${levelService.xpForNextLevel} XP',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
const SizedBox(height: 24),
// Next Unlock
if (levelService.nextLockedFeature != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(
alpha: 0.05,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (isDark ? Colors.white : Colors.black).withValues(
alpha: 0.1,
),
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.lock_outline,
color: Colors.amber,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next at Level ${levelService.nextLockedFeature!.requiredLevel}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
'Unlock ${levelService.nextLockedFeature!.name}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
],
// Feature Unlock Table
const Text(
'FEATURES',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = levelService.isFeatureUnlocked(feature);
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(
alpha: unlocked ? 0.04 : 0.02,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: unlocked
? Colors.greenAccent.withValues(alpha: 0.2)
: (isDark ? Colors.white : Colors.black).withValues(
alpha: 0.08,
),
),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 14,
fontWeight: unlocked
? FontWeight.w600
: FontWeight.normal,
color: unlocked ? null : Colors.grey,
),
),
),
Text(
unlocked ? 'Unlocked' : 'Level ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: unlocked ? Colors.greenAccent : Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 24),
// XP Rules
const Text(
'HOW TO EARN XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
_XpRuleTile(
icon: Icons.play_circle_outline,
label: 'Watch a rewarded ad',
value: '+2 XP (up to 20/day)',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.trending_down,
label: 'Watch fewer reels than your weekly average',
value: '+10 XP per reel saved',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.check_circle_outline,
label: 'Stay under your daily reel limit',
value: '+15 XP per day',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.login,
label: 'Open the app and check in',
value: '+1 XP per day',
isDark: isDark,
),
const SizedBox(height: 16),
// Watch Ad to earn XP
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _watchAdForXp(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text(
'Watch Ad to Earn +2 XP',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 16),
// XP History
const Text(
'RECENT XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...levelService.recentXpLog.take(10).map((entry) {
final dt = DateTime.tryParse(entry['time'] as String? ?? '');
final timeStr = dt != null
? '${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'
: '';
final amount = entry['amount'] as int;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(
alpha: 0.04,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
amount > 0 ? Icons.add_circle : Icons.remove_circle,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entry['reason'] as String? ?? '',
style: const TextStyle(fontSize: 13),
),
),
Text(
amount > 0 ? '+$amount XP' : '$amount XP',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
),
),
const SizedBox(width: 8),
Text(
timeStr,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
);
}),
if (levelService.recentXpLog.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No XP earned yet — watch an ad above or reduce reel time!',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white38 : Colors.black38,
),
),
),
const SizedBox(height: 20),
const Text(
'DEGRADATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.15),
),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.redAccent,
size: 18,
),
SizedBox(width: 8),
Text(
'XP decays if you backslide',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
SizedBox(height: 6),
Text(
'• Watching more reels than your weekly average deducts XP\n'
'• Exceeding limits for 3 consecutive days drops a level\n'
'• Levels are preserved on monthly reset, but XP resets',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 40),
],
),
);
}
Color _levelColor(int level) {
switch (level) {
case 1:
return Colors.grey;
case 2:
return Colors.blue;
case 3:
return Colors.purple;
case 4:
return Colors.orange;
case 5:
return Colors.amber;
default:
return Colors.grey;
}
}
List<Color> _levelColors(int level, bool isDark) {
final base = _levelColor(level);
// MaterialColor supports .shadeXXX; plain Color doesn't.
if (base is MaterialColor) {
return isDark
? [base.shade800, base.shade900]
: [base.shade400, base.shade700];
}
return [base, base];
}
/// Navigate to Adsterra ad -> grant XP on completion.
Future<void> _watchAdForXp(BuildContext context) async {
// Try Adsterra Social Bar first
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (adResult == true && context.mounted) {
context.read<LevelService>().addXpForAd();
context.read<CreditStore>().addReelsMinutes();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('+10 XP earned!'),
duration: Duration(seconds: 2),
),
);
}
}
String _levelTitle(int level) {
switch (level) {
case 1:
return 'Beginner';
case 2:
return 'Mindful Scroller';
case 3:
return 'Disciplined';
case 4:
return 'Focus Master';
case 5:
return 'Digital Monk';
default:
return 'Level $level';
}
}
}
class _XpRuleTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final bool isDark;
const _XpRuleTile({
required this.icon,
required this.label,
required this.value,
required this.isDark,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Colors.greenAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white70 : Colors.black87,
),
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
color: Colors.greenAccent,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
/// Opens a saved page offline. Uses saved HTML content when available,
/// falls back to WebView cache.
class OfflineFeedViewer extends StatelessWidget {
final String url;
final String? pageId;
const OfflineFeedViewer({super.key, required this.url, this.pageId});
@override
Widget build(BuildContext context) {
// Find the saved page with HTML content
SavedPage? page;
if (pageId != null) {
final ss = context.read<SnapshotService>();
final matches = ss.savedPages.where((p) => p.id == pageId);
if (matches.isNotEmpty) page = matches.first;
}
return Scaffold(
appBar: AppBar(
title: const Text(
'Offline View',
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: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: Colors.blue.withValues(alpha: 0.1),
child: const Row(
children: [
Icon(
Icons.wifi_off_rounded,
size: 14,
color: Colors.blueAccent,
),
SizedBox(width: 6),
Text(
'Offline — saved content shown',
style: TextStyle(fontSize: 11, color: Colors.blueAccent),
),
],
),
),
Expanded(
child: page?.htmlContent != null
? InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: page!.htmlContent!,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri(url),
);
},
)
: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: InAppWebViewSettings(
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
javaScriptEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
),
),
],
),
);
}
}
+66 -28
View File
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; 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 _kTotalPages = 5;
static const int _kBlurPage = 3; static const int _kBlurPage = 3;
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
final List<Widget> slides = [ final List<Widget> slides = [
// Page 0: Welcome // Page 0: Welcome
_StaticSlide( _StaticSlide(
icon: Icons.auto_awesome, icon: Icons.auto_awesome_rounded,
color: Colors.blue, color: const Color(0xFF4F8DFF),
title: 'Welcome to FocusGram', title: 'Welcome to FocusGram',
description: 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( _StaticSlide(
icon: Icons.timer, icon: Icons.timer_outlined,
color: Colors.orange, color: const Color(0xFFFFB74D),
title: 'Session Management', title: 'Time With Intent',
description: 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 // Page 2: Open links
_StaticSlide( _StaticSlide(
icon: Icons.link, icon: Icons.link_rounded,
color: Colors.cyan, color: const Color(0xFF35C2D6),
title: 'Open Links in FocusGram', title: 'Open Links in FocusGram',
description: description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.', '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<OnboardingPage> {
// Page 4: Notifications // Page 4: Notifications
_StaticSlide( _StaticSlide(
icon: Icons.notifications_active, icon: Icons.notifications_active_outlined,
color: Colors.green, color: const Color(0xFF5DD18A),
title: 'Stay Notified', title: 'Useful Alerts Only',
description: 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, isPermissionPage: true,
permission: Permission.notification, permission: Permission.notification,
), ),
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
), ),
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 28),
// CTA button // CTA button
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
final isBlur = _currentPage == _kBlurPage; final isBlur = _currentPage == _kBlurPage;
String label; String label;
if (isLast) { if (isNotif) {
label = 'Get Started'; label = 'Allow & Start';
} else if (isLink) { } else if (isLink) {
label = 'Configure'; label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) { } else if (isBlur) {
label = 'Save & Continue'; label = 'Save & Continue';
} else if (isLast) {
label = 'Get Started';
} else { } else {
label = 'Next'; label = 'Next';
} }
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
); );
} else if (isNotif) { } else if (isNotif) {
await Permission.notification.request(); await Permission.notification.request();
await NotificationService().init(); await NotificationService()
.requestPermissionsNow();
} }
if (!context.mounted) return; if (!context.mounted) return;
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
// Skip button (available on all pages except last) // Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1) if (_currentPage < _kTotalPages - 1)
TextButton( 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( child: const Text(
'Skip', 'Skip setup',
style: TextStyle(color: Colors.white38, fontSize: 14), style: TextStyle(color: Colors.white38, fontSize: 14),
), ),
), ),
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160), padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 120, color: color), Container(
const SizedBox(height: 48), 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( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 32, fontSize: 30,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 18, fontSize: 16,
height: 1.5, 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),
),
),
],
], ],
), ),
); );
+1
View File
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
hideReelsTab: false, hideReelsTab: false,
hideShopTab: false, hideShopTab: false,
disableReelsEntirely: false, disableReelsEntirely: false,
blockHomeFeedScroll: false,
), ),
); );
}, },
+16 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/session_manager.dart'; import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart'; import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget { class SessionModal extends StatefulWidget {
@@ -63,12 +64,12 @@ class _SessionModalState extends State<SessionModal> {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8,
children: [1, 5, 10, 15].map((m) { runSpacing: 8,
return Expanded( children: [1, 3, 5, 10, 15, 20, 30].map((m) {
child: Padding( return SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 4.0), width: 72,
child: ElevatedButton( child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted) onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null ? null
@@ -80,7 +81,6 @@ class _SessionModalState extends State<SessionModal> {
), ),
child: Text('${m}m'), child: Text('${m}m'),
), ),
),
); );
}).toList(), }).toList(),
), ),
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
Slider( Slider(
value: _customMinutes, value: _customMinutes,
min: 1, min: 1,
max: 30, max: 60,
divisions: 29, divisions: 59,
label: '${_customMinutes.toInt()}m', label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v), onChanged: (v) => setState(() => _customMinutes = v),
), ),
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
void _start(int minutes) async { void _start(int minutes) async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>();
// Always require word challenge for reel sessions (User request) if (settings.requireWordChallenge) {
final success = await DisciplineChallenge.show(context); final success = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!success) return; if (!success) return;
}
if (sm.startSession(minutes)) { if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
+504 -84
View File
@@ -1,13 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../services/session_manager.dart'; import '../services/session_manager.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/level_service.dart';
import '../services/credit_store.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed offline feature deleted
import '../services/focusgram_router.dart'; import '../services/focusgram_router.dart';
import 'app_lock_settings_page.dart';
// snapshot_manager_screen import removed offline feature deleted
import 'level_panel_screen.dart';
//import 'debug_menu_screen.dart';
import '../widgets/native_ad_banner.dart';
import '../features/screen_time/screen_time_screen.dart'; import '../features/screen_time/screen_time_screen.dart';
// reels_history_screen import removed feature deleted
import 'guardrails_page.dart'; import 'guardrails_page.dart';
import 'extras_settings_page.dart';
// Main Settings Page // Main Settings Page
@@ -34,7 +46,8 @@ class SettingsPage extends StatelessWidget {
), ),
body: ListView( body: ListView(
children: [ children: [
_buildStatsRow(sm), const _DonateTile(),
_buildStatsRow(sm, context),
const _SectionHeader(title: 'FOCUS & BLOCKING'), const _SectionHeader(title: 'FOCUS & BLOCKING'),
_SubmoduleTile( _SubmoduleTile(
@@ -43,7 +56,7 @@ class SettingsPage extends StatelessWidget {
title: 'Focus Mode', title: 'Focus Mode',
subtitle: settings.minimalModeEnabled subtitle: settings.minimalModeEnabled
? 'Minimal mode on' ? 'Minimal mode on'
: 'Blocking, friction, media', : 'Blocking, Content Hider, Feed Blur and more',
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const FocusSettingsPage()), MaterialPageRoute(builder: (_) => const FocusSettingsPage()),
@@ -63,6 +76,19 @@ class SettingsPage extends StatelessWidget {
), ),
), ),
const _SectionHeader(title: 'EXTRAS'),
_SubmoduleTile(
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Startup Page, Download media, Ghost Mode',
enabled: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
),
),
const _SectionHeader(title: 'APPEARANCE'), const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile( _SubmoduleTile(
icon: Icons.palette_outlined, icon: Icons.palette_outlined,
@@ -72,19 +98,31 @@ class SettingsPage extends StatelessWidget {
? 'Grayscale on' ? 'Grayscale on'
: settings.grayscaleSchedules.isNotEmpty : settings.grayscaleSchedules.isNotEmpty
? 'Grayscale scheduled (${settings.grayscaleSchedules.length} schedules)' ? 'Grayscale scheduled (${settings.grayscaleSchedules.length} schedules)'
: 'Theme, grayscale', : 'Grayscale and schedules',
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const AppearancePage()), MaterialPageRoute(builder: (_) => const AppearancePage()),
), ),
), ),
const _SectionHeader(title: 'SECURITY'),
_SubmoduleTile(
icon: Icons.lock_rounded,
iconColor: Colors.blueAccent,
title: 'App Lock',
subtitle: _appLockSubtitle(context.watch<AppLockService>()),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppLockSettingsPage()),
),
),
const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'), const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'),
_SubmoduleTile( _SubmoduleTile(
icon: Icons.lock_outline, icon: Icons.lock_outline,
iconColor: Colors.tealAccent, iconColor: Colors.tealAccent,
title: 'Privacy & Notifications', title: 'Privacy & Notifications',
subtitle: 'Session end alerts', subtitle: 'Manage Your Notifications',
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -104,18 +142,23 @@ class SettingsPage extends StatelessWidget {
MaterialPageRoute(builder: (_) => const ScreenTimeScreen()), MaterialPageRoute(builder: (_) => const ScreenTimeScreen()),
), ),
), ),
_SubmoduleTile(
icon: Icons.trending_up_rounded,
iconColor: Colors.amber,
title: 'Your Journey',
subtitle:
'Level ${context.watch<LevelService>().level} · ${context.watch<LevelService>().xp} XP',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LevelPanelScreen()),
),
),
// Quick XP debug grant (visible in settings for testing)
// _XpDebugTile(),
// Reels History removed
const _SectionHeader(title: 'ABOUT'), const _SectionHeader(title: 'ABOUT'),
FutureBuilder<PackageInfo>( _VersionTile(),
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
),
),
ListTile( ListTile(
title: const Text('GitHub'), title: const Text('GitHub'),
trailing: const Icon(Icons.open_in_new, size: 14), trailing: const Icon(Icons.open_in_new, size: 14),
@@ -157,6 +200,8 @@ class SettingsPage extends StatelessWidget {
'https://www.instagram.com/accounts/settings/?entrypoint=profile'; 'https://www.instagram.com/accounts/settings/?entrypoint=profile';
}, },
), ),
const SizedBox(height: 20),
const NativeAdBanner(height: 60),
const SizedBox(height: 40), const SizedBox(height: 40),
Center( Center(
child: Text( child: Text(
@@ -173,18 +218,9 @@ class SettingsPage extends StatelessWidget {
); );
} }
Widget _buildStatsRow(SessionManager sm) { Widget _buildStatsRow(SessionManager sm, BuildContext context) {
return Container( final creditStore = context.watch<CreditStore>();
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), final cells = <Widget>[
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.blue.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue), _statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(), _dividerCell(),
_statCell( _statCell(
@@ -198,7 +234,31 @@ class SettingsPage extends StatelessWidget {
'${sm.dailyRemainingSeconds ~/ 60}m', '${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent, Colors.greenAccent,
), ),
], ];
if (true) {
// ad counter always shown
cells.addAll([
_dividerCell(),
_statCell(
'XP Ads Watched',
'${creditStore.adsWatchedToday}',
Colors.purpleAccent,
),
]);
}
return Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.blue.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: cells,
), ),
); );
} }
@@ -224,6 +284,14 @@ class SettingsPage extends StatelessWidget {
color: Colors.blue.withValues(alpha: 0.1), color: Colors.blue.withValues(alpha: 0.1),
); );
String _appLockSubtitle(AppLockService a) {
if (!a.anyLockEnabled) return 'Protect FocusGram with a PIN';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages');
return '${parts.join(' + ')} lock active';
}
void _showLegalDisclaimer(BuildContext context) { void _showLegalDisclaimer(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
@@ -264,6 +332,7 @@ class FocusSettingsPage extends StatelessWidget {
body: ListView( body: ListView(
children: [ children: [
const _SectionHeader(title: 'BLOCKING'), const _SectionHeader(title: 'BLOCKING'),
Container( Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -293,12 +362,23 @@ class FocusSettingsPage extends StatelessWidget {
color: Colors.redAccent.withValues(alpha: 0.12), color: Colors.redAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), 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)), title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
subtitle: Text( subtitle: Text(
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure', settings.minimalModeEnabled
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey), ? 'Enabled - tap to customize'
: 'Disabled - tap to configure',
style: TextStyle(
fontSize: 12,
color: settings.minimalModeEnabled
? Colors.greenAccent
: Colors.grey,
),
), ),
trailing: Switch( trailing: Switch(
value: settings.minimalModeEnabled, value: settings.minimalModeEnabled,
@@ -307,29 +387,72 @@ class FocusSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
), ),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())), onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
), ),
),
const SizedBox(height: 8),
const _SectionHeader(title: 'FRICTION'), const _SectionHeader(title: 'FRICTION'),
_SwitchTile( _SwitchTile(
title: 'Mindfulness Gate', title: 'Mindfulness Gate',
subtitle: 'Breath screen before opening Instagram', subtitle: '${settings.breathGateSeconds}s before opening Instagram',
value: settings.showBreathGate, value: settings.showBreathGate,
onChanged: (v) => settings.setShowBreathGate(v), 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( _SwitchTile(
title: 'Strict Mode (Word Challenge)', title: 'Typing Challenge',
subtitle: 'Must type a phrase before starting a Reel session', subtitle: settings.wordChallengeCount == 0
? 'Random: 10-35 words'
: '${settings.wordChallengeCount} words',
value: settings.requireWordChallenge, value: settings.requireWordChallenge,
onChanged: (v) => settings.setRequireWordChallenge(v), onChanged: (v) => settings.setRequireWordChallenge(v),
), ),
const _SectionHeader(title: 'MEDIA'), if (settings.requireWordChallenge)
_SwitchTile( _ChoiceTile<int>(
title: 'Block Autoplay Videos', title: 'Typing Words',
subtitle: 'Videos won\'t play until you tap them', value: settings.wordChallengeCount,
value: settings.blockAutoplay, label: settings.wordChallengeCount == 0
onChanged: (v) => settings.setBlockAutoplay(v), ? '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),
), ),
_SwitchTile(
title: 'Effort Friction Mode',
subtitle: 'Earn credits by watching ads — enabled by default',
value: settings.effortFrictionEnabled,
onChanged: (v) async {
if (v &&
!context.read<LevelService>().isFeatureUnlocked(
AppFeature.effortFriction,
)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unlocks at Level 3')),
);
return;
}
await settings.setEffortFrictionEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'MEDIA'),
// Block Autoplay removed was unreliable
_SwitchTile( _SwitchTile(
title: 'Blur Feed & Explore', title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped', subtitle: 'Blurs post thumbnails until tapped',
@@ -348,6 +471,15 @@ class FocusSettingsPage extends StatelessWidget {
), ),
), ),
const _SectionHeader(title: 'CONTENT HIDER'),
_SwitchTile(
title: 'Hide Feed Posts',
subtitle: 'Hides home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -355,6 +487,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 // Minimal Mode Submenu
class MinimalModeSubmenuPage extends StatefulWidget { class MinimalModeSubmenuPage extends StatefulWidget {
@@ -368,6 +544,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
late bool _blurExplore; late bool _blurExplore;
late bool _disableReelsEntirely; late bool _disableReelsEntirely;
late bool _disableExploreEntirely; late bool _disableExploreEntirely;
late bool _blockHomeFeedScroll;
@override @override
void initState() { void initState() {
@@ -376,26 +553,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore; _blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely; _disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely; _disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
} }
void _updateSetting(String key, bool value) { Future<void> _updateSetting(String key, bool value) async {
final settings = context.read<SettingsService>(); final settings = context.read<SettingsService>();
setState(() { setState(() {
switch (key) { switch (key) {
case 'blurExplore': case 'blurExplore':
_blurExplore = value; _blurExplore = value;
settings.setBlurExplore(value);
break; break;
case 'disableReelsEntirely': case 'disableReelsEntirely':
_disableReelsEntirely = value; _disableReelsEntirely = value;
settings.setDisableReelsEntirelyInternal(value);
break; break;
case 'disableExploreEntirely': case 'disableExploreEntirely':
_disableExploreEntirely = value; _disableExploreEntirely = value;
settings.setDisableExploreEntirelyInternal(value); break;
case 'blockHomeFeedScroll':
_blockHomeFeedScroll = value;
break; 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<SettingsService>();
setState(() {
_blurExplore = latest.blurExplore;
_disableReelsEntirely = latest.disableReelsEntirely;
_disableExploreEntirely = latest.disableExploreEntirely;
_blockHomeFeedScroll = latest.blockHomeFeedScroll;
});
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
} }
@@ -406,6 +608,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = true; _blurExplore = true;
_disableReelsEntirely = true; _disableReelsEntirely = true;
_disableExploreEntirely = true; _disableExploreEntirely = true;
_blockHomeFeedScroll = true;
}); });
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
} }
@@ -418,6 +621,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore; _blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely; _disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely; _disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
}); });
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
} }
@@ -438,30 +642,44 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: isMinimalModeEnabled 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.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, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( 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( child: Column(
children: [ children: [
Icon( Icon(
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined, isMinimalModeEnabled
? Icons.shield_rounded
: Icons.shield_outlined,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey, color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
size: 48, size: 48,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled', isMinimalModeEnabled
? 'Minimal Mode Active'
: 'Minimal Mode Disabled',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey, color: isMinimalModeEnabled
? Colors.redAccent
: Colors.grey,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -470,20 +688,33 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
? 'Distractions are blocked. Customize which features stay enabled below.' ? 'Distractions are blocked. Customize which features stay enabled below.'
: 'Turn on to block all distractions at once, or customize individual settings below.', : 'Turn on to block all distractions at once, or customize individual settings below.',
textAlign: TextAlign.center, 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), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode, onPressed: isMinimalModeEnabled
? _turnOffMinimalMode
: _turnOnMinimalMode,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent, backgroundColor: isMinimalModeEnabled
? Colors.grey
: Colors.redAccent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14), 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'),
), ),
), ),
], ],
@@ -502,7 +733,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
), ),
child: const Row( child: const Row(
children: [ children: [
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent), Icon(
Icons.touch_app_rounded,
size: 14,
color: Colors.blueAccent,
),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -520,6 +755,12 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
value: _blurExplore, value: _blurExplore,
onChanged: (v) => _updateSetting('blurExplore', v), 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( _SwitchTile(
title: 'Disable Reels Entirely', title: 'Disable Reels Entirely',
subtitle: 'Block all Reels with no session option', subtitle: 'Block all Reels with no session option',
@@ -550,7 +791,10 @@ class AppearancePage extends StatefulWidget {
} }
class _AppearancePageState extends State<AppearancePage> { class _AppearancePageState extends State<AppearancePage> {
Future<void> _addSchedule(BuildContext context, SettingsService settings) async { Future<void> _addSchedule(
BuildContext context,
SettingsService settings,
) async {
TimeOfDay? startTime = await showTimePicker( TimeOfDay? startTime = await showTimePicker(
context: context, context: context,
initialTime: const TimeOfDay(hour: 21, minute: 0), initialTime: const TimeOfDay(hour: 21, minute: 0),
@@ -569,14 +813,20 @@ class _AppearancePageState extends State<AppearancePage> {
final newSchedule = { final newSchedule = {
'enabled': true, 'enabled': true,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', 'startTime':
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', '${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); await settings.addGrayscaleSchedule(newSchedule);
} }
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async { Future<void> _editSchedule(
BuildContext context,
SettingsService settings,
int index,
) async {
final schedules = settings.grayscaleSchedules; final schedules = settings.grayscaleSchedules;
if (index >= schedules.length) return; if (index >= schedules.length) return;
@@ -611,15 +861,19 @@ class _AppearancePageState extends State<AppearancePage> {
final updatedSchedule = { final updatedSchedule = {
...current, ...current,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', 'startTime':
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', '${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); await settings.updateGrayscaleSchedule(index, updatedSchedule);
} }
Future<void> _toggleSchedule(SettingsService settings, int index) async { Future<void> _toggleSchedule(SettingsService settings, int index) async {
final schedules = List<Map<String, dynamic>>.from(settings.grayscaleSchedules); final schedules = List<Map<String, dynamic>>.from(
settings.grayscaleSchedules,
);
if (index >= schedules.length) return; if (index >= schedules.length) return;
schedules[index] = { schedules[index] = {
@@ -669,7 +923,8 @@ class _AppearancePageState extends State<AppearancePage> {
const _SectionHeader(title: 'DISPLAY'), const _SectionHeader(title: 'DISPLAY'),
_SwitchTile( _SwitchTile(
title: 'Grayscale Mode', title: 'Grayscale Mode',
subtitle: 'Makes Instagram black & white — reduces dopamine response', subtitle:
'Makes Instagram black & white — reduces dopamine response',
value: settings.grayscaleEnabled, value: settings.grayscaleEnabled,
onChanged: (v) => settings.setGrayscaleEnabled(v), onChanged: (v) => settings.setGrayscaleEnabled(v),
), ),
@@ -695,16 +950,26 @@ class _AppearancePageState extends State<AppearancePage> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( 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), 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( child: Row(
children: [ children: [
Icon( Icon(
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule, settings.isGrayscaleActiveNow
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent, ? Icons.check_circle
size: 20 : Icons.schedule,
color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -714,7 +979,9 @@ class _AppearancePageState extends State<AppearancePage> {
: 'Grayscale is currently inactive', : 'Grayscale is currently inactive',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
), ),
), ),
), ),
@@ -732,11 +999,14 @@ class _AppearancePageState extends State<AppearancePage> {
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(10),
), ),
child: Icon( 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, color: isEnabled ? Colors.purpleAccent : Colors.grey,
size: 20, size: 20,
), ),
@@ -750,7 +1020,10 @@ class _AppearancePageState extends State<AppearancePage> {
), ),
subtitle: Text( subtitle: Text(
isEnabled ? 'Active' : 'Disabled', 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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -805,12 +1078,22 @@ class _AppearancePageState extends State<AppearancePage> {
color: Colors.green.withValues(alpha: 0.12), color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), 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( subtitle: Text(
'Add a new grayscale schedule', '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), onTap: () => _addSchedule(context, settings),
), ),
@@ -966,15 +1249,9 @@ class _SwitchTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile( return SwitchListTile(
title: Text( title: Text(title, style: const TextStyle(fontSize: 15)),
title,
style: const TextStyle(fontSize: 15),
),
subtitle: subtitle != null subtitle: subtitle != null
? Text( ? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
subtitle ?? '',
style: const TextStyle(fontSize: 12),
)
: null, : null,
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
@@ -982,6 +1259,149 @@ class _SwitchTile extends StatelessWidget {
} }
} }
class _ChoiceTile<T> extends StatelessWidget {
final String title;
final T value;
final String label;
final List<T> options;
final String Function(T value) optionLabel;
final ValueChanged<T> 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<T>(
initialValue: value,
onSelected: onSelected,
itemBuilder: (context) => options
.map(
(option) => PopupMenuItem<T>(
value: option,
child: Text(optionLabel(option)),
),
)
.toList(),
child: const Icon(Icons.expand_more_rounded, size: 22),
),
onTap: () async {
final selected = await showModalBottomSheet<T>(
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<int> 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<int>(
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 _VersionTile extends StatelessWidget {
const _VersionTile();
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
),
);
}
}
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
final String title; final String title;
const _SectionHeader({required this.title}); const _SectionHeader({required this.title});
+329
View File
@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
import '../services/level_service.dart';
import 'offline_feed_viewer.dart';
/// Manages saved pages for offline viewing via WebView cache.
/// Gated behind Level 5.
class SnapshotManagerScreen extends StatelessWidget {
const SnapshotManagerScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isUnlocked = levelService.level >= 5; // offline pages at L5
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Offline Pages',
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: isUnlocked
? const _SavedPageList()
: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 64,
color: Colors.grey.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
const Text(
'Unlocks at Level 5',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Earn XP to unlock offline browsing.\n'
'Watch ads and reduce reel time to level up.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
height: 1.5,
),
),
],
),
),
),
);
}
}
class _SavedPageList extends StatelessWidget {
const _SavedPageList();
@override
Widget build(BuildContext context) {
final snapshotService = context.watch<SnapshotService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// Info card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withValues(alpha: 0.12)),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
size: 16,
color: Colors.blueAccent,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'The WebView already caches pages you visit. '
'Save bookmarks here to easily reopen them when offline.\n'
'No API needed — the cache handles everything.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white60 : Colors.black54,
height: 1.4,
),
),
),
],
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
'${snapshotService.totalSaved} saved page${snapshotService.totalSaved == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const Spacer(),
if (snapshotService.totalSaved > 0)
GestureDetector(
onTap: () => _confirmClearAll(context, snapshotService),
child: Text(
'Clear all',
style: TextStyle(
fontSize: 12,
color: Colors.redAccent.withValues(alpha: 0.7),
),
),
),
],
),
),
// Page list
Expanded(
child: snapshotService.savedPages.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border_rounded,
size: 48,
color: Colors.grey.withValues(alpha: 0.3),
),
const SizedBox(height: 12),
Text(
'No saved pages yet',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black38,
),
),
const SizedBox(height: 4),
Text(
'Visit Instagram pages online, then save them here\nto browse offline later.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white24 : Colors.black26,
height: 1.4,
),
),
],
),
)
: ListView.builder(
itemCount: snapshotService.savedPages.length,
itemBuilder: (context, index) {
final page = snapshotService.savedPages[index];
return ListTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.web_rounded,
color: Colors.blueAccent,
size: 22,
),
),
title: Text(
page.title,
style: const TextStyle(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatDate(page.savedAt),
style: const TextStyle(fontSize: 12),
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDelete(context, snapshotService, page.id);
} else if (value == 'open') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(
url: page.url,
pageId: page.id,
),
),
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'open',
child: Row(
children: [
Icon(Icons.open_in_browser, size: 18),
SizedBox(width: 8),
Text('Open Offline'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(
Icons.delete_outline,
color: Colors.redAccent,
size: 18,
),
SizedBox(width: 8),
Text(
'Remove',
style: TextStyle(color: Colors.redAccent),
),
],
),
),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(url: page.url),
),
);
},
);
},
),
),
],
);
}
void _confirmDelete(
BuildContext context,
SnapshotService service,
String id,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove page?'),
content: const Text(
'Removes the bookmark. Cache is preserved automatically.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deletePage(id);
Navigator.pop(ctx);
},
child: const Text(
'Remove',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
}
void _confirmClearAll(BuildContext context, SnapshotService service) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all saved pages?'),
content: const Text('This removes all bookmarks.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deleteAll();
Navigator.pop(ctx);
},
child: const Text(
'Clear',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}';
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A 15-second timer that acts as the last-resort fallback
/// when both AdMob and Adsterra fail to serve an ad.
///
/// Shows a digital wellness quote while the user waits.
/// After the timer, they earn the same reward.
class TimerFallbackScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const TimerFallbackScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 15,
});
@override
State<TimerFallbackScreen> createState() => _TimerFallbackScreenState();
}
class _TimerFallbackScreenState extends State<TimerFallbackScreen> {
int _remaining = 0;
Timer? _timer;
int _quoteIndex = 0;
static const _quotes = [
'"The secret of getting ahead is getting started." — Mark Twain',
'"Focus on being productive instead of busy." — Tim Ferriss',
'"Almost everything will work if you unplug it for a few minutes." — Ann Lamott',
'"The key is not to prioritize what\'s on your schedule, but to schedule your priorities." — Stephen Covey',
'"Your mind is for having ideas, not holding them." — David Allen',
'"Simplicity is the ultimate sophistication." — Leonardo da Vinci',
'"The ability to simplify means to eliminate the unnecessary." — Hans Hofmann',
'"In the midst of chaos, there is also opportunity." — Sun Tzu',
];
@override
void initState() {
super.initState();
_remaining = widget.requiredSeconds;
_quoteIndex = DateTime.now().millisecondsSinceEpoch % _quotes.length;
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
if (_remaining > 0) {
_remaining--;
} else {
_timer?.cancel();
HapticFeedback.heavyImpact();
}
});
});
}
@override
Widget build(BuildContext context) {
final done = _remaining <= 0;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withValues(alpha: 0.1),
border: Border.all(
color: Colors.green.withValues(alpha: 0.3),
width: 2,
),
),
child: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
color: done ? Colors.greenAccent : Colors.green,
size: 36,
),
),
const SizedBox(height: 28),
// Timer
Text(
done ? 'Done!' : '$_remaining',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white,
fontSize: 56,
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 8),
Text(
done
? 'You earned ${widget.sessionType == 'reels' ? 'reel' : 'Instagram'} time'
: 'Please wait while we prepare your reward',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
const SizedBox(height: 40),
// Quote
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
_quotes[_quoteIndex],
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
fontStyle: FontStyle.italic,
),
),
),
const Spacer(flex: 1),
// Continue button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: done ? () => Navigator.pop(context, true) : null,
style: ElevatedButton.styleFrom(
backgroundColor: done ? Colors.greenAccent : Colors.grey,
foregroundColor: done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: Icon(
done ? Icons.check_circle : Icons.hourglass_empty,
size: 22,
),
label: Text(
done
? 'Continue & Earn Reward'
: 'Wait $_remaining seconds',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
const SizedBox(height: 16),
Text(
'No ad available — timer reward instead',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.2),
fontSize: 11,
),
),
const Spacer(flex: 1),
],
),
),
),
);
}
}
+33
View File
@@ -223,3 +223,36 @@ const String kAutoplayBlockerJS = r'''
}, true); }, true);
})(); })();
'''; ''';
// Reinforcement observer catches videos that Instagram creates after the
// prototype override (e.g. React re-renders). Runs a MutationObserver that
// pauses any <video> that tries to autoplay.
const String kAutoplayObserverJS = r'''
(function fgAutoplayObserver() {
if (window.__fgAutoplayObserverRunning) return;
window.__fgAutoplayObserverRunning = true;
function pauseIfBlocked(v) {
try {
if (window.__fgBlockAutoplay === false) return;
if (window.__focusgramSessionActive) return;
const url = window.location.href || '';
if (url.includes('/reels/') || url.includes('/reel/')) return;
if (v.paused) return;
if (v.getAttribute('data-fg-user-played') === '1') return;
v.pause();
} catch (_) {}
}
// Check all existing videos periodically
setInterval(function() {
document.querySelectorAll('video').forEach(pauseIfBlocked);
}, 500);
// Mark video as user-played on click
document.addEventListener('click', function(e) {
var v = e.target && e.target.closest ? e.target.closest('video') : null;
if (v) v.setAttribute('data-fg-user-played', '1');
}, true);
})();
''';
+45 -8
View File
@@ -451,16 +451,54 @@ const String kHideSuggestedPostsJS = r'''
(function() { (function() {
function hideSuggestedPosts() { function hideSuggestedPosts() {
try { try {
document.querySelectorAll('span, h3, h4').forEach(function(el) { // Target text patterns that indicate suggested content
const suggestedPatterns = [
'Suggested for you',
'Suggested posts',
"You're all caught up",
'Suggested',
'Recommendations',
'Discover more',
'Suggested Accounts',
];
// Find and hide all elements with suggested content text
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
try { try {
const text = el.textContent.trim(); const text = el.textContent.trim();
if ( const matched = suggestedPatterns.some(pattern =>
text === 'Suggested for you' || text === pattern || text.includes(pattern)
text === 'Suggested posts' || );
text === "You're all caught up"
) { if (matched) {
let parent = el.parentElement; let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) { // Traverse up to find the container section/article
for (let i = 0; i < 12 && parent; i++) {
const tag = parent.tagName.toLowerCase();
const classList = parent.className || '';
// Hide articles, sections, lists, and common suggestion containers
if (
tag === 'article' ||
tag === 'section' ||
tag === 'li' ||
classList.includes('xjx87jv0') || // Instagram suggestion container
classList.includes('x1a8lsjc') // Reel suggestion container
) {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
// Also hide by attribute patterns
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
try {
let parent = el;
for (let i = 0; i < 12 && parent; i++) {
const tag = parent.tagName.toLowerCase(); const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') { if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important'); parent.style.setProperty('display', 'none', 'important');
@@ -468,7 +506,6 @@ const String kHideSuggestedPostsJS = r'''
} }
parent = parent.parentElement; parent = parent.parentElement;
} }
}
} catch(_) {} } catch(_) {}
}); });
} catch(_) {} } catch(_) {}
+27 -10
View File
@@ -40,8 +40,11 @@ const String kBlurHomeFeedAndExploreCSS = '''
transition: filter 0.15s ease !important; transition: filter 0.15s ease !important;
} }
/* Per-post unblur override (set by kTapToUnblurJS) */ /* Per-post unblur override (set by kTapToUnblurJS) */
[data-fg-unblurred="1"] img, /* Must match the blur selector's specificity (body[path="/"] article img = 0,0,1,3) */
[data-fg-unblurred="1"] video { body[path="/"] [data-fg-unblurred="1"] img,
body[path="/"] [data-fg-unblurred="1"] video,
body[path^="/explore"] [data-fg-unblurred="1"] img,
body[path^="/explore"] [data-fg-unblurred="1"] video {
filter: none !important; filter: none !important;
-webkit-filter: none !important; -webkit-filter: none !important;
} }
@@ -149,6 +152,15 @@ const String kTapToUnblurJS = r'''
} }
} }
function unblurAllMediaInHost(host) {
try {
host.querySelectorAll('img,video').forEach(function(el) {
el.style.setProperty('filter', 'none', 'important');
el.style.setProperty('-webkit-filter', 'none', 'important');
});
} catch (_) {}
}
function unblurMedia(media) { function unblurMedia(media) {
try { try {
media.style.setProperty('filter', 'none', 'important'); media.style.setProperty('filter', 'none', 'important');
@@ -164,11 +176,15 @@ const String kTapToUnblurJS = r'''
if (!media) return; if (!media) return;
const host = getHost(media); const host = getHost(media);
if (!host) return; if (!host) return;
if (isUnblurred(host)) return; // allow normal Instagram behaviour
// ALWAYS re-unblur media Instagram swaps DOM elements in carousels,
// so the inline style applied on first tap is lost on subsequent pages.
unblurMedia(media);
if (isUnblurred(host)) return; // allow normal Instagram click-through
// First tap: unblur and swallow click so it doesn't open the post. // First tap: unblur and swallow click so it doesn't open the post.
markUnblurred(host); markUnblurred(host);
unblurMedia(media);
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} catch (_) {} } catch (_) {}
@@ -277,13 +293,15 @@ const String kReelsMutationObserverJS = r'''
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]'; const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() { 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/') && const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]'); !!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel'; if (isDmReel) return 'dm_reel';
// Only lock scroll when reel element is actually present on the page if (window.__fgBlockHomeFeedScroll === true &&
if (window.__fgDisableReelsEntirely === true && (window.location.pathname === '/' || window.location.pathname === '')) {
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled'; return 'home_feed';
}
return null; return null;
} }
@@ -338,8 +356,7 @@ const String kReelsMutationObserverJS = r'''
try { try {
const mode = lockMode(); const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL); const hasReel = !!document.querySelector(REEL_SEL);
// Apply lock for dm_reel or disabled modes when reel is present if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if (__fgOrigHtmlOverflow === null) { if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || ''; __fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : ''; __fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
-1
View File
@@ -13,4 +13,3 @@ const String kDmKeyboardFixJS = r'''
} catch (_) {} } catch (_) {}
}); });
'''; ''';
+463
View File
@@ -0,0 +1,463 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart';
/// Flutter sets these flags after settings load to enable ghost modes.
/// Must be called from onWebViewCreated or on settings change.
const String kSetGhostFlagsJS = '''
(function(){
// Placeholder Flutter replaces these with actual setting values:
// window.__fgPartialGhost = true/false;
// window.__fgFullDmGhost = true/false;
// window.__fgStoryGhost = true/false;
// window.__fgGhostReady = true; // signals scripts can proceed
})();
''';
//
// PARTIAL GHOST MODE existing behavior
// Blocks seen API patterns, WebSocket chat gateways, and uses
// first-click gate for api/graphql on /direct/* (inbox loads, then block).
//
const String kPartialGhostJS = r'''
(function() {
if (window.__fgPartialGhostPatched) return;
window.__fgPartialGhostPatched = true;
// Seen API patterns
var SEEN = [/\/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\//];
function isSeen(u) { for(var i=0;i<SEEN.length;i++){if(SEEN[i].test(u))return true;}return false; }
// First-click gate for api/graphql on /direct/*
window.__fgDirectApiBlocked = false;
document.addEventListener('click',function(){
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
},true);
document.addEventListener('touchstart',function(){
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
},true);
var _prevD=window.location.pathname.indexOf('/direct/')===0;
setInterval(function(){
var n=window.location.pathname.indexOf('/direct/')===0;
if(n!==_prevD){_prevD=n;window.__fgDirectApiBlocked=false;}
},300);
function partialEnabled() { return window.__fgPartialGhost===true; }
function shouldBlock(u) {
if (!partialEnabled()) return false;
return window.location.pathname.indexOf('/direct/')===0 &&
window.__fgDirectApiBlocked &&
u.indexOf('/api/graphql')!==-1;
}
// Fetch override (chain with previous fetch)
var _prevFetch = window.fetch;
window.fetch=function(i,init){
var u=(typeof i==='string')?i:(i&&i.url)?i.url:'';
if(partialEnabled()&&(isSeen(u)||shouldBlock(u))) return Promise.resolve(new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}));
return _prevFetch.call(window,i,init);
};
// XHR override (chain)
var _prevOpen=XMLHttpRequest.prototype.open,_prevSend=XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open=function(m,u){this.__fgU=u||'';return _prevOpen.apply(this,arguments);};
XMLHttpRequest.prototype.send=function(b){
if(partialEnabled()&&(isSeen(this.__fgU||'')||shouldBlock(this.__fgU||''))){
var self=this;setTimeout(function(){
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
},5);return;
}
return _prevSend.apply(this,arguments);
};
// Selective WS seen-message filter (no gouger)
(function() {
var _WS = window.WebSocket;
function PartialWS(url, protocols) {
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
var _send = ws.send.bind(ws);
ws.send = function(data) {
if (typeof data === 'string') {
try {
var parsed = JSON.parse(data);
if (parsed && (parsed.op === '4' || parsed.op === 'seen')) return;
} catch(e) {}
if (data.indexOf('"seen"') !== -1 && data.indexOf('"thread_id"') !== -1) return;
}
return _send(data);
};
return ws;
}
PartialWS.prototype = _WS.prototype;
PartialWS.CONNECTING = _WS.CONNECTING;
PartialWS.OPEN = _WS.OPEN;
PartialWS.CLOSING = _WS.CLOSING;
PartialWS.CLOSED = _WS.CLOSED;
window.WebSocket = PartialWS;
})();
})();
''';
//
// FULL DM GHOST blocks ALL api/graphql on /direct/* immediately
// (inbox won't load, messages can't be sent)
//
const String kFullDmGhostJS = r'''
(function() {
if (window.__fgFullDmGhostPatched) return;
window.__fgFullDmGhostPatched = true;
// Smart path-based blocking
// /direct/inbox/ allow (inbox loads)
// /direct/t/* block ALL api/graphql immediately
// any /direct/* block except /direct/inbox/
function shouldBlockDmPath() {
if (window.__fgFullDmGhost !== true) return false;
var p = window.location.pathname;
if (p.indexOf('/direct/') !== 0) return false;
if (p === '/direct/inbox/' || p === '/direct/inbox') return false;
return true;
}
// DM URL blocklist
var DM_URLS = [
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/mark_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/mark_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_visual_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/visual_thread\\/[^/]+\\/seen\\//,
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_audio_seen\\//,
/\\/api\\/v1\\/live\\/[^/]+\\/join\\//,
/\\/api\\/v1\\/live\\/[^/]+\\/get_join_requests\\//,
/\\/api\\/v1\\/media\\/seen\\//,
/\\/api\\/v1\\/feed\\/viewed_story\\//,
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
/\\/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\\//,
/\\/api\\/v1\\/qe\\//,
/\\/api\\/v1\\/launcher\\/sync\\//,
/\\/api\\/v1\\/logging\\//,
/\\/api\\/v1\\/fb_onetap_logging\\//,
/\\/ajax\\/bz/,
/\\/ajax\\/logging\\//,
/\\/api\\/v1\\/stats\\//,
/\\/api\\/v1\\/fbanalytics\\//,
];
function matchUrl(url) {
if (!url) return false;
for (var i = 0; i < DM_URLS.length; i++) { if (DM_URLS[i].test(url)) return true; }
return false;
}
// DM GraphQL operations
var DM_OPS = [
'MarkDirectThreadItemSeen','markDirectThreadItemSeen',
'DirectMarkItemSeen','DirectThreadMarkSeen',
'MarkVisualMessageSeen','DirectMarkVisualItemSeen',
'MarkAudioMessageSeen','AudioSeenMutation',
'LiveJoinBroadcast','JoinLiveBroadcast','MarkLiveViewer',
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
'LogImpression','LogClick','FeedbackSeenMutation',
];
function matchGraphQL(body) {
if (!body) return false;
var str = typeof body === 'string' ? body : String(body);
for (var i = 0; i < DM_OPS.length; i++) { if (str.indexOf(DM_OPS[i]) !== -1) return true; }
return false;
}
function isGraphql(url) {
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
}
function shouldBlock(url, init) {
// 1. Path-based: on /direct/t/* block ALL graphql
if (shouldBlockDmPath() && isGraphql(url)) return true;
// 2. URL blocklist match
if (matchUrl(url)) return true;
// 3. GraphQL body op-name match
if (isGraphql(url) && init) {
var bs = '';
if (typeof init.body === 'string') bs = init.body;
else if (init.body && init.body.toString) bs = init.body.toString();
if (matchGraphQL(bs)) return true;
}
return false;
}
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
// Fetch override (chain)
var _prevFetch = window.fetch;
window.fetch = function(i, init) {
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
return _prevFetch.apply(this, arguments);
};
// XHR override (chain)
var _prevOpen = XMLHttpRequest.prototype.open;
var _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this.__fgDU = u || ''; return _prevOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(b) {
var u = this.__fgDU || '';
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockDmPath())) {
var self = this;
setTimeout(function() {
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
}, 5);
return;
}
return _prevSend.apply(this, arguments);
};
// SW killer
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
}
// Beacon blocker
if (navigator.sendBeacon) {
navigator.sendBeacon = function(url) { return true; };
}
// MQTT WS intercept (typing / live viewer / seen)
// Instagram uses MQTT over WebSocket for real-time events.
// '/t_fs' = foreground state, '/t_mt' = mark thread seen,
// '/t_s' and '/t_se' = seen receipts, 'activity_indicator' = active status.
(function() {
var _WS = window.WebSocket;
function DmGhostWS(url, protocols) {
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
var _send = ws.send.bind(ws);
ws.send = function(data) {
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
var bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
var packetType = bytes[0] & 0xF0;
if (packetType === 0x30) {
try {
var decoded = new TextDecoder('utf-8').decode(bytes);
if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('/t_mt') !== -1 ||
decoded.indexOf('/t_s') !== -1 || decoded.indexOf('/t_se') !== -1 ||
decoded.indexOf('activity_indicator') !== -1 ||
decoded.indexOf('is_typing') !== -1 || decoded.indexOf('direct_typing') !== -1 ||
decoded.indexOf('/live/viewer') !== -1 || decoded.indexOf('live_viewer_list') !== -1) {
return;
}
} catch(e) {}
}
} else if (typeof data === 'string') {
if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 ||
data.indexOf('is_typing') !== -1 || data.indexOf('mark_seen') !== -1 ||
data.indexOf('mark_read') !== -1 || data.indexOf('receipt') !== -1) return;
}
return _send(data);
};
return ws;
}
DmGhostWS.prototype = _WS.prototype;
DmGhostWS.CONNECTING = _WS.CONNECTING;
DmGhostWS.OPEN = _WS.OPEN;
DmGhostWS.CLOSING = _WS.CLOSING;
DmGhostWS.CLOSED = _WS.CLOSED;
window.WebSocket = DmGhostWS;
})();
})();
''';
//
// STORY GHOST blocks api/graphql on homepage (/) and /stories/*
// Allows viewing stories without sending seen indicators.
//
const String kStoryGhostJS = r'''
(function() {
if (window.__fgStoryGhostPatched) return;
window.__fgStoryGhostPatched = true;
// Smart path-based blocking
// On /, /stories/*, /story/* block ALL api/graphql
// On /direct/inbox/ allow (DMs need graphql to load messages)
function shouldBlockByPath() {
if (window.__fgStoryGhost !== true) return false;
var p = window.location.pathname;
// Don't block on DM pages
if (p.indexOf('/direct/') === 0) return false;
var isStory = p.indexOf('/stories/') === 0 || p.indexOf('/story/') === 0;
var isHome = p === '/' || p === '';
return isHome || isStory;
}
// Story URL blocklist
var STORY_URLS = [
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
/\\/api\\/v1\\/feed\\/viewed_story\\//,
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
/\\/api\\/v1\\/media\\/seen\\//,
];
function matchUrl(url) {
if (!url) return false;
for (var i = 0; i < STORY_URLS.length; i++) { if (STORY_URLS[i].test(url)) return true; }
return false;
}
// Story GraphQL operations
var STORY_OPS = [
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
'FeedbackSeenMutation',
];
function matchGraphQL(body) {
if (!body) return false;
var str = typeof body === 'string' ? body : String(body);
for (var i = 0; i < STORY_OPS.length; i++) { if (str.indexOf(STORY_OPS[i]) !== -1) return true; }
return false;
}
function isGraphql(url) {
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
}
function shouldBlock(url, init) {
// 1. Path-based: on story pages block ALL graphql
if (shouldBlockByPath() && isGraphql(url)) return true;
// 2. URL blocklist match
if (matchUrl(url)) return true;
// 3. GraphQL body op-name match
if (isGraphql(url) && init) {
var bs = '';
if (typeof init.body === 'string') bs = init.body;
else if (init.body && init.body.toString) bs = init.body.toString();
if (matchGraphQL(bs)) return true;
}
return false;
}
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
// Fetch override (chain)
var _prevFetch = window.fetch;
window.fetch = function(i, init) {
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
return _prevFetch.apply(this, arguments);
};
// XHR override (chain)
var _prevOpen = XMLHttpRequest.prototype.open;
var _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this.__fgSU = u || ''; return _prevOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(b) {
var u = this.__fgSU || '';
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockByPath())) {
var self = this;
setTimeout(function() {
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
}, 5);
return;
}
return _prevSend.apply(this, arguments);
};
// SW killer
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
}
// Beacon blocker
if (navigator.sendBeacon) {
navigator.sendBeacon = function(url) { return true; };
}
})();
''';
//
// Builder injects the right scripts based on settings
//
List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[];
final endScripts = <String>[];
// Prepend flag values directly into the script so they survive page navigation.
// (evaluateJavascript-set flags are destroyed when the JS context resets on load.)
// DM Ghost uses the comprehensive Full DM approach (URL blocklist, GraphQL ops, SW killer, beacon, WS).
// it should have worked, but sadly it didnt
if (settings.ghostMode) {
startScripts.add('window.__fgFullDmGhost=true;$kFullDmGhostJS');
}
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END
if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS);
final scripts = <UserScript>[];
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;
}
// Existing non-ghost helpers (unchanged)
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') e.target.pause();
}, true);
''';
const String hideStoryTrayJS = '''
(function(){var s=document.createElement('style');s.textContent='[data-pagelet="story_tray"]{display:none!important}';document.head.appendChild(s);})();
''';
const String hideReelsJS = '''
(function(){new MutationObserver(function(){document.querySelectorAll('a[href="/reels/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')});document.querySelectorAll('a[href="/explore/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')})}).observe(document.body,{childList:true,subtree:true});})();
''';
const String hideDMsJS = '''
(function(){var s=document.createElement('style');s.textContent='a[href="/direct/inbox/"]{display:none!important}';document.head.appendChild(s);})();
''';
-1
View File
@@ -9,4 +9,3 @@ const String kHapticBridgeScript = '''
}, true); }, true);
})(); })();
'''; ''';
+2 -2
View File
@@ -15,8 +15,8 @@ const String kReelMetadataExtractorScript = r'''
return; return;
} }
// Check if this is a reel page // Check if this is a reel page (Instagram uses /reels/ not /reel/)
if (!currentUrl.includes('/reel/')) { if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) {
return; return;
} }
-1
View File
@@ -10,4 +10,3 @@ const String kScrollSmoothingJS = r'''
} catch (_) {} } catch (_) {}
})(); })();
'''; ''';
-1
View File
@@ -29,4 +29,3 @@ const String kSpaNavigationMonitorScript = '''
window.addEventListener('popstate', () => notifyUrlChange()); window.addEventListener('popstate', () => notifyUrlChange());
})(); })();
'''; ''';
+355
View File
@@ -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(/&amp;/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 '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
}
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:999',
'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();
})();
''';
@@ -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<ContentBlocker> contentBlockers;
final Set<String> blockedHosts;
final String sourceTag;
const AdblockContentBlockerData({
required this.contentBlockers,
required this.blockedHosts,
required this.sourceTag,
});
Map<String, dynamic> toJson() => {
'sourceTag': sourceTag,
'hosts': blockedHosts.toList(),
// We cant safely serialize ContentBlocker objects; rebuild from hosts.
// contentBlockers will always be regenerated from hosts when restoring.
};
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
final hosts =
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
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<AdblockContentBlockerData> 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 = <String, _CachedSource>{...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<String, dynamic>;
return AdblockContentBlockerData.fromJson(decoded);
} catch (_) {
return null;
}
}
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
final cached = prefs.getString(_keySourceCache);
if (cached == null) return {};
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return decoded.map((tag, value) {
return MapEntry(
tag,
_CachedSource.fromJson(value as Map<String, dynamic>),
);
});
} catch (_) {
return {};
}
}
AdblockContentBlockerData _buildData({
required Set<String> 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<List<_FetchedSource>> _fetchAllSources({
required Map<String, _CachedSource> 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 = <String, String>{
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<String> parseHostsFromFilterText(String raw) {
final hosts = <String>{};
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<String> hosts;
const _CachedSource({
required this.url,
required this.etag,
required this.lastModified,
required this.hosts,
});
factory _CachedSource.fromJson(Map<String, dynamic> json) {
return _CachedSource(
url: (json['url'] as String?) ?? '',
etag: json['etag'] as String?,
lastModified: json['lastModified'] as String?,
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
);
}
Map<String, dynamic> toJson() => {
'url': url,
'etag': etag,
'lastModified': lastModified,
'hosts': hosts.toList(growable: false)..sort(),
};
}
+185
View File
@@ -0,0 +1,185 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages app lock: PIN, biometrics, and two independent lock modes.
///
/// Modes (both can be on at the same time):
/// - **App-wide lock** shown on cold start (before WebView) and after
/// background timeout.
/// - **Messages tab lock** shown when navigating to Instagram DMs.
///
/// Both use the same PIN (stored in secure storage).
class AppLockService extends ChangeNotifier {
static const _pinAppWideKey = 'app_lock_pin_app_wide';
static const _pinMessagesKey = 'app_lock_pin_messages';
static const _prefAppWide = 'app_lock_app_wide';
static const _prefLockMessages = 'app_lock_lock_messages';
static const _prefScramble = 'app_lock_scramble_keypad';
static const _prefBio = 'app_lock_biometrics_enabled';
static const _prefTimeout = 'app_lock_timeout_ms';
final _secure = const FlutterSecureStorage();
final _auth = LocalAuthentication();
// Mode toggles
bool _lockAppWide = false; // locks the whole app on start / bg timeout
bool _lockMessages = false; // locks only the DMs tab
// Settings
bool _scramble = false;
bool _bioEnabled = false;
int _timeoutMs = 120000; // 2 min
bool _hasPin = false;
// Runtime state
bool _isShowingLock = false; // true while lock screen is displayed
DateTime? _bgAt;
// Getters
bool get lockAppWide => _lockAppWide;
bool get lockMessages => _lockMessages;
bool get isShowingLock => _isShowingLock;
bool get scrambleKeypad => _scramble;
bool get biometricsEnabled => _bioEnabled;
bool get hasPin => _hasPin;
bool get anyLockEnabled => _lockAppWide || _lockMessages;
/// Whether the app-wide lock screen should show on cold start.
bool get needsUnlockOnStart => _lockAppWide && _hasPin;
/// Whether the messages tab lock is enabled and can function.
bool get messagesLockReady => _lockMessages && _hasPin;
// Init
Future<void> init() async {
final p = await SharedPreferences.getInstance();
_lockAppWide = p.getBool(_prefAppWide) ?? false;
_lockMessages = p.getBool(_prefLockMessages) ?? false;
_scramble = p.getBool(_prefScramble) ?? false;
_bioEnabled = p.getBool(_prefBio) ?? true;
_timeoutMs = p.getInt(_prefTimeout) ?? 120000;
// Check if either PIN exists
final hashA = await _secure.read(key: _pinAppWideKey);
final hashM = await _secure.read(key: _pinMessagesKey);
_hasPin =
(hashA != null && hashA.isNotEmpty) ||
(hashM != null && hashM.isNotEmpty);
}
// PIN management
String _hash(String pin) => utf8
.encode('fg_${pin}_salt26')
.map((x) => x.toRadixString(16).padLeft(2, '0'))
.join();
/// Set PIN for a specific lock mode.
Future<void> setPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
await _secure.write(key: key, value: _hash(pin));
_hasPin = true;
notifyListeners();
}
/// Verify PIN for the given mode.
Future<bool> verifyPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final stored = await _secure.read(key: key);
return stored != null && stored == _hash(pin);
}
/// Check whether a specific mode has a PIN set.
Future<bool> hasPinFor({required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final hash = await _secure.read(key: key);
return hash != null && hash.isNotEmpty;
}
// Toggles
Future<void> setLockAppWide(bool v) async {
_lockAppWide = v;
(await SharedPreferences.getInstance()).setBool(_prefAppWide, v);
if (!v && !_isShowingLock) _isShowingLock = false;
notifyListeners();
}
Future<void> setLockMessages(bool v) async {
_lockMessages = v;
(await SharedPreferences.getInstance()).setBool(_prefLockMessages, v);
notifyListeners();
}
Future<void> setScrambleKeypad(bool v) async {
_scramble = v;
(await SharedPreferences.getInstance()).setBool(_prefScramble, v);
notifyListeners();
}
Future<void> setBiometricsEnabled(bool v) async {
_bioEnabled = v;
(await SharedPreferences.getInstance()).setBool(_prefBio, v);
notifyListeners();
}
// Lock / Unlock lifecycle
/// Call when app-wide lock screen is opened.
void onLockScreenShown() {
_isShowingLock = true;
notifyListeners();
}
/// Call after successful unlock (PIN or biometric).
void onUnlocked() {
_isShowingLock = false;
_bgAt = null;
notifyListeners();
}
/// Call when app goes to background.
void onBackgrounded() {
_bgAt = DateTime.now();
}
/// Whether the app-wide lock should trigger on resume.
bool get shouldLockOnResume {
if (!_lockAppWide || !_hasPin || _bgAt == null) return false;
return DateTime.now().difference(_bgAt!).inMilliseconds >= _timeoutMs;
}
// Biometrics
Future<bool> isBiometricsAvailable() async {
try {
return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
} catch (_) {
return false;
}
}
Future<bool> authenticateWithBiometrics() async {
if (!_bioEnabled) return false;
try {
return await _auth.authenticate(
localizedReason: 'Unlock FocusGram',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
),
);
} catch (_) {
return false;
}
}
// Scrambled keypad
List<int> getScrambledDigits() {
final d = List<int>.generate(10, (i) => i);
d.shuffle(Random());
return d;
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Outcome of a Bait Me activation.
enum BaitOutcome {
/// Opens your ad website and resets the reels session.
openAdSiteAndReset,
/// Adds 10 minutes to the session credit balance.
addTenMinutes,
/// Opens an external ad URL and ends the session.
openExternalAdAndEnd,
/// Randomly reduces session time (1-5 min).
reduceSessionTime,
/// Increases cooldown by 10 min.
increaseCooldown,
/// Ends the current reel session.
endReelSession,
/// Ends the current app session.
endAppSession,
}
/// Weighted random outcome engine for the Bait Me button.
class BaitEngine extends ChangeNotifier {
static const String _boxName = 'bait_engine';
late Box _box;
final Random _random = Random();
// Hardcoded ad URLs
final String _adWebsiteUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
final String _externalAdUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
// Cooldown
static const int _cooldownMinutes = 30;
DateTime? _lastActivation;
// Callbacks
void Function(int minutes)? onAddMinutes;
void Function()? onResetSession;
void Function()? onEndReelSession;
void Function()? onEndAppSession;
void Function(String url)? onOpenUrl;
void Function(int minutes)? onReduceSessionTime;
void Function(int minutes)? onIncreaseCooldown;
// Getters
String get adWebsiteUrl => _adWebsiteUrl;
String get externalAdUrl => _externalAdUrl;
bool get isOnCooldown {
if (_lastActivation == null) return false;
return DateTime.now().difference(_lastActivation!).inMinutes <
_cooldownMinutes;
}
int get cooldownRemainingMinutes {
if (_lastActivation == null) return 0;
final elapsed = DateTime.now().difference(_lastActivation!).inMinutes;
return (_cooldownMinutes - elapsed).clamp(0, _cooldownMinutes);
}
// Init
Future<void> init() async {
_box = await Hive.openBox(_boxName);
final lastMs = _box.get('last_activation_ms', defaultValue: 0) as int;
if (lastMs > 0) {
_lastActivation = DateTime.fromMillisecondsSinceEpoch(lastMs);
}
}
// Activation
BaitOutcome roll() {
final r = _random.nextInt(100);
// 30% open ad site + reset (permanent always happens when rolled)
// 20% add 10 min
// 15% reduce session time
// 15% increase cooldown
// 10% end reel session
// 10% end app session
if (r < 30) return BaitOutcome.openAdSiteAndReset;
if (r < 50) return BaitOutcome.addTenMinutes;
if (r < 65) return BaitOutcome.reduceSessionTime;
if (r < 80) return BaitOutcome.increaseCooldown;
if (r < 90) return BaitOutcome.endReelSession;
return BaitOutcome.endAppSession;
}
Future<BaitOutcome> activate() async {
final outcome = roll();
_lastActivation = DateTime.now();
await _box.put(
'last_activation_ms',
_lastActivation!.millisecondsSinceEpoch,
);
notifyListeners();
switch (outcome) {
case BaitOutcome.openAdSiteAndReset:
onResetSession?.call();
onOpenUrl?.call(_adWebsiteUrl);
break;
case BaitOutcome.addTenMinutes:
onAddMinutes?.call(10);
break;
case BaitOutcome.openExternalAdAndEnd:
onOpenUrl?.call(_externalAdUrl);
onResetSession?.call();
break;
case BaitOutcome.reduceSessionTime:
final min = 1 + _random.nextInt(5); // 1-5 min
onReduceSessionTime?.call(min);
break;
case BaitOutcome.increaseCooldown:
onIncreaseCooldown?.call(10);
break;
case BaitOutcome.endReelSession:
onEndReelSession?.call();
break;
case BaitOutcome.endAppSession:
onEndAppSession?.call();
break;
}
return outcome;
}
static String outcomeLabel(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return '💸 Session Reset!';
case BaitOutcome.addTenMinutes:
return '⏰ +10 Minutes!';
case BaitOutcome.openExternalAdAndEnd:
return '🚫 Session Ended!';
case BaitOutcome.reduceSessionTime:
return '⏳ Time Deducted!';
case BaitOutcome.increaseCooldown:
return '🧊 Cooldown Increased!';
case BaitOutcome.endReelSession:
return '🎬 Reel Session Ended!';
case BaitOutcome.endAppSession:
return '📱 App Session Ended!';
}
}
static String outcomeSubtext(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return 'All session credits have been reset. Better luck next time.';
case BaitOutcome.addTenMinutes:
return 'You earned 10 extra minutes. Use them wisely!';
case BaitOutcome.openExternalAdAndEnd:
return 'Session forcefully ended. Time for a break.';
case BaitOutcome.reduceSessionTime:
return 'The Bait Me took some time away!';
case BaitOutcome.increaseCooldown:
return 'Cooldown period extended by 10 minutes.';
case BaitOutcome.endReelSession:
return 'Your reel session has been cut short.';
case BaitOutcome.endAppSession:
return 'Your Instagram session has been ended.';
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Manages time credit balances earned by watching rewarded ads.
///
/// Two balances: [reelsMinutesRemaining] for reel sessions and
/// [instaMinutesRemaining] for Instagram app sessions.
///
/// Also tracks ad watch counts for the Ad Counter dashboard (Phase 5).
class CreditStore extends ChangeNotifier {
static const String _boxName = 'credit_store';
late Box _box;
// Balances
int _reelsMinutes = 0;
int _instaMinutes = 0;
// Ad counters
int _adsWatchedToday = 0;
int _adsWatchedAllTime = 0;
String _todayKey = '';
// Gettters
int get reelsMinutes => _reelsMinutes;
int get instaMinutes => _instaMinutes;
int get adsWatchedToday => _adsWatchedToday;
int get adsWatchedAllTime => _adsWatchedAllTime;
int get timeEarnedViaAds => (_adsWatchedAllTime * minutesPerAd);
bool get hasReelsCredits => _reelsMinutes > 0;
bool get hasInstaCredits => _instaMinutes > 0;
bool get canWatchAdToday => _adsWatchedToday < maxDailyAds;
/// Minutes earned per rewarded ad watch.
static const int minutesPerAd = 2;
static const int maxDailyAds = 5;
// Init
Future<void> init() async {
_box = await Hive.openBox(_boxName);
_reelsMinutes = (_box.get('reels_min', defaultValue: 0) as num).toInt();
_instaMinutes = (_box.get('insta_min', defaultValue: 0) as num).toInt();
_adsWatchedAllTime = (_box.get('ads_all_time', defaultValue: 0) as num)
.toInt();
_todayKey = _dayKey();
// Restore today's count, reset if date changed
final savedDate = _box.get('ads_today_date', defaultValue: '') as String;
if (savedDate == _todayKey) {
_adsWatchedToday = (_box.get('ads_today_count', defaultValue: 0) as num)
.toInt();
} else {
_adsWatchedToday = 0;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', 0);
}
}
// Credit operations
/// Add minutes earned from watching an ad.
Future<void> addReelsMinutes({int amount = minutesPerAd}) async {
_reelsMinutes += amount;
await _box.put('reels_min', _reelsMinutes);
_incrementAdCounters();
notifyListeners();
}
Future<void> addInstaMinutes({int amount = minutesPerAd}) async {
_instaMinutes += amount;
await _box.put('insta_min', _instaMinutes);
_incrementAdCounters();
notifyListeners();
}
/// Drain 1 minute from the reel balance (called every minute during a session).
Future<void> drainReelsMinute() async {
if (_reelsMinutes <= 0) return;
_reelsMinutes--;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
/// Drain 1 minute from the Instagram balance.
Future<void> drainInstaMinute() async {
if (_instaMinutes <= 0) return;
_instaMinutes--;
await _box.put('insta_min', _instaMinutes);
notifyListeners();
}
/// Reset all balances (e.g. on settings toggle off).
Future<void> resetBalances() async {
_reelsMinutes = 0;
_instaMinutes = 0;
await _box.put('reels_min', 0);
await _box.put('insta_min', 0);
notifyListeners();
}
/// Add minutes directly from the Bait Me feature.
Future<void> addBonusMinutes(int minutes) async {
// Add to reels balance (bait me rewards are for reels)
_reelsMinutes += minutes;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
// Ad counter helpers
void _incrementAdCounters() {
_adsWatchedToday++;
_adsWatchedAllTime++;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', _adsWatchedToday);
_box.put('ads_all_time', _adsWatchedAllTime);
}
/// Reset daily ad counter (call on day change).
Future<void> resetDailyIfNeeded() async {
final newKey = _dayKey();
if (newKey != _todayKey) {
_todayKey = newKey;
_adsWatchedToday = 0;
await _box.put('ads_today_date', _todayKey);
await _box.put('ads_today_count', 0);
notifyListeners();
}
}
String _dayKey() {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
}
+4 -9
View File
@@ -57,15 +57,15 @@ class InjectionController {
required bool blurReels, required bool blurReels,
required bool tapToUnblur, required bool tapToUnblur,
required bool enableTextSelection, required bool enableTextSelection,
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager required bool hideSuggestedPosts,
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager required bool hideSponsoredPosts,
required bool hideLikeCounts, required bool hideLikeCounts,
required bool hideFollowerCounts, required bool hideFollowerCounts,
// hideStoriesBar parameter removed per user request
required bool hideExploreTab, required bool hideExploreTab,
required bool hideReelsTab, required bool hideReelsTab,
required bool hideShopTab, required bool hideShopTab,
required bool disableReelsEntirely, required bool disableReelsEntirely,
required bool blockHomeFeedScroll,
}) { }) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS); final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS); if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
@@ -75,18 +75,12 @@ class InjectionController {
css.writeln(scripts.kHideReelsFeedContentCSS); 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 (blurReels) css.writeln(scripts.kBlurReelsCSS);
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS); if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS); if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS); if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
// Stories hiding removed per user request
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS); if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS); if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS); if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
@@ -94,6 +88,7 @@ class InjectionController {
return ''' return '''
${buildSessionStateJS(sessionActive)} ${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely; window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur; window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS} ${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())} ${_buildMutationObserver(css.toString())}
+46 -19
View File
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
import '../scripts/grayscale.dart' as grayscale; import '../scripts/grayscale.dart' as grayscale;
import '../scripts/ui_hider.dart' as ui_hider; import '../scripts/ui_hider.dart' as ui_hider;
import '../scripts/content_disabling.dart' as content_disabling; 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. // Core JS and CSS payloads injected into the Instagram WebView.
// //
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
// InjectionManager class // InjectionManager class
class InjectionManager { abstract class JsEvaluator {
Future<void> evaluateJavascript({required String source});
}
class _WebViewJsEvaluator implements JsEvaluator {
final InAppWebViewController controller; final InAppWebViewController controller;
_WebViewJsEvaluator(this.controller);
@override
Future<void> evaluateJavascript({required String source}) {
return controller.evaluateJavascript(source: source);
}
}
class InjectionManager {
final JsEvaluator _jsEvaluator;
final SharedPreferences prefs; final SharedPreferences prefs;
final SessionManager sessionManager; final SessionManager sessionManager;
SettingsService? _settingsService; SettingsService? _settingsService;
InjectionManager({ InjectionManager({
required this.controller, required InAppWebViewController controller,
required this.prefs, required this.prefs,
required this.sessionManager, 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) { void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService; _settingsService = settingsService;
@@ -415,18 +437,19 @@ class InjectionManager {
final blurExplore = settings.blurExplore || settings.minimalModeEnabled; final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur; final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection; 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 hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts; final hideFollowerCounts = settings.hideFollowerCounts;
// Stories hiding functionality removed per user request
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely // Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
// These are now only controllable via minimal mode submenu // These are now only controllable via minimal mode submenu
final disableExploreEntirely = settings.disableExploreEntirely; final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely; final disableReelsEntirely = settings.disableReelsEntirely;
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely; final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely; final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab; final hideShopTab = settings.hideShopTab;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS( final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive, sessionActive: sessionActive,
@@ -434,33 +457,35 @@ class InjectionManager {
blurReels: false, // Blur reels feature removed blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur, tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection, enableTextSelection: enableTextSelection,
hideSuggestedPosts: false, // Feature removed hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: hideSponsoredPosts, hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
hideLikeCounts: hideLikeCounts, hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts, hideFollowerCounts: hideFollowerCounts,
// hideStoriesBar removed per user request
hideExploreTab: hideExploreTab, hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab, hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab, hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely, disableReelsEntirely: disableReelsEntirely,
blockHomeFeedScroll: blockHomeFeedScroll,
); );
try { try {
await controller.evaluateJavascript(source: injectionJS); await _jsEvaluator.evaluateJavascript(source: injectionJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
// Inject grayscale when active, remove when not active // Inject grayscale when active, remove when not active
if (isGrayscaleActive) { if (settings.isGrayscaleActiveNow) {
try { try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS); await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
} else { } else {
try { try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS); await _jsEvaluator.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
@@ -469,7 +494,9 @@ class InjectionManager {
// Inject hide like counts JS when enabled // Inject hide like counts JS when enabled
if (hideLikeCounts) { if (hideLikeCounts) {
try { try {
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS); await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideLikeCountsJS,
);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
@@ -478,11 +505,11 @@ class InjectionManager {
// Stories hiding functionality removed per user request // Stories hiding functionality removed per user request
// No stories overlay injection needed // No stories overlay injection needed
// Inject hide sponsored posts JS when enabled // Inject video downloader UI when enabled
if (hideSponsoredPosts) { if (settings.videoDownloadEnabled) {
try { try {
await controller.evaluateJavascript( await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideSponsoredPostsJS, source: video_downloader.kVideoDownloadJS,
); );
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
@@ -492,7 +519,7 @@ class InjectionManager {
// Inject DM Reel blocker when disableReelsEntirely is enabled // Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) { if (disableReelsEntirely) {
try { try {
await controller.evaluateJavascript( await _jsEvaluator.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS, source: content_disabling.kDmReelBlockerJS,
); );
} catch (e) { } catch (e) {
+414
View File
@@ -0,0 +1,414 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
/// Feature identifiers for level gating.
/// Every gated feature checks [LevelService.isFeatureUnlocked].
class AppFeature {
final String id;
final String name;
final int requiredLevel;
const AppFeature._(this.id, this.name, this.requiredLevel);
static const effortFriction = AppFeature._(
'effort_friction',
'Effort Friction Mode',
3,
);
static const reelsHistory = AppFeature._('reels_history', 'Reels History', 2);
static const downloadMedia = AppFeature._(
'download_media',
'Download Media',
2,
);
static const fullDmGhost = AppFeature._('full_dm_ghost', 'Full DM Ghost', 1);
static const ghostMode = AppFeature._('ghost_mode', 'Ghost Mode', 2);
static const baitMe = AppFeature._('bait_me', 'Bait Me Button', 3);
static const appLock = AppFeature._('app_lock', 'App Lock', 3);
static const customFriction = AppFeature._(
'custom_friction',
'Custom Friction Rules',
4,
);
static const List<AppFeature> all = [
effortFriction,
downloadMedia,
ghostMode,
baitMe,
appLock,
];
}
/// XP thresholds for each level.
/// Level 1 = 0 XP (always start here).
const Map<int, int> levelThresholds = {1: 0, 2: 100, 3: 250, 4: 450, 5: 700};
const int maxLevel = 5;
/// A single XP event logged for the XP history view.
class _XpEvent {
final int amount;
final String reason;
final DateTime time;
_XpEvent(this.amount, this.reason, this.time);
}
/// Tracks XP, level progression, degradation, and monthly resets.
///
/// Always-on (not toggleable). All new features are gated behind levels.
///
/// **Storage:** Hive box `level_cache` (persistent local storage).
class LevelService extends ChangeNotifier {
// Hive box
static const String _hiveBox = 'level_cache';
late Box _cache;
// Runtime state
int _level = 1;
int _xp = 0;
DateTime? _lastResetDate;
List<int> _dailyReelCounts = []; // last 30 days
int _totalReelsAllTime = 0;
int _adsWatchedTotal = 0;
// Track today for daily reel logging
// Getters
int get level => _level;
int get xp => _xp;
int get totalReelsAllTime => _totalReelsAllTime;
int get adsWatchedTotal => _adsWatchedTotal;
/// XP needed for the current level (cumulative threshold for this level).
int get xpForCurrentLevel => levelThresholds[_level] ?? 0;
/// XP needed to reach the next level (or current if at max).
int get xpForNextLevel {
if (_level >= maxLevel) return levelThresholds[maxLevel]!;
return levelThresholds[_level + 1] ?? xpForCurrentLevel;
}
/// Progress 0.01.0 within the current level.
double get levelProgress {
final current = _xp - xpForCurrentLevel;
final needed = xpForNextLevel - xpForCurrentLevel;
if (needed <= 0) return 1.0;
return (current / needed).clamp(0.0, 1.0);
}
/// Whether the user has reached (or exceeded) the required level.
bool isFeatureUnlocked(AppFeature feature) => _level >= feature.requiredLevel;
/// The next locked feature with level requirement for "What's next?" display.
AppFeature? get nextLockedFeature {
for (final f in AppFeature.all) {
if (!isFeatureUnlocked(f)) return f;
}
return null;
}
// Initialization
Future<void> init() async {
// 1. Open Hive cache box
_cache = await Hive.openBox(_hiveBox);
_loadFromCache();
// 2. Check monthly reset
await _checkMonthlyReset();
// 4. Check daily degradation
await _checkDailyDegradation();
notifyListeners();
}
void _loadFromCache() {
try {
_level = (_cache.get('level') ?? 1) as int;
_xp = (_cache.get('xp') ?? 0) as int;
final lastReset = _cache.get('lastResetDate') as String?;
if (lastReset != null) {
_lastResetDate = DateTime.tryParse(lastReset);
}
final countsRaw = _cache.get('dailyReelCounts') as String?;
if (countsRaw != null) {
_dailyReelCounts = (jsonDecode(countsRaw) as List).cast<int>();
}
_totalReelsAllTime = (_cache.get('totalReelsAllTime') ?? 0) as int;
_adsWatchedTotal = (_cache.get('adsWatchedTotal') ?? 0) as int;
} catch (_) {
// Fall back to defaults
}
}
Future<void> _saveToCache() async {
await _cache.put('level', _level);
await _cache.put('xp', _xp);
await _cache.put('lastResetDate', _lastResetDate?.toIso8601String());
await _cache.put('dailyReelCounts', jsonEncode(_dailyReelCounts));
await _cache.put('totalReelsAllTime', _totalReelsAllTime);
await _cache.put('adsWatchedTotal', _adsWatchedTotal);
}
// XP History
final List<_XpEvent> _xpHistory = [];
/// Human-readable recent XP log for "Your Journey".
List<Map<String, dynamic>> get recentXpLog {
return _xpHistory.reversed
.take(50)
.map(
(e) => {
'amount': e.amount,
'reason': e.reason,
'time': e.time.toIso8601String(),
},
)
.toList();
}
// XP Earning
static const int _dailyAdXpCap = 20;
int _adsWatchedToday = 0;
/// Call when a rewarded ad is completed.
Future<void> addXpForAd() async {
if (_adsWatchedToday >= _dailyAdXpCap) return; // Cap reached
_adsWatchedToday++;
_adsWatchedTotal++;
await _awardXp(10, reason: 'Watched an ad');
}
/// Call when a session ends awards XP for self-control.
/// [reelsWatchedToday] = total reels watched so far today.
Future<void> evaluateDailyReelControl(int reelsWatchedToday) async {
// Calculate 7-day average
final avg7 = _sevenDayAverage();
if (avg7 <= 0) return; // Not enough data yet
if (reelsWatchedToday < avg7) {
// User watched fewer reels than average award XP
final reelsSaved = (avg7 - reelsWatchedToday).floor();
final xpGain = min(reelsSaved * 10, 50); // Max +50 XP per day
await _awardXp(xpGain, reason: 'Reduced reel count');
}
// Log today's count
await _logDailyReelCount(reelsWatchedToday);
}
/// Call once per day when the user opens the app.
Future<void> addDailyCheckinXp() async {
final prefs = await SharedPreferences.getInstance();
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
final lastCheckin = prefs.getString('level_last_checkin') ?? '';
if (lastCheckin == today) return; // Already checked in today
await prefs.setString('level_last_checkin', today);
await _awardXp(1, reason: 'Daily check-in');
}
/// Complete a full day under the daily reel limit.
Future<void> awardDayUnderLimit() async {
await _awardXp(15, reason: 'Day under limit');
}
Future<void> _awardXp(int amount, {String reason = 'general'}) async {
_xp += amount;
_xp = max(0, min(_xp, levelThresholds[maxLevel]!));
// Log to history
_xpHistory.add(_XpEvent(amount, reason, DateTime.now()));
// Keep last 200 entries
if (_xpHistory.length > 200) {
_xpHistory.removeRange(0, _xpHistory.length - 200);
}
await _checkLevelUp();
await _saveToCache();
notifyListeners();
}
Future<void> _checkLevelUp() async {
while (_level < maxLevel) {
final nextThreshold = levelThresholds[_level + 1]!;
if (_xp >= nextThreshold) {
_level++;
//debugPrint('🎉 Level up! Now Level $_level');
} else {
break;
}
}
}
// XP Decay / Degradation
Future<void> _checkDailyDegradation() async {
if (_dailyReelCounts.isEmpty) return;
final avg7 = _sevenDayAverage();
final allTimeAvg = _allTimeAverage();
// Check if today's count (from yesterday, since this runs at startup)
// exceeds both averages
final yesterdayCount = _dailyReelCounts.isNotEmpty
? _dailyReelCounts.last
: 0;
if (yesterdayCount > avg7 && yesterdayCount > allTimeAvg && avg7 > 0) {
// Deduct XP
_xp = max(0, _xp - 20);
notifyListeners();
}
// Check for level drop: exceeded app time limit 3 days in a row
// (We check via a streak counter stored in prefs)
await _checkLevelDropStreak();
}
Future<void> _checkLevelDropStreak() async {
final prefs = await SharedPreferences.getInstance();
final streakKey = 'level_drop_streak';
int streak = prefs.getInt(streakKey) ?? 0;
if (_dailyReelCounts.length >= 3) {
final last3 = _dailyReelCounts.sublist(_dailyReelCounts.length - 3);
final avg7 = _sevenDayAverage();
final allExceeded = last3.every((c) => c > avg7 && avg7 > 0);
if (allExceeded) {
streak++;
await prefs.setInt(streakKey, streak);
} else {
// Reset streak
await prefs.setInt(streakKey, 0);
}
if (streak >= 3 && _level > 1) {
// Drop one full level
_level = max(1, _level - 1);
// Also reduce XP to the threshold of the new level
_xp = levelThresholds[_level]!;
await prefs.setInt(streakKey, 0);
//debugPrint('⚠️ Level dropped to $_level due to 3-day streak');
}
}
await _saveToCache();
}
// Monthly Reset
Future<void> _checkMonthlyReset() async {
if (_lastResetDate == null) {
_lastResetDate = DateTime.now();
return;
}
final daysSinceReset = DateTime.now().difference(_lastResetDate!).inDays;
if (daysSinceReset >= 30) {
_xp = 0; // Reset XP to 0
// Level is preserved (loss aversion)
_lastResetDate = DateTime.now();
_dailyReelCounts = []; // Clear daily history
_dailyReelCountsAddedToday = false;
await _saveToCache();
notifyListeners();
// Show monthly summary (handled by the UI layer by checking a flag)
_showMonthlySummary = true;
}
}
/// Flag consumed by UI to show "New month, fresh start" screen.
bool _showMonthlySummary = false;
bool get showMonthlySummary => _showMonthlySummary;
void dismissMonthlySummary() {
_showMonthlySummary = false;
notifyListeners();
}
// Daily Reel Logging
bool _dailyReelCountsAddedToday = false;
Future<void> _logDailyReelCount(int reelCount) async {
if (_dailyReelCountsAddedToday) return;
_dailyReelCounts.add(reelCount);
_totalReelsAllTime += reelCount;
// Keep only last 30 days
if (_dailyReelCounts.length > 30) {
_dailyReelCounts.removeRange(0, _dailyReelCounts.length - 30);
}
_dailyReelCountsAddedToday = true;
await _saveToCache();
}
double _sevenDayAverage() {
if (_dailyReelCounts.isEmpty) return 0;
final recent = _dailyReelCounts.length >= 7
? _dailyReelCounts.sublist(_dailyReelCounts.length - 7)
: _dailyReelCounts;
final sum = recent.fold<int>(0, (a, b) => a + b);
return sum / recent.length;
}
double _allTimeAverage() {
if (_dailyReelCounts.isEmpty) return 0;
final sum = _dailyReelCounts.fold<int>(0, (a, b) => a + b);
return sum / _dailyReelCounts.length;
}
/// Call this at the end of each day to award "day under limit" XP.
Future<void> finalizeDay(
int reelsWatchedToday,
int dailyReelLimitMinutes,
) async {
final dailyReelCount = reelsWatchedToday; // in minutes
if (dailyReelCount <= dailyReelLimitMinutes) {
await awardDayUnderLimit();
}
}
/// Reset the daily ad counter (call at midnight).
void resetDailyAdCounter() {
_adsWatchedToday = 0;
}
/*/// Grant XP with a custom reason (used from the debug section in settings).
Future<void> grantDebugXp(int amount, String reason) async {
await _awardXp(amount, reason: reason);
}
// Debug Methods
/// Force-set level and XP (debug only).
Future<void> debugSetLevel(int level, int xp) async {
_level = level.clamp(1, maxLevel);
_xp = xp.clamp(0, levelThresholds[maxLevel]!);
await _saveToCache();
notifyListeners();
}
/// Reset all level data (debug only).
Future<void> debugReset() async {
_level = 1;
_xp = 0;
_dailyReelCounts = [];
_totalReelsAllTime = 0;
_adsWatchedTotal = 0;
_adsWatchedToday = 0;
_lastResetDate = DateTime.now();
_dailyReelCountsAddedToday = false;
await _saveToCache();
notifyListeners();
}*/
}
+16 -12
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService { class NotificationService {
@@ -9,16 +8,16 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin = final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
Future<void> init() async { Future<void> init({bool requestPermissions = false}) async {
const AndroidInitializationSettings initializationSettingsAndroid = const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher'); AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS // Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS = final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings( DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: requestPermissions,
requestBadgePermission: true, requestBadgePermission: requestPermissions,
requestSoundPermission: true, requestSoundPermission: requestPermissions,
defaultPresentAlert: true, defaultPresentAlert: true,
defaultPresentBadge: true, defaultPresentBadge: true,
defaultPresentSound: true, defaultPresentSound: true,
@@ -37,7 +36,12 @@ class NotificationService {
}, },
); );
// Request permissions after initialization if (requestPermissions) {
await requestPermissionsNow();
}
}
Future<void> requestPermissionsNow() async {
await _requestIOSPermissions(); await _requestIOSPermissions();
await _requestAndroidPermissions(); await _requestAndroidPermissions();
} }
@@ -50,7 +54,7 @@ class NotificationService {
>() >()
?.requestPermissions(alert: true, badge: true, sound: true); ?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) { } catch (e) {
debugPrint('iOS permission request error: $e'); // debugPrint('iOS permission request error: $e');
} }
} }
@@ -62,7 +66,7 @@ class NotificationService {
>() >()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
} catch (e) { } catch (e) {
debugPrint('Android permission request error: $e'); // debugPrint('Android permission request error: $e');
} }
} }
@@ -100,7 +104,7 @@ class NotificationService {
notificationDetails: platformDetails, notificationDetails: platformDetails,
); );
} catch (e) { } catch (e) {
debugPrint('Notification error: $e'); // debugPrint('Notification error: $e');
} }
} }
@@ -144,7 +148,7 @@ class NotificationService {
notificationDetails: platformDetails, notificationDetails: platformDetails,
); );
} catch (e) { } catch (e) {
debugPrint('Persistent notification error: $e'); // debugPrint('Persistent notification error: $e');
} }
} }
@@ -153,7 +157,7 @@ class NotificationService {
try { try {
await _notificationsPlugin.cancel(id: id); await _notificationsPlugin.cancel(id: id);
} catch (e) { } catch (e) {
debugPrint('Cancel persistent notification error: $e'); // debugPrint('Cancel persistent notification error: $e');
} }
} }
@@ -162,7 +166,7 @@ class NotificationService {
try { try {
await _notificationsPlugin.cancelAll(); await _notificationsPlugin.cancelAll();
} catch (e) { } catch (e) {
debugPrint('Cancel all notifications error: $e'); // debugPrint('Cancel all notifications error: $e');
} }
} }
} }
+82
View File
@@ -0,0 +1,82 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class RemotePopupData {
final bool show;
final String id;
final String title;
final String body;
final int maxShows;
final String buttonText;
RemotePopupData({
required this.show,
required this.id,
required this.title,
required this.body,
required this.maxShows,
required this.buttonText,
});
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
return RemotePopupData(
show: json['show'] ?? false,
id: json['id']?.toString() ?? '',
title: json['header']?.toString() ?? 'Notice',
body: json['body']?.toString() ?? '',
maxShows: json['max_shows'] ?? 1,
buttonText: json['button_text']?.toString() ?? 'OK',
);
}
}
class RemotePopupService {
// Keep placeholder value until you replace it.
static const String popupUrl =
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
static Future<RemotePopupData?> fetchPopup() async {
try {
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
final uri = Uri.parse(
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
);
final response = await http.get(
uri,
headers: const {'Cache-Control': 'no-cache'},
);
if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return null;
return RemotePopupData.fromJson(decoded);
} catch (_) {
return null;
}
}
static Future<bool> shouldShow(RemotePopupData data) async {
if (!data.show) return false;
if (data.id.isEmpty) return false;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final shownCount = prefs.getInt(key) ?? 0;
return shownCount < data.maxShows;
}
static Future<void> markShown(RemotePopupData data) async {
if (data.id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final current = prefs.getInt(key) ?? 0;
await prefs.setInt(key, current + 1);
}
}
+5 -6
View File
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ///
/// Storage format (in SharedPreferences, key `screen_time_data`): /// Storage format (in SharedPreferences, key `screen_time_data`):
/// { /// {
/// "2026-02-26": 3420, // seconds /// "2026-05-26": 3420, // seconds
/// "2026-02-25": 1800 /// "2026-05-25": 1800
/// } /// }
/// ///
/// All data stays on-device only. /// All data stays on-device only.
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
bool _tracking = false; bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate); Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
int get totalSeconds =>
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map( _secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
(k, v) => MapEntry(k, (v as num).toInt()),
);
} }
} catch (_) { } catch (_) {
_secondsByDate = {}; _secondsByDate = {};
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
super.dispose(); super.dispose();
} }
} }
+42 -6
View File
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts'; static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used'; static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts'; static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count'; static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled'; static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h'; static const _keyScheduleStartHour = 'sched_start_h';
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag = bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0; int _dailyOpenCount = 0;
int _lastAppSessionMinutes = 5;
// Scheduled Blocking runtime // Scheduled Blocking runtime
bool _scheduleEnabled = false; bool _scheduleEnabled = false;
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0; int _schedEndMin = 0;
List<FocusSchedule> _schedules = []; List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false; bool _lastScheduleState = false;
bool _scheduleNotificationShown = false; // Track if schedule notification was shown bool _scheduleNotificationShown =
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts) 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 bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0; int _cachedRemainingSessionSeconds = 0;
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today. /// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount; int get dailyOpenCount => _dailyOpenCount;
int get lastAppSessionMinutes => _lastAppSessionMinutes;
// Scheduled Blocking Getters // Scheduled Blocking Getters
bool get scheduleEnabled => _scheduleEnabled; bool get scheduleEnabled => _scheduleEnabled;
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs); _appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
} }
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false; _appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0; final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 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 (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) { if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true; _appSessionExpiredFlag = true;
changed = true;
} }
changed = true;
} }
if (isCooldownActive) { if (isCooldownActive) {
@@ -422,7 +428,8 @@ class SessionManager extends ChangeNotifier {
_sessionEndNotificationShown = true; _sessionEndNotificationShown = true;
// Check if user wants session end notifications // 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) { if (notifySessionEnd) {
NotificationService().showNotification( NotificationService().showNotification(
@@ -448,12 +455,20 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds); final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed)); _sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true; _isSessionActive = true;
_sessionEndNotificationShown = false; // Reset notification flag for new session _sessionEndNotificationShown =
false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch); _prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners(); notifyListeners();
return true; return true;
} }
/// Temporarily increase the daily limit by [minutes] (for ad rewards).
void addBonusDailyMinutes(int minutes) {
_dailyLimitSeconds += minutes * 60;
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
void endSession() { void endSession() {
if (!_isSessionActive) return; if (!_isSessionActive) return;
// Don't show notification when user manually ends the session // Don't show notification when user manually ends the session
@@ -474,6 +489,13 @@ class SessionManager extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Whether the user needs to go through the Effort Friction gate
/// before starting a reel session.
bool needsEffortFrictionGate(bool effortModeEnabled, int creditBalance) {
if (!effortModeEnabled) return false;
return creditBalance <= 0;
}
// App session API // App session API
/// Start an app session of [minutes] (160). /// Start an app session of [minutes] (160).
@@ -482,12 +504,26 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end; _appSessionEnd = end;
_appSessionExpiredFlag = false; _appSessionExpiredFlag = false;
_appExtensionUsed = false; _appExtensionUsed = false;
_lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch); _prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false); _prefs?.setBool(_keyAppSessionExtUsed, false);
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners(); notifyListeners();
} }
/// Extend the app session by 10 minutes. Only works once. /// Extend the app session by 10 minutes. Only works once.
/// Increase daily limit by [minutes] and return whether it succeeded.
bool increaseDailyLimit(int minutes) {
final current = _dailyLimitSeconds;
final added = minutes * 60;
_dailyLimitSeconds = (current + added).clamp(0, 7200); // max 2 hours
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
_dailyUsedSeconds = 0; // reset used counter so they can use the new quota
_prefs?.setInt(_keyDailyUsedSeconds, 0);
notifyListeners();
return true;
}
bool extendAppSession() { bool extendAppSession() {
if (_appExtensionUsed) return false; if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now(); final base = _appSessionEnd ?? DateTime.now();
+465 -55
View File
@@ -1,15 +1,20 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Stores and retrieves all user-configurable app settings. /// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier { class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore'; static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels'; static const _keyBlurReels = 'set_blur_reels';
static const _keyTapToUnblur = 'set_tap_to_unblur'; static const _keyTapToUnblur = 'set_tap_to_unblur';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate'; static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge'; 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 _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs'; static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings'; static const _keyShowInstaSettings = 'set_show_insta_settings';
@@ -18,12 +23,26 @@ class SettingsService extends ChangeNotifier {
// Focus / playback // Focus / playback
static const _keyBlockAutoplay = 'block_autoplay'; 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 // Grayscale mode - now supports multiple schedules
static const _keyGrayscaleEnabled = 'grayscale_enabled'; static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules'; static const _keyGrayscaleSchedules = 'grayscale_schedules';
// Content filtering / UI hiding // Content filtering / UI hiding
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts'; static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts'; static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideShopTab = 'hide_shop_tab'; static const _keyHideShopTab = 'hide_shop_tab';
@@ -32,13 +51,28 @@ class SettingsService extends ChangeNotifier {
static const _keyMinimalModeEnabled = 'minimal_mode_enabled'; static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Minimal mode state tracking for smart restore // Minimal mode state tracking for smart restore
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels'; static const _keyMinimalModePrevDisableReels =
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore'; 'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore'; 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 // Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled'; static const _keyReelsHistoryEnabled = 'reels_history_enabled';
// Adsterra fallback
static const _keyAdsterraZoneUrl = 'adsterra_zone_url';
static const _keyAdsterraAdCode = 'adsterra_ad_code';
// Startup page
static const _keyStartupPage = 'startup_page';
// Effort Friction Mode
static const _keyEffortFrictionEnabled = 'effort_friction_enabled';
// Privacy keys // Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links'; static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms'; static const _keyNotifyDMs = 'set_notify_dms';
@@ -46,6 +80,14 @@ class SettingsService extends ChangeNotifier {
static const _keyNotifySessionEnd = 'set_notify_session_end'; static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent'; 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; SharedPreferences? _prefs;
bool _blurExplore = true; bool _blurExplore = true;
@@ -54,19 +96,33 @@ class SettingsService extends ChangeNotifier {
bool _requireLongPress = true; bool _requireLongPress = true;
bool _showBreathGate = true; bool _showBreathGate = true;
bool _requireWordChallenge = true; bool _requireWordChallenge = true;
int _breathGateSeconds = 10;
int _wordChallengeCount = 30;
bool _enableTextSelection = false; bool _enableTextSelection = false;
bool _showInstaSettings = true; bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true; bool _blockAutoplay = true;
bool _grayscaleEnabled = false; bool _videoDownloadEnabled = false;
bool _hideSuggestedPosts = false;
// Grayscale schedules - list of {enabled, startTime, endTime} // FocusGram v2 overlay toggles
// startTime and endTime are in format "HH:MM" 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;
List<Map<String, dynamic>> _grayscaleSchedules = []; List<Map<String, dynamic>> _grayscaleSchedules = [];
bool _hideSponsoredPosts = false; // Content filtering / UI hiding
bool _hideLikeCounts = false; bool _hideLikeCounts = false;
bool _hideFollowerCounts = false; bool _hideFollowerCounts = false;
bool _hideShopTab = false; bool _hideShopTab = false;
@@ -74,12 +130,14 @@ class SettingsService extends ChangeNotifier {
// These are now controlled internally by minimal mode // These are now controlled internally by minimal mode
bool _disableReelsEntirely = false; bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false; bool _disableExploreEntirely = false;
bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false; bool _minimalModeEnabled = false;
// Tracking for smart restore // Tracking for smart restore
bool _prevDisableReels = false; bool _prevDisableReels = false;
bool _prevDisableExplore = false; bool _prevDisableExplore = false;
bool _prevBlurExplore = false; bool _prevBlurExplore = false;
bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true; bool _reelsHistoryEnabled = true;
@@ -90,6 +148,18 @@ class SettingsService extends ChangeNotifier {
bool _notifySessionEnd = false; bool _notifySessionEnd = false;
bool _notifyPersistent = false; bool _notifyPersistent = false;
// Focus mode settings
bool _effortFrictionEnabled = true;
String _startupPage = 'home'; // home, following, favorites, direct
String _adsterraZoneUrl = '';
String _adsterraAdCode = '';
bool _ghostMode = false;
bool _noAds = false;
bool _noStories = false;
bool _noReels = false;
bool _noAutoplay = false;
bool _noDMs = false;
List<String> _enabledTabs = [ List<String> _enabledTabs = [
'Home', 'Home',
'Search', 'Search',
@@ -105,12 +175,28 @@ class SettingsService extends ChangeNotifier {
bool get requireLongPress => _requireLongPress; bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate; bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge; bool get requireWordChallenge => _requireWordChallenge;
int get breathGateSeconds => _breathGateSeconds;
int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection; bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings; bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs; List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun; bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode; bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay; 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 notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity; bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd; bool get notifySessionEnd => _notifySessionEnd;
@@ -119,14 +205,39 @@ class SettingsService extends ChangeNotifier {
bool get grayscaleEnabled => _grayscaleEnabled; bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules; List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts; bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts; bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab; bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get effortFrictionEnabled => _effortFrictionEnabled;
String get startupPage => _startupPage;
String get startupUrl {
switch (_startupPage) {
case 'following':
return 'https://www.instagram.com/?variant=following';
case 'favorites':
return 'https://www.instagram.com/?variant=favorites';
case 'direct':
return 'https://www.instagram.com/direct/inbox/';
default:
return 'https://www.instagram.com/';
}
}
String get adsterraZoneUrl => _adsterraZoneUrl;
String get adsterraAdCode => _adsterraAdCode;
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 // These are now controlled by minimal mode only
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely; bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely; bool get disableExploreEntirely => _disableExploreEntirely;
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled; bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled; bool get reelsHistoryEnabled => _reelsHistoryEnabled;
@@ -149,7 +260,8 @@ class SettingsService extends ChangeNotifier {
if (startParts.length != 2 || endParts.length != 2) continue; 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]); final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
// Handle overnight schedules (e.g., 21:00 to 06:00) // Handle overnight schedules (e.g., 21:00 to 06:00)
@@ -182,25 +294,47 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true; _requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true; _showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? 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; _enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true; _showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? 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;
// Load grayscale schedules // 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 toggle + schedules
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules); final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) { if (schedulesJson != null) {
try { try {
_grayscaleSchedules = List<Map<String, dynamic>>.from( _grayscaleSchedules = List<Map<String, dynamic>>.from(
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e)) (jsonDecode(schedulesJson) as List).map(
(e) => Map<String, dynamic>.from(e),
),
); );
} catch (_) { } catch (_) {
_grayscaleSchedules = []; _grayscaleSchedules = [];
} }
} }
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false; _hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false; _hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false; _hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
@@ -209,16 +343,37 @@ class SettingsService extends ChangeNotifier {
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false; _minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore // Load previous states for smart restore
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false; _prevDisableReels =
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false; _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore =
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false; _prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
_prevBlockHomeFeedScroll =
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
// These are now internal states, not user-facing settings // These are now internal states, not user-facing settings
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false; _disableReelsEntirely =
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false; _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; _reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_effortFrictionEnabled =
_prefs!.getBool(_keyEffortFrictionEnabled) ?? true;
_startupPage = _prefs!.getString(_keyStartupPage) ?? 'home';
_adsterraZoneUrl = _prefs!.getString(_keyAdsterraZoneUrl) ?? '';
_adsterraAdCode = _prefs!.getString(_keyAdsterraAdCode) ?? '';
_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; _sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false; _notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false; _notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
@@ -245,12 +400,12 @@ class SettingsService extends ChangeNotifier {
Future<void> setBlurExplore(bool v) async { Future<void> setBlurExplore(bool v) async {
_blurExplore = v; _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); await _prefs?.setBool(_keyBlurExplore, v);
if (_minimalModeEnabled) {
await _checkAndAutoDisableMinimalMode();
}
notifyListeners(); notifyListeners();
} }
@@ -289,6 +444,33 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setBreathGateSeconds(int seconds) async {
final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
// Defer notifyListeners to after the current frame to avoid
// Flutter's 'Dependents.isEmpty' assertion error.
WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners());
}
Future<void> 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<void> setEnableTextSelection(bool v) async { Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v; _enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v); await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -307,13 +489,29 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Extras (Phase 2)
Future<void> setVideoDownloadEnabled(bool v) async {
_videoDownloadEnabled = v;
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
notifyListeners();
}
Future<void> setHideSuggestedPosts(bool v) async {
_hideSuggestedPosts = v;
await _prefs?.setBool(_keyHideSuggestedPosts, v);
notifyListeners();
}
Future<void> setGrayscaleEnabled(bool v) async { Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v; _grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v); await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners(); notifyListeners();
} }
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async { Future<void> setGrayscaleSchedules(
List<Map<String, dynamic>> schedules,
) async {
_grayscaleSchedules = schedules; _grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules)); await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners(); notifyListeners();
@@ -321,14 +519,23 @@ class SettingsService extends ChangeNotifier {
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async { Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
_grayscaleSchedules.add(schedule); _grayscaleSchedules.add(schedule);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async { Future<void> updateGrayscaleSchedule(
int index,
Map<String, dynamic> schedule,
) async {
if (index >= 0 && index < _grayscaleSchedules.length) { if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules[index] = schedule; _grayscaleSchedules[index] = schedule;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -336,20 +543,76 @@ class SettingsService extends ChangeNotifier {
Future<void> removeGrayscaleSchedule(int index) async { Future<void> removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) { if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index); _grayscaleSchedules.removeAt(index);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
} }
Future<void> setHideSponsoredPosts(bool v) async { Future<void> setHideShopTab(bool v) async {
_hideSponsoredPosts = v; _hideShopTab = v;
await _prefs?.setBool(_keyHideSponsoredPosts, v); await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners(); notifyListeners();
} }
Future<void> setHideLikeCounts(bool v) async { // FocusGram v2 overlay setters
_hideLikeCounts = v; Future<void> setV2GhostModeEnabled(bool v) async {
await _prefs?.setBool(_keyHideLikeCounts, v); _v2GhostModeEnabled = v;
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
notifyListeners();
}
Future<void> setV2AdBlockerDomEnabled(bool v) async {
_v2AdBlockerDomEnabled = v;
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
notifyListeners();
}
Future<void> setV2ContentHiderEnabled(bool v) async {
_v2ContentHiderEnabled = v;
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
notifyListeners();
}
Future<void> setContentStoriesEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentStories = v;
await _prefs?.setBool(_keyContentStories, v);
notifyListeners();
}
Future<void> setContentPostsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentPosts = v;
await _prefs?.setBool(_keyContentPosts, v);
notifyListeners();
}
Future<void> setContentReelsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentReels = v;
await _prefs?.setBool(_keyContentReels, v);
notifyListeners();
}
Future<void> setContentSuggestedEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentSuggested = v;
await _prefs?.setBool(_keyContentSuggested, v);
notifyListeners(); notifyListeners();
} }
@@ -359,62 +622,138 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
/// Setter for internal disable reels state (used by minimal mode submenu) /// Setter for internal disable reels state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableReelsEntirelyInternal(bool v) async { Future<void> setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v; _disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v); await _prefs?.setBool('internal_disable_reels_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for internal disable explore state (used by minimal mode submenu) /// Setter for internal disable explore state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableExploreEntirelyInternal(bool v) async { Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v; _disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v); await _prefs?.setBool('internal_disable_explore_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
Future<void> 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<void> _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 /// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async { Future<void> setMinimalModeEnabled(bool v) async {
if (v) { 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; _prevDisableReels = _disableReelsEntirely;
_prevDisableExplore = _disableExploreEntirely; _prevDisableExplore = _disableExploreEntirely;
_prevBlurExplore = _blurExplore; _prevBlurExplore = _blurExplore;
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels); await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore); await _prefs?.setBool(
_keyMinimalModePrevDisableExplore,
_prevDisableExplore,
);
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore); await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
await _prefs?.setBool(
_keyMinimalModePrevBlockHomeFeedScroll,
_prevBlockHomeFeedScroll,
);
// Enable all minimal mode settings
_minimalModeEnabled = true; _minimalModeEnabled = true;
_disableReelsEntirely = true; _disableReelsEntirely = true;
_disableExploreEntirely = 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(_keyMinimalModeEnabled, true);
await _prefs?.setBool('internal_disable_reels_entirely', true); await _prefs?.setBool('internal_disable_reels_entirely', true);
await _prefs?.setBool('internal_disable_explore_entirely', true); await _prefs?.setBool('internal_disable_explore_entirely', true);
await _prefs?.setBool('internal_block_home_feed_scroll', true);
await _prefs?.setBool(_keyBlurExplore, true); await _prefs?.setBool(_keyBlurExplore, true);
} else { } 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; _minimalModeEnabled = false;
// Simply restore to the states that were saved BEFORE minimal mode was enabled
_disableReelsEntirely = _prevDisableReels; _disableReelsEntirely = _prevDisableReels;
_disableExploreEntirely = _prevDisableExplore; _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; _blurExplore = _prevBlurExplore;
// Save the restored states
await _prefs?.setBool(_keyMinimalModeEnabled, false); await _prefs?.setBool(_keyMinimalModeEnabled, false);
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely); await _prefs?.setBool(
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely); '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); 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(); notifyListeners();
} }
@@ -441,24 +780,95 @@ class SettingsService extends ChangeNotifier {
Future<void> setNotifyDMs(bool v) async { Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v; _notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v); await _prefs?.setBool(_keyNotifyDMs, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifyActivity(bool v) async { Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v; _notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v); await _prefs?.setBool(_keyNotifyActivity, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifySessionEnd(bool v) async { Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v; _notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v); await _prefs?.setBool(_keyNotifySessionEnd, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifyPersistent(bool v) async { Future<void> setNotifyPersistent(bool v) async {
_notifyPersistent = v; _notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v); await _prefs?.setBool(_keyNotifyPersistent, v);
if (v) {
await NotificationService().requestPermissionsNow();
} else {
await NotificationService().cancelPersistentNotification(id: 5001);
}
notifyListeners();
}
// Startup page
Future<void> setStartupPage(String page) async {
_startupPage = page;
await _prefs?.setString(_keyStartupPage, page);
notifyListeners();
}
// Adsterra zone config
Future<void> setAdsterraZoneUrl(String url) async {
_adsterraZoneUrl = url;
await _prefs?.setString(_keyAdsterraZoneUrl, url);
notifyListeners();
}
Future<void> setAdsterraAdCode(String code) async {
_adsterraAdCode = code;
await _prefs?.setString(_keyAdsterraAdCode, code);
notifyListeners();
}
// Focus mode settings
Future<void> setEffortFrictionEnabled(bool v) async {
_effortFrictionEnabled = v;
await _prefs?.setBool(_keyEffortFrictionEnabled, v);
notifyListeners();
}
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setNoAds(bool v) async {
_noAds = v;
await _prefs?.setBool(_keyNoAds, v);
notifyListeners();
}
Future<void> setNoStories(bool v) async {
_noStories = v;
await _prefs?.setBool(_keyNoStories, v);
notifyListeners();
}
Future<void> setNoReels(bool v) async {
_noReels = v;
await _prefs?.setBool(_keyNoReels, v);
notifyListeners();
}
Future<void> setNoAutoplay(bool v) async {
_noAutoplay = v;
await _prefs?.setBool(_keyNoAutoplay, v);
notifyListeners();
}
Future<void> setNoDMs(bool v) async {
_noDMs = v;
await _prefs?.setBool(_keyNoDMs, v);
notifyListeners(); notifyListeners();
} }
+123
View File
@@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// A saved page that can be viewed offline via WebView cache.
/// No API calls just bookmarks URLs you've already visited
/// so the WebView's built-in cache (`LOAD_CACHE_ELSE_NETWORK`)
/// can serve them when offline.
class SavedPage {
final String id;
final String url;
final String title;
final DateTime savedAt;
final String? htmlContent; // captured page HTML for offline viewing
const SavedPage({
required this.id,
required this.url,
required this.title,
required this.savedAt,
this.htmlContent,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'savedAt': savedAt.toIso8601String(),
if (htmlContent != null) 'html': htmlContent,
};
factory SavedPage.fromJson(Map<String, dynamic> json) => SavedPage(
id: json['id'] as String? ?? '',
url: json['url'] as String? ?? '',
title: json['title'] as String? ?? 'Instagram',
savedAt:
DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(),
htmlContent: json['html'] as String?,
);
}
/// Manages saved pages for offline viewing.
///
/// How it works:
/// 1. The WebView already has `cacheMode: LOAD_CACHE_ELSE_NETWORK`
/// 2. When you visit a page online, the WebView caches it automatically
/// 3. This service just bookmarks URLs so you can navigate to them offline
/// 4. The WebView serves the cached version when there's no internet
///
/// No Instagram API needed. No content downloading. Just cache + bookmarks.
class SnapshotService extends ChangeNotifier {
static const String _hiveBox = 'saved_pages';
late Box _box;
List<SavedPage> _savedPages = [];
List<SavedPage> get savedPages => List.unmodifiable(_savedPages);
int get totalSaved => _savedPages.length;
Future<void> init() async {
_box = await Hive.openBox(_hiveBox);
_loadFromCache();
}
void _loadFromCache() {
try {
final raw = _box.get('page_list') as String?;
if (raw != null) {
final decoded = jsonDecode(raw) as List;
_savedPages =
decoded
.map((e) => SavedPage.fromJson(e as Map<String, dynamic>))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
}
} catch (_) {}
}
Future<void> _saveToCache() async {
final json = jsonEncode(_savedPages.map((e) => e.toJson()).toList());
await _box.put('page_list', json);
}
/// Save a page. Optionally pass [htmlContent] captured from the WebView.
Future<void> savePage(
String url, {
String title = 'Instagram',
String? htmlContent,
}) async {
if (url.isEmpty) return;
// Avoid duplicates
if (_savedPages.any((p) => p.url == url)) return;
final page = SavedPage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
url: url,
title: title,
savedAt: DateTime.now(),
htmlContent: htmlContent,
);
_savedPages.insert(0, page);
await _saveToCache();
notifyListeners();
}
/// Remove a saved page.
Future<void> deletePage(String id) async {
_savedPages.removeWhere((p) => p.id == id);
await _saveToCache();
notifyListeners();
}
/// Remove all saved pages.
Future<void> deleteAll() async {
_savedPages.clear();
await _saveToCache();
notifyListeners();
}
/// Get the total count.
int get count => _savedPages.length;
}
+1 -1
View File
@@ -517,7 +517,7 @@ class DisciplineChallenge {
]; ];
/// Shows the word challenge dialog. Returns true if successful. /// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 15}) async { static Future<bool> show(BuildContext context, {int count = 30}) async {
final list = List<String>.from(_words)..shuffle(); final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' '); final challenge = list.take(count).join(' ');
final controller = TextEditingController(); final controller = TextEditingController();
@@ -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<String, String> _cache = {};
ScriptEngineV2Overlay({required this.controller, required this.prefs});
Future<void> 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<void> 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<void> 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<void> _pushContentFlagsIfNeeded() async {
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
final contentFlags = <String, bool>{
'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<String?> _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;
}
}
}
@@ -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<V2OverlayInstaScript> 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);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Adsterra 300×250 medium rectangle banner.
/// Native-looking container, no "AD" label.
/// Best for in-content placements (settings page, panel).
const String _kMediumRectCode = '''
<script>
atOptions = {
'key' : '99233324430f9128f2b01c30b6eebc20',
'format' : 'iframe',
'height' : 250,
'width' : 300,
'params' : {}
};
</script>
<script src="https://www.highperformanceformat.com/99233324430f9128f2b01c30b6eebc20/invoke.js"></script>
''';
class MediumRectBanner extends StatelessWidget {
const MediumRectBanner({super.key});
String get _html =>
'''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
background:transparent;
display:flex; align-items:center; justify-content:center;
}
iframe { border:none; max-width:100%; }
</style>
</head>
<body>$_kMediumRectCode</body>
</html>
''';
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 270),
decoration: BoxDecoration(
color: Colors.transparent,
border: Border(
top: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
),
),
child: SizedBox(
height: 250,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: _html,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
// Adsterra banner codes
// 320×50 standard mobile banner, used at bottom of screens
const String _kBanner320x50 = '''
<script>
atOptions = {
'key' : 'd00c3602dafbd199f16d4a6426156cd6',
'format' : 'iframe',
'height' : 50,
'width' : 320,
'params' : {}
};
</script>
<script src="https://www.highperformanceformat.com/d00c3602dafbd199f16d4a6426156cd6/invoke.js"></script>
''';
/// A small 320×50 banner that loads natively inside the app.
/// Place at the bottom of screens.
class NativeAdBanner extends StatelessWidget {
final double height;
final String? customCode;
const NativeAdBanner({super.key, this.height = 60, this.customCode});
String get _html {
final code = customCode ?? _kBanner320x50;
return '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
background:transparent;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
}
iframe { border:none; max-width:100%; }
</style>
</head>
<body>$code</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
return Container(
// Subtle native look barely visible border, no "AD" label
decoration: BoxDecoration(
color: Colors.transparent,
border: Border(
top: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
),
),
child: SizedBox(
height: height,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: _html,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
),
),
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../services/remote_popup_service.dart';
class RemotePopupHandler {
static Future<void> checkAndShow(BuildContext context) async {
final popup = await RemotePopupService.fetchPopup();
if (popup == null) return;
final shouldShow = await RemotePopupService.shouldShow(popup);
if (!shouldShow) return;
await RemotePopupService.markShown(popup);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: true,
builder: (_) {
return AlertDialog(
title: Text(popup.title),
content: Text(popup.body),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(popup.buttonText),
),
],
);
},
);
}
}
-45
View File
@@ -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
+134 -22
View File
@@ -37,10 +37,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: app_settings name: app_settings
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795" sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.1" version: "7.0.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -213,10 +213,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_chart name: fl_chart
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.69.2" version: "0.71.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -290,10 +290,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.1" version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -342,6 +342,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.33" version: "2.0.33"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -372,10 +420,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.2" version: "8.1.0"
gtk: gtk:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +432,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
hive:
dependency: "direct main"
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hive_flutter:
dependency: "direct main"
description:
name: hive_flutter
sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc
url: "https://pub.dev"
source: hosted
version: "1.1.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -488,6 +552,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -528,6 +600,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -540,10 +652,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -556,10 +668,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -596,10 +708,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.3.1" version: "9.0.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -668,18 +780,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.1" version: "11.4.0"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.0.1" version: "12.1.0"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
@@ -764,10 +876,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4" version: "2.5.5"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
@@ -865,10 +977,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.11"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+28 -12
View File
@@ -2,7 +2,7 @@ name: focusgram
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally." description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
publish_to: 'none' publish_to: 'none'
version: 1.1.0 version: 2.1.0
environment: environment:
sdk: ^3.10.7 sdk: ^3.10.7
@@ -11,11 +11,11 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely # WebView engine
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
# Local key-value persistence — latest stable # Local key-value persistence — latest stable
shared_preferences: ^2.5.4 shared_preferences: ^2.5.5
# Date/time formatting for daily resets — latest stable # Date/time formatting for daily resets — latest stable
intl: ^0.20.2 intl: ^0.20.2
@@ -28,26 +28,36 @@ dependencies:
# URL launcher for About page links — latest stable # URL launcher for About page links — latest stable
url_launcher: ^6.3.2 url_launcher: ^6.3.2
package_info_plus: ^8.1.2 package_info_plus: ^9.0.0
# Handling Instagram deep links — latest stable # Handling Instagram deep links — latest stable
app_links: ^6.3.2 app_links: ^6.4.1
# Open system settings — latest stable # Open system settings — latest stable
app_settings: ^6.1.1 app_settings: ^7.0.0
google_fonts: ^8.0.2 google_fonts: ^8.1.0
http: ^1.3.0 http: ^1.6.0
permission_handler: ^12.0.1 permission_handler: ^11.4.0
# Image/file picker for story uploads on Android # Image/file picker for story uploads on Android
image_picker: ^1.1.2 image_picker: ^1.2.0
flutter_windowmanager_plus: ^1.0.1 flutter_windowmanager_plus: ^1.0.1
# Charts for on-device screen time dashboard (MIT) # Charts for on-device screen time dashboard (MIT)
fl_chart: ^0.69.0 fl_chart: ^0.71.0
# ── Local storage ──────────────────────────────────────────
# google_mobile_ads removed — switched to Adsterra only
# firebase removed — level data stored in Hive locally
hive: ^2.2.3
hive_flutter: ^1.1.0
flutter_secure_storage: ^9.2.4
local_auth: ^2.3.0
# connectivity_plus, workmanager, flutter_background_service removed —
# unused after offline snapshots pivoted to WebView cache.
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.14.4
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -55,6 +65,12 @@ flutter:
assets: assets:
- assets/images/focusgram.png - assets/images/focusgram.png
- assets/images/focusgram.ico - 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: flutter_launcher_icons:
android: true android: true
@@ -0,0 +1,65 @@
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 = <Uri>[];
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 = <Uri>[];
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 = <Uri>[];
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 = <Uri>[];
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 = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
});
}
+90
View File
@@ -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<String> sources = [];
@override
Future<void> 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')));
},
);
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/app_lock_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('AppLockService — PIN verification', () {
test('verifyPin returns true for correct PIN', () async {
final service = AppLockService();
await service.init();
await service.setPin('1234', forAppWide: true);
final valid = await service.verifyPin('1234', forAppWide: true);
expect(valid, isTrue);
});
test('verifyPin returns false for wrong PIN', () async {
final service = AppLockService();
await service.init();
await service.setPin('1234', forAppWide: true);
final valid = await service.verifyPin('0000', forAppWide: true);
expect(valid, isFalse);
});
test('verifyPin with forAppWide:false checks messages PIN', () async {
final service = AppLockService();
await service.init();
await service.setPin('5678', forAppWide: false);
final valid = await service.verifyPin('5678', forAppWide: false);
expect(valid, isTrue);
});
test('onUnlocked resets lock state', () async {
final service = AppLockService();
await service.init();
await service.setPin('1234', forAppWide: true);
service.onBackgrounded();
expect(service.shouldLockOnResume, isTrue);
service.onUnlocked();
expect(service.shouldLockOnResume, isFalse);
expect(service.isShowingLock, isFalse);
});
});
group('AppLockService — PIN management', () {
test('hasPin returns true after PIN is set', () async {
final service = AppLockService();
await service.init();
expect(service.hasPin, isFalse);
await service.setPin('1234', forAppWide: true);
expect(service.hasPin, isTrue);
});
test('verifyPin returns false when no PIN is set', () async {
final service = AppLockService();
await service.init();
final valid = await service.verifyPin('1234', forAppWide: true);
expect(valid, isFalse);
});
});
}
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:focusgram/focus_settings.dart';
import 'package:focusgram/scripts/focus_scripts.dart';
void main() {
group('FocusSettings — Field cleanup', () {
test(
'only ghostMode remains (fullDmGhost and storyGhost removed)',
() async {
const settings = FocusSettings(ghostMode: true);
expect(settings.ghostMode, isTrue);
expect(settings.noAds, isTrue);
expect(settings.noStories, isFalse);
expect(settings.noReels, isFalse);
expect(settings.noAutoplay, isFalse);
expect(settings.noDMs, isFalse);
// Verify fullDmGhost and storyGhost are NOT fields anymore
// (these would be compile errors if they existed)
},
);
test('default ghostMode is false', () async {
const settings = FocusSettings();
expect(settings.ghostMode, isFalse);
});
});
group('buildUserScripts — DM Ghost injection', () {
test('injects kFullDmGhostJS when ghostMode is true', () async {
final scripts = buildUserScripts(const FocusSettings(ghostMode: true));
expect(scripts.length, equals(1));
expect(
scripts[0].injectionTime,
equals(UserScriptInjectionTime.AT_DOCUMENT_START),
);
// Verify the comprehensive Full DM ghost JS is injected
final src = scripts[0].source;
expect(src, contains('__fgFullDmGhost=true'));
expect(src, contains('__fgFullDmGhostPatched'));
expect(src, contains('shouldBlockDmPath'));
expect(src, contains('DM_URLS'));
expect(src, contains('DM_OPS'));
expect(src, contains('serviceWorker'));
expect(src, contains('sendBeacon'));
});
test('does NOT inject ghost scripts when ghostMode is false', () async {
final scripts = buildUserScripts(const FocusSettings(ghostMode: false));
// Should have no DOCUMENT_START scripts
final startScripts = scripts.where(
(s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START,
);
for (final s in startScripts) {
expect(s.source.contains('__fgFullDmGhost'), isFalse);
}
});
test('injects noAutoplay alongside DM Ghost', () async {
final scripts = buildUserScripts(
const FocusSettings(ghostMode: true, noAutoplay: true),
);
final startScripts = scripts.where(
(s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START,
);
expect(startScripts.length, equals(1));
expect(startScripts.first.source, contains('__fgFullDmGhost=true'));
expect(startScripts.first.source, contains('document.addEventListener'));
});
test(
'injects hideStoryTray at DOCUMENT_END when noStories is true',
() async {
final scripts = buildUserScripts(const FocusSettings(noStories: true));
final endScripts = scripts.where(
(s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END,
);
expect(endScripts.length, equals(1));
expect(
endScripts.first.source,
contains('[data-pagelet="story_tray"]'),
);
},
);
});
}
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:focusgram/focus_settings.dart';
import 'package:focusgram/scripts/focus_scripts.dart';
void main() {
group('FocusSettings — Field cleanup', () {
test(
'only ghostMode remains (fullDmGhost and storyGhost removed)',
() async {
const settings = FocusSettings(ghostMode: true);
expect(settings.ghostMode, isTrue);
expect(settings.noAds, isTrue);
expect(settings.noStories, isFalse);
expect(settings.noReels, isFalse);
expect(settings.noAutoplay, isFalse);
expect(settings.noDMs, isFalse);
// Verify fullDmGhost and storyGhost are NOT fields anymore
// (these would be compile errors if they existed)
},
);
test('default ghostMode is false', () async {
const settings = FocusSettings();
expect(settings.ghostMode, isFalse);
});
});
group('buildUserScripts — Ghost mode injection', () {
test('injects kFullDmGhostJS when ghostMode is true', () async {
final scripts = buildUserScripts(const FocusSettings(ghostMode: true));
// Should have exactly 1 DOCUMENT_START script
expect(scripts.length, equals(1));
expect(
scripts[0].injectionTime,
equals(UserScriptInjectionTime.AT_DOCUMENT_START),
);
// The script source should contain the Full DM ghost code
expect(scripts[0].source, contains('__fgFullDmGhost=true'));
expect(scripts[0].source, contains('__fgFullDmGhostPatched'));
});
test('does NOT inject ghost scripts when ghostMode is false', () async {
final scripts = buildUserScripts(const FocusSettings(ghostMode: false));
// Should have no start scripts (ghostMode is the only start-level script)
// unless other features like noAutoplay are also false
if (scripts.isEmpty) return;
// If scripts exist (e.g. noAutoplay), verify ghost mode NOT in them
for (final s in scripts) {
expect(s.source.contains('__fgFullDmGhost'), isFalse);
}
});
test('injects noAutoplay when set', () async {
final scripts = buildUserScripts(
const FocusSettings(ghostMode: true, noAutoplay: true),
);
// Should have 1 DOCUMENT_START script combining ghost + autoplay
final startScripts = scripts.where(
(s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START,
);
expect(startScripts.length, equals(1));
expect(startScripts.first.source, contains('__fgFullDmGhost=true'));
expect(startScripts.first.source, contains('document.addEventListener'));
});
});
}
@@ -0,0 +1,80 @@
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 — Ghost mode toggle', () {
test('ghostMode defaults to false', () async {
final s = SettingsService();
await s.init();
expect(s.ghostMode, isFalse);
});
test('ghostMode toggle persists and loads on restart', () async {
final s = SettingsService();
await s.init();
await s.setGhostMode(true);
expect(s.ghostMode, isTrue);
// Simulate restart by creating a new instance with saved prefs
final s2 = SettingsService();
await s2.init();
expect(s2.ghostMode, isTrue);
});
test('ghostMode toggles off correctly', () async {
final s = SettingsService();
await s.init();
await s.setGhostMode(true);
expect(s.ghostMode, isTrue);
await s.setGhostMode(false);
expect(s.ghostMode, isFalse);
});
});
group('SettingsService — Grayscale persistence', () {
test('grayscaleEnabled defaults to false', () async {
final s = SettingsService();
await s.init();
expect(s.grayscaleEnabled, isFalse);
});
test('setGrayscaleEnabled persists and isActiveNow returns true', () async {
final s = SettingsService();
await s.init();
await s.setGrayscaleEnabled(true);
expect(s.grayscaleEnabled, isTrue);
expect(s.isGrayscaleActiveNow, isTrue);
// Simulate restart
final s2 = SettingsService();
await s2.init();
expect(s2.grayscaleEnabled, isTrue);
});
test('isGrayscaleActiveNow returns true when toggle is on', () async {
final s = SettingsService();
await s.init();
await s.setGrayscaleEnabled(true);
expect(s.isGrayscaleActiveNow, isTrue);
});
test(
'isGrayscaleActiveNow returns false when toggle off and no schedules',
() async {
final s = SettingsService();
await s.init();
expect(s.isGrayscaleActiveNow, isFalse);
},
);
});
}
@@ -0,0 +1,249 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
// The regex patterns used in shouldInterceptRequest for DM Ghost blocking.
// These are the same patterns embedded in main_webview_page.dart.
final seenPattern = RegExp(
r'/api/v1/media/[\w-]+/seen/|'
r'/api/v1/stories/reel/seen/|'
r'/api/v1/direct_v2/threads/[\w-]+/seen/|'
r'/api/v1/direct_v2/visual_message/[\w-]+/seen/|'
r'/api/v1/live/[\w-]+/comment/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/|'
r'/api/v1/direct_v2/mark_item_seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/|'
r'/api/v1/direct_v2/visual_thread/[^/]+/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/|'
r'/api/v1/live/[^/]+/join/|'
r'/api/v1/live/[^/]+/get_join_requests/|'
r'/api/v1/media/seen/|'
r'/api/v1/feed/viewed_story/|'
r'/api/v1/feed/reels_tray/seen/|'
r'/api/v1/qe/|'
r'/api/v1/launcher/sync/|'
r'/api/v1/logging/|'
r'/api/v1/fb_onetap_logging/|'
r'/ajax/bz|'
r'/ajax/logging/|'
r'/api/v1/stats/|'
r'/api/v1/fbanalytics/',
);
group('DM Ghost — Seen endpoint pattern matching', () {
// Story seen endpoints
test('blocks /api/v1/media/{id}/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/media/12345/seen/',
),
isTrue,
);
});
test('blocks /api/v1/stories/reel/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/stories/reel/seen/',
),
isTrue,
);
});
test('blocks /api/v1/feed/viewed_story/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/feed/viewed_story/',
),
isTrue,
);
});
test('blocks /api/v1/feed/reels_tray/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/feed/reels_tray/seen/',
),
isTrue,
);
});
// DM read receipts
test('blocks /api/v1/direct_v2/threads/{id}/mark_item_seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/threads/abc123/mark_item_seen/',
),
isTrue,
);
});
test('blocks /api/v1/direct_v2/mark_item_seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/mark_item_seen/',
),
isTrue,
);
});
test('blocks /api/v1/direct_v2/threads/{id}/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/threads/abc123/seen/',
),
isTrue,
);
});
test('blocks /api/v1/direct_v2/visual_message/{id}/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/visual_message/xyz/seen/',
),
isTrue,
);
});
// Ephemeral / visual seen
test(
'blocks /api/v1/direct_v2/threads/{id}/items/{id}/mark_visual_item_seen/',
() {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/threads/abc/items/def/mark_visual_item_seen/',
),
isTrue,
);
},
);
test('blocks /api/v1/direct_v2/visual_thread/{id}/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/visual_thread/abc/seen/',
),
isTrue,
);
});
// Audio seen
test('blocks /api/v1/direct_v2/threads/{id}/items/{id}/mark_audio_seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/threads/abc/items/def/mark_audio_seen/',
),
isTrue,
);
});
// Live
test('blocks /api/v1/live/{id}/join/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/live/abc123/join/',
),
isTrue,
);
});
test('blocks /api/v1/live/{id}/get_join_requests/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/live/abc123/get_join_requests/',
),
isTrue,
);
});
test('blocks /api/v1/live/{id}/comment/seen/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/live/abc123/comment/seen/',
),
isTrue,
);
});
// Analytics / tracking
test('blocks /api/v1/qe/', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/v1/qe/some_param'),
isTrue,
);
});
test('blocks /api/v1/launcher/sync/', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/v1/launcher/sync/'),
isTrue,
);
});
test('blocks /api/v1/logging/', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/v1/logging/event'),
isTrue,
);
});
test('blocks /api/v1/stats/', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/v1/stats/'),
isTrue,
);
});
test('blocks /api/v1/fbanalytics/', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/fbanalytics/event',
),
isTrue,
);
});
test('blocks /ajax/bz', () {
expect(seenPattern.hasMatch('https://www.instagram.com/ajax/bz'), isTrue);
});
test('blocks /ajax/logging/', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/ajax/logging/'),
isTrue,
);
});
// Should NOT block legitimate endpoints
test('does NOT block normal feed timeline request', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/v1/feed/timeline/'),
isFalse,
);
});
test('does NOT block graphql queries', () {
expect(
seenPattern.hasMatch('https://www.instagram.com/api/graphql'),
isFalse,
);
});
test('does NOT block direct_v2 inbox', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/direct_v2/inbox/',
),
isFalse,
);
});
test('does NOT block user posts', () {
expect(
seenPattern.hasMatch(
'https://www.instagram.com/api/v1/users/12345/posts/',
),
isFalse,
);
});
});
}
+155
View File
@@ -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<String> sources = [];
@override
Future<void> 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);
});
}

Some files were not shown because too many files have changed in this diff Show More