mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-04 02:07:52 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c8120496 | |||
| 39b6545e4a | |||
| f1bd12f0bd | |||
| 7d13ad64f1 | |||
| 5f86441675 | |||
| 842dc70829 | |||
| 2d33dcb889 | |||
| 4f63e784ac | |||
| a504c51ac5 | |||
| 5fafb9f142 | |||
| d2a0294ab3 | |||
| 2e3823cdf7 |
-45
@@ -1,45 +0,0 @@
|
||||
Categories:
|
||||
- Connectivity
|
||||
- Social Network
|
||||
License: AGPL-3.0-only
|
||||
AuthorName: Ujwal Chapagain
|
||||
AuthorEmail: notujwal@proton.me
|
||||
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
|
||||
|
||||
AutoName: FocusGram
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/Ujwal223/FocusGram
|
||||
|
||||
Builds:
|
||||
- versionName: 1.0.0
|
||||
versionCode: 3
|
||||
commit: v1.0.0
|
||||
output: build/app/outputs/flutter-apk/app-release.apk
|
||||
srclibs:
|
||||
- flutter@stable
|
||||
prebuild:
|
||||
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
|
||||
- '[[ $flutterVersion ]]'
|
||||
- git -C $$flutter$$ checkout -f $flutterVersion
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter config --no-analytics
|
||||
- .flutter/bin/flutter pub get
|
||||
scanignore:
|
||||
- .flutter/bin/cache
|
||||
scandelete:
|
||||
- .flutter
|
||||
- .pub-cache
|
||||
build:
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
VercodeOperation:
|
||||
- '%c * 10 + 1'
|
||||
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||
CurrentVersion: 1.0.0
|
||||
CurrentVersionCode: 3
|
||||
@@ -0,0 +1,10 @@
|
||||
import os, re
|
||||
from pathlib import Path
|
||||
|
||||
version = os.environ["VERSION"]
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
pattern = rf"(?ms)^##\s+FocusGram\s+{re.escape(version)}\s*$.*?(?=^##\s+|\Z)"
|
||||
m = re.search(pattern, text)
|
||||
if not m:
|
||||
raise SystemExit(f"Could not find changelog section for version {version}")
|
||||
Path("release_notes.md").write_text(m.group(0).strip() + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
m = re.search(r"^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", text, re.M)
|
||||
if not m:
|
||||
raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.1.0")
|
||||
print(m.group(1))
|
||||
@@ -1,54 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '15 14 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Build Android (for CodeQL)
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Build APK and Create GitHub Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
|
||||
required: false
|
||||
type: string
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
- name: Install required Android SDK packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"build-tools;34.0.0" \
|
||||
"build-tools;35.0.0" \
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
- name: Resolve version and tag
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
||||
VERSION="${INPUT_VERSION#v}"
|
||||
else
|
||||
VERSION="$(python3 .github/scripts/get_version.py)"
|
||||
fi
|
||||
TAG="v${VERSION}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: python3 .github/scripts/get_notes.py
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release
|
||||
- name: Rename APK
|
||||
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: focusgram-apk-${{ steps.meta.outputs.tag }}
|
||||
path: build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
if-no-files-found: error
|
||||
- name: Create Git tag
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "Tag already exists on remote: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "${TAG}"
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
name: FocusGram ${{ steps.meta.outputs.tag }}
|
||||
body_path: release_notes.md
|
||||
files: build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,12 +0,0 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
+3
-3
@@ -12,8 +12,10 @@
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
PRD.md
|
||||
.reasonix/
|
||||
.agents/
|
||||
TODO.md
|
||||
|
||||
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
@@ -25,9 +27,7 @@ TODO.md
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
RELEASE_GUIDE.md
|
||||
android/key.properties
|
||||
android/fdroid-config.properties
|
||||
android/app/*.jks
|
||||
upload-keystore.jks
|
||||
|
||||
|
||||
+17
-8
@@ -1,13 +1,22 @@
|
||||
## FocusGram 1.0.0
|
||||
## FocusGram 2.1.0
|
||||
|
||||
### What's new
|
||||
- Reordered Settings Page.
|
||||
- Added "Click to Unblur" for posts.
|
||||
- Added Persistent Notification
|
||||
- Improved Grayscale Scheduling.
|
||||
|
||||
- NEW: Startup Page - choose which page to launch on app launch.
|
||||
- NEW: App lock and DM Lock.
|
||||
- NEW: Bait me button in Focus Control.
|
||||
- NEW: Interactive Level based system for unlocking features.
|
||||
- NEW: Effort Friction Mode.
|
||||
- NEW: Strict and fully working Ghost Mode.
|
||||
|
||||
|
||||
### Bug fixes
|
||||
- Fixed a Bug Where Reels Werent playing despite Reels Sessions being ON.
|
||||
- Fixed a Bug Where Session End Popup could be just dismissed and app ran Normally despite session already ended.
|
||||
|
||||
- Fixed: Greyscale mode used to turn off when app was restarted.
|
||||
- Fixed: Images in posts containing multiple images werent getting unblurred when tapped.
|
||||
- Fixed: You could send message as "Ghost" in GHost mode (Ghost's cant talk with real people 🤪).
|
||||
- Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs.
|
||||
- Fixed: Download media button (rarely) opened random media rather than desired one.
|
||||
- Fixed: Reel Session could be started despite quota being finished.
|
||||
- Perfomance Optimizations
|
||||
- Other Minor Changes.
|
||||
- A lof of other Minor fixes.
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
**Use social media on your terms.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
|
||||
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram' >
|
||||
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram'>
|
||||
</a>
|
||||
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
|
||||
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram on Uptodown'>
|
||||
</a>
|
||||
|
||||
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues/new)
|
||||
|
||||
@@ -20,42 +20,48 @@
|
||||
|
||||
---
|
||||
|
||||
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 👉👈.
|
||||
>
|
||||
> [](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" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
**Focus tools**
|
||||
|
||||
- Block Reels entirely, or allow them in timed sessions (1–15 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
|
||||
- Block Reels entirely, or allow them in timed sessions (1–30 min) with daily limits and cooldowns
|
||||
- Minimal Mode strips everything down to Feed and DMs
|
||||
- Hide ALL feed posts entirely.
|
||||
|
||||
**Content filtering**
|
||||
|
||||
- Hide the Explore tab, Reels tab, or Shop tab individually
|
||||
- Disable Explore and suggested content entirely
|
||||
- Disable Reels Entirely
|
||||
- Hide the Explore tab or Reels tab individually
|
||||
- Disable Explore and blur posts, videos on feed entirely
|
||||
- Click to unblur feed posts
|
||||
- Disable Reels entirely
|
||||
- Disable scrolling of home feed
|
||||
|
||||
**Habit tools**
|
||||
|
||||
- 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
|
||||
- Session intentions — optionally set a reason before opening the app
|
||||
- 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
|
||||
- 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**
|
||||
|
||||
- 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
|
||||
**Other Features**
|
||||
|
||||
- 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
|
||||
@@ -66,35 +72,36 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
||||
3. Open the file and allow "Install from unknown sources" if prompted
|
||||
|
||||
### Uptodown
|
||||
Go to the [Focusgram - Uptodown](https://focusgram.en.uptodown.com/android) page<br>
|
||||
2. Click "Get the Latest Version"<br>
|
||||
3. Click "Download"<br>
|
||||
3. Open the file and allow "Install from unknown sources" if prompted
|
||||
1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page
|
||||
2. Click "Get the Latest Version"
|
||||
3. Click "Download"
|
||||
4. Open the file and allow "Install from unknown sources" if prompted
|
||||
|
||||
---
|
||||
|
||||
## 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 crash reporting
|
||||
- No third-party SDKs
|
||||
- No Logging
|
||||
- No data leaves your device
|
||||
- All settings and history are stored locally using Android's standard storage APIs
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Will this get my account banned?**
|
||||
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Why is it free?**
|
||||
Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0.
|
||||
**How do i support this project?**<br>
|
||||
You can support this project by donating here: [Donate](https://buymemomo.com/ujwal)
|
||||
|
||||
---
|
||||
|
||||
@@ -112,8 +119,7 @@ Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](htt
|
||||
- JDK 17 (Eclipse Adoptium 17.0.17+)
|
||||
|
||||
### Build
|
||||
```
|
||||
bash
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
@@ -124,17 +130,8 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
|
||||
- CSS injection (element hiding, grayscale, scroll behaviour)
|
||||
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
|
||||
|
||||
Nothing is modified server-side. The app never reads, intercepts, or stores Instagram content beyond what is explicitly listed (Reel URL, title, and thumbnail URL for the local history feature).
|
||||
|
||||
### Permissions
|
||||
| Permission | Reason |
|
||||
|---|---|
|
||||
| `INTERNET` | Load instagram.com |
|
||||
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
|
||||
| `WAKE_LOCK` | Keep device awake during active Focus sessions |
|
||||
| `FOREGROUND_SERVICE` | Run background service for session tracking |
|
||||
|
||||
### Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Framework | Flutter (Dart) |
|
||||
@@ -150,11 +147,11 @@ Nothing is modified server-side. The app never reads, intercepts, or stores Inst
|
||||
|
||||
FocusGram is an independent, free, and open-source productivity tool licensed under AGPL-3.0. It is not affiliated with, endorsed by, or associated with Meta Platforms, Inc. or Instagram in any way.
|
||||
|
||||
**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:**
|
||||
- Use Instagram's or Meta's private APIs
|
||||
- Intercept, read, log, or store user credentials, session data, or any content
|
||||
- Use/Alter Instagram's or Meta's private APIs
|
||||
- Intercept, read, log, or store user credentials, session data, or any sensitive content
|
||||
- Modify any server-side Meta or Instagram services
|
||||
- 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.
|
||||
@@ -167,6 +164,8 @@ For legal concerns, contact `notujwal@proton.me` before taking any other action.
|
||||
|
||||
## 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.
|
||||
|
||||
FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) under AGPL-3.0, Thanks for Reading README.
|
||||
@@ -9,6 +9,10 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- v2/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
|
||||
Copyright (c) 2005-2014, The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -45,7 +45,7 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "1.1.0"
|
||||
versionName = "2.1.0"
|
||||
}
|
||||
|
||||
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 {
|
||||
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-common")
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* FocusGram DOM Ad Blocker (Fallback)
|
||||
*
|
||||
* DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
|
||||
*
|
||||
* This script provides DOM-based ad removal as a FALLBACK for ads that slip through
|
||||
* GraphQL filtering. It's not reliable because Instagram has already rendered the content.
|
||||
*
|
||||
* Injected at DOCUMENT_END
|
||||
* Removes sponsored/posts/tracking elements from the DOM.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const AD_SIGNALS = [
|
||||
'Sponsored',
|
||||
'paid partnership',
|
||||
'Promoted',
|
||||
];
|
||||
|
||||
const textMatchesSignal = (txt) => {
|
||||
if (!txt) return false;
|
||||
const t = txt.trim().toLowerCase();
|
||||
return AD_SIGNALS.some((s) => t === s.toLowerCase());
|
||||
};
|
||||
|
||||
const removeSponsoredArticles = () => {
|
||||
try {
|
||||
// aria-label routes (best-effort; localization may break)
|
||||
document.querySelectorAll('a[aria-label]').forEach((a) => {
|
||||
const aria = a.getAttribute('aria-label') || '';
|
||||
if (textMatchesSignal(aria)) {
|
||||
const article = a.closest('article');
|
||||
if (article) article.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Text-based removal inside feed articles (best-effort)
|
||||
document.querySelectorAll('article').forEach((article) => {
|
||||
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
const txt = node.nodeValue;
|
||||
if (textMatchesSignal(txt)) {
|
||||
article.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Suggested content is intentionally left alone. Removing suggested
|
||||
// units after Instagram has virtualized the feed can snap the viewport
|
||||
// back to the top on some accounts.
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => removeSponsoredArticles());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
removeSponsoredArticles();
|
||||
})();
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* FocusGram Autoplay Blocker
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Prevents video autoplay by:
|
||||
* 1. Blocking play() calls on video elements
|
||||
* 2. Disabling autoplay attribute
|
||||
* 3. Removing preload attributes
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// This script is only registered when the setting is enabled, so default ON.
|
||||
window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
|
||||
? window.__fgBlockAutoplay : true;
|
||||
const ALLOW_KEY = '__fgUserStartedPlayback';
|
||||
let userGestureUntil = 0;
|
||||
|
||||
function isReelRoute() {
|
||||
const path = window.location.pathname || '';
|
||||
return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
|
||||
}
|
||||
|
||||
function isUserGestureActive() {
|
||||
return Date.now() < userGestureUntil;
|
||||
}
|
||||
|
||||
function markUserGesture(target) {
|
||||
userGestureUntil = Date.now() + 1200;
|
||||
try {
|
||||
let video = target && target.closest ? target.closest('video') : null;
|
||||
if (!video && target && target.querySelector) video = target.querySelector('video');
|
||||
if (video) video[ALLOW_KEY] = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', function (event) {
|
||||
markUserGesture(event.target);
|
||||
}, true);
|
||||
document.addEventListener('touchstart', function (event) {
|
||||
markUserGesture(event.target);
|
||||
}, true);
|
||||
document.addEventListener('click', function (event) {
|
||||
markUserGesture(event.target);
|
||||
}, true);
|
||||
|
||||
// Override HTMLMediaElement.play() to check our flag
|
||||
const _play = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
if (
|
||||
window.__fgBlockAutoplay &&
|
||||
!isReelRoute() &&
|
||||
this[ALLOW_KEY] !== true &&
|
||||
!isUserGestureActive()
|
||||
) {
|
||||
// Return a resolved promise to avoid breaking Instagram's code
|
||||
try { this.pause(); } catch (_) {}
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _play.call(this);
|
||||
};
|
||||
|
||||
// Override autoplay property setter
|
||||
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
|
||||
const _originalAutoplaySetter = _videoDescriptor.set;
|
||||
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
|
||||
set: function (value) {
|
||||
if (window.__fgBlockAutoplay && value) {
|
||||
// Silently ignore autoplay attempts when blocking is enabled
|
||||
return;
|
||||
}
|
||||
if (_originalAutoplaySetter) {
|
||||
_originalAutoplaySetter.call(this, value);
|
||||
}
|
||||
},
|
||||
get: function () {
|
||||
if (_videoDescriptor.get) {
|
||||
return _videoDescriptor.get.call(this);
|
||||
}
|
||||
return this.getAttribute('autoplay') !== null;
|
||||
},
|
||||
enumerable: _videoDescriptor.enumerable,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// On page load and SPA navigation, scan for video elements and remove autoplay
|
||||
const removeAutoplayFromVideos = () => {
|
||||
document.querySelectorAll('video, [role="video"]').forEach(el => {
|
||||
if (window.__fgBlockAutoplay && !isReelRoute() && el[ALLOW_KEY] !== true) {
|
||||
el.autoplay = false;
|
||||
el.removeAttribute('autoplay');
|
||||
el.removeAttribute('preload');
|
||||
try { el.preload = 'none'; } catch (_) {}
|
||||
if (el.paused === false) {
|
||||
el.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run on load and when document changes
|
||||
removeAutoplayFromVideos();
|
||||
|
||||
if (!window.__fgAutoplayObserver) {
|
||||
let _timer = null;
|
||||
window.__fgAutoplayObserver = new MutationObserver(() => {
|
||||
clearTimeout(_timer);
|
||||
_timer = setTimeout(removeAutoplayFromVideos, 500);
|
||||
});
|
||||
window.__fgAutoplayObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Allow Flutter to toggle
|
||||
window.__fgSetBlockAutoplay = function (enabled) {
|
||||
window.__fgBlockAutoplay = !!enabled;
|
||||
if (enabled) {
|
||||
removeAutoplayFromVideos();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('play', function (event) {
|
||||
if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
|
||||
event.target[ALLOW_KEY] = true;
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* FocusGram Content Hider
|
||||
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
|
||||
* Flutter controls via window.__fgContent.*
|
||||
* Injected at DOCUMENT_END.
|
||||
*
|
||||
* Key fixes applied:
|
||||
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
|
||||
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
|
||||
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
|
||||
* - Stories tray detection strengthened for fresh SPA navigations
|
||||
* - Suggested posts detection uses multiple text-node matching strategies
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.__fgContent && window.__fgContent.__focusgramReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const STYLE_ID = 'fg-content-hider';
|
||||
let hideStories = false;
|
||||
let hidePosts = false;
|
||||
let hideSuggested = false;
|
||||
let hideReels = false;
|
||||
|
||||
// ─── CSS rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildCSS() {
|
||||
const selectors = [];
|
||||
|
||||
if (hideStories) {
|
||||
selectors.push(
|
||||
'[role="list"]:has([aria-label*="tory"])',
|
||||
'[role="listbox"]:has([aria-label*="tory"])',
|
||||
'[role="menu"] > ul',
|
||||
'section > div > div:first-child [style*="overflow"]',
|
||||
'[role="list"] [style*="overflow"]',
|
||||
);
|
||||
}
|
||||
|
||||
if (hidePosts) {
|
||||
selectors.push(
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
|
||||
);
|
||||
}
|
||||
|
||||
// hideReels CSS is intentionally NOT added here.
|
||||
// We use DOM removal instead (see removeReels()) so that room is never left
|
||||
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
|
||||
|
||||
return selectors.length
|
||||
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
|
||||
: '';
|
||||
}
|
||||
|
||||
function applyCSS() {
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = buildCSS();
|
||||
}
|
||||
|
||||
// ─── Story tray JS ─────────────────────────────────────────────────────────
|
||||
|
||||
function hideStoryTray() {
|
||||
if (!hideStories) return;
|
||||
|
||||
// Strategy 1: <ul> children of a named list or menu
|
||||
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
|
||||
try {
|
||||
const items = ul.querySelectorAll('li, button, a');
|
||||
if (items.length < 2) return;
|
||||
ul.style.setProperty('display', 'none', 'important');
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Strategy 2: horizontally scrolling container with circle items
|
||||
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
|
||||
try {
|
||||
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
|
||||
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
|
||||
if (cands.length < 2) return;
|
||||
const s0 = window.getComputedStyle(cands[0]);
|
||||
if (s0.width && parseFloat(s0.width) <= 90) {
|
||||
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Suggested posts ───────────────────────────────────────────────────────
|
||||
|
||||
function removeSuggested() {
|
||||
if (!hideSuggested) return;
|
||||
|
||||
var SIGNALS = [
|
||||
'suggested for you',
|
||||
'suggested posts',
|
||||
'suggested reels',
|
||||
'suggested',
|
||||
'because you watched',
|
||||
'because you follow',
|
||||
'you might like',
|
||||
'posts you might like',
|
||||
'accounts you might like',
|
||||
'recommendations',
|
||||
];
|
||||
|
||||
function norm(s) {
|
||||
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasSignal(s) {
|
||||
var t = norm(s);
|
||||
if (!t) return false;
|
||||
return SIGNALS.some(function (signal) {
|
||||
if (signal === 'suggested') return t === signal;
|
||||
return t.indexOf(signal) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hideContainer(from) {
|
||||
var parent = from;
|
||||
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
|
||||
var role = parent.getAttribute && parent.getAttribute('role');
|
||||
var tag = parent.tagName;
|
||||
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
|
||||
if (
|
||||
tag === 'ARTICLE' ||
|
||||
tag === 'SECTION' ||
|
||||
role === 'listitem' ||
|
||||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
parent.setAttribute('data-fg-hidden-suggested', '1');
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
|
||||
try {
|
||||
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
|
||||
var ownLabel = node.getAttribute('aria-label');
|
||||
if (hasSignal(ownLabel)) { hideContainer(node); return; }
|
||||
var text = norm(node.innerText || node.textContent || '');
|
||||
if (
|
||||
text.indexOf('suggested for you') >= 0 ||
|
||||
text.indexOf('suggested posts') >= 0 ||
|
||||
text.indexOf('suggested reels') >= 0 ||
|
||||
text.indexOf('because you watched') >= 0 ||
|
||||
text.indexOf('because you follow') >= 0
|
||||
) {
|
||||
hideContainer(node);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
|
||||
try {
|
||||
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
|
||||
hideContainer(el);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
|
||||
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
|
||||
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
|
||||
// gap cleanly and lets the feed flow naturally.
|
||||
function removeReels() {
|
||||
if (!hideReels) return;
|
||||
|
||||
var toRemove = [];
|
||||
document.querySelectorAll('article').forEach(function (el) {
|
||||
try {
|
||||
// Fast path: check for a reel-signal attribute first
|
||||
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
|
||||
if (mt === '2') { toRemove.push(el); return; }
|
||||
|
||||
// Fallback: text-node scan for /reels/ markers
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
var n;
|
||||
while ((n = walker.nextNode())) {
|
||||
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
|
||||
toRemove.push(el); break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
window.__fgContent = {
|
||||
__focusgramReady: true,
|
||||
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
|
||||
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
|
||||
setHideSuggested: function (val) {
|
||||
hideSuggested = !!val;
|
||||
applyCSS();
|
||||
if (val) removeSuggested();
|
||||
},
|
||||
setHideReels: function (val) {
|
||||
hideReels = !!val;
|
||||
applyCSS();
|
||||
if (val) removeReels();
|
||||
},
|
||||
applyAll: function (flags) {
|
||||
hideStories = !!flags.stories;
|
||||
hidePosts = !!flags.posts;
|
||||
hideReels = !!flags.reels;
|
||||
hideSuggested = !!flags.suggested;
|
||||
applyCSS();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideReels) removeReels();
|
||||
},
|
||||
};
|
||||
|
||||
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
|
||||
// pushState/replaceState don't fire any DOM event we can listen for.
|
||||
// Hook the methods themselves so we know a navigation happened, then debounce
|
||||
// re-apply. This also catches the case where the MutationObserver was on `body`
|
||||
// and that node got replaced by Instagram's SPA re-render.
|
||||
|
||||
function scheduleReapply() {
|
||||
clearTimeout(window.__fg_applyTimer);
|
||||
window.__fg_applyTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
var _origPush = history.pushState;
|
||||
var _origReplace = history.replaceState;
|
||||
|
||||
history.pushState = function () {
|
||||
_origPush.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
history.replaceState = function () {
|
||||
_origReplace.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
// Reinforce on popstate too (user hits back/forward)
|
||||
window.addEventListener('popstate', scheduleReapply, { passive: true });
|
||||
// For pushState on the same URL (rare but possible) – poll path briefly
|
||||
window.addEventListener('pageshow', scheduleReapply, { passive: true });
|
||||
window.addEventListener('focus', scheduleReapply, { passive: true });
|
||||
|
||||
// ─── MutationObserver ───────────────────────────────────────────────────────
|
||||
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
|
||||
// re-applies everything on each cycle. Does NOT guard on a per-element timer
|
||||
// that would never re-fire after the body is replaced by SPA re-render.
|
||||
|
||||
if (!window.__fgContentObserver) {
|
||||
window.__fgContentObserver = new MutationObserver(function () {
|
||||
clearTimeout(window.__fg_moTimer);
|
||||
window.__fg_moTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// `document.documentElement` survives SPA navigations (body gets replaced
|
||||
// but <html> stays). Observing it catches both subtree mutations and, via
|
||||
// the SPA heartbeat above, re-applies after pushState.
|
||||
window.__fgContentObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Initial run ────────────────────────────────────────────────────────────
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
|
||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
||||
if (window.ContentChannel) {
|
||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* FocusGram Unified Feed Filter via Fetch Interception
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
*
|
||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
||||
* - Autoplay blocking (video autoplay prevention)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Configuration flags (set by Flutter via prefs)
|
||||
window.__fgFilterConfig = {
|
||||
blockAds: false,
|
||||
blockSponsored: false,
|
||||
blockSuggested: false,
|
||||
blockVideos: false,
|
||||
blockAutoplay: false,
|
||||
blockGraphQLQueryWhenFeedPosts: false,
|
||||
};
|
||||
|
||||
const textHasAdSignal = (value) => {
|
||||
const s = String(value || '').toLowerCase();
|
||||
return (
|
||||
s === 'sponsored' ||
|
||||
s.includes('"sponsored"') ||
|
||||
s.includes('paid partnership') ||
|
||||
s.includes('promoted') ||
|
||||
s.includes('ad_id') ||
|
||||
s.includes('ad_tracking') ||
|
||||
s.includes('sponsor_tags')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is an ad
|
||||
const isAdNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
const typename = String(node.__typename || '');
|
||||
const adText = JSON.stringify({
|
||||
organic_tracking_token: node.organic_tracking_token,
|
||||
sponsor_tags: node.sponsor_tags,
|
||||
social_context: node.social_context,
|
||||
title: node.title,
|
||||
header: node.header,
|
||||
label: node.label,
|
||||
overlay_text: node.overlay_text,
|
||||
});
|
||||
|
||||
return !!(
|
||||
node.is_ad ||
|
||||
node.is_paid_partnership ||
|
||||
node.sponsor_tags ||
|
||||
node.ad_tracking_token ||
|
||||
node.ad_action_link ||
|
||||
node.ad_id ||
|
||||
node.ad_impression_token ||
|
||||
node.ad_metadata ||
|
||||
node.commerciality_status === 'commercial' ||
|
||||
(node.product_type && node.product_type === 'ad') ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
typename === 'GraphAdStory' ||
|
||||
typename.includes('Ad') ||
|
||||
textHasAdSignal(adText)
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is sponsored
|
||||
const isSponsoredNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_paid_partnership ||
|
||||
node.sponsor_tags ||
|
||||
(node.ad_action_link && node.ad_action_link.href) ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is suggested content
|
||||
const isSuggestedNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
const typename = String(node.__typename || '');
|
||||
const reason = JSON.stringify({
|
||||
reason: node.suggested_reason,
|
||||
social_context: node.social_context,
|
||||
title: node.title,
|
||||
header: node.header,
|
||||
label: node.label,
|
||||
}).toLowerCase();
|
||||
|
||||
return !!(
|
||||
node.is_suggested ||
|
||||
node.is_suggested_for_you ||
|
||||
node.is_recommendation ||
|
||||
node.suggested_users ||
|
||||
node.suggested_media ||
|
||||
node.suggested_content ||
|
||||
node.recommendation_source ||
|
||||
typename.includes('Suggested') ||
|
||||
typename.includes('Recommendation') ||
|
||||
reason.includes('suggested') ||
|
||||
reason.includes('recommend')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is a video/reel
|
||||
const isVideoNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_video ||
|
||||
(node.media_type === 2) ||
|
||||
node.clips_metadata ||
|
||||
(node.__typename && (
|
||||
node.__typename.includes('Clips') ||
|
||||
node.__typename.includes('Video')
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
const isFeedMediaNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
return !!(
|
||||
node.pk ||
|
||||
node.id ||
|
||||
node.code ||
|
||||
node.media_type ||
|
||||
node.image_versions2 ||
|
||||
node.video_versions ||
|
||||
node.carousel_media ||
|
||||
node.__typename?.includes('Media') ||
|
||||
node.__typename?.includes('Timeline')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check for media in carousel
|
||||
const hasVideoInCarousel = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
if (node.media_type === 8) {
|
||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
||||
return edges.some(edge => isVideoNode(edge.node));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Main filter function for feed nodes
|
||||
const shouldFilterNode = (node) => {
|
||||
const config = window.__fgFilterConfig;
|
||||
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check ads
|
||||
if (config.blockAds && isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check sponsored (separate from ads)
|
||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check suggested content
|
||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check videos/reels
|
||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Recursively filter GraphQL response edges
|
||||
const filterEdges = (edges, path = []) => {
|
||||
if (!Array.isArray(edges)) return edges;
|
||||
|
||||
return edges.filter(edge => {
|
||||
if (!edge || !edge.node) return true;
|
||||
const node = edge.node;
|
||||
|
||||
// Keep the edge if it doesn't match any filter
|
||||
if (!shouldFilterNode(node)) return true;
|
||||
|
||||
// Log filtered content for debugging
|
||||
if (window.__fgDebugFilter) {
|
||||
const type = node.__typename || 'Unknown';
|
||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Recursively walk GraphQL response and filter edges
|
||||
const walkAndFilter = (obj, visited = new Set()) => {
|
||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
||||
visited.add(obj);
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => walkAndFilter(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for edges array (common GraphQL pattern)
|
||||
if (obj.edges && Array.isArray(obj.edges)) {
|
||||
obj.edges = filterEdges(obj.edges);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
walkAndFilter(val, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override fetch
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function (input, init) {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Call original fetch
|
||||
let response = await _fetch(input, init);
|
||||
|
||||
// Only intercept GraphQL feed queries
|
||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone response to read body
|
||||
const cloned = response.clone();
|
||||
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
|
||||
// Filter the response data
|
||||
walkAndFilter(data);
|
||||
|
||||
// Return modified response
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
// On error, return original response
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
// Preserve native function appearance
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: window.fetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
|
||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
|
||||
return _xhrOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
if (
|
||||
window.__fgFilterConfig.blockVideos &&
|
||||
this.__fgUrl &&
|
||||
(this.__fgUrl.includes('/api/v1/clips/') ||
|
||||
this.__fgUrl.includes('/api/v1/discover/'))
|
||||
) {
|
||||
try { this.abort(); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
return _xhrSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Allow Flutter to update config flags
|
||||
window.__fgSetFilterConfig = function (config) {
|
||||
if (typeof config === 'object') {
|
||||
Object.assign(window.__fgFilterConfig, config);
|
||||
if (window.__fgDebugFilter) {
|
||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Enable debug logging
|
||||
window.__fgDebugFilter = false;
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* FocusGram Ghost Mode (V2 Overlay)
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Blocks story-seen, message-seen, and online-presence signals.
|
||||
*
|
||||
* Uses _prev chain pattern: each section saves the PREVIOUS fetch/XHR
|
||||
* before overriding, so they compose rather than conflict.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── First-interaction DM gate ──────────────────────────────────────────
|
||||
// On /direct/*, first click blocks all api/graphql (inbox loads first).
|
||||
window.__fgDirectApiBlocked = false;
|
||||
document.addEventListener('click', function() {
|
||||
if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
|
||||
}, true);
|
||||
document.addEventListener('touchstart', function() {
|
||||
if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
|
||||
}, true);
|
||||
var _prevD = window.location.pathname.indexOf('/direct/') === 0;
|
||||
setInterval(function() {
|
||||
var now = window.location.pathname.indexOf('/direct/') === 0;
|
||||
if (now !== _prevD) { _prevD = now; window.__fgDirectApiBlocked = false; }
|
||||
}, 300);
|
||||
|
||||
function _blockIfNeeded(url) {
|
||||
return window.__fgDirectApiBlocked &&
|
||||
window.location.pathname.indexOf('/direct/') === 0 &&
|
||||
url.indexOf('/api/graphql') !== -1;
|
||||
}
|
||||
|
||||
// ─── SEEN + ACTIVITY patterns ───────────────────────────────────────────
|
||||
const SEEN_PATTERNS = [
|
||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
||||
];
|
||||
|
||||
const ACTIVITY_PATTERNS = [
|
||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
||||
/\/api\/v1\/web\/comments\/add\//,
|
||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
||||
];
|
||||
|
||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
||||
|
||||
// ─── Fetch override — chains with whatever was there ──────────────────────
|
||||
const _prevFetch = window.fetch;
|
||||
window.fetch = async function (input, init) {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// DM first-interaction gate
|
||||
if (_blockIfNeeded(url)) {
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Seen pattern block
|
||||
if (isSeen(url)) {
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'seen_blocked', url }));
|
||||
}
|
||||
return new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Activity interceptor for local history
|
||||
if (isActivity(url) && window.ActivityChannel) {
|
||||
const body = init?.body;
|
||||
const bodyText =
|
||||
body instanceof URLSearchParams
|
||||
? body.toString()
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: '';
|
||||
window.ActivityChannel.postMessage(
|
||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
||||
);
|
||||
}
|
||||
|
||||
return _prevFetch(input, init);
|
||||
};
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
|
||||
// ─── XHR override — chains ──────────────────────────────────────────────
|
||||
const _prevOpen = XMLHttpRequest.prototype.open;
|
||||
const _prevSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
||||
this._fg_url = url ?? '';
|
||||
this._fg_method = (method ?? '').toUpperCase();
|
||||
return _prevOpen.call(this, method, url, ...args);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
const url = this._fg_url || '';
|
||||
|
||||
// DM first-interaction gate
|
||||
if (_blockIfNeeded(url)) {
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
||||
Object.defineProperty(self, 'responseText', { get: () => '{"status":"ok"}' });
|
||||
Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' });
|
||||
['readystatechange', 'load'].forEach(function(t) {
|
||||
try { self.dispatchEvent(new Event(t)); } catch(e) {}
|
||||
});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// Seen pattern block
|
||||
if (url && isSeen(url)) {
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
||||
Object.defineProperty(self, 'responseText', { get: () => '{"status":"ok"}' });
|
||||
Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' });
|
||||
['readystatechange', 'load'].forEach(function(t) {
|
||||
try { self.dispatchEvent(new Event(t)); } catch(e) {}
|
||||
});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
|
||||
return _prevSend.call(this, body);
|
||||
};
|
||||
|
||||
// ─── WebSocket intercept (message-seen via WS) ──────────────────────────
|
||||
const _WS = window.WebSocket;
|
||||
|
||||
function PatchedWebSocket(url, protocols) {
|
||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
|
||||
ws.send = function (data) {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (
|
||||
parsed?.op === '4' ||
|
||||
parsed?.op === 'seen' ||
|
||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
||||
) {
|
||||
return; // drop
|
||||
}
|
||||
} catch (_) {}
|
||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
PatchedWebSocket.prototype = _WS.prototype;
|
||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
|
||||
// ─── Visibility trick — hide "Active Now" ──────────────────────────────
|
||||
window.__fgEnableOnlineHide = function () {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
get: () => 'hidden', configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
get: () => true, configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
window.__fgDisableOnlineHide = function () {
|
||||
delete document.visibilityState;
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
// Signal to Flutter that ghost mode JS is active
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* FocusGram Theme Detector
|
||||
* Reads light/dark theme from page and bridges to Flutter.
|
||||
* Injected at DOCUMENT_END.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
|
||||
function getTheme() {
|
||||
try {
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
|
||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (rgb && rgb.length >= 3) {
|
||||
const luminance =
|
||||
(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramThemeChannel',
|
||||
current
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
})();
|
||||
@@ -1,5 +0,0 @@
|
||||
Initial open-source release of FocusGram.
|
||||
- Complete Reels and Explore hiding.
|
||||
- Timed Reel sessions and daily limits.
|
||||
- Isolated DM Reel player.
|
||||
- Privacy-first: No Firebase or trackers.
|
||||
@@ -1 +0,0 @@
|
||||
Same as1st version. just version pump
|
||||
@@ -1,7 +0,0 @@
|
||||
New: Reels History, Minimal Mode, Disable Reels/Explore toggles, Autoplay Blocker, Screen Time Dashboard, Grayscale Mode, 8 content hide toggles.
|
||||
|
||||
Fixes: DM keyboard bug, Reels scroll lag.
|
||||
|
||||
Performance: Native nav bar, background preloading, skeleton screen, haptic feedback, smooth scrolling.
|
||||
|
||||
F-Droid: Removed all Google dependencies. No Play Services in APK.
|
||||
@@ -1,6 +0,0 @@
|
||||
What's new
|
||||
- Reordered Settings Page.
|
||||
- Added "Click to Unblur" for posts.
|
||||
- Added Persistent Notification
|
||||
- Improved Grayscale Scheduling.
|
||||
and more.
|
||||
@@ -1,10 +0,0 @@
|
||||
FocusGram is a free and open-source Android app designed to help you regain control over your social media usage. It wraps the mobile version of Instagram in a secure WebView and injects features to minimize distractions.
|
||||
|
||||
Features:
|
||||
- **Focus Mode**: Blur explore posts and hide reel buttons.
|
||||
- **Guardrails**: Set daily usage limits and session cooldowns.
|
||||
- **Mindfulness**: A mandatory breathing exercise before entering the app.
|
||||
- **Privacy First**: No analytics, no tracking, and no proprietary Google Play Services requirements.
|
||||
- **Hybrid Composition**: Optimized WebView performance for smooth scrolling.
|
||||
|
||||
FocusGram is NOT an Instagram mod. It uses the standard web version of Instagram and applies client-side CSS and JS modifications only.
|
||||
@@ -1 +0,0 @@
|
||||
A digital wellness wrapper for Instagram.
|
||||
@@ -1 +0,0 @@
|
||||
FocusGram
|
||||
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
SizedBox(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 8,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -35,12 +35,11 @@ class NativeBottomNav extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
final bgColor =
|
||||
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
|
||||
final iconColorInactive =
|
||||
isDark ? Colors.white70 : Colors.black54;
|
||||
final iconColorActive =
|
||||
theme.colorScheme.primary;
|
||||
final bgColor = theme.colorScheme.surface.withValues(
|
||||
alpha: isDark ? 0.95 : 0.98,
|
||||
);
|
||||
final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
|
||||
final iconColorActive = theme.colorScheme.primary;
|
||||
|
||||
final tabs = <_NavItem>[
|
||||
_NavItem(
|
||||
@@ -103,8 +102,7 @@ class NativeBottomNav extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: tabs.map((item) {
|
||||
final color =
|
||||
item.active ? iconColorActive : iconColorInactive;
|
||||
final color = item.active ? iconColorActive : iconColorInactive;
|
||||
final opacity = item.enabled ? 1.0 : 0.35;
|
||||
|
||||
return Expanded(
|
||||
@@ -129,10 +127,7 @@ class NativeBottomNav extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
),
|
||||
style: TextStyle(fontSize: 10, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,4 +159,3 @@ class _NavItem {
|
||||
required this.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:collection';
|
||||
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../../scripts/autoplay_blocker.dart';
|
||||
import '../../scripts/spa_navigation_monitor.dart';
|
||||
import '../../scripts/native_feel.dart';
|
||||
import '../../scripts/focus_scripts.dart';
|
||||
|
||||
class InstagramPreloader {
|
||||
static HeadlessInAppWebView? _headlessWebView;
|
||||
@@ -13,13 +13,11 @@ class InstagramPreloader {
|
||||
static bool isReady = false;
|
||||
|
||||
static Future<void> start(String userAgent) async {
|
||||
if (_headlessWebView != null) return; // don't start twice
|
||||
|
||||
if (_headlessWebView != null) return;
|
||||
|
||||
_headlessWebView = HeadlessInAppWebView(
|
||||
keepAlive: keepAlive,
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri('https://www.instagram.com/'),
|
||||
),
|
||||
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent: userAgent,
|
||||
mediaPlaybackRequiresUserGesture: true,
|
||||
@@ -33,12 +31,10 @@ class InstagramPreloader {
|
||||
safeBrowsingEnabled: false,
|
||||
),
|
||||
initialUserScripts: UnmodifiableListView([
|
||||
// DM Ghost — comprehensive blocking, gated by window.__fgFullDmGhost flag.
|
||||
// it should have worked, but sadly it didnt
|
||||
UserScript(
|
||||
source: 'window.__fgBlockAutoplay = true;',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kAutoplayBlockerJS,
|
||||
source: kFullDmGhostJS,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
@@ -49,6 +45,7 @@ class InstagramPreloader {
|
||||
source: kNativeFeelingScript,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
// ReelMetadataExtractor removed — reel history feature deleted
|
||||
]),
|
||||
onWebViewCreated: (c) {
|
||||
controller = c;
|
||||
@@ -69,4 +66,3 @@ class InstagramPreloader {
|
||||
isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ class ReelsHistoryEntry {
|
||||
final String title;
|
||||
final String thumbnailUrl;
|
||||
final DateTime visitedAt;
|
||||
final int durationSeconds; // How long the session lasted
|
||||
final int adsWatchedInSession; // How many ads watched during this session
|
||||
|
||||
const ReelsHistoryEntry({
|
||||
required this.id,
|
||||
@@ -15,15 +17,19 @@ class ReelsHistoryEntry {
|
||||
required this.title,
|
||||
required this.thumbnailUrl,
|
||||
required this.visitedAt,
|
||||
this.durationSeconds = 0,
|
||||
this.adsWatchedInSession = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'title': title,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'visitedAt': visitedAt.toUtc().toIso8601String(),
|
||||
};
|
||||
'id': id,
|
||||
'url': url,
|
||||
'title': title,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'visitedAt': visitedAt.toUtc().toIso8601String(),
|
||||
'durationSeconds': durationSeconds,
|
||||
'adsWatchedInSession': adsWatchedInSession,
|
||||
};
|
||||
|
||||
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
|
||||
return ReelsHistoryEntry(
|
||||
@@ -31,8 +37,11 @@ class ReelsHistoryEntry {
|
||||
url: (json['url'] as String?) ?? '',
|
||||
title: (json['title'] as String?) ?? 'Instagram Reel',
|
||||
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
|
||||
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
||||
visitedAt:
|
||||
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
||||
DateTime.now().toUtc(),
|
||||
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
|
||||
adsWatchedInSession: (json['adsWatchedInSession'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +79,8 @@ class ReelsHistoryService {
|
||||
required String url,
|
||||
required String title,
|
||||
required String thumbnailUrl,
|
||||
int durationSeconds = 0,
|
||||
int adsWatchedInSession = 0,
|
||||
}) async {
|
||||
if (url.isEmpty) return;
|
||||
final now = DateTime.now().toUtc();
|
||||
@@ -88,6 +99,8 @@ class ReelsHistoryService {
|
||||
title: title.isEmpty ? 'Instagram Reel' : title,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
visitedAt: now,
|
||||
durationSeconds: durationSeconds,
|
||||
adsWatchedInSession: adsWatchedInSession,
|
||||
);
|
||||
|
||||
final updated = [entry, ...entries];
|
||||
@@ -103,6 +116,47 @@ class ReelsHistoryService {
|
||||
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 {
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.remove(_prefsKey);
|
||||
@@ -114,4 +168,3 @@ class ReelsHistoryService {
|
||||
await prefs.setString(_prefsKey, jsonEncode(jsonList));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -121,10 +118,11 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
||||
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
|
||||
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
|
||||
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
|
||||
text =
|
||||
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
|
||||
text = text.replaceAll(
|
||||
RegExp(r'\[([^\]]+)\]\([^)]+\)'),
|
||||
r'\1',
|
||||
); // links -> text
|
||||
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,9 @@ class UpdateCheckerService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final cleanVersion =
|
||||
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
|
||||
final cleanVersion = gitVersionTag.startsWith('v')
|
||||
? gitVersionTag.substring(1)
|
||||
: gitVersionTag;
|
||||
|
||||
var trimmed = body.trim();
|
||||
if (trimmed.length > 1500) {
|
||||
@@ -73,7 +74,7 @@ class UpdateCheckerService extends ChangeNotifier {
|
||||
_isDismissed = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Update check failed: $e');
|
||||
// debugPrint('Update check failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class FocusSettings {
|
||||
final bool ghostMode; // DM ghost — blocks seen/DM signals comprehensively
|
||||
final bool noAds; // strip ads and sponsored posts
|
||||
final bool noStories; // hide story tray
|
||||
final bool noReels; // hide reels tab
|
||||
final bool noAutoplay; // stop videos autoplaying
|
||||
final bool noDMs; // block direct messages
|
||||
|
||||
const FocusSettings({
|
||||
this.ghostMode = false,
|
||||
this.noAds = true,
|
||||
this.noStories = false,
|
||||
this.noReels = false,
|
||||
this.noAutoplay = false,
|
||||
this.noDMs = false,
|
||||
});
|
||||
}
|
||||
+81
-6
@@ -4,11 +4,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.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/settings_service.dart';
|
||||
import 'services/screen_time_service.dart';
|
||||
import 'services/focusgram_router.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/main_webview_page.dart';
|
||||
import 'screens/breath_gate_screen.dart';
|
||||
@@ -17,6 +25,7 @@ import 'screens/cooldown_gate_screen.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'features/update_checker/update_checker_service.dart';
|
||||
import 'features/preloader/instagram_preloader.dart';
|
||||
import 'widgets/remote_popup_handler.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -27,23 +36,40 @@ void main() async {
|
||||
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 settingsService = SettingsService();
|
||||
final screenTimeService = ScreenTimeService();
|
||||
|
||||
final updateChecker = UpdateCheckerService();
|
||||
|
||||
await creditStore.init();
|
||||
await baitEngine.init();
|
||||
await appLockService.init();
|
||||
await levelService.init();
|
||||
await snapshotService.init();
|
||||
await sessionManager.init();
|
||||
await settingsService.init();
|
||||
await screenTimeService.init();
|
||||
await NotificationService().init();
|
||||
|
||||
await NotificationService().init(requestPermissions: true);
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: sessionManager),
|
||||
ChangeNotifierProvider.value(value: settingsService),
|
||||
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),
|
||||
],
|
||||
child: const FocusGramApp(),
|
||||
@@ -88,7 +114,7 @@ class FocusGramApp extends StatelessWidget {
|
||||
/// 2. Cooldown Gate (if app-open cooldown active)
|
||||
/// 3. Breath Gate (if enabled in settings)
|
||||
/// 4. If an app session is already active, resume it
|
||||
/// otherwise show App Session Picker
|
||||
/// otherwise show App Session Picker
|
||||
/// 5. Main WebView
|
||||
class InitialRouteHandler extends StatefulWidget {
|
||||
const InitialRouteHandler({super.key});
|
||||
@@ -97,30 +123,67 @@ class InitialRouteHandler extends StatefulWidget {
|
||||
State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
|
||||
}
|
||||
|
||||
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
class _InitialRouteHandlerState extends State<InitialRouteHandler>
|
||||
with WidgetsBindingObserver {
|
||||
bool _breathCompleted = false;
|
||||
bool _appSessionStarted = false;
|
||||
bool _onboardingCompleted = false;
|
||||
bool _lockScreenDismissed = false;
|
||||
late AppLinks _appLinks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_appLinks = AppLinks();
|
||||
_initDeepLinks();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
RemotePopupHandler.checkAndShow(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final appLock = context.read<AppLockService>();
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
appLock.onBackgrounded();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
if (appLock.shouldLockOnResume) {
|
||||
appLock.onLockScreenShown();
|
||||
_showLockScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showLockScreen() async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)),
|
||||
);
|
||||
if (result == true && mounted) {
|
||||
setState(() => _lockScreenDismissed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initDeepLinks() async {
|
||||
// 1. Handle background links while app is running
|
||||
_appLinks.uriLinkStream.listen((uri) {
|
||||
debugPrint('Incoming Deep Link: $uri');
|
||||
// debugPrint('Incoming Deep Link: $uri');
|
||||
FocusGramRouter.pendingUrl.value = uri.toString();
|
||||
});
|
||||
|
||||
// 2. Handle the initial link that opened the app
|
||||
final initialUri = await _appLinks.getInitialLink();
|
||||
if (initialUri != null) {
|
||||
debugPrint('Initial Deep Link: $initialUri');
|
||||
// debugPrint('Initial Deep Link: $initialUri');
|
||||
FocusGramRouter.pendingUrl.value = initialUri.toString();
|
||||
}
|
||||
}
|
||||
@@ -129,6 +192,17 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
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
|
||||
if (settings.isFirstRun && !_onboardingCompleted) {
|
||||
@@ -145,6 +219,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
// Step 3: Breath gate
|
||||
if (settings.showBreathGate && !_breathCompleted) {
|
||||
return BreathGateScreen(
|
||||
durationSeconds: settings.breathGateSeconds,
|
||||
onFinish: () => setState(() => _breathCompleted = true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
55,
|
||||
60,
|
||||
];
|
||||
int _selectedIndex = 2; // default: 15 min
|
||||
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
|
||||
late final FixedExtentScrollController _scrollController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
|
||||
final lastIndex = _minuteOptions.indexOf(lastMinutes);
|
||||
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
|
||||
_scrollController = FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
perspective: 0.003,
|
||||
squeeze: 1.1,
|
||||
diameterRatio: 2.5,
|
||||
controller: _scrollController,
|
||||
onSelectedItemChanged: (i) {
|
||||
setState(() => _selectedIndex = i);
|
||||
},
|
||||
controller: FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
),
|
||||
childDelegate: ListWheelChildListDelegate(
|
||||
children: _minuteOptions.asMap().entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedIndex;
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 10-second breath.
|
||||
/// A mindfulness screen shown before Instagram opens.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
final int durationSeconds;
|
||||
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
const BreathGateScreen({
|
||||
super.key,
|
||||
required this.onFinish,
|
||||
this.durationSeconds = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
int _secondsRemaining = 10;
|
||||
late int _secondsRemaining;
|
||||
Timer? _timer;
|
||||
bool _canContinue = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
|
||||
|
||||
// 10-second breathing animation: 5s in, 5s out
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 5),
|
||||
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Are you sure you want to open FocusGram?',
|
||||
'Are you sure you want to open Instagram?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
),
|
||||
child: const Text('Continue to FocusGram'),
|
||||
child: const Text('Continue to Instagram'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/settings_service.dart';
|
||||
import 'ghost_mode_submenu_page.dart';
|
||||
|
||||
class ExtrasSettingsPage extends StatelessWidget {
|
||||
const ExtrasSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsService>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Extras',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const _SectionHeader(title: 'STARTUP'),
|
||||
_LaunchPagePicker(settings: settings),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
const _SectionHeader(title: 'MEDIA'),
|
||||
_SwitchTile(
|
||||
title: 'Download Media (Feed + Reels)',
|
||||
subtitle: 'Adds a download icon on posts and reels',
|
||||
value: settings.videoDownloadEnabled,
|
||||
onChanged: (v) async {
|
||||
await settings.setVideoDownloadEnabled(v);
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'FOCUS'),
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: settings.ghostMode
|
||||
? Colors.purple.withValues(alpha: 0.15)
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.visibility_off_rounded,
|
||||
color: settings.ghostMode ? Colors.purpleAccent : Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: const Text('Ghost Mode', style: TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
_ghostSubtitle(settings),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right, size: 20),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const GhostModeSubmenuPage()),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _ghostSubtitle(SettingsService s) {
|
||||
if (s.ghostMode) return 'DM Ghost active — works inside chat only';
|
||||
return 'Tap to configure ghost modes';
|
||||
}
|
||||
|
||||
class _LaunchPagePicker extends StatelessWidget {
|
||||
final SettingsService settings;
|
||||
const _LaunchPagePicker({required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final options = ['home', 'following', 'favorites', 'direct'];
|
||||
final labels = {
|
||||
'home': 'Home Feed',
|
||||
'following': 'Following',
|
||||
'favorites': 'Favorites',
|
||||
'direct': 'Direct Messages',
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: settings.startupPage,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Launch Page',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
items: options
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p,
|
||||
child: Text(
|
||||
labels[p] ?? p,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) settings.setStartupPage(v);
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Choose which page opens when you launch Focusgram.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.white38 : Colors.black38,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
const _SwitchTile({
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle!, style: const TextStyle(fontSize: 12))
|
||||
: null,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/level_service.dart';
|
||||
import 'adsterra_ad_screen.dart';
|
||||
import '../utils/discipline_challenge.dart';
|
||||
|
||||
class GuardrailsPage extends StatefulWidget {
|
||||
@@ -18,7 +20,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
if (sm.isScheduledBlockActive) {
|
||||
final ok = await DisciplineChallenge.show(context, count: 35);
|
||||
final settings = context.read<SettingsService>();
|
||||
final ok = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!context.mounted || !ok) return;
|
||||
}
|
||||
await action();
|
||||
@@ -109,20 +115,33 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildFrictionSliderTile(
|
||||
context: context,
|
||||
sm: sm,
|
||||
title: 'Daily Reel Limit',
|
||||
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
|
||||
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
|
||||
min: 5,
|
||||
max: 120,
|
||||
divisor: 5,
|
||||
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
|
||||
warningText:
|
||||
'Increasing your limit makes it easier to scroll. Are you sure?',
|
||||
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()),
|
||||
),
|
||||
// If quota used up, show earn page instead of slider
|
||||
if (sm.dailyRemainingSeconds <= 0)
|
||||
_buildQuotaExhaustedTile(context, sm)
|
||||
else
|
||||
_buildFrictionSliderTile(
|
||||
context: context,
|
||||
sm: sm,
|
||||
title: 'Daily Reel Limit',
|
||||
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
|
||||
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
|
||||
min: 5,
|
||||
max: 120,
|
||||
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(
|
||||
context: context,
|
||||
sm: sm,
|
||||
@@ -221,6 +240,71 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuotaExhaustedTile(BuildContext context, SessionManager sm) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.hourglass_empty,
|
||||
color: Colors.orangeAccent,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Daily Reel Quota Used Up',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Watch an ad to earn 3 more minutes of reel time.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _earnQuota(context, sm),
|
||||
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
|
||||
label: const Text('Watch Ad (+3 min reels)'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _earnQuota(BuildContext context, SessionManager sm) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
|
||||
),
|
||||
);
|
||||
if (result == true && context.mounted) {
|
||||
sm.increaseDailyLimit(3);
|
||||
context.read<LevelService>().addXpForAd();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('+3 min reel quota earned!')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFrictionSliderTile({
|
||||
required BuildContext context,
|
||||
required SessionManager sm,
|
||||
@@ -321,7 +405,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final sm = context.read<SessionManager>();
|
||||
int wordCount = 15;
|
||||
final settings = context.read<SettingsService>();
|
||||
int wordCount = settings.resolvedWordChallengeCount();
|
||||
// If we are at 0 quota, increase difficulty to 35 words
|
||||
if (widget.title.contains('Daily Reel Limit') &&
|
||||
sm.dailyRemainingSeconds <= 0) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1577
-276
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
|
||||
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
|
||||
static const int _kTotalPages = 5;
|
||||
|
||||
static const int _kBlurPage = 3;
|
||||
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final List<Widget> slides = [
|
||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.auto_awesome,
|
||||
color: Colors.blue,
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
color: const Color(0xFF4F8DFF),
|
||||
title: 'Welcome to FocusGram',
|
||||
description:
|
||||
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
||||
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
|
||||
),
|
||||
|
||||
// ── Page 1: Session Management ───────────────────────────────────────
|
||||
// ── Page 1: Focus controls ───────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.timer,
|
||||
color: Colors.orange,
|
||||
title: 'Session Management',
|
||||
icon: Icons.timer_outlined,
|
||||
color: const Color(0xFFFFB74D),
|
||||
title: 'Time With Intent',
|
||||
description:
|
||||
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
||||
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
|
||||
),
|
||||
|
||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.link,
|
||||
color: Colors.cyan,
|
||||
icon: Icons.link_rounded,
|
||||
color: const Color(0xFF35C2D6),
|
||||
title: 'Open Links in FocusGram',
|
||||
description:
|
||||
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
||||
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
|
||||
// ── Page 4: Notifications ────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.notifications_active,
|
||||
color: Colors.green,
|
||||
title: 'Stay Notified',
|
||||
icon: Icons.notifications_active_outlined,
|
||||
color: const Color(0xFF5DD18A),
|
||||
title: 'Useful Alerts Only',
|
||||
description:
|
||||
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
||||
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
|
||||
isPermissionPage: true,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 28),
|
||||
// CTA button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final isBlur = _currentPage == _kBlurPage;
|
||||
|
||||
String label;
|
||||
if (isLast) {
|
||||
label = 'Get Started';
|
||||
if (isNotif) {
|
||||
label = 'Allow & Start';
|
||||
} else if (isLink) {
|
||||
label = 'Configure';
|
||||
} else if (isNotif) {
|
||||
label = 'Allow Notifications';
|
||||
} else if (isBlur) {
|
||||
label = 'Save & Continue';
|
||||
} else if (isLast) {
|
||||
label = 'Get Started';
|
||||
} else {
|
||||
label = 'Next';
|
||||
}
|
||||
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
);
|
||||
} else if (isNotif) {
|
||||
await Permission.notification.request();
|
||||
await NotificationService().init();
|
||||
await NotificationService()
|
||||
.requestPermissionsNow();
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
// Skip button (available on all pages except last)
|
||||
if (_currentPage < _kTotalPages - 1)
|
||||
TextButton(
|
||||
onPressed: () => _finish(context),
|
||||
onPressed: () {
|
||||
if (_currentPage == _kNotifPage) {
|
||||
_finish(context);
|
||||
} else {
|
||||
_pageController.animateToPage(
|
||||
_kTotalPages - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Skip',
|
||||
'Skip setup',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
),
|
||||
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
||||
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 120, color: color),
|
||||
const SizedBox(height: 48),
|
||||
Container(
|
||||
width: 112,
|
||||
height: 112,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
||||
),
|
||||
child: Icon(icon, size: 54, color: color),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isPermissionPage || isAppSettingsPage) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Text(
|
||||
isPermissionPage
|
||||
? 'Permission is optional and can be changed later.'
|
||||
: 'This opens Android settings; return here when done.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: false,
|
||||
blockHomeFeedScroll: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/discipline_challenge.dart';
|
||||
|
||||
class SessionModal extends StatefulWidget {
|
||||
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [1, 5, 10, 15].map((m) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
||||
Slider(
|
||||
value: _customMinutes,
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
max: 60,
|
||||
divisions: 59,
|
||||
label: '${_customMinutes.toInt()}m',
|
||||
onChanged: (v) => setState(() => _customMinutes = v),
|
||||
),
|
||||
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
|
||||
|
||||
void _start(int minutes) async {
|
||||
final sm = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
|
||||
// Always require word challenge for reel sessions (User request)
|
||||
final success = await DisciplineChallenge.show(context);
|
||||
if (!success) return;
|
||||
if (settings.requireWordChallenge) {
|
||||
final success = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!success) return;
|
||||
}
|
||||
|
||||
if (sm.startSession(minutes)) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
+535
-115
File diff suppressed because it is too large
Load Diff
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,3 +223,36 @@ const String kAutoplayBlockerJS = r'''
|
||||
}, 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);
|
||||
})();
|
||||
''';
|
||||
|
||||
@@ -451,18 +451,40 @@ const String kHideSuggestedPostsJS = r'''
|
||||
(function() {
|
||||
function hideSuggestedPosts() {
|
||||
try {
|
||||
document.querySelectorAll('span, h3, h4').forEach(function(el) {
|
||||
// Target text patterns that indicate suggested content
|
||||
const suggestedPatterns = [
|
||||
'Suggested for you',
|
||||
'Suggested posts',
|
||||
"You're all caught up",
|
||||
'Suggested',
|
||||
'Recommendations',
|
||||
'Discover more',
|
||||
'Suggested Accounts',
|
||||
];
|
||||
|
||||
// Find and hide all elements with suggested content text
|
||||
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
|
||||
try {
|
||||
const text = el.textContent.trim();
|
||||
if (
|
||||
text === 'Suggested for you' ||
|
||||
text === 'Suggested posts' ||
|
||||
text === "You're all caught up"
|
||||
) {
|
||||
const matched = suggestedPatterns.some(pattern =>
|
||||
text === pattern || text.includes(pattern)
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
let parent = el.parentElement;
|
||||
for (let i = 0; i < 8 && parent; i++) {
|
||||
// Traverse up to find the container section/article
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||
const classList = parent.className || '';
|
||||
|
||||
// Hide articles, sections, lists, and common suggestion containers
|
||||
if (
|
||||
tag === 'article' ||
|
||||
tag === 'section' ||
|
||||
tag === 'li' ||
|
||||
classList.includes('xjx87jv0') || // Instagram suggestion container
|
||||
classList.includes('x1a8lsjc') // Reel suggestion container
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
@@ -471,6 +493,21 @@ const String kHideSuggestedPostsJS = r'''
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Also hide by attribute patterns
|
||||
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
|
||||
try {
|
||||
let parent = el;
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,11 @@ const String kBlurHomeFeedAndExploreCSS = '''
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
/* Per-post unblur override (set by kTapToUnblurJS) */
|
||||
[data-fg-unblurred="1"] img,
|
||||
[data-fg-unblurred="1"] video {
|
||||
/* Must match the blur selector's specificity (body[path="/"] article img = 0,0,1,3) */
|
||||
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;
|
||||
-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) {
|
||||
try {
|
||||
media.style.setProperty('filter', 'none', 'important');
|
||||
@@ -164,11 +176,15 @@ const String kTapToUnblurJS = r'''
|
||||
if (!media) return;
|
||||
const host = getHost(media);
|
||||
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.
|
||||
markUnblurred(host);
|
||||
unblurMedia(media);
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} catch (_) {}
|
||||
@@ -277,13 +293,15 @@ const String kReelsMutationObserverJS = r'''
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present
|
||||
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
|
||||
// feed as a separate Minimal Mode control.
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
// Only lock scroll when reel element is actually present on the page
|
||||
if (window.__fgDisableReelsEntirely === true &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
|
||||
if (window.__fgBlockHomeFeedScroll === true &&
|
||||
(window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
return 'home_feed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -338,8 +356,7 @@ const String kReelsMutationObserverJS = r'''
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
// Apply lock for dm_reel or disabled modes when reel is present
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
|
||||
@@ -13,4 +13,3 @@ const String kDmKeyboardFixJS = r'''
|
||||
} catch (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import '../focus_settings.dart';
|
||||
|
||||
/// Flutter sets these flags after settings load to enable ghost modes.
|
||||
/// Must be called from onWebViewCreated or on settings change.
|
||||
const String kSetGhostFlagsJS = '''
|
||||
(function(){
|
||||
// Placeholder — Flutter replaces these with actual setting values:
|
||||
// window.__fgPartialGhost = true/false;
|
||||
// window.__fgFullDmGhost = true/false;
|
||||
// window.__fgStoryGhost = true/false;
|
||||
// window.__fgGhostReady = true; // signals scripts can proceed
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIAL GHOST MODE — existing behavior
|
||||
// Blocks seen API patterns, WebSocket chat gateways, and uses
|
||||
// first-click gate for api/graphql on /direct/* (inbox loads, then block).
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kPartialGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgPartialGhostPatched) return;
|
||||
window.__fgPartialGhostPatched = true;
|
||||
|
||||
// ── Seen API patterns ──────────────────────────────────────
|
||||
var SEEN = [/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//];
|
||||
function isSeen(u) { for(var i=0;i<SEEN.length;i++){if(SEEN[i].test(u))return true;}return false; }
|
||||
|
||||
// ── First-click gate for api/graphql on /direct/* ──────────
|
||||
window.__fgDirectApiBlocked = false;
|
||||
document.addEventListener('click',function(){
|
||||
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
|
||||
},true);
|
||||
document.addEventListener('touchstart',function(){
|
||||
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
|
||||
},true);
|
||||
var _prevD=window.location.pathname.indexOf('/direct/')===0;
|
||||
setInterval(function(){
|
||||
var n=window.location.pathname.indexOf('/direct/')===0;
|
||||
if(n!==_prevD){_prevD=n;window.__fgDirectApiBlocked=false;}
|
||||
},300);
|
||||
|
||||
function partialEnabled() { return window.__fgPartialGhost===true; }
|
||||
function shouldBlock(u) {
|
||||
if (!partialEnabled()) return false;
|
||||
return window.location.pathname.indexOf('/direct/')===0 &&
|
||||
window.__fgDirectApiBlocked &&
|
||||
u.indexOf('/api/graphql')!==-1;
|
||||
}
|
||||
|
||||
// ── Fetch override (chain with previous fetch) ─────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch=function(i,init){
|
||||
var u=(typeof i==='string')?i:(i&&i.url)?i.url:'';
|
||||
if(partialEnabled()&&(isSeen(u)||shouldBlock(u))) return Promise.resolve(new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
return _prevFetch.call(window,i,init);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen=XMLHttpRequest.prototype.open,_prevSend=XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open=function(m,u){this.__fgU=u||'';return _prevOpen.apply(this,arguments);};
|
||||
XMLHttpRequest.prototype.send=function(b){
|
||||
if(partialEnabled()&&(isSeen(this.__fgU||'')||shouldBlock(this.__fgU||''))){
|
||||
var self=this;setTimeout(function(){
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
},5);return;
|
||||
}
|
||||
return _prevSend.apply(this,arguments);
|
||||
};
|
||||
|
||||
// ── Selective WS seen-message filter (no gouger) ───────────
|
||||
(function() {
|
||||
var _WS = window.WebSocket;
|
||||
function PartialWS(url, protocols) {
|
||||
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
var _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
var parsed = JSON.parse(data);
|
||||
if (parsed && (parsed.op === '4' || parsed.op === 'seen')) return;
|
||||
} catch(e) {}
|
||||
if (data.indexOf('"seen"') !== -1 && data.indexOf('"thread_id"') !== -1) return;
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
PartialWS.prototype = _WS.prototype;
|
||||
PartialWS.CONNECTING = _WS.CONNECTING;
|
||||
PartialWS.OPEN = _WS.OPEN;
|
||||
PartialWS.CLOSING = _WS.CLOSING;
|
||||
PartialWS.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PartialWS;
|
||||
})();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// FULL DM GHOST — blocks ALL api/graphql on /direct/* immediately
|
||||
// (inbox won't load, messages can't be sent)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kFullDmGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgFullDmGhostPatched) return;
|
||||
window.__fgFullDmGhostPatched = true;
|
||||
|
||||
// ── Smart path-based blocking ──────────────────────────────
|
||||
// /direct/inbox/ → allow (inbox loads)
|
||||
// /direct/t/* → block ALL api/graphql immediately
|
||||
// any /direct/* → block except /direct/inbox/
|
||||
function shouldBlockDmPath() {
|
||||
if (window.__fgFullDmGhost !== true) return false;
|
||||
var p = window.location.pathname;
|
||||
if (p.indexOf('/direct/') !== 0) return false;
|
||||
if (p === '/direct/inbox/' || p === '/direct/inbox') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── DM URL blocklist ───────────────────────────────────────
|
||||
var DM_URLS = [
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/mark_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/mark_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_visual_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/visual_thread\\/[^/]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_audio_seen\\//,
|
||||
/\\/api\\/v1\\/live\\/[^/]+\\/join\\//,
|
||||
/\\/api\\/v1\\/live\\/[^/]+\\/get_join_requests\\//,
|
||||
/\\/api\\/v1\\/media\\/seen\\//,
|
||||
/\\/api\\/v1\\/feed\\/viewed_story\\//,
|
||||
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
|
||||
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/visual_message\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/live\\/[\\w-]+\\/comment\\/seen\\//,
|
||||
/\\/api\\/v1\\/qe\\//,
|
||||
/\\/api\\/v1\\/launcher\\/sync\\//,
|
||||
/\\/api\\/v1\\/logging\\//,
|
||||
/\\/api\\/v1\\/fb_onetap_logging\\//,
|
||||
/\\/ajax\\/bz/,
|
||||
/\\/ajax\\/logging\\//,
|
||||
/\\/api\\/v1\\/stats\\//,
|
||||
/\\/api\\/v1\\/fbanalytics\\//,
|
||||
];
|
||||
|
||||
function matchUrl(url) {
|
||||
if (!url) return false;
|
||||
for (var i = 0; i < DM_URLS.length; i++) { if (DM_URLS[i].test(url)) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── DM GraphQL operations ──────────────────────────────────
|
||||
var DM_OPS = [
|
||||
'MarkDirectThreadItemSeen','markDirectThreadItemSeen',
|
||||
'DirectMarkItemSeen','DirectThreadMarkSeen',
|
||||
'MarkVisualMessageSeen','DirectMarkVisualItemSeen',
|
||||
'MarkAudioMessageSeen','AudioSeenMutation',
|
||||
'LiveJoinBroadcast','JoinLiveBroadcast','MarkLiveViewer',
|
||||
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
|
||||
'LogImpression','LogClick','FeedbackSeenMutation',
|
||||
];
|
||||
|
||||
function matchGraphQL(body) {
|
||||
if (!body) return false;
|
||||
var str = typeof body === 'string' ? body : String(body);
|
||||
for (var i = 0; i < DM_OPS.length; i++) { if (str.indexOf(DM_OPS[i]) !== -1) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGraphql(url) {
|
||||
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
|
||||
}
|
||||
|
||||
function shouldBlock(url, init) {
|
||||
// 1. Path-based: on /direct/t/* block ALL graphql
|
||||
if (shouldBlockDmPath() && isGraphql(url)) return true;
|
||||
// 2. URL blocklist match
|
||||
if (matchUrl(url)) return true;
|
||||
// 3. GraphQL body op-name match
|
||||
if (isGraphql(url) && init) {
|
||||
var bs = '';
|
||||
if (typeof init.body === 'string') bs = init.body;
|
||||
else if (init.body && init.body.toString) bs = init.body.toString();
|
||||
if (matchGraphQL(bs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
|
||||
|
||||
// ── Fetch override (chain) ─────────────────────────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch = function(i, init) {
|
||||
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
|
||||
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
|
||||
return _prevFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen = XMLHttpRequest.prototype.open;
|
||||
var _prevSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(m, u) { this.__fgDU = u || ''; return _prevOpen.apply(this, arguments); };
|
||||
XMLHttpRequest.prototype.send = function(b) {
|
||||
var u = this.__fgDU || '';
|
||||
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockDmPath())) {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
return _prevSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── SW killer ──────────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Beacon blocker ─────────────────────────────────────────
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon = function(url) { return true; };
|
||||
}
|
||||
|
||||
// ── MQTT WS intercept (typing / live viewer / seen) ────────
|
||||
// Instagram uses MQTT over WebSocket for real-time events.
|
||||
// '/t_fs' = foreground state, '/t_mt' = mark thread seen,
|
||||
// '/t_s' and '/t_se' = seen receipts, 'activity_indicator' = active status.
|
||||
(function() {
|
||||
var _WS = window.WebSocket;
|
||||
function DmGhostWS(url, protocols) {
|
||||
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
var _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
var bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
var packetType = bytes[0] & 0xF0;
|
||||
if (packetType === 0x30) {
|
||||
try {
|
||||
var decoded = new TextDecoder('utf-8').decode(bytes);
|
||||
if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('/t_mt') !== -1 ||
|
||||
decoded.indexOf('/t_s') !== -1 || decoded.indexOf('/t_se') !== -1 ||
|
||||
decoded.indexOf('activity_indicator') !== -1 ||
|
||||
decoded.indexOf('is_typing') !== -1 || decoded.indexOf('direct_typing') !== -1 ||
|
||||
decoded.indexOf('/live/viewer') !== -1 || decoded.indexOf('live_viewer_list') !== -1) {
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
} else if (typeof data === 'string') {
|
||||
if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 ||
|
||||
data.indexOf('is_typing') !== -1 || data.indexOf('mark_seen') !== -1 ||
|
||||
data.indexOf('mark_read') !== -1 || data.indexOf('receipt') !== -1) return;
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
DmGhostWS.prototype = _WS.prototype;
|
||||
DmGhostWS.CONNECTING = _WS.CONNECTING;
|
||||
DmGhostWS.OPEN = _WS.OPEN;
|
||||
DmGhostWS.CLOSING = _WS.CLOSING;
|
||||
DmGhostWS.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = DmGhostWS;
|
||||
})();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STORY GHOST — blocks api/graphql on homepage (/) and /stories/*
|
||||
// Allows viewing stories without sending seen indicators.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kStoryGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgStoryGhostPatched) return;
|
||||
window.__fgStoryGhostPatched = true;
|
||||
|
||||
// ── Smart path-based blocking ──────────────────────────────
|
||||
// On /, /stories/*, /story/* → block ALL api/graphql
|
||||
// On /direct/inbox/ → allow (DMs need graphql to load messages)
|
||||
function shouldBlockByPath() {
|
||||
if (window.__fgStoryGhost !== true) return false;
|
||||
var p = window.location.pathname;
|
||||
// Don't block on DM pages
|
||||
if (p.indexOf('/direct/') === 0) return false;
|
||||
var isStory = p.indexOf('/stories/') === 0 || p.indexOf('/story/') === 0;
|
||||
var isHome = p === '/' || p === '';
|
||||
return isHome || isStory;
|
||||
}
|
||||
|
||||
// ── Story URL blocklist ────────────────────────────────────
|
||||
var STORY_URLS = [
|
||||
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
|
||||
/\\/api\\/v1\\/feed\\/viewed_story\\//,
|
||||
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
|
||||
/\\/api\\/v1\\/media\\/seen\\//,
|
||||
];
|
||||
|
||||
function matchUrl(url) {
|
||||
if (!url) return false;
|
||||
for (var i = 0; i < STORY_URLS.length; i++) { if (STORY_URLS[i].test(url)) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Story GraphQL operations ───────────────────────────────
|
||||
var STORY_OPS = [
|
||||
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
|
||||
'FeedbackSeenMutation',
|
||||
];
|
||||
|
||||
function matchGraphQL(body) {
|
||||
if (!body) return false;
|
||||
var str = typeof body === 'string' ? body : String(body);
|
||||
for (var i = 0; i < STORY_OPS.length; i++) { if (str.indexOf(STORY_OPS[i]) !== -1) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGraphql(url) {
|
||||
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
|
||||
}
|
||||
|
||||
function shouldBlock(url, init) {
|
||||
// 1. Path-based: on story pages block ALL graphql
|
||||
if (shouldBlockByPath() && isGraphql(url)) return true;
|
||||
// 2. URL blocklist match
|
||||
if (matchUrl(url)) return true;
|
||||
// 3. GraphQL body op-name match
|
||||
if (isGraphql(url) && init) {
|
||||
var bs = '';
|
||||
if (typeof init.body === 'string') bs = init.body;
|
||||
else if (init.body && init.body.toString) bs = init.body.toString();
|
||||
if (matchGraphQL(bs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
|
||||
|
||||
// ── Fetch override (chain) ─────────────────────────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch = function(i, init) {
|
||||
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
|
||||
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
|
||||
return _prevFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen = XMLHttpRequest.prototype.open;
|
||||
var _prevSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(m, u) { this.__fgSU = u || ''; return _prevOpen.apply(this, arguments); };
|
||||
XMLHttpRequest.prototype.send = function(b) {
|
||||
var u = this.__fgSU || '';
|
||||
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockByPath())) {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
return _prevSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── SW killer ──────────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Beacon blocker ─────────────────────────────────────────
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon = function(url) { return true; };
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Builder — injects the right scripts based on settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
List<UserScript> buildUserScripts(FocusSettings settings) {
|
||||
final startScripts = <String>[];
|
||||
final endScripts = <String>[];
|
||||
|
||||
// Prepend flag values directly into the script so they survive page navigation.
|
||||
// (evaluateJavascript-set flags are destroyed when the JS context resets on load.)
|
||||
// DM Ghost uses the comprehensive Full DM approach (URL blocklist, GraphQL ops, SW killer, beacon, WS).
|
||||
// it should have worked, but sadly it didnt
|
||||
if (settings.ghostMode) {
|
||||
startScripts.add('window.__fgFullDmGhost=true;$kFullDmGhostJS');
|
||||
}
|
||||
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
|
||||
|
||||
// AT_DOCUMENT_END
|
||||
if (settings.noStories) endScripts.add(hideStoryTrayJS);
|
||||
if (settings.noReels) endScripts.add(hideReelsJS);
|
||||
if (settings.noDMs) endScripts.add(hideDMsJS);
|
||||
|
||||
final scripts = <UserScript>[];
|
||||
if (startScripts.isNotEmpty) {
|
||||
scripts.add(
|
||||
UserScript(
|
||||
source: startScripts.join('\n'),
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
forMainFrameOnly: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (endScripts.isNotEmpty) {
|
||||
scripts.add(
|
||||
UserScript(
|
||||
source: endScripts.join('\n'),
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
forMainFrameOnly: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
// ── Existing non-ghost helpers (unchanged) ───────────────────
|
||||
|
||||
const String noAutoplayJS = '''
|
||||
document.addEventListener('play', function(e) {
|
||||
if (e.target.tagName === 'VIDEO') e.target.pause();
|
||||
}, true);
|
||||
''';
|
||||
|
||||
const String hideStoryTrayJS = '''
|
||||
(function(){var s=document.createElement('style');s.textContent='[data-pagelet="story_tray"]{display:none!important}';document.head.appendChild(s);})();
|
||||
''';
|
||||
|
||||
const String hideReelsJS = '''
|
||||
(function(){new MutationObserver(function(){document.querySelectorAll('a[href="/reels/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')});document.querySelectorAll('a[href="/explore/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')})}).observe(document.body,{childList:true,subtree:true});})();
|
||||
''';
|
||||
|
||||
const String hideDMsJS = '''
|
||||
(function(){var s=document.createElement('style');s.textContent='a[href="/direct/inbox/"]{display:none!important}';document.head.appendChild(s);})();
|
||||
''';
|
||||
@@ -9,4 +9,3 @@ const String kHapticBridgeScript = '''
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ const String kReelMetadataExtractorScript = r'''
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reel page
|
||||
if (!currentUrl.includes('/reel/')) {
|
||||
// Check if this is a reel page (Instagram uses /reels/ not /reel/)
|
||||
if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@ const String kScrollSmoothingJS = r'''
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -29,4 +29,3 @@ const String kSpaNavigationMonitorScript = '''
|
||||
window.addEventListener('popstate', () => notifyUrlChange());
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/// Best-effort Instagram media downloader UI.
|
||||
///
|
||||
/// The script only exposes URLs already rendered in the WebView. It cannot
|
||||
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
|
||||
/// feed posts, reels, profile avatars, and DM visual/video messages.
|
||||
const String kVideoDownloadJS = r'''
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if (window.__fgMediaDownloadRunning) return;
|
||||
window.__fgMediaDownloadRunning = true;
|
||||
|
||||
const BTN_ATTR = 'data-fg-download-btn';
|
||||
const URL_ATTR = 'data-fg-download-url';
|
||||
const TYPE_ATTR = 'data-fg-download-type';
|
||||
const MAX_PER_PASS = 60;
|
||||
|
||||
function text(value) {
|
||||
try { return (value || '').toString(); } catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function isHttp(value) {
|
||||
const s = text(value);
|
||||
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
|
||||
}
|
||||
|
||||
function cleanUrl(value) {
|
||||
const s = text(value).trim();
|
||||
if (!isHttp(s)) return null;
|
||||
return s.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function bestFromSrcset(srcset) {
|
||||
const raw = text(srcset);
|
||||
if (!raw) return null;
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
raw.split(',').forEach(function(part) {
|
||||
const bits = part.trim().split(/\s+/);
|
||||
const url = cleanUrl(bits[0]);
|
||||
if (!url) return;
|
||||
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
|
||||
if (score >= bestScore) {
|
||||
bestScore = score;
|
||||
best = url;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function backgroundUrl(el) {
|
||||
try {
|
||||
const bg = window.getComputedStyle(el).backgroundImage || '';
|
||||
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
||||
return match ? cleanUrl(match[1]) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function urlFromJsonishAttribute(el) {
|
||||
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
|
||||
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
|
||||
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mediaUrl(el) {
|
||||
if (!el) return null;
|
||||
const tag = text(el.tagName).toLowerCase();
|
||||
if (tag === 'video') {
|
||||
return cleanUrl(el.currentSrc || el.src) ||
|
||||
cleanUrl(el.getAttribute('src')) ||
|
||||
cleanUrl(el.getAttribute('poster')) ||
|
||||
firstSource(el);
|
||||
}
|
||||
if (tag === 'img') {
|
||||
return cleanUrl(el.currentSrc || el.src) ||
|
||||
bestFromSrcset(el.getAttribute('srcset')) ||
|
||||
cleanUrl(el.getAttribute('src'));
|
||||
}
|
||||
return backgroundUrl(el) || urlFromJsonishAttribute(el);
|
||||
}
|
||||
|
||||
function firstSource(video) {
|
||||
try {
|
||||
const sources = video.querySelectorAll('source');
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
|
||||
if (url) return url;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeFrom(el, url) {
|
||||
const tag = text(el && el.tagName).toLowerCase();
|
||||
const u = text(url).toLowerCase();
|
||||
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
|
||||
return 'video';
|
||||
}
|
||||
return 'photo';
|
||||
}
|
||||
|
||||
function looksLikeAvatar(el) {
|
||||
try {
|
||||
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
|
||||
if (!img) return false;
|
||||
const alt = text(img.getAttribute('alt')).toLowerCase();
|
||||
const r = img.getBoundingClientRect();
|
||||
const rounded =
|
||||
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
|
||||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
|
||||
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mediaScore(item) {
|
||||
try {
|
||||
const r = item.el.getBoundingClientRect();
|
||||
let score = Math.max(0, r.width) * Math.max(0, r.height);
|
||||
if (item.type === 'video') score += 10000000;
|
||||
if (looksLikeAvatar(item.el)) score -= 10000000;
|
||||
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
|
||||
return score;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function filename(type) {
|
||||
const ext = type === 'video' ? 'mp4' : 'jpg';
|
||||
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
|
||||
}
|
||||
|
||||
function inView(el) {
|
||||
try {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function icon() {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
|
||||
}
|
||||
|
||||
function sendDownload(url, type) {
|
||||
try {
|
||||
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
|
||||
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
|
||||
type: type,
|
||||
url: url,
|
||||
filename: filename(type),
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function makeButton(url, type, mode) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.setAttribute(BTN_ATTR, '1');
|
||||
btn.setAttribute(URL_ATTR, url);
|
||||
btn.setAttribute(TYPE_ATTR, type);
|
||||
btn.setAttribute('aria-label', 'Download media');
|
||||
btn.innerHTML = icon();
|
||||
btn.style.cssText = [
|
||||
'position:absolute',
|
||||
'z-index:999',
|
||||
'width:34px',
|
||||
'height:34px',
|
||||
'border-radius:10px',
|
||||
'border:1px solid rgba(255,255,255,.18)',
|
||||
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
|
||||
'color:rgba(255,255,255,.94)',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'padding:0',
|
||||
'cursor:pointer',
|
||||
'pointer-events:auto',
|
||||
'backdrop-filter:blur(8px)',
|
||||
'-webkit-backdrop-filter:blur(8px)',
|
||||
].join(';');
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
|
||||
}, true);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function ensureRelative(container) {
|
||||
try {
|
||||
const pos = window.getComputedStyle(container).position;
|
||||
if (!pos || pos === 'static') container.style.position = 'relative';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function placeNearSave(article, url, type) {
|
||||
const ref = article.querySelector([
|
||||
'button[aria-label*="Save" i]',
|
||||
'button[aria-label*="Bookmark" i]',
|
||||
'svg[aria-label*="Save" i]',
|
||||
'svg[aria-label*="Bookmark" i]',
|
||||
'a[href*="/save"]',
|
||||
].join(','));
|
||||
if (!ref) return false;
|
||||
|
||||
const target = ref.closest('button,a,div') || ref;
|
||||
const bar = target.parentElement || article;
|
||||
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
||||
|
||||
const btn = makeButton(url, type, 'inline');
|
||||
btn.style.position = 'relative';
|
||||
btn.style.inset = 'auto';
|
||||
btn.style.marginLeft = '8px';
|
||||
btn.style.color = 'currentColor';
|
||||
btn.style.border = '0';
|
||||
btn.style.backdropFilter = 'none';
|
||||
btn.style.webkitBackdropFilter = 'none';
|
||||
try {
|
||||
target.insertAdjacentElement('afterend', btn);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function placeOverlay(container, url, type, where) {
|
||||
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
||||
ensureRelative(container);
|
||||
const btn = makeButton(url, type, 'overlay');
|
||||
if (where === 'reel') {
|
||||
btn.style.top = '12px';
|
||||
btn.style.right = '12px';
|
||||
} else if (where === 'profile') {
|
||||
btn.style.top = '8px';
|
||||
btn.style.right = '8px';
|
||||
} else {
|
||||
btn.style.right = '10px';
|
||||
btn.style.bottom = '10px';
|
||||
}
|
||||
container.appendChild(btn);
|
||||
return true;
|
||||
}
|
||||
|
||||
function visibleMedia(root) {
|
||||
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
|
||||
.filter(inView)
|
||||
.map(function(el) {
|
||||
const url = mediaUrl(el);
|
||||
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function handleFeed() {
|
||||
let added = 0;
|
||||
document.querySelectorAll('article').forEach(function(article) {
|
||||
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
|
||||
const media = visibleMedia(article)
|
||||
.filter(function(item) { return !looksLikeAvatar(item.el); })
|
||||
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
|
||||
if (!media) return;
|
||||
if (placeNearSave(article, media.url, media.type) ||
|
||||
placeOverlay(article, media.url, media.type, 'feed')) {
|
||||
added++;
|
||||
}
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleReels() {
|
||||
let added = 0;
|
||||
visibleMedia(document).forEach(function(media) {
|
||||
if (added >= MAX_PER_PASS) return;
|
||||
const container =
|
||||
media.el.closest('[class*="ReelsVideoPlayer"]') ||
|
||||
media.el.closest('article') ||
|
||||
media.el.closest('[role="presentation"]') ||
|
||||
media.el.parentElement;
|
||||
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleDirect() {
|
||||
let added = 0;
|
||||
visibleMedia(document).forEach(function(media) {
|
||||
if (added >= MAX_PER_PASS) return;
|
||||
const bubble =
|
||||
media.el.closest('[role="button"]') ||
|
||||
media.el.closest('div[style*="max-width"]') ||
|
||||
media.el.closest('article') ||
|
||||
media.el.parentElement;
|
||||
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleProfile() {
|
||||
let added = 0;
|
||||
const path = window.location.pathname || '/';
|
||||
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
|
||||
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
|
||||
if (added >= 4 || !inView(img)) return;
|
||||
const url = mediaUrl(img);
|
||||
if (!url) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
if (r.width < 56 && r.height < 56) return;
|
||||
const container = img.closest('div') || img.parentElement;
|
||||
if (placeOverlay(container, url, 'photo', 'profile')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function pass() {
|
||||
try {
|
||||
const path = window.location.pathname || '/';
|
||||
if (path.indexOf('/direct') === 0) {
|
||||
handleDirect();
|
||||
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
|
||||
handleReels();
|
||||
} else {
|
||||
handleFeed();
|
||||
handleProfile();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(pass, 220);
|
||||
}
|
||||
|
||||
new MutationObserver(schedule).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'srcset', 'style'],
|
||||
});
|
||||
window.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
window.addEventListener('focus', schedule, { passive: true });
|
||||
pass();
|
||||
})();
|
||||
''';
|
||||
@@ -0,0 +1,430 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AdblockContentBlockerData {
|
||||
final List<ContentBlocker> contentBlockers;
|
||||
final Set<String> blockedHosts;
|
||||
final String sourceTag;
|
||||
|
||||
const AdblockContentBlockerData({
|
||||
required this.contentBlockers,
|
||||
required this.blockedHosts,
|
||||
required this.sourceTag,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'sourceTag': sourceTag,
|
||||
'hosts': blockedHosts.toList(),
|
||||
// We can’t safely serialize ContentBlocker objects; rebuild from hosts.
|
||||
// contentBlockers will always be regenerated from hosts when restoring.
|
||||
};
|
||||
|
||||
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
|
||||
final hosts =
|
||||
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
|
||||
return AdblockContentBlockerData(
|
||||
contentBlockers: hosts
|
||||
.map(
|
||||
(h) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(
|
||||
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
|
||||
),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.BLOCK,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
blockedHosts: hosts,
|
||||
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AdblockContentBlockerLoader {
|
||||
// Cache keys
|
||||
static const _keyCache = 'adblock_cb_cache_v2';
|
||||
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
|
||||
static const _keySourceCache = 'adblock_source_cache_v1';
|
||||
|
||||
static const _maxContentBlockerRules = 5000;
|
||||
|
||||
// Raw GitHub sources, intentionally split by repository sections so the app
|
||||
// follows upstream changes without depending on third-party packaged mirrors.
|
||||
static const _sources = <_SourceSpec>[
|
||||
// uBlock Origin built-in Annoyances family:
|
||||
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances_cookies',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances_others',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
|
||||
),
|
||||
|
||||
// EasyList network-blocking sections:
|
||||
// https://github.com/easylist/easylist/tree/master/easylist
|
||||
_SourceSpec(
|
||||
tag: 'easylist_adservers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_general_block',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_specific_block',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_thirdparty',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
|
||||
),
|
||||
|
||||
// AdGuard BaseFilter network-blocking sections:
|
||||
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_adservers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_adservers_firstparty',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_antiadblock',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_cryptominers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_general_url',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_specific',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
|
||||
),
|
||||
];
|
||||
|
||||
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
|
||||
required bool enabled,
|
||||
required SharedPreferences prefs,
|
||||
int timeoutMs = 8000,
|
||||
}) async {
|
||||
if (!enabled) {
|
||||
return const AdblockContentBlockerData(
|
||||
contentBlockers: [],
|
||||
blockedHosts: {},
|
||||
sourceTag: 'disabled',
|
||||
);
|
||||
}
|
||||
|
||||
final cachedData = _readCachedData(prefs);
|
||||
final sourceCache = _readSourceCache(prefs);
|
||||
|
||||
final fetchResults = await _fetchAllSources(
|
||||
cache: sourceCache,
|
||||
timeoutMs: timeoutMs,
|
||||
);
|
||||
|
||||
if (fetchResults.isEmpty && cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final sourceEntries = <String, _CachedSource>{...sourceCache};
|
||||
for (final result in fetchResults) {
|
||||
sourceEntries[result.tag] = result.source;
|
||||
}
|
||||
|
||||
final hosts = sourceEntries.values
|
||||
.expand((source) => source.hosts)
|
||||
.where(_isValidHostname)
|
||||
.toSet();
|
||||
|
||||
if (hosts.isEmpty && cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final data = _buildData(
|
||||
hosts: hosts,
|
||||
sourceTag: fetchResults.any((r) => r.changed)
|
||||
? 'updated-github'
|
||||
: 'validated-github-cache',
|
||||
);
|
||||
|
||||
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
|
||||
await prefs.setString(
|
||||
_keySourceCache,
|
||||
jsonEncode({
|
||||
for (final entry in sourceEntries.entries) entry.key: entry.value,
|
||||
}),
|
||||
);
|
||||
await prefs.setInt(
|
||||
_keyCacheUpdatedAt,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
|
||||
final cached = prefs.getString(_keyCache);
|
||||
if (cached == null) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
||||
return AdblockContentBlockerData.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
|
||||
final cached = prefs.getString(_keySourceCache);
|
||||
if (cached == null) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
||||
return decoded.map((tag, value) {
|
||||
return MapEntry(
|
||||
tag,
|
||||
_CachedSource.fromJson(value as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
AdblockContentBlockerData _buildData({
|
||||
required Set<String> hosts,
|
||||
required String sourceTag,
|
||||
}) {
|
||||
final sortedHosts = hosts.toList(growable: false)..sort();
|
||||
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
|
||||
|
||||
return AdblockContentBlockerData(
|
||||
contentBlockers: cappedHosts
|
||||
.map(
|
||||
(h) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.BLOCK,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
blockedHosts: cappedHosts,
|
||||
sourceTag: sourceTag,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<_FetchedSource>> _fetchAllSources({
|
||||
required Map<String, _CachedSource> cache,
|
||||
required int timeoutMs,
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
return Future.wait(
|
||||
_sources.map(
|
||||
(source) => _fetchSource(
|
||||
client: client,
|
||||
source: source,
|
||||
cached: cache[source.tag],
|
||||
timeout: timeout,
|
||||
),
|
||||
),
|
||||
).then((results) => results.whereType<_FetchedSource>().toList());
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_FetchedSource?> _fetchSource({
|
||||
required http.Client client,
|
||||
required _SourceSpec source,
|
||||
required _CachedSource? cached,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
try {
|
||||
final headers = <String, String>{
|
||||
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
|
||||
if (cached?.lastModified != null)
|
||||
'If-Modified-Since': cached!.lastModified!,
|
||||
'User-Agent': 'FocusGram-AdblockListUpdater',
|
||||
};
|
||||
|
||||
final res = await client
|
||||
.get(Uri.parse(source.url), headers: headers)
|
||||
.timeout(timeout);
|
||||
|
||||
if (res.statusCode == 304 && cached != null) {
|
||||
return _FetchedSource(tag: source.tag, source: cached, changed: false);
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || res.body.isEmpty) return null;
|
||||
|
||||
return _FetchedSource(
|
||||
tag: source.tag,
|
||||
source: _CachedSource(
|
||||
url: source.url,
|
||||
etag: res.headers['etag'],
|
||||
lastModified: res.headers['last-modified'],
|
||||
hosts: parseHostsFromFilterText(res.body),
|
||||
),
|
||||
changed: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
|
||||
/// syntax forms:
|
||||
/// - ||example.com^
|
||||
/// - ||example.com/
|
||||
/// - ||example.com
|
||||
///
|
||||
/// We ignore all element-hiding/cosmetic rules and $ options.
|
||||
@visibleForTesting
|
||||
static Set<String> parseHostsFromFilterText(String raw) {
|
||||
final hosts = <String>{};
|
||||
|
||||
for (final line in raw.split('\n')) {
|
||||
final l = line.trim();
|
||||
if (l.isEmpty) continue;
|
||||
if (l.startsWith('!')) continue;
|
||||
if (l.startsWith('@@')) continue;
|
||||
|
||||
// Skip comments / metadata
|
||||
if (l.startsWith('[')) continue;
|
||||
|
||||
// Skip cosmetic element-hiding rules
|
||||
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// uBlock-style host anchors
|
||||
if (l.startsWith('||')) {
|
||||
final body = l.substring(2);
|
||||
|
||||
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
|
||||
// e.g. "example.com^" -> "example.com"
|
||||
// e.g. "example.com/" -> "example.com"
|
||||
// e.g. "example.com^$third-party" -> "example.com"
|
||||
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
|
||||
|
||||
String host = body;
|
||||
for (final sc in stopChars) {
|
||||
final idx = host.indexOf(sc);
|
||||
if (idx >= 0) host = host.substring(0, idx);
|
||||
}
|
||||
|
||||
host = host.trim();
|
||||
|
||||
// Remove leading/trailing dots
|
||||
host = host
|
||||
.replaceAll(RegExp(r'^\.+'), '')
|
||||
.replaceAll(RegExp(r'\.+$'), '');
|
||||
|
||||
if (host.isEmpty) continue;
|
||||
if (host.contains('*') || host.contains(',')) continue;
|
||||
|
||||
final normalized = host.toLowerCase();
|
||||
if (!_isValidHostname(normalized)) continue;
|
||||
|
||||
hosts.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
static String _urlFilterForHost(String host) {
|
||||
final escaped = RegExp.escape(host);
|
||||
return r'^https?://([^/?#]+\.)?'
|
||||
'$escaped'
|
||||
r'([/?#:].*)?$';
|
||||
}
|
||||
|
||||
static bool _isValidHostname(String host) {
|
||||
if (!host.contains('.')) return false;
|
||||
if (host.length > 255) return false;
|
||||
if (host.startsWith('.') || host.endsWith('.')) return false;
|
||||
if (host.contains('..')) return false;
|
||||
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceSpec {
|
||||
final String tag;
|
||||
final String url;
|
||||
|
||||
const _SourceSpec({required this.tag, required this.url});
|
||||
}
|
||||
|
||||
class _FetchedSource {
|
||||
final String tag;
|
||||
final _CachedSource source;
|
||||
final bool changed;
|
||||
|
||||
_FetchedSource({
|
||||
required this.tag,
|
||||
required this.source,
|
||||
required this.changed,
|
||||
});
|
||||
}
|
||||
|
||||
class _CachedSource {
|
||||
final String url;
|
||||
final String? etag;
|
||||
final String? lastModified;
|
||||
final Set<String> hosts;
|
||||
|
||||
const _CachedSource({
|
||||
required this.url,
|
||||
required this.etag,
|
||||
required this.lastModified,
|
||||
required this.hosts,
|
||||
});
|
||||
|
||||
factory _CachedSource.fromJson(Map<String, dynamic> json) {
|
||||
return _CachedSource(
|
||||
url: (json['url'] as String?) ?? '',
|
||||
etag: json['etag'] as String?,
|
||||
lastModified: json['lastModified'] as String?,
|
||||
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'etag': etag,
|
||||
'lastModified': lastModified,
|
||||
'hosts': hosts.toList(growable: false)..sort(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -57,15 +57,15 @@ class InjectionController {
|
||||
required bool blurReels,
|
||||
required bool tapToUnblur,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSuggestedPosts,
|
||||
required bool hideSponsoredPosts,
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
// hideStoriesBar parameter removed per user request
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
required bool blockHomeFeedScroll,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
@@ -75,18 +75,12 @@ class InjectionController {
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
}
|
||||
|
||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||
// Previously it was inside that block alongside display:none on the parent —
|
||||
// you cannot blur children of a display:none element, making it dead code.
|
||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||
|
||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
// Stories hiding removed per user request
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
@@ -94,6 +88,7 @@ class InjectionController {
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
||||
window.__fgTapToUnblur = $tapToUnblur;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
import '../scripts/content_disabling.dart' as content_disabling;
|
||||
import '../scripts/video_downloader.dart' as video_downloader;
|
||||
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
abstract class JsEvaluator {
|
||||
Future<void> evaluateJavascript({required String source});
|
||||
}
|
||||
|
||||
class _WebViewJsEvaluator implements JsEvaluator {
|
||||
final InAppWebViewController controller;
|
||||
_WebViewJsEvaluator(this.controller);
|
||||
|
||||
@override
|
||||
Future<void> evaluateJavascript({required String source}) {
|
||||
return controller.evaluateJavascript(source: source);
|
||||
}
|
||||
}
|
||||
|
||||
class InjectionManager {
|
||||
final JsEvaluator _jsEvaluator;
|
||||
final SharedPreferences prefs;
|
||||
final SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required InAppWebViewController controller,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
});
|
||||
JsEvaluator? jsEvaluator,
|
||||
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
|
||||
|
||||
InjectionManager.forTest({
|
||||
required JsEvaluator jsEvaluator,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
}) : _jsEvaluator = jsEvaluator;
|
||||
|
||||
void setSettingsService(SettingsService settingsService) {
|
||||
_settingsService = settingsService;
|
||||
@@ -415,18 +437,19 @@ class InjectionManager {
|
||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||
final tapToUnblur = settings.tapToUnblur;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||
|
||||
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
|
||||
final hideSuggestedPosts = false;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
// Stories hiding functionality removed per user request
|
||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
||||
// These are now only controllable via minimal mode submenu
|
||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
final hideExploreTab = disableExploreEntirely;
|
||||
final hideReelsTab = disableReelsEntirely;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
@@ -434,33 +457,35 @@ class InjectionManager {
|
||||
blurReels: false, // Blur reels feature removed
|
||||
tapToUnblur: blurExplore && tapToUnblur,
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: false, // Feature removed
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
// hideStoriesBar removed per user request
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
blockHomeFeedScroll: blockHomeFeedScroll,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
if (settings.isGrayscaleActiveNow) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: grayscale.kGrayscaleOffJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -469,7 +494,9 @@ class InjectionManager {
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: ui_hider.kHideLikeCountsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -478,11 +505,11 @@ class InjectionManager {
|
||||
// Stories hiding functionality removed per user request
|
||||
// No stories overlay injection needed
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
// Inject video downloader UI when enabled
|
||||
if (settings.videoDownloadEnabled) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: video_downloader.kVideoDownloadJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
@@ -492,7 +519,7 @@ class InjectionManager {
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -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.0–1.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();
|
||||
}*/
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class NotificationService {
|
||||
@@ -9,16 +8,16 @@ class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> init() async {
|
||||
Future<void> init({bool requestPermissions = false}) async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: requestPermissions,
|
||||
requestBadgePermission: requestPermissions,
|
||||
requestSoundPermission: requestPermissions,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
@@ -37,7 +36,12 @@ class NotificationService {
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
if (requestPermissions) {
|
||||
await requestPermissionsNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissionsNow() async {
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
@@ -50,7 +54,7 @@ class NotificationService {
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
} catch (e) {
|
||||
debugPrint('iOS permission request error: $e');
|
||||
// debugPrint('iOS permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +66,7 @@ class NotificationService {
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
} catch (e) {
|
||||
debugPrint('Android permission request error: $e');
|
||||
// debugPrint('Android permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +104,7 @@ class NotificationService {
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Notification error: $e');
|
||||
// debugPrint('Notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +148,7 @@ class NotificationService {
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Persistent notification error: $e');
|
||||
// debugPrint('Persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +157,7 @@ class NotificationService {
|
||||
try {
|
||||
await _notificationsPlugin.cancel(id: id);
|
||||
} catch (e) {
|
||||
debugPrint('Cancel persistent notification error: $e');
|
||||
// debugPrint('Cancel persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +166,7 @@ class NotificationService {
|
||||
try {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
} catch (e) {
|
||||
debugPrint('Cancel all notifications error: $e');
|
||||
// debugPrint('Cancel all notifications error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class RemotePopupData {
|
||||
final bool show;
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final int maxShows;
|
||||
final String buttonText;
|
||||
|
||||
RemotePopupData({
|
||||
required this.show,
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.maxShows,
|
||||
required this.buttonText,
|
||||
});
|
||||
|
||||
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
|
||||
return RemotePopupData(
|
||||
show: json['show'] ?? false,
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['header']?.toString() ?? 'Notice',
|
||||
body: json['body']?.toString() ?? '',
|
||||
maxShows: json['max_shows'] ?? 1,
|
||||
buttonText: json['button_text']?.toString() ?? 'OK',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePopupService {
|
||||
// Keep placeholder value until you replace it.
|
||||
static const String popupUrl =
|
||||
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
|
||||
|
||||
static Future<RemotePopupData?> fetchPopup() async {
|
||||
try {
|
||||
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
|
||||
final uri = Uri.parse(
|
||||
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: const {'Cache-Control': 'no-cache'},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
return RemotePopupData.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> shouldShow(RemotePopupData data) async {
|
||||
if (!data.show) return false;
|
||||
if (data.id.isEmpty) return false;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'popup_count_${data.id}';
|
||||
final shownCount = prefs.getInt(key) ?? 0;
|
||||
|
||||
return shownCount < data.maxShows;
|
||||
}
|
||||
|
||||
static Future<void> markShown(RemotePopupData data) async {
|
||||
if (data.id.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'popup_count_${data.id}';
|
||||
final current = prefs.getInt(key) ?? 0;
|
||||
await prefs.setInt(key, current + 1);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// "2026-05-26": 3420, // seconds
|
||||
/// "2026-05-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
int get totalSeconds =>
|
||||
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
|
||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
|
||||
static const _keyDailyOpenCount = 'app_open_count';
|
||||
static const _keyScheduleEnabled = 'sched_enabled';
|
||||
static const _keyScheduleStartHour = 'sched_start_h';
|
||||
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
|
||||
bool _appSessionExpiredFlag =
|
||||
false; // set when time runs out, waiting for user action
|
||||
int _dailyOpenCount = 0;
|
||||
int _lastAppSessionMinutes = 5;
|
||||
|
||||
// ── Scheduled Blocking runtime ─────────────────────────────
|
||||
bool _scheduleEnabled = false;
|
||||
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
|
||||
int _schedEndMin = 0;
|
||||
List<FocusSchedule> _schedules = [];
|
||||
bool _lastScheduleState = false;
|
||||
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
|
||||
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
||||
bool _scheduleNotificationShown =
|
||||
false; // Track if schedule notification was shown
|
||||
bool _sessionEndNotificationShown =
|
||||
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
||||
|
||||
bool _isInForeground = true; // Tracking app lifecycle state
|
||||
int _cachedRemainingSessionSeconds = 0;
|
||||
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
|
||||
|
||||
/// How many times the user has opened the app today.
|
||||
int get dailyOpenCount => _dailyOpenCount;
|
||||
int get lastAppSessionMinutes => _lastAppSessionMinutes;
|
||||
|
||||
// ── Scheduled Blocking Getters ─────────────────────────────
|
||||
bool get scheduleEnabled => _scheduleEnabled;
|
||||
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||
}
|
||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
|
||||
|
||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
||||
if (lastAppEndMs > 0) {
|
||||
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// App session expiry check
|
||||
// App session countdown / expiry check
|
||||
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
||||
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
||||
_appSessionExpiredFlag = true;
|
||||
changed = true;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isCooldownActive) {
|
||||
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
|
||||
if (sched != _lastScheduleState) {
|
||||
_lastScheduleState = sched;
|
||||
changed = true;
|
||||
|
||||
|
||||
// Show notification when schedule becomes active
|
||||
if (sched && !_scheduleNotificationShown) {
|
||||
_scheduleNotificationShown = true;
|
||||
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
|
||||
// (i.e., when loading an expired session from a previous app session)
|
||||
if (showNotification && !_sessionEndNotificationShown) {
|
||||
_sessionEndNotificationShown = true;
|
||||
|
||||
|
||||
// Check if user wants session end notifications
|
||||
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
|
||||
|
||||
final notifySessionEnd =
|
||||
_prefs?.getBool('set_notify_session_end') ?? false;
|
||||
|
||||
if (notifySessionEnd) {
|
||||
NotificationService().showNotification(
|
||||
id: 999,
|
||||
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_isSessionActive = false;
|
||||
_sessionExpiry = null;
|
||||
_lastSessionEnd = DateTime.now();
|
||||
@@ -448,12 +455,20 @@ class SessionManager extends ChangeNotifier {
|
||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||
_isSessionActive = true;
|
||||
_sessionEndNotificationShown = false; // Reset notification flag for new session
|
||||
_sessionEndNotificationShown =
|
||||
false; // Reset notification flag for new session
|
||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Temporarily increase the daily limit by [minutes] (for ad rewards).
|
||||
void addBonusDailyMinutes(int minutes) {
|
||||
_dailyLimitSeconds += minutes * 60;
|
||||
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void endSession() {
|
||||
if (!_isSessionActive) return;
|
||||
// Don't show notification when user manually ends the session
|
||||
@@ -474,6 +489,13 @@ class SessionManager extends ChangeNotifier {
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
/// Start an app session of [minutes] (1–60).
|
||||
@@ -482,12 +504,26 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = end;
|
||||
_appSessionExpiredFlag = false;
|
||||
_appExtensionUsed = false;
|
||||
_lastAppSessionMinutes = minutes;
|
||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
if (_appExtensionUsed) return false;
|
||||
final base = _appSessionEnd ?? DateTime.now();
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Stores and retrieves all user-configurable app settings.
|
||||
class SettingsService extends ChangeNotifier {
|
||||
static const _keyBlurExplore = 'set_blur_explore';
|
||||
static const _keyBlurReels = 'set_blur_reels';
|
||||
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyBreathGateSeconds = 'breath_gate_seconds';
|
||||
static const _keyWordChallengeCount = 'word_challenge_count';
|
||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
||||
static const _keyEnabledTabs = 'set_enabled_tabs';
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
@@ -18,27 +23,56 @@ class SettingsService extends ChangeNotifier {
|
||||
// Focus / playback
|
||||
static const _keyBlockAutoplay = 'block_autoplay';
|
||||
|
||||
// Extras (Phase 2)
|
||||
static const _keyVideoDownloadEnabled = 'video_download_enabled';
|
||||
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
|
||||
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
|
||||
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
|
||||
|
||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
||||
static const _keyContentStories = 'content_stories';
|
||||
static const _keyContentPosts = 'content_posts';
|
||||
static const _keyContentReels = 'content_reels';
|
||||
static const _keyContentSuggested = 'content_suggested';
|
||||
|
||||
// Grayscale mode - now supports multiple schedules
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Minimal mode
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
|
||||
// Minimal mode state tracking for smart restore
|
||||
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
|
||||
static const _keyMinimalModePrevDisableReels =
|
||||
'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore =
|
||||
'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore =
|
||||
'minimal_mode_prev_blur_explore';
|
||||
static const _keyMinimalModePrevBlockHomeFeedScroll =
|
||||
'minimal_mode_prev_block_home_feed_scroll';
|
||||
|
||||
// Reels History
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
|
||||
// ── 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
|
||||
static const _keySanitizeLinks = 'set_sanitize_links';
|
||||
static const _keyNotifyDMs = 'set_notify_dms';
|
||||
@@ -46,6 +80,14 @@ class SettingsService extends ChangeNotifier {
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
static const _keyNotifyPersistent = 'set_notify_persistent';
|
||||
|
||||
// Focus mode settings
|
||||
static const _keyGhostMode = 'ghost_mode';
|
||||
static const _keyNoAds = 'no_ads';
|
||||
static const _keyNoStories = 'no_stories';
|
||||
static const _keyNoReels = 'no_reels';
|
||||
static const _keyNoAutoplay = 'no_autoplay';
|
||||
static const _keyNoDMs = 'no_dms';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool _blurExplore = true;
|
||||
@@ -54,19 +96,33 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _requireLongPress = true;
|
||||
bool _showBreathGate = true;
|
||||
bool _requireWordChallenge = true;
|
||||
int _breathGateSeconds = 10;
|
||||
int _wordChallengeCount = 30;
|
||||
bool _enableTextSelection = false;
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
bool _blockAutoplay = true;
|
||||
|
||||
bool _videoDownloadEnabled = false;
|
||||
bool _hideSuggestedPosts = false;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
bool _v2GhostModeEnabled = false;
|
||||
bool _v2AdBlockerDomEnabled = false;
|
||||
bool _v2ContentHiderEnabled = false;
|
||||
|
||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
||||
bool _contentStories = false;
|
||||
bool _contentPosts = false;
|
||||
bool _contentReels = false;
|
||||
bool _contentSuggested = false;
|
||||
|
||||
// Grayscale mode - now supports multiple schedules
|
||||
bool _grayscaleEnabled = false;
|
||||
|
||||
// Grayscale schedules - list of {enabled, startTime, endTime}
|
||||
// startTime and endTime are in format "HH:MM"
|
||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
||||
|
||||
bool _hideSponsoredPosts = false;
|
||||
// Content filtering / UI hiding
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideShopTab = false;
|
||||
@@ -74,12 +130,14 @@ class SettingsService extends ChangeNotifier {
|
||||
// These are now controlled internally by minimal mode
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _blockHomeFeedScroll = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
// Tracking for smart restore
|
||||
bool _prevDisableReels = false;
|
||||
bool _prevDisableExplore = false;
|
||||
bool _prevBlurExplore = false;
|
||||
bool _prevBlockHomeFeedScroll = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
@@ -90,6 +148,18 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _notifySessionEnd = false;
|
||||
bool _notifyPersistent = false;
|
||||
|
||||
// Focus mode settings
|
||||
bool _effortFrictionEnabled = true;
|
||||
String _startupPage = 'home'; // home, following, favorites, direct
|
||||
String _adsterraZoneUrl = '';
|
||||
String _adsterraAdCode = '';
|
||||
bool _ghostMode = false;
|
||||
bool _noAds = false;
|
||||
bool _noStories = false;
|
||||
bool _noReels = false;
|
||||
bool _noAutoplay = false;
|
||||
bool _noDMs = false;
|
||||
|
||||
List<String> _enabledTabs = [
|
||||
'Home',
|
||||
'Search',
|
||||
@@ -105,12 +175,28 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
bool get requireWordChallenge => _requireWordChallenge;
|
||||
int get breathGateSeconds => _breathGateSeconds;
|
||||
int get wordChallengeCount => _wordChallengeCount;
|
||||
bool get enableTextSelection => _enableTextSelection;
|
||||
bool get showInstaSettings => _showInstaSettings;
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
bool get blockAutoplay => _blockAutoplay;
|
||||
|
||||
// Extras (Phase 2)
|
||||
bool get videoDownloadEnabled => _videoDownloadEnabled;
|
||||
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
|
||||
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
|
||||
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
|
||||
|
||||
bool get contentStories => _contentStories;
|
||||
bool get contentPosts => _contentPosts;
|
||||
bool get contentReels => _contentReels;
|
||||
bool get contentSuggested => _contentSuggested;
|
||||
bool get notifyDMs => _notifyDMs;
|
||||
bool get notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
@@ -119,14 +205,39 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
||||
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
bool get hideShopTab => _hideShopTab;
|
||||
|
||||
// Focus mode settings
|
||||
bool get effortFrictionEnabled => _effortFrictionEnabled;
|
||||
String get startupPage => _startupPage;
|
||||
String get startupUrl {
|
||||
switch (_startupPage) {
|
||||
case 'following':
|
||||
return 'https://www.instagram.com/?variant=following';
|
||||
case 'favorites':
|
||||
return 'https://www.instagram.com/?variant=favorites';
|
||||
case 'direct':
|
||||
return 'https://www.instagram.com/direct/inbox/';
|
||||
default:
|
||||
return 'https://www.instagram.com/';
|
||||
}
|
||||
}
|
||||
|
||||
String get adsterraZoneUrl => _adsterraZoneUrl;
|
||||
String get adsterraAdCode => _adsterraAdCode;
|
||||
bool get ghostMode => _ghostMode;
|
||||
bool get noAds => _noAds;
|
||||
bool get noStories => _noStories;
|
||||
bool get noReels => _noReels;
|
||||
bool get noAutoplay => _noAutoplay;
|
||||
bool get noDMs => _noDMs;
|
||||
|
||||
// These are now controlled by minimal mode only
|
||||
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
|
||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
@@ -136,22 +247,23 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (_grayscaleSchedules.isEmpty) return false;
|
||||
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
|
||||
|
||||
for (final schedule in _grayscaleSchedules) {
|
||||
if (schedule['enabled'] != true) continue;
|
||||
|
||||
|
||||
try {
|
||||
final startParts = (schedule['startTime'] as String).split(':');
|
||||
final endParts = (schedule['endTime'] as String).split(':');
|
||||
|
||||
|
||||
if (startParts.length != 2 || endParts.length != 2) continue;
|
||||
|
||||
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||
|
||||
final startMinutes =
|
||||
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
||||
|
||||
|
||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
||||
if (endMinutes < startMinutes) {
|
||||
// Overnight: active if current time is >= start OR < end
|
||||
@@ -182,43 +294,86 @@ class SettingsService extends ChangeNotifier {
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
|
||||
.clamp(3, 60)
|
||||
.toInt();
|
||||
_wordChallengeCount = _normaliseWordChallengeCount(
|
||||
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
|
||||
);
|
||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||
|
||||
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
|
||||
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
|
||||
_v2AdBlockerDomEnabled =
|
||||
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
|
||||
_v2ContentHiderEnabled =
|
||||
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
|
||||
|
||||
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
|
||||
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
|
||||
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
|
||||
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
|
||||
// Load grayscale toggle + schedules
|
||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||
|
||||
// Load grayscale schedules
|
||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
||||
if (schedulesJson != null) {
|
||||
try {
|
||||
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
||||
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
|
||||
(jsonDecode(schedulesJson) as List).map(
|
||||
(e) => Map<String, dynamic>.from(e),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
_grayscaleSchedules = [];
|
||||
}
|
||||
}
|
||||
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
// Load minimal mode
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
|
||||
// Load previous states for smart restore
|
||||
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevDisableReels =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
||||
_prevBlockHomeFeedScroll =
|
||||
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
|
||||
|
||||
// These are now internal states, not user-facing settings
|
||||
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
_disableReelsEntirely =
|
||||
_prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely =
|
||||
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
_blockHomeFeedScroll =
|
||||
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
|
||||
|
||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||
|
||||
// Focus mode settings
|
||||
_effortFrictionEnabled =
|
||||
_prefs!.getBool(_keyEffortFrictionEnabled) ?? true;
|
||||
_startupPage = _prefs!.getString(_keyStartupPage) ?? 'home';
|
||||
_adsterraZoneUrl = _prefs!.getString(_keyAdsterraZoneUrl) ?? '';
|
||||
_adsterraAdCode = _prefs!.getString(_keyAdsterraAdCode) ?? '';
|
||||
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
|
||||
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
|
||||
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
|
||||
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
|
||||
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
|
||||
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
|
||||
|
||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
@@ -245,12 +400,12 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_blurExplore = v;
|
||||
// Sync blur explore with blur reels - enabling one enables the other
|
||||
if (v && !_blurReels) {
|
||||
_blurReels = true;
|
||||
await _prefs?.setBool(_keyBlurReels, true);
|
||||
}
|
||||
await _prefs?.setBool(_keyBlurExplore, v);
|
||||
|
||||
if (_minimalModeEnabled) {
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -289,6 +444,33 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBreathGateSeconds(int seconds) async {
|
||||
final clamped = seconds.clamp(3, 60);
|
||||
_breathGateSeconds = clamped.toInt();
|
||||
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
|
||||
// Defer notifyListeners to after the current frame to avoid
|
||||
// Flutter's 'Dependents.isEmpty' assertion error.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners());
|
||||
}
|
||||
|
||||
Future<void> setWordChallengeCount(int count) async {
|
||||
_wordChallengeCount = _normaliseWordChallengeCount(count);
|
||||
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int resolvedWordChallengeCount() {
|
||||
if (_wordChallengeCount != 0) return _wordChallengeCount;
|
||||
final now = DateTime.now().microsecondsSinceEpoch;
|
||||
return 10 + (now % 26);
|
||||
}
|
||||
|
||||
static int _normaliseWordChallengeCount(int count) {
|
||||
if (count == 0) return 0;
|
||||
const allowed = [20, 25, 30, 35];
|
||||
return allowed.contains(count) ? count : 30;
|
||||
}
|
||||
|
||||
Future<void> setEnableTextSelection(bool v) async {
|
||||
_enableTextSelection = v;
|
||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||
@@ -307,13 +489,29 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> setVideoDownloadEnabled(bool v) async {
|
||||
_videoDownloadEnabled = v;
|
||||
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSuggestedPosts(bool v) async {
|
||||
_hideSuggestedPosts = v;
|
||||
await _prefs?.setBool(_keyHideSuggestedPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleEnabled(bool v) async {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
|
||||
Future<void> setGrayscaleSchedules(
|
||||
List<Map<String, dynamic>> schedules,
|
||||
) async {
|
||||
_grayscaleSchedules = schedules;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
||||
notifyListeners();
|
||||
@@ -321,14 +519,23 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
||||
_grayscaleSchedules.add(schedule);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
|
||||
Future<void> updateGrayscaleSchedule(
|
||||
int index,
|
||||
Map<String, dynamic> schedule,
|
||||
) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules[index] = schedule;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -336,20 +543,76 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> removeGrayscaleSchedule(int index) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules.removeAt(index);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
|
||||
Future<void> setV2GhostModeEnabled(bool v) async {
|
||||
_v2GhostModeEnabled = v;
|
||||
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setV2AdBlockerDomEnabled(bool v) async {
|
||||
_v2AdBlockerDomEnabled = v;
|
||||
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setV2ContentHiderEnabled(bool v) async {
|
||||
_v2ContentHiderEnabled = v;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentStoriesEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentStories = v;
|
||||
await _prefs?.setBool(_keyContentStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentPostsEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentPosts = v;
|
||||
await _prefs?.setBool(_keyContentPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentReelsEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentReels = v;
|
||||
await _prefs?.setBool(_keyContentReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentSuggestedEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentSuggested = v;
|
||||
await _prefs?.setBool(_keyContentSuggested, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -359,62 +622,138 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable reels state (used by minimal mode submenu)
|
||||
/// Auto-disables minimal mode if all features are turned off
|
||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable explore state (used by minimal mode submenu)
|
||||
/// Auto-disables minimal mode if all features are turned off
|
||||
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
|
||||
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
|
||||
_blockHomeFeedScroll = v;
|
||||
await _prefs?.setBool('internal_block_home_feed_scroll', v);
|
||||
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Helper: Auto-disable minimal mode if all its features are disabled
|
||||
/// This ensures minimal mode auto-turns-off when user disables all sub-features
|
||||
///
|
||||
/// NOTE: We must check the RAW state variables here, NOT the public getters
|
||||
/// (disableReelsEntirely/disableExploreEntirely), because those getters
|
||||
/// unconditionally return true when _minimalModeEnabled is true, which would
|
||||
/// make the "all disabled" condition impossible to reach.
|
||||
Future<void> _checkAndAutoDisableMinimalMode() async {
|
||||
if (!_minimalModeEnabled) return;
|
||||
|
||||
// Check the RAW saved state, not the getters
|
||||
final rawReels =
|
||||
_prefs?.getBool('internal_disable_reels_entirely') ??
|
||||
_disableReelsEntirely;
|
||||
final rawExplore =
|
||||
_prefs?.getBool('internal_disable_explore_entirely') ??
|
||||
_disableExploreEntirely;
|
||||
|
||||
final rawHomeFeedScroll =
|
||||
_prefs?.getBool('internal_block_home_feed_scroll') ??
|
||||
_blockHomeFeedScroll;
|
||||
|
||||
final allDisabled =
|
||||
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
|
||||
|
||||
if (allDisabled) {
|
||||
_minimalModeEnabled = false;
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart minimal mode toggle with state preservation
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
if (v) {
|
||||
// Turning ON - save current states BEFORE enabling minimal mode
|
||||
// ── Turning ON ──────────────────────────────────────────────────────────
|
||||
// Save current pre-minimal-mode states so we can restore them later
|
||||
_prevDisableReels = _disableReelsEntirely;
|
||||
_prevDisableExplore = _disableExploreEntirely;
|
||||
_prevBlurExplore = _blurExplore;
|
||||
|
||||
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevDisableExplore,
|
||||
_prevDisableExplore,
|
||||
);
|
||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
||||
|
||||
// Enable all minimal mode settings
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevBlockHomeFeedScroll,
|
||||
_prevBlockHomeFeedScroll,
|
||||
);
|
||||
|
||||
_minimalModeEnabled = true;
|
||||
_disableReelsEntirely = true;
|
||||
_disableExploreEntirely = true;
|
||||
_blurExplore = true;
|
||||
|
||||
_blockHomeFeedScroll = true;
|
||||
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
||||
await _prefs?.setBool('internal_block_home_feed_scroll', true);
|
||||
await _prefs?.setBool(_keyBlurExplore, true);
|
||||
} else {
|
||||
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
|
||||
// ── Turning OFF ─────────────────────────────────────────────────────────
|
||||
// Restore states that were saved BEFORE minimal mode was enabled.
|
||||
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
|
||||
_minimalModeEnabled = false;
|
||||
|
||||
// Simply restore to the states that were saved BEFORE minimal mode was enabled
|
||||
_disableReelsEntirely = _prevDisableReels;
|
||||
_disableExploreEntirely = _prevDisableExplore;
|
||||
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
|
||||
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
|
||||
// to the saved prefs value (covers the case where no prev was saved).
|
||||
_blurExplore = _prevBlurExplore;
|
||||
|
||||
// Save the restored states
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
|
||||
await _prefs?.setBool(
|
||||
'internal_disable_reels_entirely',
|
||||
_disableReelsEntirely,
|
||||
);
|
||||
await _prefs?.setBool(
|
||||
'internal_disable_explore_entirely',
|
||||
_disableExploreEntirely,
|
||||
);
|
||||
await _prefs?.setBool(
|
||||
'internal_block_home_feed_scroll',
|
||||
_blockHomeFeedScroll,
|
||||
);
|
||||
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
||||
|
||||
// After restoring, check whether the user had ALL minimal features OFF
|
||||
// already — if so, minimal mode should stay off (no-op).
|
||||
if (!_disableReelsEntirely &&
|
||||
!_disableExploreEntirely &&
|
||||
!_blockHomeFeedScroll &&
|
||||
!_blurExplore) {
|
||||
// All features are off — minimal mode correctly stays off. No action needed.
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -441,24 +780,95 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> setNotifyDMs(bool v) async {
|
||||
_notifyDMs = v;
|
||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyActivity(bool v) async {
|
||||
_notifyActivity = v;
|
||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyPersistent(bool v) async {
|
||||
_notifyPersistent = v;
|
||||
await _prefs?.setBool(_keyNotifyPersistent, v);
|
||||
if (v) {
|
||||
await NotificationService().requestPermissionsNow();
|
||||
} else {
|
||||
await NotificationService().cancelPersistentNotification(id: 5001);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Startup page ─────────────────────────────────────────────────────────────
|
||||
Future<void> setStartupPage(String page) async {
|
||||
_startupPage = page;
|
||||
await _prefs?.setString(_keyStartupPage, page);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Adsterra zone config ────────────────────────────────────────────────────
|
||||
Future<void> setAdsterraZoneUrl(String url) async {
|
||||
_adsterraZoneUrl = url;
|
||||
await _prefs?.setString(_keyAdsterraZoneUrl, url);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setAdsterraAdCode(String code) async {
|
||||
_adsterraAdCode = code;
|
||||
await _prefs?.setString(_keyAdsterraAdCode, code);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Focus mode settings ──────────────────────────────────────────────────────
|
||||
Future<void> setEffortFrictionEnabled(bool v) async {
|
||||
_effortFrictionEnabled = v;
|
||||
await _prefs?.setBool(_keyEffortFrictionEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostMode(bool v) async {
|
||||
_ghostMode = v;
|
||||
await _prefs?.setBool(_keyGhostMode, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoAds(bool v) async {
|
||||
_noAds = v;
|
||||
await _prefs?.setBool(_keyNoAds, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoStories(bool v) async {
|
||||
_noStories = v;
|
||||
await _prefs?.setBool(_keyNoStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoReels(bool v) async {
|
||||
_noReels = v;
|
||||
await _prefs?.setBool(_keyNoReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoAutoplay(bool v) async {
|
||||
_noAutoplay = v;
|
||||
await _prefs?.setBool(_keyNoAutoplay, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoDMs(bool v) async {
|
||||
_noDMs = v;
|
||||
await _prefs?.setBool(_keyNoDMs, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -517,7 +517,7 @@ class DisciplineChallenge {
|
||||
];
|
||||
|
||||
/// Shows the word challenge dialog. Returns true if successful.
|
||||
static Future<bool> show(BuildContext context, {int count = 15}) async {
|
||||
static Future<bool> show(BuildContext context, {int count = 30}) async {
|
||||
final list = List<String>.from(_words)..shuffle();
|
||||
final challenge = list.take(count).join(' ');
|
||||
final controller = TextEditingController();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'script_registry_v2_overlay.dart';
|
||||
|
||||
class ScriptEngineV2Overlay {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
|
||||
final Map<String, String> _cache = {};
|
||||
|
||||
ScriptEngineV2Overlay({required this.controller, required this.prefs});
|
||||
|
||||
Future<void> initDocumentStartScripts() async {
|
||||
for (final s in V2OverlayScriptRegistry.all) {
|
||||
final enabled = _getEnabled(s.id);
|
||||
s.enabled = enabled;
|
||||
|
||||
if (!enabled) continue;
|
||||
|
||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
final code = await _load(s.assetPath);
|
||||
if (code == null) continue;
|
||||
|
||||
await controller.addUserScript(
|
||||
userScript: UserScript(
|
||||
source: code,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
allowedOriginRules: {'https://www.instagram.com'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> injectDocumentEndScripts() async {
|
||||
for (final s in V2OverlayScriptRegistry.all) {
|
||||
final enabled = _getEnabled(s.id);
|
||||
s.enabled = enabled;
|
||||
if (!enabled) continue;
|
||||
|
||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
|
||||
final code = await _load(s.assetPath);
|
||||
if (code == null) continue;
|
||||
try {
|
||||
await controller.evaluateJavascript(source: code);
|
||||
} catch (_) {
|
||||
// Best-effort injection; never crash UI.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _pushContentFlagsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
|
||||
await prefs.setBool(_enabledKey(id), enabled);
|
||||
|
||||
// For DOCUMENT_START scripts, require reload for clean removal.
|
||||
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
|
||||
UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
await controller.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
|
||||
await controller.reload();
|
||||
}
|
||||
|
||||
bool _getEnabled(V2OverlayScriptId id) {
|
||||
return prefs.getBool(_enabledKey(id)) ??
|
||||
(id == V2OverlayScriptId.themeDetector);
|
||||
}
|
||||
|
||||
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
|
||||
|
||||
Future<void> _pushContentFlagsIfNeeded() async {
|
||||
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
|
||||
|
||||
final contentFlags = <String, bool>{
|
||||
'stories': prefs.getBool('content_stories') ?? false,
|
||||
'posts': prefs.getBool('content_posts') ?? false,
|
||||
'reels': prefs.getBool('content_reels') ?? false,
|
||||
'suggested': prefs.getBool('content_suggested') ?? false,
|
||||
};
|
||||
|
||||
// Apply DOM content hider flags
|
||||
if (contentScriptEnabled) {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
|
||||
);
|
||||
}
|
||||
|
||||
// Also push network filter flags used by fetch_interceptor.js
|
||||
// so toggles actually affect request/response behavior.
|
||||
final noAds =
|
||||
(prefs.getBool('no_ads') ?? false) ||
|
||||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
|
||||
final blockFeedPosts = contentFlags['posts'] ?? false;
|
||||
final blockSuggested = contentFlags['suggested'] ?? false;
|
||||
final blockReels = contentFlags['reels'] ?? false;
|
||||
final blockAutoplay =
|
||||
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
|
||||
|
||||
await controller.evaluateJavascript(
|
||||
source:
|
||||
'window.__fgSetFilterConfig?.(${jsonEncode({
|
||||
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
|
||||
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
|
||||
|
||||
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
|
||||
'blockAds': noAds,
|
||||
'blockSponsored': noAds,
|
||||
|
||||
'blockSuggested': blockSuggested,
|
||||
|
||||
// Keep video blocking controlled by existing toggles if desired.
|
||||
'blockVideos': blockReels,
|
||||
'blockAutoplay': blockAutoplay,
|
||||
})});',
|
||||
);
|
||||
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _load(String assetPath) async {
|
||||
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
||||
try {
|
||||
final code = await rootBundle.loadString(assetPath);
|
||||
_cache[assetPath] = code;
|
||||
return code;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
enum V2OverlayScriptId {
|
||||
ghostMode,
|
||||
themeDetector,
|
||||
adBlockerDom,
|
||||
contentHider,
|
||||
fetchInterceptor,
|
||||
autoplayBlocker,
|
||||
}
|
||||
|
||||
class V2OverlayInstaScript {
|
||||
final V2OverlayScriptId id;
|
||||
final String name;
|
||||
final String assetPath;
|
||||
final UserScriptInjectionTime injectionTime;
|
||||
bool enabled;
|
||||
|
||||
V2OverlayInstaScript({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.assetPath,
|
||||
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
this.enabled = false,
|
||||
});
|
||||
}
|
||||
|
||||
class V2OverlayScriptRegistry {
|
||||
static final List<V2OverlayInstaScript> all = [
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.ghostMode,
|
||||
name: 'ghost_mode',
|
||||
assetPath: 'assets/scripts/ghost_mode.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.themeDetector,
|
||||
name: 'theme_detector',
|
||||
assetPath: 'assets/scripts/theme_detector.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: true,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.adBlockerDom,
|
||||
name: 'ad_blocker_dom',
|
||||
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.contentHider,
|
||||
name: 'content_hider',
|
||||
assetPath: 'assets/scripts/content_hider.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.fetchInterceptor,
|
||||
name: 'fetch_interceptor',
|
||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.autoplayBlocker,
|
||||
name: 'autoplay_blocker',
|
||||
assetPath: 'assets/scripts/autoplay_blocker.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
];
|
||||
|
||||
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
|
||||
return all.firstWhere((s) => s.id == id);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/remote_popup_service.dart';
|
||||
|
||||
class RemotePopupHandler {
|
||||
static Future<void> checkAndShow(BuildContext context) async {
|
||||
final popup = await RemotePopupService.fetchPopup();
|
||||
if (popup == null) return;
|
||||
|
||||
final shouldShow = await RemotePopupService.shouldShow(popup);
|
||||
if (!shouldShow) return;
|
||||
|
||||
await RemotePopupService.markShown(popup);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) {
|
||||
return AlertDialog(
|
||||
title: Text(popup.title),
|
||||
content: Text(popup.body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(popup.buttonText),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
Categories:
|
||||
- Connectivity
|
||||
- Social Network
|
||||
License: AGPL-3.0-only
|
||||
AuthorName: Ujwal Chapagain
|
||||
AuthorEmail: notujwal@proton.me
|
||||
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
|
||||
|
||||
AutoName: FocusGram
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/Ujwal223/FocusGram
|
||||
|
||||
Builds:
|
||||
- versionName: 1.0.0
|
||||
versionCode: 3
|
||||
commit: v1.0.0
|
||||
output: build/app/outputs/flutter-apk/app-release.apk
|
||||
srclibs:
|
||||
- flutter@stable
|
||||
prebuild:
|
||||
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
|
||||
- '[[ $flutterVersion ]]'
|
||||
- git -C $$flutter$$ checkout -f $flutterVersion
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter config --no-analytics
|
||||
- .flutter/bin/flutter pub get
|
||||
scanignore:
|
||||
- .flutter/bin/cache
|
||||
scandelete:
|
||||
- .flutter
|
||||
- .pub-cache
|
||||
build:
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
VercodeOperation:
|
||||
- '%c * 10 + 1'
|
||||
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||
CurrentVersion: 1.0.0
|
||||
CurrentVersionCode: 3
|
||||
+134
-22
@@ -37,10 +37,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_settings
|
||||
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
|
||||
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.1"
|
||||
version: "7.0.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -213,10 +213,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
version: "0.71.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -290,10 +290,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -342,6 +342,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -372,10 +420,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
version: "8.1.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +432,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -488,6 +552,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -528,6 +600,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -540,10 +652,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -556,10 +668,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -596,10 +708,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,18 +780,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -764,10 +876,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -865,10 +977,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+28
-12
@@ -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."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.1.0
|
||||
version: 2.1.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
@@ -11,11 +11,11 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
||||
# WebView engine
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# Local key-value persistence — latest stable
|
||||
shared_preferences: ^2.5.4
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
# Date/time formatting for daily resets — latest stable
|
||||
intl: ^0.20.2
|
||||
@@ -28,26 +28,36 @@ dependencies:
|
||||
|
||||
# URL launcher for About page links — latest stable
|
||||
url_launcher: ^6.3.2
|
||||
package_info_plus: ^8.1.2
|
||||
package_info_plus: ^9.0.0
|
||||
# Handling Instagram deep links — latest stable
|
||||
app_links: ^6.3.2
|
||||
app_links: ^6.4.1
|
||||
# Open system settings — latest stable
|
||||
app_settings: ^6.1.1
|
||||
google_fonts: ^8.0.2
|
||||
http: ^1.3.0
|
||||
permission_handler: ^12.0.1
|
||||
app_settings: ^7.0.0
|
||||
google_fonts: ^8.1.0
|
||||
http: ^1.6.0
|
||||
permission_handler: ^11.4.0
|
||||
# Image/file picker for story uploads on Android
|
||||
image_picker: ^1.1.2
|
||||
image_picker: ^1.2.0
|
||||
flutter_windowmanager_plus: ^1.0.1
|
||||
|
||||
# Charts for on-device screen time dashboard (MIT)
|
||||
fl_chart: ^0.69.0
|
||||
fl_chart: ^0.71.0
|
||||
|
||||
# ── 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:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -55,6 +65,12 @@ flutter:
|
||||
assets:
|
||||
- assets/images/focusgram.png
|
||||
- assets/images/focusgram.ico
|
||||
- assets/scripts/ghost_mode.js
|
||||
- assets/scripts/ad_blocker_dom.js
|
||||
- assets/scripts/content_hider.js
|
||||
- assets/scripts/theme_detector.js
|
||||
- assets/scripts/fetch_interceptor.js
|
||||
- assets/scripts/autoplay_blocker.js
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focusgram/screens/main_webview_page.dart';
|
||||
|
||||
void main() {
|
||||
group('handleFocusGramMediaDownload', () {
|
||||
test('rejects non-http(s) schemes', () async {
|
||||
final launched = <Uri>[];
|
||||
final ok = await handleFocusGramMediaDownload(
|
||||
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
|
||||
launch: (uri) async => launched.add(uri),
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(launched, isEmpty);
|
||||
});
|
||||
|
||||
test('accepts http(s) instagram-like hosts and calls launcher', () async {
|
||||
final launched = <Uri>[];
|
||||
final ok = await handleFocusGramMediaDownload(
|
||||
raw:
|
||||
'{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
|
||||
launch: (uri) async => launched.add(uri),
|
||||
);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(launched, hasLength(1));
|
||||
expect(launched.first.scheme, 'https');
|
||||
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
|
||||
});
|
||||
|
||||
test('rejects non-instagram hosts even if http(s)', () async {
|
||||
final launched = <Uri>[];
|
||||
final ok = await handleFocusGramMediaDownload(
|
||||
raw:
|
||||
'{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
|
||||
launch: (uri) async => launched.add(uri),
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(launched, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects malformed JSON safely', () async {
|
||||
final launched = <Uri>[];
|
||||
final ok = await handleFocusGramMediaDownload(
|
||||
raw: '{not json',
|
||||
launch: (uri) async => launched.add(uri),
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(launched, isEmpty);
|
||||
});
|
||||
|
||||
test('rejects missing url field', () async {
|
||||
final launched = <Uri>[];
|
||||
final ok = await handleFocusGramMediaDownload(
|
||||
raw: '{"type":"video","filename":"x"}',
|
||||
launch: (uri) async => launched.add(uri),
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(launched, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:focusgram/services/injection_manager.dart';
|
||||
import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
|
||||
import 'package:focusgram/services/session_manager.dart';
|
||||
import 'package:focusgram/services/settings_service.dart';
|
||||
|
||||
class _FakeJsEvaluator implements JsEvaluator {
|
||||
final List<String> sources = [];
|
||||
|
||||
@override
|
||||
Future<void> evaluateJavascript({required String source}) async {
|
||||
sources.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test(
|
||||
'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setV2AdBlockerDomEnabled(true);
|
||||
|
||||
expect(settings.v2AdBlockerDomEnabled, isTrue);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
// Verify that sponsored posts JS injection is NOT triggered by InjectionManager
|
||||
// (it's handled by the V2 DOM Ad Blocker engine instead)
|
||||
final sponsoredPostsInjected = fakeEval.sources.any(
|
||||
(s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
|
||||
);
|
||||
|
||||
expect(
|
||||
sponsoredPostsInjected,
|
||||
isFalse,
|
||||
reason:
|
||||
'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
|
||||
() {
|
||||
final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
|
||||
! comment
|
||||
[Adblock Plus 2.0]
|
||||
||ads.example.com^
|
||||
||tracker.example.net/path.js\$third-party
|
||||
@@||allowed.example.com^
|
||||
example.com##.sponsored
|
||||
||wild*.example.com^
|
||||
||bad,domain.example^
|
||||
||sub.adguard.example.org^\$script,third-party
|
||||
''');
|
||||
|
||||
expect(
|
||||
hosts,
|
||||
containsAll({
|
||||
'ads.example.com',
|
||||
'tracker.example.net',
|
||||
'sub.adguard.example.org',
|
||||
}),
|
||||
);
|
||||
expect(hosts, isNot(contains('allowed.example.com')));
|
||||
expect(hosts, isNot(contains('wild*.example.com')));
|
||||
expect(hosts, isNot(contains('bad,domain.example')));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:focusgram/services/injection_manager.dart';
|
||||
import 'package:focusgram/services/session_manager.dart';
|
||||
import 'package:focusgram/services/settings_service.dart';
|
||||
|
||||
class _FakeJsEvaluator implements JsEvaluator {
|
||||
final List<String> sources = [];
|
||||
|
||||
@override
|
||||
Future<void> evaluateJavascript({required String source}) async {
|
||||
sources.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test(
|
||||
'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setHideSuggestedPosts(true);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
|
||||
expect(any, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setHideSuggestedPosts(false);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
|
||||
expect(any, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'injects video downloader JS only when settings.videoDownloadEnabled=true',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setVideoDownloadEnabled(true);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
final any = fakeEval.sources.any(
|
||||
(s) => s.contains('__fgMediaDownloadRunning'),
|
||||
);
|
||||
expect(any, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setVideoDownloadEnabled(false);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
final any = fakeEval.sources.any(
|
||||
(s) => s.contains('__fgMediaDownloadRunning'),
|
||||
);
|
||||
expect(any, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('injects home feed scroll lock flag when enabled', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final sm = SessionManager();
|
||||
final fakeEval = _FakeJsEvaluator();
|
||||
|
||||
final mgr = InjectionManager.forTest(
|
||||
jsEvaluator: fakeEval,
|
||||
prefs: prefs,
|
||||
sessionManager: sm,
|
||||
);
|
||||
|
||||
final settings = SettingsService();
|
||||
await settings.init();
|
||||
await settings.setBlockHomeFeedScrollInternal(true);
|
||||
|
||||
mgr.setSettingsService(settings);
|
||||
|
||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
||||
|
||||
final any = fakeEval.sources.any(
|
||||
(s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
|
||||
);
|
||||
expect(any, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user