9 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
60 changed files with 7478 additions and 374 deletions
+1 -1
View File
@@ -4,5 +4,5 @@ import re
text = Path("CHANGELOG.md").read_text(encoding="utf-8") 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) m = re.search(r"^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", text, re.M)
if not m: if not m:
raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.0.0") raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.1.0")
print(m.group(1)) print(m.group(1))
+70 -1
View File
@@ -1,4 +1,5 @@
name: Build APK and Create GitHub Release name: Build APK and Create GitHub Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -6,28 +7,35 @@ on:
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading." description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
required: false required: false
type: string type: string
permissions: permissions:
contents: write contents: write
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Java 17 - name: Set up Java 17
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v4 uses: android-actions/setup-android@v4
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
channel: stable channel: stable
cache: true cache: true
- name: Install required Android SDK packages - name: Install required Android SDK packages
shell: bash shell: bash
run: | run: |
@@ -36,9 +44,11 @@ jobs:
"platform-tools" \ "platform-tools" \
"platforms;android-35" \ "platforms;android-35" \
"build-tools;34.0.0" \ "build-tools;34.0.0" \
"build-tools;35.0.0" \ "build-tools;35.0.0"
- name: Get dependencies - name: Get dependencies
run: flutter pub get run: flutter pub get
- name: Resolve version and tag - name: Resolve version and tag
id: meta id: meta
shell: bash shell: bash
@@ -53,21 +63,79 @@ jobs:
TAG="v${VERSION}" TAG="v${VERSION}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Extract release notes from CHANGELOG.md - name: Extract release notes from CHANGELOG.md
shell: bash shell: bash
env: env:
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
run: python3 .github/scripts/get_notes.py 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 - name: Build release APK
run: flutter build apk --release run: flutter build apk --release
- name: Rename APK - name: Rename APK
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: focusgram-apk-${{ steps.meta.outputs.tag }} name: focusgram-apk-${{ steps.meta.outputs.tag }}
path: build/app/outputs/flutter-apk/focusgram-release.apk path: build/app/outputs/flutter-apk/focusgram-release.apk
if-no-files-found: error if-no-files-found: error
- name: Create Git tag - name: Create Git tag
shell: bash shell: bash
env: env:
@@ -82,6 +150,7 @@ jobs:
fi fi
git tag -a "${TAG}" -m "Release ${TAG}" git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}" git push origin "${TAG}"
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
+2 -3
View File
@@ -12,10 +12,9 @@
.swiftpm/ .swiftpm/
migrate_working_dir/ migrate_working_dir/
PRD.md PRD.md
.reasonix/
.agents/ .agents/
TODO.md
v2/FOCUSGRAM_V2_PLAN.md
v2/FocusGram_Feed_Filtering_Reference.docx
# IntelliJ related # IntelliJ related
+15 -16
View File
@@ -1,23 +1,22 @@
## FocusGram 2.0.0 ## FocusGram 2.1.0
### What's new ### What's new
- NEW: Added Media Downloader for downloading images and videos - NEW: Startup Page - choose which page to launch on app launch.
- NEW: Added Ghost Mode - NEW: App lock and DM Lock.
- NEW: Added a toggle for scroll lock in minimal mode - NEW: Bait me button in Focus Control.
- NEW: Added Option to Choose Duration of Mindfulness Gate - NEW: Interactive Level based system for unlocking features.
- NEW: Added ability to customize number of words in typing challenge - NEW: Effort Friction Mode.
- UPDATED: Redesigned Focus Control Flyout - NEW: Strict and fully working Ghost Mode.
- UPDATED: Settings and Reordered items
- UPDATED: Added more time Choices for reels session
- UPDATED: Improved Permission Request invocation in onboarding page.
- UPDATED: Improved Notification Alerts
### Bug fixes ### Bug fixes
- Fixed: back button on homepage didnt exit the app.
- Fixed: Only First image of multiple imaged posts was blurred. - Fixed: Greyscale mode used to turn off when app was restarted.
- FIxed: Couldn't scroll the home feed after enabling minimal mode - 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
- A lof of other Minor fixes . - A lof of other Minor fixes.
+34 -36
View File
@@ -7,7 +7,7 @@
**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)
[![Version](https://img.shields.io/badge/version-2.0.0-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=30)](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'>
@@ -20,15 +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.
> FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo 👉👈. > 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) > [![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://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" /> <img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/assets/images/app-demo.png" />
--- ---
@@ -36,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 blur posts entirely - Disable Explore and blur posts, videos on feed entirely
- Click to unblur feed posts
- Disable Reels entirely - 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
@@ -78,25 +81,27 @@ FocusGram is an Android app that loads the Instagram website with the distractin
## 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
--- ---
## 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. 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)
--- ---
@@ -125,15 +130,6 @@ 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)
### 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
| | | | | |
@@ -151,11 +147,11 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
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.
@@ -168,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.
+3 -5
View File
@@ -45,7 +45,7 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 4
versionName = "2.0.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>
+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

+80 -60
View File
@@ -1,12 +1,36 @@
/** /**
* FocusGram Ghost Mode * FocusGram Ghost Mode (V2 Overlay)
* Injected at DOCUMENT_START — before Instagram's JS loads. * Injected at DOCUMENT_START — before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals. * 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 () { (function () {
'use strict'; 'use strict';
// ─── Seen API patterns ──────────────────────────────────────────────────── // ─── 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 = [ const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//, /\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//, /\/api\/v1\/stories\/reel\/seen\//,
@@ -15,7 +39,6 @@
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//, /\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
]; ];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [ const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//, /\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//, /\/api\/v1\/web\/comments\/add\//,
@@ -25,16 +48,9 @@
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url)); const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url)); const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () => // ─── Fetch override — chains with whatever was there ──────────────────────
new Response(JSON.stringify({ status: 'ok' }), { const _prevFetch = window.fetch;
status: 200, window.fetch = async function (input, init) {
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url = const url =
typeof input === 'string' typeof input === 'string'
? input ? input
@@ -42,17 +58,24 @@
? input.href ? input.href
: input?.url ?? ''; : input?.url ?? '';
// Block seen // DM first-interaction gate
if (isSeen(url)) { if (_blockIfNeeded(url)) {
if (window.GhostChannel) { return new Response(JSON.stringify({ status: 'ok' }), {
window.GhostChannel.postMessage( status: 200, headers: { 'Content-Type': 'application/json' }
JSON.stringify({ type: 'seen_blocked', url }) });
);
}
return fakeOkResponse();
} }
// Intercept activity for local history // 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) { if (isActivity(url) && window.ActivityChannel) {
const body = init?.body; const body = init?.body;
const bodyText = const bodyText =
@@ -66,51 +89,57 @@
); );
} }
return _fetch(input, init); return _prevFetch(input, init);
}; };
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }'; window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ────────────────────────────────────────────── // ─── XHR override — chains ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open; const _prevOpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send; const _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) { XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? ''; this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase(); this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args); return _prevOpen.call(this, method, url, ...args);
}; };
XMLHttpRequest.prototype.send = function (body) { XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) { const url = this._fg_url || '';
// Fire readyState 4 with fake success without actually sending
// DM first-interaction gate
if (_blockIfNeeded(url)) {
const self = this; const self = this;
setTimeout(() => { setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 }); Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 }); Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', { Object.defineProperty(self, 'responseText', { get: () => '{"status":"ok"}' });
get: () => '{"status":"ok"}', Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' });
['readystatechange', 'load'].forEach(function(t) {
try { self.dispatchEvent(new Event(t)); } catch(e) {}
}); });
Object.defineProperty(self, 'response', { }, 5);
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return; return;
} }
return _XHRSend.call(this, body);
// 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) ──────────────────────────── // ─── WebSocket intercept (message-seen via WS) ──────────────────────────
const _WS = window.WebSocket; const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) { function PatchedWebSocket(url, protocols) {
@@ -119,7 +148,6 @@
ws.send = function (data) { ws.send = function (data) {
if (typeof data === 'string') { if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
if ( if (
@@ -130,7 +158,6 @@
return; // drop return; // drop
} }
} catch (_) {} } catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) { if (data.includes('"seen"') && data.includes('"thread_id"')) {
return; return;
} }
@@ -141,7 +168,6 @@
return ws; return ws;
} }
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype; PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING; PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN; PatchedWebSocket.OPEN = _WS.OPEN;
@@ -149,24 +175,18 @@
PatchedWebSocket.CLOSED = _WS.CLOSED; PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket; window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ──────────────────────────────── // ─── Visibility trick — hide "Active Now" ──────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () { window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', { Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden', get: () => 'hidden', configurable: true,
configurable: true,
}); });
Object.defineProperty(document, 'hidden', { Object.defineProperty(document, 'hidden', {
get: () => true, get: () => true, configurable: true,
configurable: true,
}); });
document.dispatchEvent(new Event('visibilitychange')); document.dispatchEvent(new Event('visibilitychange'));
}; };
window.__fgDisableOnlineHide = function () { window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState; delete document.visibilityState;
delete document.hidden; delete document.hidden;
document.dispatchEvent(new Event('visibilitychange')); document.dispatchEvent(new Event('visibilitychange'));
View File
@@ -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,7 +13,7 @@ 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,
@@ -31,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(
@@ -47,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;
@@ -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) {
@@ -34,6 +40,8 @@ class ReelsHistoryEntry {
visitedAt: visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ?? 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,
); );
} }
} }
@@ -71,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();
@@ -89,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];
@@ -104,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);
@@ -74,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');
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
class FocusSettings { class FocusSettings {
final bool ghostMode; // hide read receipts final bool ghostMode; // DM ghost — blocks seen/DM signals comprehensively
final bool noAds; // strip ads and sponsored posts final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray final bool noStories; // hide story tray
final bool noReels; // hide reels tab final bool noReels; // hide reels tab
+74 -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';
@@ -28,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(),
@@ -98,15 +123,18 @@ 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();
@@ -115,17 +143,47 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
}); });
} }
@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();
} }
} }
@@ -134,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) {
+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);
}
}
}
+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();
}
}
+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();
}
}
+94 -60
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import 'ghost_mode_submenu_page.dart';
class ExtrasSettingsPage extends StatelessWidget { class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key}); const ExtrasSettingsPage({super.key});
@@ -10,7 +11,6 @@ class ExtrasSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsService>(); final settings = context.watch<SettingsService>();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
@@ -25,6 +25,10 @@ class ExtrasSettingsPage extends StatelessWidget {
), ),
body: ListView( body: ListView(
children: [ children: [
const _SectionHeader(title: 'STARTUP'),
_LaunchPagePicker(settings: settings),
const SizedBox(height: 8),
const _SectionHeader(title: 'MEDIA'), const _SectionHeader(title: 'MEDIA'),
_SwitchTile( _SwitchTile(
title: 'Download Media (Feed + Reels)', title: 'Download Media (Feed + Reels)',
@@ -37,68 +41,34 @@ class ExtrasSettingsPage extends StatelessWidget {
), ),
const _SectionHeader(title: 'FOCUS'), const _SectionHeader(title: 'FOCUS'),
_SwitchTile( ListTile(
title: 'GHOST MODE', leading: Container(
subtitle: 'Hide seen indicator / read receipts', width: 36,
value: settings.ghostMode, height: 36,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.07), color: settings.ghostMode
borderRadius: BorderRadius.circular(8), ? Colors.purple.withValues(alpha: 0.15)
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)), : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
), ),
child: Row( child: Icon(
crossAxisAlignment: CrossAxisAlignment.start, Icons.visibility_off_rounded,
children: [ color: settings.ghostMode ? Colors.purpleAccent : Colors.grey,
Padding( size: 20,
padding: const EdgeInsets.only(right: 8, top: 2),
child: Icon(
Icons.info_outline,
size: 14,
color: Colors.amber,
),
),
const Expanded(
child: Text(
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
style: TextStyle(fontSize: 11, color: Colors.amber),
),
),
],
), ),
), ),
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()),
),
), ),
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
*/
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -106,12 +76,77 @@ class ExtrasSettingsPage extends StatelessWidget {
} }
} }
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 { class _SwitchTile extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
final bool value; final bool value;
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
const _SwitchTile({ const _SwitchTile({
required this.title, required this.title,
this.subtitle, this.subtitle,
@@ -124,7 +159,7 @@ class _SwitchTile extends StatelessWidget {
return SwitchListTile( return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)), title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12)) ? Text(subtitle!, style: const TextStyle(fontSize: 12))
: null, : null,
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
@@ -135,7 +170,6 @@ class _SwitchTile extends StatelessWidget {
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
final String title; final String title;
const _SectionHeader({required this.title}); const _SectionHeader({required this.title});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
+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,
),
),
),
],
),
),
),
],
),
);
}
}
+94 -14
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 {
@@ -113,20 +115,33 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
), ),
), ),
), ),
_buildFrictionSliderTile( // If quota used up, show earn page instead of slider
context: context, if (sm.dailyRemainingSeconds <= 0)
sm: sm, _buildQuotaExhaustedTile(context, sm)
title: 'Daily Reel Limit', else
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day', _buildFrictionSliderTile(
value: (sm.dailyLimitSeconds ~/ 60).toDouble(), context: context,
min: 5, sm: sm,
max: 120, title: 'Daily Reel Limit',
divisor: 5, subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60), value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
warningText: min: 5,
'Increasing your limit makes it easier to scroll. Are you sure?', max: 120,
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()), divisor: 5,
), isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
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,
sm: sm, sm: sm,
@@ -225,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,
+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,
),
),
],
),
);
}
}
+633 -40
View File
@@ -13,11 +13,20 @@ import '../services/settings_service.dart';
import '../services/injection_controller.dart'; import '../services/injection_controller.dart';
import '../services/injection_manager.dart'; import '../services/injection_manager.dart';
import '../scripts/native_feel.dart'; import '../scripts/native_feel.dart';
import '../scripts/grayscale.dart' as grayscale;
import '../services/screen_time_service.dart'; import '../services/screen_time_service.dart';
import '../services/navigation_guard.dart'; import '../services/navigation_guard.dart';
import '../services/focusgram_router.dart'; import '../services/focusgram_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'bait_me_full_screen.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed — offline feature deleted
// reels_history_service import removed — feature deleted
import 'app_lock_screen.dart';
import '../features/update_checker/update_checker_service.dart'; import '../features/update_checker/update_checker_service.dart';
import '../utils/discipline_challenge.dart'; import '../utils/discipline_challenge.dart';
import 'settings_page.dart'; import 'settings_page.dart';
@@ -26,9 +35,9 @@ import '../features/preloader/instagram_preloader.dart';
import '../v2_integration/script_engine_v2_overlay.dart'; import '../v2_integration/script_engine_v2_overlay.dart';
import '../v2_integration/script_registry_v2_overlay.dart'; import '../v2_integration/script_registry_v2_overlay.dart';
import '../scripts/focus_scripts.dart'; import '../scripts/focus_scripts.dart';
import 'adsterra_ad_screen.dart';
import '../focus_settings.dart'; import '../focus_settings.dart';
import '../services/adblock/adblock_content_blocker_loader.dart'; import '../services/adblock/adblock_content_blocker_loader.dart';
/// Core validator/dispatcher for the JS → Flutter bridge: /// Core validator/dispatcher for the JS → Flutter bridge:
@@ -100,10 +109,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
bool _isPreloaded = false; bool _isPreloaded = false;
bool _minimalModeBannerDismissed = false; bool _minimalModeBannerDismissed = false;
bool _isInDirectThread = false; bool _isInDirectThread = false;
bool _dmThreadCdnBlockArmed = false;
DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0);
SkeletonType _skeletonType = SkeletonType.generic; SkeletonType _skeletonType = SkeletonType.generic;
/// True when on the homepage and should block api/graphql + gateway.
/// Updated in onLoadStart / UrlChange before shouldInterceptRequest fires.
bool _blockHomepageGraphql = false;
/// Helper to determine if we are on a login/onboarding page. /// Helper to determine if we are on a login/onboarding page.
bool get _isOnOnboardingPage { bool get _isOnOnboardingPage {
final path = Uri.tryParse(_currentUrl)?.path ?? ''; final path = Uri.tryParse(_currentUrl)?.path ?? '';
@@ -227,6 +239,121 @@ class _MainWebViewPageState extends State<MainWebViewPage>
); );
} }
/// Show a full-screen lock gate when navigating to Instagram DMs.
void _showDmLockGate() {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.lock_outline,
color: Colors.white54,
size: 64,
),
const SizedBox(height: 24),
const Text(
'Messages Locked',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Enter your PIN to access Direct Messages',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
const SizedBox(height: 40),
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push<bool>(
ctx,
MaterialPageRoute(
builder: (_) => const AppLockScreen(
forAppWide: false,
title: 'Messages Locked',
subtitle:
'Enter your PIN to access Direct Messages',
),
),
);
if (!ctx.mounted) return;
if (result == true) {
_dmLockOverride = true;
Navigator.pop(ctx);
} else {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
}
},
icon: const Icon(Icons.lock_open_rounded),
label: const Text('Unlock'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
},
child: const Text(
'Cancel — Go to Home',
style: TextStyle(color: Colors.white38),
),
),
],
),
),
),
),
),
),
),
);
}
/// Set ghost mode flags in the WebView so the pre-injected scripts activate.
void _setGhostModeFlags(InAppWebViewController c, SettingsService s) {
c.evaluateJavascript(
source:
'''
window.__fgFullDmGhost = ${s.ghostMode};
''',
);
}
/// Re-inject grayscale on app resume (fixes cold-start persistence bug
/// where the preloader cache can bypass onLoadStop).
void _syncGrayscaleOnResume(SettingsService settings) {
if (_injectionManager == null || _controller == null) return;
if (settings.isGrayscaleActiveNow) {
_injectionManager!.runAllPostLoadInjections(_currentUrl);
} else {
// Explicitly remove grayscale
_controller?.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
}
}
void _onSessionChanged() { void _onSessionChanged() {
if (!mounted) return; if (!mounted) return;
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
@@ -360,6 +487,21 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_injectionManager!.runAllPostLoadInjections(_currentUrl); _injectionManager!.runAllPostLoadInjections(_currentUrl);
} }
// Ghost mode flags update + reload (scripts already injected by preloader,
// but need to reload so the fetch/XHR interceptors see the new flags from
// the start of page load).
if (_lastGhostMode != settings.ghostMode) {
_lastGhostMode = settings.ghostMode;
if (_controller != null) {
_setGhostModeFlags(_controller!, settings);
// Schedule a reload so the flags take effect on fresh page load
_reloadDebounce?.cancel();
_reloadDebounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _controller?.reload();
});
}
}
// 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state) // 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state)
setState(() {}); setState(() {});
@@ -425,6 +567,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
screenTime.startTracking(); screenTime.startTracking();
// Cancel persistent notification when app comes to foreground // Cancel persistent notification when app comes to foreground
NotificationService().cancelPersistentNotification(id: 5001); NotificationService().cancelPersistentNotification(id: 5001);
// Re-inject grayscale on resume — schedules may have changed
// while the app was backgrounded, and injection can be lost on cold
// start due to the preloader cache bypassing onLoadStop.
_syncGrayscaleOnResume(settings);
} else if (state == AppLifecycleState.paused || } else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive || state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) { state == AppLifecycleState.detached) {
@@ -535,10 +682,24 @@ class _MainWebViewPageState extends State<MainWebViewPage>
), ),
if (sm.canExtendAppSession) if (sm.canExtendAppSession)
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context, rootNavigator: true).pop();
sm.extendAppSession(); // Keep _extensionDialogShown = true while ad runs so the
// watchdog timer doesn't re-show the dialog over the ad screen.
if (!mounted) return;
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
_extensionDialogShown = false; _extensionDialogShown = false;
if (adResult == true && mounted) {
sm.extendAppSession();
}
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
@@ -546,7 +707,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: const Text('+10 minutes'), child: const Text('Watch Ad (+10 min)'),
), ),
], ],
), ),
@@ -673,21 +834,37 @@ class _MainWebViewPageState extends State<MainWebViewPage>
static bool _isDirectThreadUrl(String url) { static bool _isDirectThreadUrl(String url) {
final path = Uri.tryParse(url)?.path ?? url; final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/[^/]+/?$').hasMatch(path); // Match both /direct/inbox/ and /direct/t/{thread_id}
return RegExp(r'^/direct/').hasMatch(path);
} }
/* unused after CDN block was removed
static bool _isFktmInstagramCdn(String url) { static bool _isFktmInstagramCdn(String url) {
final host = Uri.tryParse(url)?.host.toLowerCase() ?? ''; final host = Uri.tryParse(url)?.host.toLowerCase() ?? '';
return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host); return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host);
} }
*/
void _syncDirectThreadState(String url) { void _syncDirectThreadState(String url) {
final active = _isDirectThreadUrl(url); final active = _isDirectThreadUrl(url);
if (_isInDirectThread == active) return; if (_isInDirectThread == active) return;
_isInDirectThread = active; _isInDirectThread = active;
_dmThreadCdnBlockArmed = false;
// Reset override when leaving DMs
if (!active) _dmLockOverride = false;
// If Messages Tab Lock is enabled and user navigated to DMs,
// show a lock overlay.
if (active && mounted) {
final appLock = context.read<AppLockService>();
if (appLock.messagesLockReady && !_dmLockOverride) {
_showDmLockGate();
}
}
} }
bool _dmLockOverride = false;
Future<void> _showReelSessionPicker() async { Future<void> _showReelSessionPicker() async {
final settings = context.read<SettingsService>(); final settings = context.read<SettingsService>();
if (settings.requireWordChallenge) { if (settings.requireWordChallenge) {
@@ -836,6 +1013,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_BrandedTopBar( _BrandedTopBar(
onFocusControlTap: () => onFocusControlTap: () =>
_edgePanelKey.currentState?._toggleExpansion(), _edgePanelKey.currentState?._toggleExpansion(),
onDmGhostToggle: () {
context.read<SettingsService>().setGhostMode(false);
_controller?.reload();
},
onReload: () => _controller?.reload(),
currentUrl: _currentUrl,
dmGhostActive: context.read<SettingsService>().ghostMode,
), ),
Expanded( Expanded(
child: Consumer<SessionManager>( child: Consumer<SessionManager>(
@@ -1029,6 +1213,98 @@ class _MainWebViewPageState extends State<MainWebViewPage>
); );
} }
// ── DM Ghost: block ALL seen signals ────────────────
// Like Chrome DevTools "Block request URL" — catches all
// sources at the native WebView level.
//
// Rules:
// 1. Block specific seen endpoint patterns everywhere
// 2. Block /api/graphql on homepage (/) and DM threads
// (/direct/t/*). Allow on /direct/inbox/ so inbox loads.
if (settings.ghostMode) {
// — Seen endpoint patterns (always block) —
final seenBlocked = 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/',
).hasMatch(url);
if (seenBlocked) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
// — Block /api/graphql + gateway on homepage &
// ANY /direct/* page (not just /direct/t/).
// Allow on /direct/inbox/ so inbox loads.
// Broader scope catches seen indicators sent
// during SPA transitions on re-entry.
final currentPath =
Uri.tryParse(_currentUrl)?.path ??
_currentUrl;
final isHomepage =
currentPath == '/' || currentPath == '';
final isOnDirect = currentPath.startsWith(
'/direct/',
);
if (!currentPath.startsWith(
'/direct/inbox/',
) &&
(isHomepage || isOnDirect) &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
}
// Legacy homepage graphql + gateway block
// (kept for safety — the ghost mode block above now covers it)
if (_blockHomepageGraphql &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
/* Strip ads from feed (JS handles it) /* Strip ads from feed (JS handles it)
if (settings.noAds && if (settings.noAds &&
url.contains( url.contains(
@@ -1158,6 +1434,29 @@ class _MainWebViewPageState extends State<MainWebViewPage>
settingsService, settingsService,
); );
// Set ghost mode flags (scripts already injected by preloader)
_setGhostModeFlags(controller, settingsService);
// Navigate to startup page if not Home
if (settingsService.startupPage != 'home') {
await controller.loadUrl(
urlRequest: URLRequest(
url: WebUri(settingsService.startupUrl),
),
);
}
// Force-inject grayscale on initial WebView creation,
// because the preloader's keepAlive causes the main
// WebView to skip onLoadStop on cold start.
if (settingsService.isGrayscaleActiveNow) {
try {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} catch (_) {}
}
_registerJavaScriptHandlers(controller); _registerJavaScriptHandlers(controller);
// ── FocusGram v2 overlay initial sync ─────────────── // ── FocusGram v2 overlay initial sync ───────────────
@@ -1223,6 +1522,14 @@ class _MainWebViewPageState extends State<MainWebViewPage>
if (!mounted) return; if (!mounted) return;
final u = url?.toString() ?? ''; final u = url?.toString() ?? '';
_syncDirectThreadState(u); _syncDirectThreadState(u);
// Update homepage graphql block flag SYNCHRONOUSLY
// (before setState, so shouldInterceptRequest sees it)
final path = Uri.tryParse(u)?.path ?? u;
_blockHomepageGraphql =
settings.ghostMode &&
(path == '/' ||
path == '' ||
path == '/explore/');
final lower = u.toLowerCase(); final lower = u.toLowerCase();
final isOnboardingUrl = final isOnboardingUrl =
lower.contains('/accounts/login') || lower.contains('/accounts/login') ||
@@ -1251,6 +1558,15 @@ class _MainWebViewPageState extends State<MainWebViewPage>
final current = url?.toString() ?? ''; final current = url?.toString() ?? '';
_syncDirectThreadState(current); _syncDirectThreadState(current);
// Re-set ghost mode flags on every page load.
// evaluateJavascript-set flags are destroyed when
// the JS context resets on navigation. The flags
// are also prepended to initialUserScripts, but
// this covers the toggle-off → reload case.
final s = context.read<SettingsService>();
_setGhostModeFlags(controller, s);
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_currentUrl = current; _currentUrl = current;
@@ -1263,6 +1579,17 @@ class _MainWebViewPageState extends State<MainWebViewPage>
// Phase 1 V2 overlay DOM scripts // Phase 1 V2 overlay DOM scripts
await _v2Engine?.injectDocumentEndScripts(); await _v2Engine?.injectDocumentEndScripts();
// Re-inject grayscale on every page load
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
await controller.evaluateJavascript( await controller.evaluateJavascript(
source: source:
InjectionController.notificationBridgeJS, InjectionController.notificationBridgeJS,
@@ -1458,7 +1785,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
right: 0, right: 0,
child: const _InstagramGradientProgressBar(), child: const _InstagramGradientProgressBar(),
), ),
_EdgePanel(key: _edgePanelKey), _EdgePanel(key: _edgePanelKey, currentUrl: _currentUrl),
if (_exploreBlockedOverlay) if (_exploreBlockedOverlay)
Positioned.fill( Positioned.fill(
@@ -1850,13 +2177,40 @@ class _MainWebViewPageState extends State<MainWebViewPage>
}, },
); );
// ReelMetadata handler removed — reel history feature deleted
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'UrlChange', handlerName: 'UrlChange',
callback: (args) async { callback: (args) async {
final url = (args.isNotEmpty ? args[0] : '') as String? ?? ''; final url = (args.isNotEmpty ? args[0] : '') as String? ?? '';
// Update _currentUrl SYNCHRONOUSLY before any async operations,
// so shouldInterceptRequest sees the correct path immediately.
_currentUrl = url;
_syncDirectThreadState(url); _syncDirectThreadState(url);
final s = context.read<SettingsService>();
// Update homepage graphql block for SPA navigation
final path = Uri.tryParse(url)?.path ?? url;
_blockHomepageGraphql =
s.ghostMode && (path == '/' || path == '' || path == '/explore/');
// Re-set ghost mode flags on SPA navigation (no page reload).
_setGhostModeFlags(controller, s);
await _injectionManager?.runAllPostLoadInjections(url); await _injectionManager?.runAllPostLoadInjections(url);
// Re-inject grayscale on SPA nav (no page reload)
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
// Phase 1 V2 overlay re-inject on SPA route changes // Phase 1 V2 overlay re-inject on SPA route changes
await _v2Engine?.injectDocumentEndScripts(); await _v2Engine?.injectDocumentEndScripts();
@@ -1876,7 +2230,6 @@ class _MainWebViewPageState extends State<MainWebViewPage>
.read<SettingsService>() .read<SettingsService>()
.disableExploreEntirely; .disableExploreEntirely;
final path = Uri.tryParse(url)?.path ?? url;
final isReels = path.startsWith('/reels') || path.startsWith('/reel/'); final isReels = path.startsWith('/reels') || path.startsWith('/reel/');
final isExplore = path.startsWith('/explore'); final isExplore = path.startsWith('/explore');
@@ -1967,7 +2320,8 @@ class _MinimalModeBanner extends StatelessWidget {
} }
class _EdgePanel extends StatefulWidget { class _EdgePanel extends StatefulWidget {
const _EdgePanel({super.key}); final String currentUrl;
const _EdgePanel({super.key, this.currentUrl = ''});
@override @override
State<_EdgePanel> createState() => _EdgePanelState(); State<_EdgePanel> createState() => _EdgePanelState();
} }
@@ -2091,6 +2445,38 @@ class _EdgePanelState extends State<_EdgePanel> {
], ],
), ),
), ),
// Level badge
Consumer<LevelService>(
builder: (context, lv, _) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: lv.level >= 3
? Colors.purple.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: lv.level >= 3
? Colors.purpleAccent.withValues(alpha: 0.4)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Text(
'Lv ${lv.level}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: lv.level >= 3
? Colors.purpleAccent
: Colors.grey,
),
),
),
),
// Save current page — REMOVED
const SizedBox(width: 4),
IconButton( IconButton(
tooltip: 'Close', tooltip: 'Close',
icon: Icon( icon: Icon(
@@ -2102,6 +2488,8 @@ class _EdgePanelState extends State<_EdgePanel> {
), ),
], ],
), ),
// Bait Me button row
_BaitMeButtonRow(),
const SizedBox(height: 18), const SizedBox(height: 18),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -2189,6 +2577,7 @@ class _EdgePanelState extends State<_EdgePanel> {
color: reelsHardDisabled ? Colors.redAccent : textSub, color: reelsHardDisabled ? Colors.redAccent : textSub,
isDark: isDark, isDark: isDark,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (sm.isSessionActive) if (sm.isSessionActive)
SizedBox( SizedBox(
@@ -2226,17 +2615,86 @@ class _EdgePanelState extends State<_EdgePanel> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (!canStart && !sm.isSessionActive) if (!canStart && !sm.isSessionActive)
Text( Column(
reelsHardDisabled crossAxisAlignment: CrossAxisAlignment.start,
? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.' children: [
: sm.isCooldownActive Text(
? 'A cooldown is active before the next Reel session.' reelsHardDisabled
: 'Your daily Reel quota is used up.', ? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
style: TextStyle( : sm.isCooldownActive
color: textSub, ? 'A cooldown is active before the next Reel session.'
fontSize: 12, : 'Your daily Reel quota is used up.',
height: 1.35, style: TextStyle(
), color: textSub,
fontSize: 12,
height: 1.35,
),
),
if (sm.dailyRemainingSeconds <= 0 &&
!reelsHardDisabled &&
!sm.isCooldownActive)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Consumer<CreditStore>(
builder: (ctx, credits, _) {
if (!credits.canWatchAdToday) {
return Text(
'Ad limit reached (3/day)',
style: TextStyle(
color: textSub,
fontSize: 11,
),
);
}
return SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: () async {
final adResult =
await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) =>
const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
if (adResult == true && context.mounted) {
context
.read<CreditStore>()
.addReelsMinutes(amount: 2);
context
.read<SessionManager>()
.addBonusDailyMinutes(2);
HapticFeedback.heavyImpact();
}
},
icon: const Icon(Icons.videocam, size: 16),
label: Text(
'Watch Ad (+2 min) '
'(${CreditStore.maxDailyAds - credits.adsWatchedToday}/3 today)',
style: const TextStyle(fontSize: 12),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(
alpha: 0.4,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
},
),
),
],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Divider(color: border), Divider(color: border),
@@ -2345,16 +2803,88 @@ class _EdgePanelState extends State<_EdgePanel> {
} }
} }
class _BrandedTopBar extends StatelessWidget { /// Small row showing the Bait Me button and daily XP for the edge panel.
final VoidCallback? onFocusControlTap; class _BaitMeButtonRow extends StatelessWidget {
const _BrandedTopBar({this.onFocusControlTap}); const _BaitMeButtonRow();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = context.watch<SettingsService>().isDarkMode; final levelService = context.watch<LevelService>();
final baitEngine = context.watch<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: baitEngine.isOnCooldown
? null
: () => _openBaitMe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purpleAccent.withValues(alpha: 0.2),
foregroundColor: Colors.purpleAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.casino_rounded, size: 20),
label: Text(
baitEngine.isOnCooldown
? 'Bait Me (${baitEngine.cooldownRemainingMinutes}m cooldown)'
: '🎲 Bait Me — Feel Lucky?',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
);
}
void _openBaitMe(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BaitMeFullScreen()),
);
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
final VoidCallback? onDmGhostToggle;
final VoidCallback? onReload;
final String currentUrl;
final bool dmGhostActive;
const _BrandedTopBar({
this.onFocusControlTap,
this.onDmGhostToggle,
this.onReload,
this.currentUrl = '',
this.dmGhostActive = false,
});
static bool _isDirectInbox(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return path == '/direct/inbox/' || path == '/direct/inbox';
}
static bool _isDirectThread(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/').hasMatch(path);
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final barBg = isDark ? Colors.black : Colors.white; final barBg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black; final textMain = isDark ? Colors.white : Colors.black;
final iconColor = isDark ? Colors.white70 : Colors.black54; final iconColor = isDark ? Colors.white70 : Colors.black54;
final border = isDark ? Colors.white12 : Colors.black12; final border = isDark ? Colors.white12 : Colors.black12;
final showDmGhostBtn = _isDirectThread(currentUrl) && dmGhostActive;
final showReloadBtn = _isDirectInbox(currentUrl);
return Container( return Container(
height: 60, height: 60,
@@ -2363,10 +2893,11 @@ class _BrandedTopBar extends StatelessWidget {
border: Border(bottom: BorderSide(color: border, width: 0.5)), border: Border(bottom: BorderSide(color: border, width: 0.5)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
// Left: settings icon
IconButton( IconButton(
icon: Icon(Icons.settings_outlined, color: iconColor, size: 22), icon: Icon(Icons.settings_outlined, color: iconColor, size: 22),
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
@@ -2374,21 +2905,83 @@ class _BrandedTopBar extends StatelessWidget {
MaterialPageRoute(builder: (_) => const SettingsPage()), MaterialPageRoute(builder: (_) => const SettingsPage()),
), ),
), ),
Text(
'FocusGram', // Center: FocusGram logo (or DM ghost badge)
style: GoogleFonts.grandHotel( if (showDmGhostBtn)
color: textMain, GestureDetector(
fontSize: 32, onTap: onDmGhostToggle,
letterSpacing: 0.5, child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off,
color: Colors.redAccent,
size: 16,
),
const SizedBox(width: 4),
Text(
'DM Ghost ON',
style: TextStyle(
color: Colors.redAccent,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.close,
color: Colors.redAccent.withValues(alpha: 0.6),
size: 14,
),
],
),
),
)
else
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
),
), ),
),
IconButton( // Right: reload button + timer icon
icon: const Icon( Row(
Icons.timer_outlined, mainAxisSize: MainAxisSize.min,
color: Colors.blueAccent, children: [
size: 22, if (showReloadBtn)
), IconButton(
onPressed: onFocusControlTap, icon: Icon(
Icons.refresh_rounded,
color: iconColor,
size: 22,
),
onPressed: onReload,
tooltip: 'Reload page',
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
),
],
), ),
], ],
), ),
+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,
),
),
),
],
),
);
}
}
+122 -41
View File
@@ -6,8 +6,18 @@ 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'; import 'extras_settings_page.dart';
@@ -37,7 +47,7 @@ class SettingsPage extends StatelessWidget {
body: ListView( body: ListView(
children: [ children: [
const _DonateTile(), const _DonateTile(),
_buildStatsRow(sm), _buildStatsRow(sm, context),
const _SectionHeader(title: 'FOCUS & BLOCKING'), const _SectionHeader(title: 'FOCUS & BLOCKING'),
_SubmoduleTile( _SubmoduleTile(
@@ -71,7 +81,7 @@ class SettingsPage extends StatelessWidget {
icon: Icons.download_rounded, icon: Icons.download_rounded,
iconColor: Colors.orangeAccent, iconColor: Colors.orangeAccent,
title: 'Extras', title: 'Extras',
subtitle: 'Download media, Ghost Mode', subtitle: 'Startup Page, Download media, Ghost Mode',
enabled: true, enabled: true,
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
@@ -88,13 +98,25 @@ 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,
@@ -120,18 +142,23 @@ class SettingsPage extends StatelessWidget {
MaterialPageRoute(builder: (_) => const ScreenTimeScreen()), MaterialPageRoute(builder: (_) => const ScreenTimeScreen()),
), ),
), ),
_SubmoduleTile(
const _SectionHeader(title: 'ABOUT'), icon: Icons.trending_up_rounded,
FutureBuilder<PackageInfo>( iconColor: Colors.amber,
future: PackageInfo.fromPlatform(), title: 'Your Journey',
builder: (context, snapshot) => ListTile( subtitle:
title: const Text('Version'), 'Level ${context.watch<LevelService>().level} · ${context.watch<LevelService>().xp} XP',
trailing: Text( onTap: () => Navigator.push(
snapshot.data?.version ?? '', context,
style: const TextStyle(color: Colors.grey), MaterialPageRoute(builder: (_) => const LevelPanelScreen()),
),
), ),
), ),
// Quick XP debug grant (visible in settings for testing)
// _XpDebugTile(),
// Reels History removed
const _SectionHeader(title: 'ABOUT'),
_VersionTile(),
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),
@@ -173,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(
@@ -189,7 +218,36 @@ class SettingsPage extends StatelessWidget {
); );
} }
Widget _buildStatsRow(SessionManager sm) { Widget _buildStatsRow(SessionManager sm, BuildContext context) {
final creditStore = context.watch<CreditStore>();
final cells = <Widget>[
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
];
if (true) {
// ad counter always shown
cells.addAll([
_dividerCell(),
_statCell(
'XP Ads Watched',
'${creditStore.adsWatchedToday}',
Colors.purpleAccent,
),
]);
}
return Container( return Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -200,21 +258,7 @@ class SettingsPage extends StatelessWidget {
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: cells,
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
],
), ),
); );
} }
@@ -240,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,
@@ -341,6 +393,8 @@ class FocusSettingsPage extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8),
const _SectionHeader(title: 'FRICTION'), const _SectionHeader(title: 'FRICTION'),
_SwitchTile( _SwitchTile(
title: 'Mindfulness Gate', title: 'Mindfulness Gate',
@@ -378,17 +432,27 @@ class FocusSettingsPage extends StatelessWidget {
onSelected: (v) => settings.setWordChallengeCount(v), onSelected: (v) => settings.setWordChallengeCount(v),
), ),
const _SectionHeader(title: 'MEDIA'),
/*
( I TRIED SO HARD, AND GOT SO FAR, BUT IN THE END...
IT DOESNT EVEN MATTER ..... (didnt work))
_SwitchTile( _SwitchTile(
title: 'Block Autoplay Videos', title: 'Effort Friction Mode',
subtitle: 'Videos won\'t play until you tap them', subtitle: 'Earn credits by watching ads — enabled by default',
value: settings.blockAutoplay, value: settings.effortFrictionEnabled,
onChanged: (v) => settings.setBlockAutoplay(v), 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',
@@ -411,8 +475,7 @@ class FocusSettingsPage extends StatelessWidget {
_SwitchTile( _SwitchTile(
title: 'Hide Feed Posts', title: 'Hide Feed Posts',
subtitle: subtitle: 'Hides home feed posts',
'Hides home feed posts (stories tray, posts, suggested content)',
value: settings.contentPosts, value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v), onChanged: (v) => settings.setContentPostsEnabled(v),
), ),
@@ -1321,6 +1384,24 @@ class _NumberEditTile extends StatelessWidget {
} }
} }
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);
})();
''';
+20 -4
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 (_) {}
+418 -54
View File
@@ -1,77 +1,421 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart'; import '../focus_settings.dart';
// Ghost Mode /// Flutter sets these flags after settings load to enable ghost modes.
const String ghostModeJS = ''' /// Must be called from onWebViewCreated or on settings change.
const _WS = window.WebSocket; const String kSetGhostFlagsJS = '''
window.WebSocket = function(url, protocols) { (function(){
if (url.includes('edge-chat.instagram.com') || // Placeholder — Flutter replaces these with actual setting values:
url.includes('gateway.instagram.com')) { // window.__fgPartialGhost = true/false;
return { // window.__fgFullDmGhost = true/false;
send: ()=>{}, close: ()=>{}, // window.__fgStoryGhost = true/false;
readyState: 1, // window.__fgGhostReady = true; // signals scripts can proceed
addEventListener: ()=>{}, })();
removeEventListener: ()=>{}, ''';
};
// ═══════════════════════════════════════════════════════════════
// 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;
} }
return new _WS(url, protocols);
}; // ── Fetch override (chain with previous fetch) ─────────────
window.WebSocket.prototype = _WS.prototype; 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;
})();
})();
'''; ''';
// No Story Tray // ═══════════════════════════════════════════════════════════════
const String hideStoryTrayJS = ''' // FULL DM GHOST — blocks ALL api/graphql on /direct/* immediately
const style = document.createElement('style'); // (inbox won't load, messages can't be sent)
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }'; // ═══════════════════════════════════════════════════════════════
document.head.appendChild(style); const String kFullDmGhostJS = r'''
'''; (function() {
if (window.__fgFullDmGhostPatched) return;
window.__fgFullDmGhostPatched = true;
// No Autoplay // ── Smart path-based blocking ──────────────────────────────
const String noAutoplayJS = ''' // /direct/inbox/ → allow (inbox loads)
document.addEventListener('play', function(e) { // /direct/t/* → block ALL api/graphql immediately
if (e.target.tagName === 'VIDEO') { // any /direct/* → block except /direct/inbox/
e.target.pause(); 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;
} }
}, 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;
})();
})();
'''; ''';
// No Reels / Explore // ═══════════════════════════════════════════════════════════════
const String hideReelsJS = ''' // STORY GHOST — blocks api/graphql on homepage (/) and /stories/*
const hideReels = () => { // Allows viewing stories without sending seen indicators.
// nav bar reels icon // ═══════════════════════════════════════════════════════════════
document.querySelectorAll('a[href="/reels/"]').forEach(el => { const String kStoryGhostJS = r'''
el.closest('div')?.style.setProperty('display', 'none', 'important'); (function() {
}); if (window.__fgStoryGhostPatched) return;
// explore page window.__fgStoryGhostPatched = true;
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
};
new MutationObserver(hideReels).observe(document.body, { // ── Smart path-based blocking ──────────────────────────────
childList: true, // On /, /stories/*, /story/* → block ALL api/graphql
subtree: true // 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;
}
hideReels(); // ── Story URL blocklist ────────────────────────────────────
'''; var STORY_URLS = [
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
// No DMs /\\/api\\/v1\\/stories\\/reel\\/seen\\//,
const String hideDMsJS = ''' /\\/api\\/v1\\/feed\\/viewed_story\\//,
const style = document.createElement('style'); /\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }'; /\\/api\\/v1\\/media\\/seen\\//,
document.head.appendChild(style); ];
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) { List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[]; final startScripts = <String>[];
final endScripts = <String>[]; final endScripts = <String>[];
// AT_DOCUMENT_START scripts // Prepend flag values directly into the script so they survive page navigation.
if (settings.ghostMode) startScripts.add(ghostModeJS); // (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); if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END scripts // AT_DOCUMENT_END
if (settings.noStories) endScripts.add(hideStoryTrayJS); if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS); if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS); if (settings.noDMs) endScripts.add(hideDMsJS);
@@ -97,3 +441,23 @@ List<UserScript> buildUserScripts(FocusSettings settings) {
} }
return scripts; 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);})();
''';
+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;
} }
+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')}';
}
}
+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();
}*/
}
+6 -7
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 {
@@ -55,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');
} }
} }
@@ -67,7 +66,7 @@ class NotificationService {
>() >()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
} catch (e) { } catch (e) {
debugPrint('Android permission request error: $e'); // debugPrint('Android permission request error: $e');
} }
} }
@@ -105,7 +104,7 @@ class NotificationService {
notificationDetails: platformDetails, notificationDetails: platformDetails,
); );
} catch (e) { } catch (e) {
debugPrint('Notification error: $e'); // debugPrint('Notification error: $e');
} }
} }
@@ -149,7 +148,7 @@ class NotificationService {
notificationDetails: platformDetails, notificationDetails: platformDetails,
); );
} catch (e) { } catch (e) {
debugPrint('Persistent notification error: $e'); // debugPrint('Persistent notification error: $e');
} }
} }
@@ -158,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');
} }
} }
@@ -167,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');
} }
} }
} }
+1 -3
View File
@@ -46,9 +46,7 @@ class RemotePopupService {
final response = await http.get( final response = await http.get(
uri, uri,
headers: const { headers: const {'Cache-Control': 'no-cache'},
'Cache-Control': 'no-cache',
},
); );
if (response.statusCode != 200) return null; if (response.statusCode != 200) return null;
+26
View File
@@ -462,6 +462,13 @@ class SessionManager extends ChangeNotifier {
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
@@ -482,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).
@@ -498,6 +512,18 @@ class SessionManager extends ChangeNotifier {
} }
/// 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();
+68 -4
View File
@@ -1,6 +1,6 @@
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'; import 'notification_service.dart';
@@ -63,6 +63,16 @@ class SettingsService extends ChangeNotifier {
// 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';
@@ -139,6 +149,10 @@ class SettingsService extends ChangeNotifier {
bool _notifyPersistent = false; bool _notifyPersistent = false;
// Focus mode settings // Focus mode settings
bool _effortFrictionEnabled = true;
String _startupPage = 'home'; // home, following, favorites, direct
String _adsterraZoneUrl = '';
String _adsterraAdCode = '';
bool _ghostMode = false; bool _ghostMode = false;
bool _noAds = false; bool _noAds = false;
bool _noStories = false; bool _noStories = false;
@@ -196,6 +210,23 @@ class SettingsService extends ChangeNotifier {
bool get hideShopTab => _hideShopTab; bool get hideShopTab => _hideShopTab;
// Focus mode settings // 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 ghostMode => _ghostMode;
bool get noAds => _noAds; bool get noAds => _noAds;
bool get noStories => _noStories; bool get noStories => _noStories;
@@ -290,7 +321,8 @@ class SettingsService extends ChangeNotifier {
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false; _contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false; _hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules // 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 {
@@ -330,6 +362,11 @@ class SettingsService extends ChangeNotifier {
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true; _reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings // 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; _ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false; _noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false; _noStories = _prefs!.getBool(_keyNoStories) ?? false;
@@ -411,8 +448,9 @@ class SettingsService extends ChangeNotifier {
final clamped = seconds.clamp(3, 60); final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt(); _breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds); await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
// Defer notifyListeners to next microtask to avoid rebuild conflicts // Defer notifyListeners to after the current frame to avoid
Future.microtask(notifyListeners); // Flutter's 'Dependents.isEmpty' assertion error.
WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners());
} }
Future<void> setWordChallengeCount(int count) async { Future<void> setWordChallengeCount(int count) async {
@@ -771,7 +809,33 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); 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 // Focus mode settings
Future<void> setEffortFrictionEnabled(bool v) async {
_effortFrictionEnabled = v;
await _prefs?.setBool(_keyEffortFrictionEnabled, v);
notifyListeners();
}
Future<void> setGhostMode(bool v) async { Future<void> setGhostMode(bool v) async {
_ghostMode = v; _ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v); await _prefs?.setBool(_keyGhostMode, v);
+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;
}
+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'),
);
},
),
),
);
}
}
+118 -6
View File
@@ -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
@@ -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:
@@ -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:
+11 -1
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: 2.0.0 version: 2.1.0
environment: environment:
sdk: ^3.10.7 sdk: ^3.10.7
@@ -43,6 +43,16 @@ dependencies:
# Charts for on-device screen time dashboard (MIT) # Charts for on-device screen time dashboard (MIT)
fl_chart: ^0.71.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
+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,
);
});
});
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:focusgram/services/level_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
if (!Hive.isAdapterRegistered(0)) {
await Hive.initFlutter();
}
});
group('AppFeature — Your Journey unlock table', () {
test('fullDmGhost is NOT in the all list', () async {
expect(AppFeature.fullDmGhost, isNotNull);
final contains = AppFeature.all.any((f) => f.id == 'full_dm_ghost');
expect(contains, isFalse);
});
test('storyGhost and reelsHistory are NOT in the all list', () async {
final hasStory = AppFeature.all.any((f) => f.id == 'custom_friction');
final hasReels = AppFeature.all.any((f) => f.id == 'reels_history');
expect(hasStory, isFalse);
expect(hasReels, isFalse);
});
test('all list contains only active features', () async {
final ids = AppFeature.all.map((f) => f.id).toSet();
expect(ids, contains('ghost_mode'));
expect(ids, contains('effort_friction'));
expect(ids, contains('download_media'));
expect(ids, contains('bait_me'));
expect(ids, contains('app_lock'));
expect(ids.length, equals(5));
});
});
group('LevelService — No Firestore dependency', () {
test('init succeeds without Firestore (uses Hive only)', () async {
final levelService = LevelService();
await expectLater(() => levelService.init(), returnsNormally);
expect(levelService.level, equals(1));
expect(levelService.xp, equals(0));
});
test('addXpForAd awards XP without Firestore', () async {
final levelService = LevelService();
await levelService.init();
await levelService.addXpForAd();
expect(levelService.xp, greaterThan(0));
expect(levelService.adsWatchedTotal, equals(1));
});
test('level progresses from XP', () async {
final levelService = LevelService();
await levelService.init();
expect(levelService.level, equals(1));
expect(levelService.xp, equals(0));
expect(levelService.levelProgress, equals(0.0));
});
});
}
+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/session_manager.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('SessionManager — Extension flow', () {
test('canExtendAppSession is true when session just ended', () async {
final sm = SessionManager();
await sm.init();
// Start an app session
sm.startAppSession(60);
expect(sm.isSessionActive, isTrue);
// End it
sm.endAppSession();
expect(sm.isAppSessionExpired, isTrue);
expect(sm.canExtendAppSession, isTrue);
});
test('extendAppSession sets canExtendAppSession to false', () async {
final sm = SessionManager();
await sm.init();
sm.startAppSession(60);
sm.endAppSession();
expect(sm.canExtendAppSession, isTrue);
sm.extendAppSession();
expect(sm.canExtendAppSession, isFalse);
expect(sm.isSessionActive, isTrue);
});
test(
'canExtendAppSession is false after re-ending an extended session',
() async {
final sm = SessionManager();
await sm.init();
sm.startAppSession(60);
sm.endAppSession();
sm.extendAppSession();
sm.endAppSession();
expect(sm.canExtendAppSession, isFalse);
},
);
});
group('SessionManager — App session lifecycle', () {
test('startAppSession sets isSessionActive', () async {
final sm = SessionManager();
await sm.init();
sm.startAppSession(30);
expect(sm.isSessionActive, isTrue);
});
test('endAppSession clears session and sets expired', () async {
final sm = SessionManager();
await sm.init();
sm.startAppSession(30);
sm.endAppSession();
expect(sm.isSessionActive, isFalse);
expect(sm.isAppSessionExpired, isTrue);
});
});
}
+49
View File
@@ -6,6 +6,55 @@
(function () { (function () {
'use strict'; 'use strict';
// ─── Direct Message API block ────────────────────────────────────────────
// ── First-interaction gate: allow inbox to load, then block ─
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;
}
const _f = window.fetch.bind(window);
window.fetch = function(input, init) {
const url = (typeof input === 'string') ? input : (input && input.url) ? input.url : '';
if (_blockIfNeeded(url)) {
return Promise.resolve(new Response(JSON.stringify({status:'ok'}), {
status: 200, headers: {'Content-Type': 'application/json'}
}));
}
return _f(input, init);
};
const _o = XMLHttpRequest.prototype.open;
const _s = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) {
this.__fgUrl = u || ''; return _o.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (_blockIfNeeded(this.__fgUrl || '')) {
const 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"}'}});
self.dispatchEvent(new Event('readystatechange')); self.dispatchEvent(new Event('load'));
}, 5); return;
}
return _s.apply(this, arguments);
};
// ─── Seen API patterns ──────────────────────────────────────────────────── // ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [ const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//, /\/api\/v1\/media\/[\w-]+\/seen\//,
+4 -1
View File
@@ -213,9 +213,12 @@ const String kGhostModeJS = r"""
// MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len) // MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len)
try { try {
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
// Block typing / activity indicator publishes // Block typing / activity indicator / seen-receipt publishes
if ( if (
decoded.includes('/t_fs') || // foreground state (typing) decoded.includes('/t_fs') || // foreground state (typing)
decoded.includes('/t_mt') || // mark thread seen
decoded.includes('/t_s') || // seen receipt
decoded.includes('/t_se') || // seen receipt (alt)
decoded.includes('activity_indicator') || decoded.includes('activity_indicator') ||
decoded.includes('is_typing') || decoded.includes('is_typing') ||
decoded.includes('direct_typing') || decoded.includes('direct_typing') ||