mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-04 10:17:52 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a1ff5a9fe | |||
| 6db4e0fe92 | |||
| 7622106695 | |||
| 97e8d12f86 | |||
| 3ff20e329a | |||
| 24f43603ad | |||
| f87597276c | |||
| b5ef642683 | |||
| f497730015 | |||
| 4e6c3f122a | |||
| e751e14a6b | |||
| 5b8d59e98b | |||
| 719badb2f5 | |||
| c0354ae7aa | |||
| 3769ff2c38 | |||
| d4be176f73 | |||
| 5232b8b0a9 | |||
| 878e625f0e | |||
| e23731d9e8 | |||
| fe2d793b93 | |||
| 354f7413d1 | |||
| 9ab4fc503a | |||
| a848b9222d |
Submodule
+1
Submodule .flutter added at b31548feb9
@@ -1,10 +0,0 @@
|
||||
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")
|
||||
@@ -1,8 +0,0 @@
|
||||
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,164 +0,0 @@
|
||||
name: Build APK and Create GitHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install required Android SDK packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"build-tools;34.0.0" \
|
||||
"build-tools;35.0.0"
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Resolve version and tag
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
||||
VERSION="${INPUT_VERSION#v}"
|
||||
else
|
||||
VERSION="$(python3 .github/scripts/get_version.py)"
|
||||
fi
|
||||
TAG="v${VERSION}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: python3 .github/scripts/get_notes.py
|
||||
|
||||
- name: Decode Android keystore
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p android/app
|
||||
# tr -d strips any newlines/spaces introduced when the secret was stored
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" \
|
||||
| tr -d '[:space:]' \
|
||||
| base64 --decode > android/app/upload-keystore.jks
|
||||
chmod 600 android/app/upload-keystore.jks
|
||||
echo "Keystore written: $(wc -c < android/app/upload-keystore.jks) bytes"
|
||||
|
||||
- name: Create Android key.properties
|
||||
shell: bash
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Trim any accidental whitespace/newlines from secret values
|
||||
KS_PASS="$(printf '%s' "${KEYSTORE_PASSWORD}" | tr -d '[:space:]')"
|
||||
K_PASS="$(printf '%s' "${KEY_PASSWORD}" | tr -d '[:space:]')"
|
||||
K_ALIAS="$(printf '%s' "${KEY_ALIAS}" | tr -d '[:space:]')"
|
||||
# Absolute path prevents Gradle from misresolving a relative storeFile
|
||||
KEYSTORE_PATH="${GITHUB_WORKSPACE}/android/app/upload-keystore.jks"
|
||||
{
|
||||
printf 'storePassword=%s\n' "${KS_PASS}"
|
||||
printf 'keyPassword=%s\n' "${K_PASS}"
|
||||
printf 'keyAlias=%s\n' "${K_ALIAS}"
|
||||
printf 'storeFile=%s\n' "${KEYSTORE_PATH}"
|
||||
} > android/key.properties
|
||||
|
||||
- name: Verify keystore
|
||||
shell: bash
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KS_PASS="$(printf '%s' "${KEYSTORE_PASSWORD}" | tr -d '[:space:]')"
|
||||
echo "=== Keystore file ==="
|
||||
ls -lh android/app/upload-keystore.jks
|
||||
file android/app/upload-keystore.jks
|
||||
echo ""
|
||||
echo "=== key.properties keys (values hidden) ==="
|
||||
cut -d'=' -f1 android/key.properties
|
||||
echo ""
|
||||
echo "=== Keystore verification via keytool ==="
|
||||
keytool -list \
|
||||
-keystore android/app/upload-keystore.jks \
|
||||
-storepass "${KS_PASS}" \
|
||||
2>&1 | grep -vE "^(Warning|$)"
|
||||
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Rename APK
|
||||
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: focusgram-apk-${{ steps.meta.outputs.tag }}
|
||||
path: build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Git tag
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "Tag already exists on remote: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "${TAG}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
name: FocusGram ${{ steps.meta.outputs.tag }}
|
||||
body_path: release_notes.md
|
||||
files: build/app/outputs/flutter-apk/focusgram-release.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,12 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
+1
-7
@@ -12,10 +12,6 @@
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
PRD.md
|
||||
.reasonix/
|
||||
.agents/
|
||||
|
||||
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
@@ -27,9 +23,9 @@ PRD.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/app/*.jks
|
||||
upload-keystore.jks
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
@@ -51,5 +47,3 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
.flutter/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule ".flutter"]
|
||||
path = .flutter
|
||||
url = https://github.com/flutter/flutter.git
|
||||
@@ -1,22 +0,0 @@
|
||||
## FocusGram 2.1.0
|
||||
|
||||
### What's new
|
||||
|
||||
- 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: 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
|
||||
- A lof of other Minor fixes.
|
||||
@@ -1,171 +1,87 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/images/focusgram.png" alt="FocusGram" width="96" height="96" />
|
||||
|
||||
# FocusGram
|
||||
|
||||
**Use social media on your terms.**
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/Ujwal223/FocusGram/releases)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
**Take back your time.** FocusGram is a distraction-free client for Instagram on Android that hides Reels and Explore, so you can stay connected without getting lost in the scroll.
|
||||
|
||||
<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)
|
||||
|
||||
</div>
|
||||
[🌟 Star on GitHub](https://github.com/Ujwal223/FocusGram) | [📥 Download Latest APK](https://github.com/Ujwal223/FocusGram/releases)
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
## Why FocusGram?
|
||||
|
||||
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.
|
||||
Most people don't want to delete Instagram entirely—they just want to stop wasting hours on Reels. FocusGram surgically removes the parts of Instagram designed for compulsive scrolling, while keeping your feed, stories, and DMs fully functional.
|
||||
|
||||
> 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="Purple and Pink Pastel Simple Modern Payment Mobile App Presentation" src="https://github.com/user-attachments/assets/a2da0c58-b7a1-4ac4-a5a7-0e30b751a111" />
|
||||
|
||||
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/assets/images/app-demo.png" />
|
||||
|
||||
### Key Benefits
|
||||
- **Mental Health**: Stop the dopamine loop of endless autoplay videos.
|
||||
- **Productivity**: Open Instagram to check a message or post a story, and get out in seconds.
|
||||
- **Privacy**: No tracking, no analytics, and no third-party SDKs. Your data stays on your device.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
## Master Your Usage
|
||||
|
||||
**Focus tools**
|
||||
FocusGram doesn't just block Reels—it gives you tools to build better habits:
|
||||
|
||||
- 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.
|
||||
- ✅ **Controlled Reel Sessions**: Need to watch a Reel? Start a timed session (1 to 15 minutes). When the time is up, they're blocked again.
|
||||
- ✅ **Daily Limits**: Set a maximum amount of Reel time per day.
|
||||
- ✅ **Habit-Building Cooldowns**: Enforce a mandatory break between sessions to prevent bingeing.
|
||||
|
||||
**Content filtering**
|
||||
|
||||
- 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
|
||||
- Reel & App Quota: Allocate only certain time for reels and/or instagram
|
||||
|
||||
**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
|
||||
|
||||
### Direct download
|
||||
1. Go to the [Releases](https://github.com/ujwal223/focusgram/releases) page
|
||||
2. Download `focusgram-release.apk`
|
||||
3. Open the file and allow "Install from unknown sources" if prompted
|
||||
### 1. From GitHub (Current)
|
||||
1. Go to the [Releases](https://github.com/Ujwal223/FocusGram/releases) page.
|
||||
2. Download the `focusgram-release.apk`.
|
||||
3. Open the file on your phone and allow "Install from unknown sources" if prompted.
|
||||
|
||||
### Uptodown
|
||||
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
|
||||
### 2. From F-Droid (Soon)
|
||||
We are currently in the process of submitting FocusGram to the F-Droid store for easier updates.
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
## Frequently Asked Questions
|
||||
|
||||
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.
|
||||
**Is my login safe?**
|
||||
Yes. FocusGram uses a standard system WebView. Your credentials go directly to Instagram/Meta's servers, just like in a mobile browser. We do not (and cannot) see your password.
|
||||
|
||||
Our app has:
|
||||
- No analytics
|
||||
- No crash reporting
|
||||
- No third-party SDKs
|
||||
- No Logging
|
||||
- No data leaves your device
|
||||
|
||||
**Why is it free?**
|
||||
FocusGram is Open Source software created by [Ujwal Chapagain](https://github.com/Ujwal223). It is built for everyone who wants a healthier relationship with social media.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**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?**<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.
|
||||
|
||||
**How do i support this project?**<br>
|
||||
You can support this project by donating here: [Donate](https://buymemomo.com/ujwal)
|
||||
|
||||
---
|
||||
|
||||
## Building from source
|
||||
## Development & Technical Details
|
||||
|
||||
<details>
|
||||
<summary>Technical details and build instructions</summary>
|
||||
<summary>View Technical Info</summary>
|
||||
|
||||
### Requirements
|
||||
- Flutter stable channel (3.38+)
|
||||
- Android SDK
|
||||
- NDK 28.2.12676356
|
||||
- Android SDK cmdline tools 20
|
||||
- Android build tools 34 and 35
|
||||
- JDK 17 (Eclipse Adoptium 17.0.17+)
|
||||
|
||||
### Build
|
||||
### Build from Source
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
### Architecture
|
||||
FocusGram uses a standard Android System WebView to load `instagram.com`. All features are implemented client-side via:
|
||||
- JavaScript injection (autoplay blocking, metadata extraction, SPA navigation monitoring)
|
||||
- CSS injection (element hiding, grayscale, scroll behaviour)
|
||||
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
|
||||
|
||||
### Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Framework | Flutter (Dart) |
|
||||
| WebView | flutter_inappwebview (Apache 2.0) |
|
||||
| Storage | shared_preferences |
|
||||
| License | AGPL-3.0 |
|
||||
### Permissions
|
||||
- `INTERNET`: To load Instagram.
|
||||
- `RECEIVE_BOOT_COMPLETED`: To keep your session timers and notifications accurate after a restart.
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Flutter (Dart)
|
||||
- **Engine**: webview_flutter
|
||||
- **License**: AGPL-3.0 (Affero General Public License)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Legal disclaimer
|
||||
|
||||
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.
|
||||
|
||||
**What we do not do:**
|
||||
- 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.
|
||||
|
||||
Using FocusGram is functionally equivalent to accessing Instagram through a mobile web browser with a content blocker extension. By using FocusGram, you acknowledge that you remain bound by Instagram's own Terms of Service.
|
||||
|
||||
For legal concerns, contact `notujwal@proton.me` before taking any other action.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2025-2026 Ujwal Chapagain
|
||||
Copyright (C) 2025 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.
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
# 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`
|
||||
|
||||
@@ -12,5 +12,3 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
upload-keystore.jks
|
||||
|
||||
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ plugins {
|
||||
android {
|
||||
namespace = "com.ujwal.focusgram"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
buildToolsVersion = "35.0.0"
|
||||
buildToolsVersion = "34.0.0"
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
@@ -42,10 +42,10 @@ android {
|
||||
applicationId = "com.ujwal.focusgram"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "2.1.0"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = 2
|
||||
versionName = "0.9.8-beta.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -63,11 +63,7 @@ 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.play", module = "core")
|
||||
exclude(group = "com.google.android.play", module = "core-common")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
-5
@@ -1,8 +1,3 @@
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class com.pichillilorenzo.flutter_inappwebview.** { *; }
|
||||
-keep class **.GeneratedPluginRegistrant { *; }
|
||||
|
||||
# Strip Google Play Core (Flutter engine bundles these unnecessarily for F-Droid)
|
||||
-dontwarn com.google.android.play.core.**
|
||||
-dontwarn com.google.android.play.core.splitinstall.**
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="FocusGram"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
@@ -52,17 +50,9 @@
|
||||
</activity>
|
||||
|
||||
<!-- Flutter tool meta-data -->
|
||||
<!-- Disable Impeller — causes blank/noisy WebView on some Samsung devices -->
|
||||
<!-- Flutter issue #162439. Remove when Flutter fixes this. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package com.google.android.play.core.splitcompat;
|
||||
import android.app.Application;
|
||||
public class SplitCompatApplication extends Application {}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
public class SplitInstallException extends Exception {
|
||||
public int getErrorCode() { return 0; }
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
public interface SplitInstallManager {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
import android.content.Context;
|
||||
public class SplitInstallManagerFactory {
|
||||
public static SplitInstallManager create(Context context) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
|
||||
public class SplitInstallRequest {
|
||||
public static Builder newBuilder() { return new Builder(); }
|
||||
public static class Builder {
|
||||
public Builder addModule(String moduleName) { return this; }
|
||||
public SplitInstallRequest build() { return new SplitInstallRequest(); }
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
public class SplitInstallSessionState {
|
||||
public int sessionId() { return 0; }
|
||||
public int status() { return 0; }
|
||||
public long bytesDownloaded() { return 0; }
|
||||
public long totalBytesToDownload() { return 0; }
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
|
||||
public interface SplitInstallStateUpdatedListener {
|
||||
void onStateUpdate(SplitInstallSessionState state);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
public interface OnFailureListener {
|
||||
void onFailure(Exception e);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
public interface OnSuccessListener<TResult> {
|
||||
void onSuccess(TResult result);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
|
||||
public abstract class Task<TResult> {
|
||||
public abstract boolean isSuccessful();
|
||||
public abstract TResult getResult();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,2 @@
|
||||
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
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"show": false,
|
||||
"id": "popup_005",
|
||||
"header": "FOCUSGRAM UPDATE DISCONTINUITION!!",
|
||||
"body": "Due to NO Support from community, it is being difficult for me to maintain this project, due to which, Next coming update might be the LAST update for FocusGram. THANKS FOR UNDERSTANDING! ",
|
||||
"max_shows": 6,
|
||||
"button_text": "OKAY..."
|
||||
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 401 KiB |
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
})();
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
})();
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* 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' }));
|
||||
}
|
||||
})();
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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' }));
|
||||
}
|
||||
})();
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 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,3 +0,0 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -0,0 +1,5 @@
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
FocusGram is a distraction-free client for Instagram that allows you to use the core features—feed, stories, DMs, and profile—without getting stuck in the endless scroll of Reels and Explore.
|
||||
|
||||
Key Features:
|
||||
- Adds blur in explore feeds and homepage posts.
|
||||
- Blocks navigation to /reel/ URLs to prevent accidental distraction.
|
||||
- Implement controlled Reel sessions with customizable time limits and daily totals.
|
||||
- Enforces cooldown periods between sessions to build better habits.
|
||||
- Privacy-focused: No ads, no tracking, and no proprietary SDKs.
|
||||
- 100% Free and Open Source.
|
||||
@@ -0,0 +1 @@
|
||||
Distraction-free Instagram with controlled Reel access.
|
||||
@@ -0,0 +1 @@
|
||||
FocusGram
|
||||
@@ -1,532 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum SkeletonType { feed, reels, explore, messages, profile, generic }
|
||||
|
||||
class SkeletonScreen extends StatefulWidget {
|
||||
final SkeletonType skeletonType;
|
||||
|
||||
const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic});
|
||||
|
||||
@override
|
||||
State<SkeletonScreen> createState() => _SkeletonScreenState();
|
||||
}
|
||||
|
||||
class _SkeletonScreenState extends State<SkeletonScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _shimmerController;
|
||||
late Animation<double> _shimmerAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shimmerController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
_shimmerAnimation = Tween<double>(
|
||||
begin: -1.0,
|
||||
end: 2.0,
|
||||
).animate(_shimmerController);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final baseColor = theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: theme.brightness == Brightness.dark ? 0.25 : 0.4,
|
||||
);
|
||||
final highlightColor = theme.colorScheme.onSurface.withValues(alpha: 0.08);
|
||||
|
||||
return Container(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: AnimatedBuilder(
|
||||
animation: _shimmerAnimation,
|
||||
builder: (context, child) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (rect) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [baseColor, highlightColor, baseColor],
|
||||
stops: const [0.1, 0.3, 0.6],
|
||||
transform: _SlidingGradientTransform(
|
||||
slidePercent: _shimmerAnimation.value,
|
||||
),
|
||||
).createShader(rect);
|
||||
},
|
||||
blendMode: BlendMode.srcATop,
|
||||
child: _buildSkeletonContent(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonContent(BuildContext context) {
|
||||
switch (widget.skeletonType) {
|
||||
case SkeletonType.feed:
|
||||
return _buildFeedSkeleton(context);
|
||||
case SkeletonType.reels:
|
||||
return _buildReelsSkeleton(context);
|
||||
case SkeletonType.explore:
|
||||
return _buildExploreSkeleton(context);
|
||||
case SkeletonType.messages:
|
||||
return _buildMessagesSkeleton(context);
|
||||
case SkeletonType.profile:
|
||||
return _buildProfileSkeleton(context);
|
||||
case SkeletonType.generic:
|
||||
return _buildGenericSkeleton(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFeedSkeleton(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
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,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: width,
|
||||
color: Colors.white,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
3,
|
||||
(i) => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Container(
|
||||
width: width * 0.7,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReelsSkeleton(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExploreSkeleton(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 2,
|
||||
mainAxisSpacing: 2,
|
||||
),
|
||||
itemCount: 15,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(color: Colors.white);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagesSkeleton(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileSkeleton(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: 120,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 50,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 2,
|
||||
mainAxisSpacing: 2,
|
||||
),
|
||||
itemCount: 9,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(color: Colors.white);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGenericSkeleton(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
6,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: width * 0.4,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: width * 0.25,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: width * 1.1,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: width * 0.7,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: width * 0.5,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlidingGradientTransform extends GradientTransform {
|
||||
final double slidePercent;
|
||||
|
||||
const _SlidingGradientTransform({required this.slidePercent});
|
||||
|
||||
@override
|
||||
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
|
||||
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
SkeletonType getSkeletonTypeFromUrl(String url) {
|
||||
final parsed = Uri.tryParse(url);
|
||||
if (parsed == null) return SkeletonType.generic;
|
||||
final path = parsed.path.toLowerCase();
|
||||
|
||||
if (path.startsWith('/reels') || path.startsWith('/reel/')) {
|
||||
return SkeletonType.reels;
|
||||
} else if (path.startsWith('/explore')) {
|
||||
return SkeletonType.explore;
|
||||
} else if (path.startsWith('/messages') || path.startsWith('/inbox')) {
|
||||
return SkeletonType.messages;
|
||||
} else if (path.startsWith('/') && !path.startsWith('/accounts')) {
|
||||
if (path.split('/').length <= 2) {
|
||||
return SkeletonType.feed;
|
||||
}
|
||||
return SkeletonType.profile;
|
||||
}
|
||||
return SkeletonType.generic;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NativeBottomNav extends StatelessWidget {
|
||||
final String currentUrl;
|
||||
final bool reelsEnabled;
|
||||
final bool exploreEnabled;
|
||||
final bool minimalMode;
|
||||
final Function(String path) onNavigate;
|
||||
|
||||
const NativeBottomNav({
|
||||
super.key,
|
||||
required this.currentUrl,
|
||||
required this.reelsEnabled,
|
||||
required this.exploreEnabled,
|
||||
required this.minimalMode,
|
||||
required this.onNavigate,
|
||||
});
|
||||
|
||||
String get _path {
|
||||
final parsed = Uri.tryParse(currentUrl);
|
||||
if (parsed != null && parsed.path.isNotEmpty) return parsed.path;
|
||||
return currentUrl; // may already be a path from SPA callbacks
|
||||
}
|
||||
|
||||
bool get _onHome => _path == '/' || _path.isEmpty;
|
||||
bool get _onExplore => _path.startsWith('/explore');
|
||||
bool get _onReels => _path.startsWith('/reels') || _path.startsWith('/reel/');
|
||||
bool get _onProfile =>
|
||||
_path.startsWith('/accounts') ||
|
||||
_path.contains('/profile') ||
|
||||
_path.split('/').where((p) => p.isNotEmpty).length == 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 tabs = <_NavItem>[
|
||||
_NavItem(
|
||||
icon: Icons.home_outlined,
|
||||
activeIcon: Icons.home,
|
||||
label: 'Home',
|
||||
path: '/',
|
||||
active: _onHome,
|
||||
enabled: true,
|
||||
),
|
||||
if (!minimalMode)
|
||||
_NavItem(
|
||||
icon: Icons.search_outlined,
|
||||
activeIcon: Icons.search,
|
||||
label: 'Search',
|
||||
path: '/explore/',
|
||||
active: _onExplore,
|
||||
enabled: exploreEnabled,
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.add_box_outlined,
|
||||
activeIcon: Icons.add_box,
|
||||
label: 'New',
|
||||
path: '/create/select/',
|
||||
active: false,
|
||||
enabled: true,
|
||||
),
|
||||
if (!minimalMode)
|
||||
_NavItem(
|
||||
icon: Icons.play_circle_outline,
|
||||
activeIcon: Icons.play_circle,
|
||||
label: 'Reels',
|
||||
path: '/reels/',
|
||||
active: _onReels,
|
||||
enabled: reelsEnabled,
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.person_outline,
|
||||
activeIcon: Icons.person,
|
||||
label: 'Profile',
|
||||
path: '/accounts/edit/',
|
||||
active: _onProfile,
|
||||
enabled: true,
|
||||
),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: isDark ? Colors.white10 : Colors.black12,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: tabs.map((item) {
|
||||
final color = item.active ? iconColorActive : iconColorInactive;
|
||||
final opacity = item.enabled ? 1.0 : 0.35;
|
||||
|
||||
return Expanded(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: InkWell(
|
||||
onTap: item.enabled ? () => onNavigate(item.path) : null,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
item.active ? item.activeIcon : item.icon,
|
||||
size: 24,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(fontSize: 10, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String label;
|
||||
final String path;
|
||||
final bool active;
|
||||
final bool enabled;
|
||||
|
||||
_NavItem({
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.label,
|
||||
required this.path,
|
||||
required this.active,
|
||||
required this.enabled,
|
||||
});
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../../scripts/spa_navigation_monitor.dart';
|
||||
import '../../scripts/native_feel.dart';
|
||||
import '../../scripts/focus_scripts.dart';
|
||||
|
||||
class InstagramPreloader {
|
||||
static HeadlessInAppWebView? _headlessWebView;
|
||||
static InAppWebViewController? controller;
|
||||
static final InAppWebViewKeepAlive keepAlive = InAppWebViewKeepAlive();
|
||||
static bool isReady = false;
|
||||
|
||||
static Future<void> start(String userAgent) async {
|
||||
if (_headlessWebView != null) return;
|
||||
|
||||
_headlessWebView = HeadlessInAppWebView(
|
||||
keepAlive: keepAlive,
|
||||
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent: userAgent,
|
||||
mediaPlaybackRequiresUserGesture: true,
|
||||
useHybridComposition: true,
|
||||
cacheEnabled: true,
|
||||
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
|
||||
domStorageEnabled: true,
|
||||
databaseEnabled: true,
|
||||
hardwareAcceleration: true,
|
||||
transparentBackground: true,
|
||||
safeBrowsingEnabled: false,
|
||||
),
|
||||
initialUserScripts: UnmodifiableListView([
|
||||
// DM Ghost — comprehensive blocking, gated by window.__fgFullDmGhost flag.
|
||||
// it should have worked, but sadly it didnt
|
||||
UserScript(
|
||||
source: kFullDmGhostJS,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kSpaNavigationMonitorScript,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kNativeFeelingScript,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
// ReelMetadataExtractor removed — reel history feature deleted
|
||||
]),
|
||||
onWebViewCreated: (c) {
|
||||
controller = c;
|
||||
},
|
||||
onLoadStop: (c, url) async {
|
||||
isReady = true;
|
||||
await c.evaluateJavascript(source: kNativeFeelingPostLoadScript);
|
||||
},
|
||||
);
|
||||
|
||||
await _headlessWebView!.run();
|
||||
}
|
||||
|
||||
static void dispose() {
|
||||
_headlessWebView?.dispose();
|
||||
_headlessWebView = null;
|
||||
controller = null;
|
||||
isReady = false;
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'reels_history_service.dart';
|
||||
|
||||
class ReelsHistoryScreen extends StatefulWidget {
|
||||
const ReelsHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReelsHistoryScreen> createState() => _ReelsHistoryScreenState();
|
||||
}
|
||||
|
||||
class _ReelsHistoryScreenState extends State<ReelsHistoryScreen> {
|
||||
final _service = ReelsHistoryService();
|
||||
late Future<List<ReelsHistoryEntry>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _service.getEntries();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _future = _service.getEntries());
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime dt) =>
|
||||
DateFormat('EEE, MMM d • h:mm a').format(dt.toLocal());
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final diff = DateTime.now().difference(dt.toLocal());
|
||||
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 _formatTimestamp(dt);
|
||||
}
|
||||
|
||||
Future<void> _confirmClearAll() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear Reels History?'),
|
||||
content: const Text(
|
||||
'This removes all history entries stored locally on this device.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Clear All'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
await _service.clearAll();
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Reels History',
|
||||
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),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Clear All',
|
||||
onPressed: _confirmClearAll,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: FutureBuilder<List<ReelsHistoryEntry>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final entries = snapshot.data ?? const <ReelsHistoryEntry>[];
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${entries.length} reels stored locally on device only',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (entries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'No Reels history yet.\nWatch a Reel and it will appear here.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...entries.map((entry) {
|
||||
return Dismissible(
|
||||
key: ValueKey(entry.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.redAccent.withValues(alpha: 0.15),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
),
|
||||
onDismissed: (_) async {
|
||||
await _service.deleteEntry(entry.id);
|
||||
// Don't call _refresh() on dismiss — removes the entry from
|
||||
// the live list already via Dismissible, avoids double setState
|
||||
},
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: _ReelThumbnail(url: entry.thumbnailUrl),
|
||||
title: Text(
|
||||
entry.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
_relativeTime(entry.visitedAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: Colors.blue,
|
||||
size: 20,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, entry.url),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail widget that correctly sends Referer + User-Agent headers
|
||||
/// required by Instagram's CDN. Without these the CDN returns 403.
|
||||
class _ReelThumbnail extends StatelessWidget {
|
||||
final String url;
|
||||
const _ReelThumbnail({required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: url.isEmpty
|
||||
? _placeholder()
|
||||
: Image.network(
|
||||
url,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
headers: const {
|
||||
// Instagram CDN requires a valid Referer header
|
||||
'Referer': 'https://www.instagram.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86',
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
loadingBuilder: (_, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return Container(
|
||||
color: Colors.white10,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => Container(
|
||||
color: Colors.white10,
|
||||
child: const Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: Colors.white30,
|
||||
size: 28,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ReelsHistoryEntry {
|
||||
final String id;
|
||||
final String url;
|
||||
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,
|
||||
required this.url,
|
||||
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(),
|
||||
'durationSeconds': durationSeconds,
|
||||
'adsWatchedInSession': adsWatchedInSession,
|
||||
};
|
||||
|
||||
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
|
||||
return ReelsHistoryEntry(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
url: (json['url'] as String?) ?? '',
|
||||
title: (json['title'] as String?) ?? 'Instagram Reel',
|
||||
thumbnailUrl: (json['thumbnailUrl'] 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReelsHistoryService {
|
||||
static const String _prefsKey = 'reels_history';
|
||||
static const int _maxEntries = 200;
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<SharedPreferences> _getPrefs() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
return _prefs!;
|
||||
}
|
||||
|
||||
Future<List<ReelsHistoryEntry>> getEntries() async {
|
||||
final prefs = await _getPrefs();
|
||||
final raw = prefs.getString(_prefsKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||
final entries = decoded
|
||||
.whereType<Map>()
|
||||
.map((e) => ReelsHistoryEntry.fromJson(e.cast<String, dynamic>()))
|
||||
.where((e) => e.url.isNotEmpty)
|
||||
.toList();
|
||||
entries.sort((a, b) => b.visitedAt.compareTo(a.visitedAt));
|
||||
return entries;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addEntry({
|
||||
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();
|
||||
|
||||
final entries = await getEntries();
|
||||
final recentDuplicate = entries.any((e) {
|
||||
if (e.url != url) return false;
|
||||
final diff = now.difference(e.visitedAt).inSeconds.abs();
|
||||
return diff <= 60;
|
||||
});
|
||||
if (recentDuplicate) return;
|
||||
|
||||
final entry = ReelsHistoryEntry(
|
||||
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
url: url,
|
||||
title: title.isEmpty ? 'Instagram Reel' : title,
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
visitedAt: now,
|
||||
durationSeconds: durationSeconds,
|
||||
adsWatchedInSession: adsWatchedInSession,
|
||||
);
|
||||
|
||||
final updated = [entry, ...entries];
|
||||
if (updated.length > _maxEntries) {
|
||||
updated.removeRange(_maxEntries, updated.length);
|
||||
}
|
||||
await _save(updated);
|
||||
}
|
||||
|
||||
Future<void> deleteEntry(String id) async {
|
||||
final entries = await getEntries();
|
||||
entries.removeWhere((e) => e.id == id);
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> _save(List<ReelsHistoryEntry> entries) async {
|
||||
final prefs = await _getPrefs();
|
||||
final jsonList = entries.map((e) => e.toJson()).toList();
|
||||
await prefs.setString(_prefsKey, jsonEncode(jsonList));
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../services/screen_time_service.dart';
|
||||
|
||||
class ScreenTimeScreen extends StatelessWidget {
|
||||
const ScreenTimeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Screen Time',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: Consumer<ScreenTimeService>(
|
||||
builder: (context, service, _) {
|
||||
final data = service.secondsByDate;
|
||||
final todayKey = _todayKey();
|
||||
final todaySeconds = data[todayKey] ?? 0;
|
||||
|
||||
final last7 = _lastNDays(7);
|
||||
final barSpots = <BarChartGroupData>[];
|
||||
int totalSeconds = 0;
|
||||
for (var i = 0; i < last7.length; i++) {
|
||||
final key = last7[i];
|
||||
final sec = data[key] ?? 0;
|
||||
totalSeconds += sec;
|
||||
barSpots.add(
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: sec / 60.0,
|
||||
width: 10,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final daysWithData = data.values.isEmpty ? 0 : data.length;
|
||||
final weeklyAvgMinutes = last7.isEmpty
|
||||
? 0.0
|
||||
: totalSeconds / 60.0 / last7.length;
|
||||
final allTimeMinutes = totalSeconds / 60.0;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildStatCard(
|
||||
title: 'Today',
|
||||
value: _formatDuration(todaySeconds),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildChartCard(barSpots, last7),
|
||||
const SizedBox(height: 16),
|
||||
_buildInlineStats(
|
||||
weeklyAvgMinutes: weeklyAvgMinutes,
|
||||
allTimeMinutes: allTimeMinutes,
|
||||
daysWithData: daysWithData,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'All data stored locally on your device only',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _confirmReset(context, service),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Reset all data'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _todayKey() {
|
||||
final now = DateTime.now();
|
||||
return '${now.year.toString().padLeft(4, '0')}-'
|
||||
'${now.month.toString().padLeft(2, '0')}-'
|
||||
'${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
static List<String> _lastNDays(int n) {
|
||||
final now = DateTime.now();
|
||||
return List.generate(n, (i) {
|
||||
final d = now.subtract(Duration(days: n - 1 - i));
|
||||
return '${d.year.toString().padLeft(4, '0')}-'
|
||||
'${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildStatCard({required String title, required String value}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartCard(List<BarChartGroupData> bars, List<String> last7Keys) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
height: 220,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
barGroups: bars,
|
||||
gridData: FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index < 0 || index >= last7Keys.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final label = last7Keys[index].substring(
|
||||
last7Keys[index].length - 2,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineStats({
|
||||
required double weeklyAvgMinutes,
|
||||
required double allTimeMinutes,
|
||||
required int daysWithData,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.02),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_inlineStat(
|
||||
label: '7-day avg',
|
||||
value: '${weeklyAvgMinutes.toStringAsFixed(1)} min',
|
||||
),
|
||||
_inlineDivider(),
|
||||
_inlineStat(
|
||||
label: 'All-time total',
|
||||
value: '${allTimeMinutes.toStringAsFixed(0)} min',
|
||||
),
|
||||
_inlineDivider(),
|
||||
_inlineStat(label: 'Tracked days', value: '$daysWithData'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inlineStat({required String label, required String value}) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inlineDivider() {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDuration(int seconds) {
|
||||
if (seconds < 60) {
|
||||
return '0:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
if (h > 0) {
|
||||
return '${h}h ${m.toString().padLeft(2, '0')}m';
|
||||
}
|
||||
final s = seconds % 60;
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _confirmReset(
|
||||
BuildContext context,
|
||||
ScreenTimeService service,
|
||||
) async {
|
||||
final first = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reset screen time?'),
|
||||
content: const Text(
|
||||
'This will clear all locally stored screen time data for the last 30 days.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text(
|
||||
'Continue',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (first != true) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final second = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Confirm reset'),
|
||||
content: const Text(
|
||||
'Are you sure you want to permanently delete all screen time data?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text(
|
||||
'Yes, delete',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (second == true) {
|
||||
await service.resetAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'update_checker_service.dart';
|
||||
|
||||
class UpdateBanner extends StatefulWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const UpdateBanner({
|
||||
super.key,
|
||||
required this.updateInfo,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdateBanner> createState() => _UpdateBannerState();
|
||||
}
|
||||
|
||||
class _UpdateBannerState extends State<UpdateBanner> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('🎉', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'FocusGram ${widget.updateInfo.latestVersion} available',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() => _isExpanded = !_isExpanded);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
widget.onDismiss();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isExpanded) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"What's new",
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_formatReleaseNotes(widget.updateInfo.whatsNew),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse(widget.updateInfo.releaseUrl);
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: const Text('Download on GitHub'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatReleaseNotes(String raw) {
|
||||
var text = raw;
|
||||
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');
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class UpdateInfo {
|
||||
final String latestVersion; // e.g. "1.0.0"
|
||||
final String releaseUrl; // html_url
|
||||
final String whatsNew; // trimmed body
|
||||
final bool isUpdateAvailable;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.latestVersion,
|
||||
required this.releaseUrl,
|
||||
required this.whatsNew,
|
||||
required this.isUpdateAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
class UpdateCheckerService extends ChangeNotifier {
|
||||
static const String _lastDismissedKey = 'last_dismissed_update_version';
|
||||
static const String _githubUrl =
|
||||
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest';
|
||||
|
||||
UpdateInfo? _updateInfo;
|
||||
bool _isDismissed = false;
|
||||
|
||||
bool get hasUpdate => _updateInfo != null && !_isDismissed;
|
||||
UpdateInfo? get updateInfo => hasUpdate ? _updateInfo : null;
|
||||
|
||||
Future<void> checkForUpdates() async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(_githubUrl))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) return;
|
||||
|
||||
final data = json.decode(response.body);
|
||||
final String gitVersionTag =
|
||||
data['tag_name'] ?? ''; // e.g. "v0.9.8-beta.2"
|
||||
final String htmlUrl = data['html_url'] ?? '';
|
||||
final String body = (data['body'] as String?) ?? '';
|
||||
|
||||
if (gitVersionTag.isEmpty || htmlUrl.isEmpty) return;
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version; // e.g. "0.9.8-beta.2"
|
||||
|
||||
if (!_isNewerVersion(gitVersionTag, currentVersion)) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final dismissedVersion = prefs.getString(_lastDismissedKey);
|
||||
if (dismissedVersion == gitVersionTag) {
|
||||
_isDismissed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final cleanVersion = gitVersionTag.startsWith('v')
|
||||
? gitVersionTag.substring(1)
|
||||
: gitVersionTag;
|
||||
|
||||
var trimmed = body.trim();
|
||||
if (trimmed.length > 1500) {
|
||||
trimmed = trimmed.substring(0, 1500).trim();
|
||||
}
|
||||
|
||||
_updateInfo = UpdateInfo(
|
||||
latestVersion: cleanVersion,
|
||||
releaseUrl: htmlUrl,
|
||||
whatsNew: trimmed,
|
||||
isUpdateAvailable: true,
|
||||
);
|
||||
_isDismissed = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
// debugPrint('Update check failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dismissUpdate() async {
|
||||
if (_updateInfo == null) return;
|
||||
_isDismissed = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastDismissedKey, _updateInfo!.latestVersion);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool _isNewerVersion(String gitTag, String current) {
|
||||
// Clean versions: strip 'v' and everything after '-' (beta/rc)
|
||||
String cleanGit = gitTag.startsWith('v') ? gitTag.substring(1) : gitTag;
|
||||
String cleanCurrent = current;
|
||||
|
||||
List<String> gitParts = cleanGit.split('-')[0].split('.');
|
||||
List<String> currentParts = cleanCurrent.split('-')[0].split('.');
|
||||
|
||||
for (int i = 0; i < gitParts.length && i < currentParts.length; i++) {
|
||||
int gitNum = int.tryParse(gitParts[i]) ?? 0;
|
||||
int curNum = int.tryParse(currentParts[i]) ?? 0;
|
||||
if (gitNum > curNum) return true;
|
||||
if (gitNum < curNum) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
+10
-106
@@ -1,31 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
import 'screens/app_session_picker.dart';
|
||||
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();
|
||||
@@ -36,48 +21,22 @@ 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(requestPermissions: true);
|
||||
await NotificationService().init();
|
||||
|
||||
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(),
|
||||
),
|
||||
);
|
||||
|
||||
// Fire and forget — preloads Instagram while app UI initialises.
|
||||
unawaited(InstagramPreloader.start(InjectionController.iOSUserAgent));
|
||||
}
|
||||
|
||||
class FocusGramApp extends StatelessWidget {
|
||||
@@ -113,8 +72,7 @@ class FocusGramApp extends StatelessWidget {
|
||||
/// 1. Onboarding (if first run)
|
||||
/// 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
|
||||
/// 4. App Session Picker (always)
|
||||
/// 5. Main WebView
|
||||
class InitialRouteHandler extends StatefulWidget {
|
||||
const InitialRouteHandler({super.key});
|
||||
@@ -123,67 +81,30 @@ class InitialRouteHandler extends StatefulWidget {
|
||||
State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
|
||||
}
|
||||
|
||||
class _InitialRouteHandlerState extends State<InitialRouteHandler>
|
||||
with WidgetsBindingObserver {
|
||||
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -192,17 +113,6 @@ 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) {
|
||||
@@ -219,21 +129,15 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler>
|
||||
// Step 3: Breath gate
|
||||
if (settings.showBreathGate && !_breathCompleted) {
|
||||
return BreathGateScreen(
|
||||
durationSeconds: settings.breathGateSeconds,
|
||||
onFinish: () => setState(() => _breathCompleted = true),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: App session picker / resume existing session
|
||||
// Step 4: App session picker
|
||||
if (!_appSessionStarted) {
|
||||
if (sm.isAppSessionActive) {
|
||||
// User already has an active app session — don't ask intention again.
|
||||
_appSessionStarted = true;
|
||||
} else {
|
||||
return AppSessionPickerScreen(
|
||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||
);
|
||||
}
|
||||
return AppSessionPickerScreen(
|
||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Main app
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class AboutPage extends StatefulWidget {
|
||||
const AboutPage({super.key});
|
||||
|
||||
@override
|
||||
State<AboutPage> createState() => _AboutPageState();
|
||||
}
|
||||
|
||||
class _AboutPageState extends State<AboutPage> {
|
||||
final String _currentVersion = '0.9.8-beta.2';
|
||||
bool _isChecking = false;
|
||||
|
||||
Future<void> _checkUpdate() async {
|
||||
setState(() => _isChecking = true);
|
||||
try {
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(
|
||||
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest',
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final latestVersion = data['tag_name'].toString().replaceAll('v', '');
|
||||
final downloadUrl = data['html_url'];
|
||||
|
||||
if (latestVersion != _currentVersion) {
|
||||
_showUpdateDialog(latestVersion, downloadUrl);
|
||||
} else {
|
||||
_showSnackBar('You are up to date! 🎉');
|
||||
}
|
||||
} else {
|
||||
_showSnackBar('Could not check for updates.');
|
||||
}
|
||||
} catch (_) {
|
||||
_showSnackBar('Connectivity issue. Try again later.');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isChecking = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateDialog(String version, String url) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
title: const Text(
|
||||
'Update Available!',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: Text(
|
||||
'A new version ($version) is available on GitHub.',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later', style: TextStyle(color: Colors.white38)),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_launchURL(url);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String msg) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text(
|
||||
'About FocusGram',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/images/focusgram.png',
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'FocusGram',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Version $_currentVersion',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const Text(
|
||||
'Developed with passion for digital discipline by',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Ujwal Chapagain',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isChecking ? null : _checkUpdate,
|
||||
icon: _isChecking
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.update),
|
||||
label: Text(_isChecking ? 'Checking...' : 'Check for Update'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent.withValues(alpha: 0.2),
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(200, 45),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () =>
|
||||
_launchURL('https://github.com/Ujwal223/FocusGram'),
|
||||
icon: const Icon(Icons.code),
|
||||
label: const Text('View on GitHub'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white10,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(200, 45),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'FocusGram is not affiliated with Instagram.',
|
||||
style: TextStyle(
|
||||
color: Color.fromARGB(48, 255, 255, 255),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launchURL(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
try {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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,25 +27,7 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
55,
|
||||
60,
|
||||
];
|
||||
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();
|
||||
}
|
||||
int _selectedIndex = 2; // default: 15 min
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -136,10 +118,12 @@ 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;
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
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,16 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before Instagram opens.
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 10-second breath.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
final int durationSeconds;
|
||||
|
||||
const BreathGateScreen({
|
||||
super.key,
|
||||
required this.onFinish,
|
||||
this.durationSeconds = 10,
|
||||
});
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
@@ -20,15 +16,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late int _secondsRemaining;
|
||||
int _secondsRemaining = 10;
|
||||
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),
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
/*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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,325 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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,8 +2,6 @@ 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 {
|
||||
@@ -20,11 +18,7 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
if (sm.isScheduledBlockActive) {
|
||||
final settings = context.read<SettingsService>();
|
||||
final ok = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
final ok = await DisciplineChallenge.show(context, count: 35);
|
||||
if (!context.mounted || !ok) return;
|
||||
}
|
||||
await action();
|
||||
@@ -115,33 +109,20 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
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()),
|
||||
),
|
||||
_buildFrictionSliderTile(
|
||||
context: context,
|
||||
sm: sm,
|
||||
@@ -240,71 +221,6 @@ 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,
|
||||
@@ -405,8 +321,7 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final sm = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
int wordCount = settings.resolvedWordChallengeCount();
|
||||
int wordCount = 15;
|
||||
// If we are at 0 quota, increase difficulty to 35 words
|
||||
if (widget.title.contains('Daily Reel Limit') &&
|
||||
sm.dailyRemainingSeconds <= 0) {
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+523
-2454
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
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,61 +18,58 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
|
||||
static const int _kTotalPages = 5;
|
||||
|
||||
static const int _kBlurPage = 3;
|
||||
static const int _kLinkPage = 2;
|
||||
static const int _kNotifPage = 4;
|
||||
final List<OnboardingData> _pages = [
|
||||
OnboardingData(
|
||||
title: 'Welcome to FocusGram',
|
||||
description:
|
||||
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
||||
icon: Icons.auto_awesome,
|
||||
color: Colors.blue,
|
||||
),
|
||||
OnboardingData(
|
||||
title: 'Ghost Mode',
|
||||
description:
|
||||
'Browse with total privacy. We block typing indicators and read receipts automatically.',
|
||||
icon: Icons.visibility_off,
|
||||
color: Colors.purple,
|
||||
),
|
||||
OnboardingData(
|
||||
title: 'Session Management',
|
||||
description:
|
||||
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
||||
icon: Icons.timer,
|
||||
color: Colors.orange,
|
||||
),
|
||||
OnboardingData(
|
||||
title: 'Open Links in FocusGram',
|
||||
description:
|
||||
'To open Instagram links directly here: Tap "Configure", then "Open by default" -> "Add link" and select all.',
|
||||
icon: Icons.link,
|
||||
color: Colors.cyan,
|
||||
isAppSettingsPage: true,
|
||||
),
|
||||
OnboardingData(
|
||||
title: 'Upload Content',
|
||||
description:
|
||||
'We need access to your gallery if you want to upload stories or posts directly from FocusGram.',
|
||||
icon: Icons.photo_library,
|
||||
color: Colors.orange,
|
||||
isPermissionPage: true,
|
||||
permission: Permission.photos,
|
||||
),
|
||||
OnboardingData(
|
||||
title: 'Stay Notified',
|
||||
description:
|
||||
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
||||
icon: Icons.notifications_active,
|
||||
color: Colors.green,
|
||||
isPermissionPage: true,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsService>();
|
||||
|
||||
final List<Widget> slides = [
|
||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
color: const Color(0xFF4F8DFF),
|
||||
title: 'Welcome to FocusGram',
|
||||
description:
|
||||
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
|
||||
),
|
||||
|
||||
// ── Page 1: Focus controls ───────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.timer_outlined,
|
||||
color: const Color(0xFFFFB74D),
|
||||
title: 'Time With Intent',
|
||||
description:
|
||||
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
|
||||
),
|
||||
|
||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
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.',
|
||||
isAppSettingsPage: true,
|
||||
),
|
||||
|
||||
// ── Page 3: Blur Settings ────────────────────────────────────────────
|
||||
_BlurSettingsSlide(settings: settings),
|
||||
|
||||
// ── Page 4: Notifications ────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
color: const Color(0xFF5DD18A),
|
||||
title: 'Useful Alerts Only',
|
||||
description:
|
||||
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
|
||||
isPermissionPage: true,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
@@ -80,8 +77,9 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) => setState(() => _currentPage = index),
|
||||
itemCount: _kTotalPages,
|
||||
itemBuilder: (context, index) => slides[index],
|
||||
itemCount: _pages.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_OnboardingSlide(data: _pages[index]),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
@@ -89,13 +87,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
// Dot indicators
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
_kTotalPages,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
_pages.length,
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: _currentPage == index ? 12 : 8,
|
||||
height: 8,
|
||||
@@ -108,8 +104,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
// CTA button
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: SizedBox(
|
||||
@@ -117,39 +112,24 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
height: 56,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final isLast = _currentPage == _kTotalPages - 1;
|
||||
final isLink = _currentPage == _kLinkPage;
|
||||
final isNotif = _currentPage == _kNotifPage;
|
||||
final isBlur = _currentPage == _kBlurPage;
|
||||
|
||||
String label;
|
||||
if (isNotif) {
|
||||
label = 'Allow & Start';
|
||||
} else if (isLink) {
|
||||
label = 'Configure';
|
||||
} else if (isBlur) {
|
||||
label = 'Save & Continue';
|
||||
} else if (isLast) {
|
||||
label = 'Get Started';
|
||||
} else {
|
||||
label = 'Next';
|
||||
}
|
||||
|
||||
final data = _pages[_currentPage];
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (isLink) {
|
||||
if (data.isAppSettingsPage) {
|
||||
await AppSettings.openAppSettings(
|
||||
type: AppSettingsType.settings,
|
||||
);
|
||||
} else if (isNotif) {
|
||||
await Permission.notification.request();
|
||||
await NotificationService()
|
||||
.requestPermissionsNow();
|
||||
} else if (data.isPermissionPage) {
|
||||
if (data.permission != null) {
|
||||
await data.permission!.request();
|
||||
}
|
||||
if (data.title == 'Stay Notified') {
|
||||
await NotificationService().init();
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (isLast) {
|
||||
_finish(context);
|
||||
if (_currentPage == _pages.length - 1) {
|
||||
_finish();
|
||||
} else {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -165,7 +145,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
_currentPage == _pages.length - 1
|
||||
? 'Get Started'
|
||||
: (data.isAppSettingsPage
|
||||
? 'Configure'
|
||||
: 'Next'),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -176,25 +160,6 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip button (available on all pages except last)
|
||||
if (_currentPage < _kTotalPages - 1)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (_currentPage == _kNotifPage) {
|
||||
_finish(context);
|
||||
} else {
|
||||
_pageController.animateToPage(
|
||||
_kTotalPages - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Skip setup',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -203,232 +168,65 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _finish(BuildContext context) {
|
||||
void _finish() {
|
||||
context.read<SettingsService>().setFirstRunCompleted();
|
||||
widget.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Static info slide ──────────────────────────────────────────────────────────
|
||||
|
||||
class _StaticSlide extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
class OnboardingData {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool isPermissionPage;
|
||||
final bool isAppSettingsPage;
|
||||
final Permission? permission;
|
||||
|
||||
const _StaticSlide({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
OnboardingData({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.isPermissionPage = false,
|
||||
this.isAppSettingsPage = false,
|
||||
this.permission,
|
||||
});
|
||||
}
|
||||
|
||||
class _OnboardingSlide extends StatelessWidget {
|
||||
final OnboardingData data;
|
||||
|
||||
const _OnboardingSlide({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
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),
|
||||
Icon(data.icon, size: 120, color: data.color),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
title,
|
||||
data.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
description,
|
||||
data.description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blur settings slide ────────────────────────────────────────────────────────
|
||||
|
||||
class _BlurSettingsSlide extends StatelessWidget {
|
||||
final SettingsService settings;
|
||||
|
||||
const _BlurSettingsSlide({required this.settings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 40, 32, 160),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.blur_on_rounded,
|
||||
size: 90,
|
||||
color: Colors.purpleAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Distraction Shield',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Blur feeds you don\'t want to be tempted by. You can change these anytime in Settings.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Blur Home Feed toggle
|
||||
_BlurToggleTile(
|
||||
icon: Icons.home_rounded,
|
||||
label: 'Blur Home Feed',
|
||||
subtitle: 'Posts in your feed will be blurred until tapped',
|
||||
value: settings.blurReels,
|
||||
onChanged: (v) => settings.setBlurReels(v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Blur Explore toggle
|
||||
_BlurToggleTile(
|
||||
icon: Icons.explore_rounded,
|
||||
label: 'Blur Explore Feed',
|
||||
subtitle: 'Explore thumbnails stay blurred until you tap',
|
||||
value: settings.blurExplore,
|
||||
onChanged: (v) => settings.setBlurExplore(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlurToggleTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _BlurToggleTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? Colors.purpleAccent.withValues(alpha: 0.12)
|
||||
: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? Colors.purpleAccent.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: value ? Colors.purpleAccent : Colors.white38,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: value ? Colors.white : Colors.white70,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeThumbColor: Colors.purpleAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../services/injection_controller.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -15,12 +15,58 @@ class ReelPlayerOverlay extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
late final WebViewController _controller;
|
||||
DateTime? _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTime = DateTime.now();
|
||||
_initWebView();
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(InjectionController.iOSUserAgent)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (url) {
|
||||
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
|
||||
_controller.runJavaScript(
|
||||
'window.__focusgramIsolatedPlayer = true;',
|
||||
);
|
||||
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
|
||||
_controller.runJavaScript(
|
||||
InjectionController.reelsMutationObserverJS,
|
||||
);
|
||||
// Also hide Instagram's bottom nav inside this overlay
|
||||
_controller.runJavaScript(
|
||||
InjectionController.buildInjectionJS(
|
||||
sessionActive: true,
|
||||
blurExplore: false,
|
||||
blurReels: false,
|
||||
ghostTyping: false,
|
||||
ghostSeen: false,
|
||||
ghostStories: false,
|
||||
ghostDmPhotos: false,
|
||||
enableTextSelection: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
// Allow only the initial reel URL and instagram.com generally
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.prevent;
|
||||
final host = uri.host;
|
||||
if (!host.contains('instagram.com')) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.url));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -68,67 +114,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: InAppWebView(
|
||||
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent: InjectionController.iOSUserAgent,
|
||||
mediaPlaybackRequiresUserGesture: true,
|
||||
useHybridComposition: true,
|
||||
cacheEnabled: true,
|
||||
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
|
||||
domStorageEnabled: true,
|
||||
databaseEnabled: true,
|
||||
hardwareAcceleration: true,
|
||||
transparentBackground: true,
|
||||
safeBrowsingEnabled: false,
|
||||
supportZoom: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
verticalScrollBarEnabled: false,
|
||||
horizontalScrollBarEnabled: false,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
// Controller is not stored; this overlay is self-contained.
|
||||
},
|
||||
onLoadStop: (controller, url) async {
|
||||
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__focusgramIsolatedPlayer = true;',
|
||||
);
|
||||
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
|
||||
await controller.evaluateJavascript(
|
||||
source: InjectionController.reelsMutationObserverJS,
|
||||
);
|
||||
// Also apply FocusGram baseline CSS (hides bottom nav etc.)
|
||||
await controller.evaluateJavascript(
|
||||
source: InjectionController.buildInjectionJS(
|
||||
sessionActive: true,
|
||||
blurExplore: false,
|
||||
blurReels: false,
|
||||
tapToUnblur: false,
|
||||
enableTextSelection: true,
|
||||
hideSuggestedPosts: false,
|
||||
hideSponsoredPosts: false,
|
||||
hideLikeCounts: false,
|
||||
hideFollowerCounts: false,
|
||||
// hideStoriesBar removed per user request
|
||||
hideExploreTab: false,
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: false,
|
||||
blockHomeFeedScroll: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
shouldOverrideUrlLoading: (controller, action) async {
|
||||
// Keep this overlay locked to instagram.com pages only
|
||||
final uri = action.request.url;
|
||||
if (uri == null) return NavigationActionPolicy.CANCEL;
|
||||
if (!uri.host.contains('instagram.com')) {
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -64,22 +63,23 @@ class _SessionModalState extends State<SessionModal> {
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
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'),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
||||
Slider(
|
||||
value: _customMinutes,
|
||||
min: 1,
|
||||
max: 60,
|
||||
divisions: 59,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
label: '${_customMinutes.toInt()}m',
|
||||
onChanged: (v) => setState(() => _customMinutes = v),
|
||||
),
|
||||
@@ -126,15 +126,10 @@ class _SessionModalState extends State<SessionModal> {
|
||||
|
||||
void _start(int minutes) async {
|
||||
final sm = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
|
||||
if (settings.requireWordChallenge) {
|
||||
final success = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!success) return;
|
||||
}
|
||||
// Always require word challenge for reel sessions (User request)
|
||||
final success = await DisciplineChallenge.show(context);
|
||||
if (!success) return;
|
||||
|
||||
if (sm.startSession(minutes)) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
+518
-1241
File diff suppressed because it is too large
Load Diff
@@ -1,329 +0,0 @@
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/// JavaScript to block autoplaying videos on Instagram feed/explore while:
|
||||
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF
|
||||
/// - Allowing user-initiated playback on click when blocking is ON
|
||||
/// - NEVER blocking reels (they should always play normally per user request)
|
||||
///
|
||||
/// This script:
|
||||
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
|
||||
/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore).
|
||||
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
|
||||
/// - Uses a per-element flag set by user clicks to permanently allow that video to play.
|
||||
/// - Strips the autoplay attribute from dynamically added <video> elements.
|
||||
/// - Respects session state - allows autoplay when session is active.
|
||||
/// - NEVER blocks reels - they always play normally.
|
||||
/// - Once a video is explicitly played by user, it plays fully without interruption.
|
||||
const String kAutoplayBlockerJS = r'''
|
||||
(function fgAutoplayBlocker() {
|
||||
if (window.__fgAutoplayPatched) return;
|
||||
window.__fgAutoplayPatched = true;
|
||||
|
||||
// Default to blocking autoplay if not set
|
||||
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false;
|
||||
|
||||
// Session state - set by FocusGram when session is active
|
||||
// window.__focusgramSessionActive = true/false
|
||||
|
||||
// Helper to check if this is a reel video (should NEVER be blocked)
|
||||
function isReelVideo() {
|
||||
try {
|
||||
const url = window.location.href || '';
|
||||
// Check if we're on a reel page
|
||||
if (url.includes('/reels/') || url.includes('/reel/')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if we should allow autoplay
|
||||
function shouldBlockAutoplay() {
|
||||
// If we're on reels page, never block
|
||||
if (isReelVideo()) return false;
|
||||
|
||||
// If autoplay setting is false, don't block at all
|
||||
if (window.__fgBlockAutoplay === false) return false;
|
||||
|
||||
// If session is active, don't block autoplay (allow all videos)
|
||||
if (window.__focusgramSessionActive === true) return false;
|
||||
|
||||
// Otherwise block autoplay for feed/explore videos
|
||||
return true;
|
||||
}
|
||||
|
||||
// Key to mark a video as explicitly started by user (permanent for that video instance)
|
||||
const ALLOW_KEY = '__fgUserExplicitlyPlayed';
|
||||
|
||||
// Mark video as allowed permanently once user explicitly plays it
|
||||
function markAllow(video) {
|
||||
try {
|
||||
video[ALLOW_KEY] = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Check if user has explicitly played this video
|
||||
function shouldAllow(video) {
|
||||
try {
|
||||
return video[ALLOW_KEY] === true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pause video and strip autoplay attribute (for blocked autoplay videos)
|
||||
function pauseAndFreezeVideo(video) {
|
||||
try {
|
||||
// Remove autoplay attribute completely
|
||||
video.removeAttribute('autoplay');
|
||||
try { video.autoplay = false; } catch (_) {}
|
||||
// Pause the video
|
||||
video.pause();
|
||||
// Reset to beginning
|
||||
video.currentTime = 0;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Store original play and pause
|
||||
const _origPlay = HTMLVideoElement.prototype.play;
|
||||
const _origPause = HTMLVideoElement.prototype.pause;
|
||||
|
||||
// Override play method
|
||||
if (HTMLVideoElement.prototype.play) {
|
||||
HTMLVideoElement.prototype.play = function() {
|
||||
try {
|
||||
// NEVER block reels - they always play normally
|
||||
if (isReelVideo()) {
|
||||
return _origPlay.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Check if we should block based on both settings and session
|
||||
if (!shouldBlockAutoplay()) {
|
||||
// Autoplay is OFF or session is active - allow all playback
|
||||
return _origPlay.apply(this, arguments);
|
||||
}
|
||||
|
||||
// If user has explicitly played this video before, allow it to continue
|
||||
if (shouldAllow(this)) {
|
||||
return _origPlay.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Block autoplay: pause immediately and return resolved promise
|
||||
pauseAndFreezeVideo(this);
|
||||
return Promise.resolve();
|
||||
} catch (_) {
|
||||
// Fall back to original behaviour
|
||||
try {
|
||||
return _origPlay.apply(this, arguments);
|
||||
} catch (_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Override pause method to work normally
|
||||
if (HTMLVideoElement.prototype.pause) {
|
||||
HTMLVideoElement.prototype.pause = function() {
|
||||
try {
|
||||
return _origPause.apply(this, arguments);
|
||||
} catch (_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Additional safeguard for dynamically created videos
|
||||
try {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('video').forEach(function(v) {
|
||||
if (v.play) {
|
||||
const originalPlay = v.play;
|
||||
v.play = function() {
|
||||
// NEVER block reels
|
||||
if (isReelVideo()) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
if (!shouldBlockAutoplay()) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
if (shouldAllow(this)) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
pauseAndFreezeVideo(this);
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
// Also handle videos that might be created after DOMContentLoaded
|
||||
try {
|
||||
const originalCreateElement = document.createElement;
|
||||
document.createElement = function(tagName) {
|
||||
const element = originalCreateElement.apply(this, arguments);
|
||||
if (tagName.toLowerCase() === 'video') {
|
||||
// Intercept the play method on dynamically created videos
|
||||
const originalPlay = element.play;
|
||||
if (originalPlay) {
|
||||
element.play = function() {
|
||||
// NEVER block reels
|
||||
if (isReelVideo()) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
if (!shouldBlockAutoplay()) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
if (shouldAllow(this)) {
|
||||
return originalPlay.apply(this, arguments);
|
||||
}
|
||||
pauseAndFreezeVideo(this);
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
}
|
||||
return element;
|
||||
};
|
||||
} catch (_) {}
|
||||
|
||||
// Mark video as allowed on user interaction (click/tap) - permanent for that video
|
||||
document.addEventListener('click', function(e) {
|
||||
try {
|
||||
const video = e.target.closest ? e.target.closest('video') : e.target;
|
||||
if (video) {
|
||||
// Mark this specific video as user-initiated - permanent
|
||||
markAllow(video);
|
||||
// Try to play the video if it was previously blocked
|
||||
if (shouldBlockAutoplay() && !shouldAllow(video)) {
|
||||
// Video will be allowed now, try to play
|
||||
try { video.play(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
try {
|
||||
const video = e.target.closest ? e.target.closest('video') : e.target;
|
||||
if (video) {
|
||||
markAllow(video);
|
||||
if (shouldBlockAutoplay() && !shouldAllow(video)) {
|
||||
try { video.play(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
|
||||
// Also handle play events directly (for Instagram's internal play buttons)
|
||||
document.addEventListener('play', function(e) {
|
||||
if (e.target && e.target.tagName === 'VIDEO') {
|
||||
markAllow(e.target);
|
||||
}
|
||||
}, 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);
|
||||
})();
|
||||
''';
|
||||
@@ -1,648 +0,0 @@
|
||||
// UI element hiding for Instagram web.
|
||||
//
|
||||
// SCROLL LOCK WARNING:
|
||||
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
|
||||
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
|
||||
// calls inside these callbacks block the main thread = scroll jank.
|
||||
// The JS hiders below use requestIdleCallback + a 300ms debounce so they run
|
||||
// only during idle time and never on every single mutation.
|
||||
|
||||
// ─── CSS-based (reliable, zero perf cost) ────────────────────────────────────
|
||||
|
||||
const String kHideLikeCountsCSS =
|
||||
"""
|
||||
[role="button"][aria-label${r"$"}=" like"],
|
||||
[role="button"][aria-label${r"$"}=" likes"],
|
||||
[role="button"][aria-label${r"$"}=" view"],
|
||||
[role="button"][aria-label${r"$"}=" views"],
|
||||
a[href*="/liked_by/"] {
|
||||
display: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
const String kHideFollowerCountsCSS = """
|
||||
a[href*="/followers/"] span,
|
||||
a[href*="/following/"] span {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
// Stories bar — broad selector covering multiple Instagram DOM layouts
|
||||
const String kHideStoriesBarCSS = """
|
||||
[aria-label*="Stories"],
|
||||
[aria-label*="stories"],
|
||||
[role="list"][aria-label*="tories"],
|
||||
[role="listbox"][aria-label*="tories"],
|
||||
div[style*="overflow"][style*="scroll"]:has(canvas),
|
||||
section > div > div[style*="overflow-x"] {
|
||||
display: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
// Also do a JS sweep for stories — CSS alone isn't reliable across Instagram versions
|
||||
const String kHideStoriesBarJS = r'''
|
||||
(function() {
|
||||
function hideStories() {
|
||||
try {
|
||||
// Target the horizontal scrollable stories container
|
||||
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
|
||||
try {
|
||||
const label = (el.getAttribute('aria-label') || '').toLowerCase();
|
||||
if (label.includes('stori')) {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
// Fallback: find story bubbles (circular avatar containers at top of feed)
|
||||
document.querySelectorAll('section > div > div').forEach(function(el) {
|
||||
try {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.overflowX === 'scroll' || style.overflowX === 'auto') {
|
||||
const circles = el.querySelectorAll('canvas, [style*="border-radius: 50%"]');
|
||||
if (circles.length > 2) {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideStories();
|
||||
|
||||
if (!window.__fgStoriesObserver) {
|
||||
let _storiesTimer = null;
|
||||
window.__fgStoriesObserver = new MutationObserver(() => {
|
||||
// Debounce — only run after mutations settle, not on every single one
|
||||
clearTimeout(_storiesTimer);
|
||||
_storiesTimer = setTimeout(hideStories, 300);
|
||||
});
|
||||
window.__fgStoriesObserver.observe(
|
||||
document.documentElement,
|
||||
{ childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Robust stories overlay - blocks clicking and applies blur when hide stories is enabled.
|
||||
/// This is a more aggressive approach that places an overlay with blur on top of stories area.
|
||||
const String kStoriesOverlayJS = r'''
|
||||
(function() {
|
||||
if (window.__fgStoriesOverlayRunning) return;
|
||||
window.__fgStoriesOverlayRunning = true;
|
||||
|
||||
const BLOCKED_ATTR = 'data-fg-stories-blocked';
|
||||
|
||||
function buildOverlay(container) {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute(BLOCKED_ATTR, '1');
|
||||
div.style.cssText = [
|
||||
'position: absolute',
|
||||
'inset: 0',
|
||||
'z-index: 99998',
|
||||
'display: flex',
|
||||
'align-items: center',
|
||||
'justify-content: center',
|
||||
'background: rgba(0, 0, 0, 0.6)',
|
||||
'backdrop-filter: blur(10px)',
|
||||
'-webkit-backdrop-filter: blur(10px)',
|
||||
'border-radius: 8px',
|
||||
'pointer-events: all',
|
||||
'cursor: not-allowed',
|
||||
].join(';');
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Stories blocked';
|
||||
label.style.cssText = [
|
||||
'color: rgba(255, 255, 255, 0.8)',
|
||||
'font-size: 12px',
|
||||
'font-weight: 600',
|
||||
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
'text-align: center',
|
||||
'padding: 8px 16px',
|
||||
'background: rgba(0, 0, 0, 0.5)',
|
||||
'border-radius: 20px',
|
||||
].join(';');
|
||||
|
||||
div.appendChild(label);
|
||||
|
||||
// Swallow all interaction
|
||||
['click', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'mouseenter'].forEach(function(evt) {
|
||||
div.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}, { capture: true });
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function overlayStoriesContainer(container) {
|
||||
if (!container) return;
|
||||
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return;
|
||||
|
||||
// Check if this looks like a stories container
|
||||
const hasStories = container.querySelector('canvas, [style*="border-radius: 50%"], [aria-label*="story"], [role="list"]');
|
||||
if (!hasStories) return;
|
||||
|
||||
container.style.position = 'relative';
|
||||
container.style.overflow = 'hidden';
|
||||
container.appendChild(buildOverlay(container));
|
||||
}
|
||||
|
||||
function findAndOverlayStories() {
|
||||
try {
|
||||
// Method 1: Find by role="list" with story-related aria-label
|
||||
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
|
||||
try {
|
||||
const label = (el.getAttribute('aria-label') || '').toLowerCase();
|
||||
if (label.includes('stori')) {
|
||||
overlayStoriesContainer(el.parentElement);
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Method 2: Find horizontal scroll containers at top of feed
|
||||
document.querySelectorAll('header + div > div, main > div > div > div').forEach(function(el) {
|
||||
try {
|
||||
const style = window.getComputedStyle(el);
|
||||
if ((style.overflowX === 'scroll' || style.overflowX === 'auto') &&
|
||||
(style.display === 'flex' || style.display === '')) {
|
||||
const children = el.children;
|
||||
let hasAvatar = false;
|
||||
for (let i = 0; i < Math.min(children.length, 10); i++) {
|
||||
const child = children[i];
|
||||
const childStyle = window.getComputedStyle(child);
|
||||
if (childStyle.width === '60px' || childStyle.width === '66px' ||
|
||||
child.querySelector('canvas, [style*="border-radius: 50%"]')) {
|
||||
hasAvatar = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasAvatar) {
|
||||
overlayStoriesContainer(el);
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Method 3: Find story avatars directly
|
||||
document.querySelectorAll('[href*="/stories/"], [aria-label*="Your Story"]').forEach(function(el) {
|
||||
try {
|
||||
let container = el.parentElement;
|
||||
for (let i = 0; i < 5 && container; i++) {
|
||||
const style = window.getComputedStyle(container);
|
||||
if (style.position !== 'static' && container.children.length < 20) {
|
||||
overlayStoriesContainer(container);
|
||||
break;
|
||||
}
|
||||
container = container.parentElement;
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
// Initial run
|
||||
findAndOverlayStories();
|
||||
|
||||
// Watch for dynamic changes
|
||||
let _overlayTimer = null;
|
||||
new MutationObserver(function() {
|
||||
clearTimeout(_overlayTimer);
|
||||
_overlayTimer = setTimeout(findAndOverlayStories, 500);
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
// Also run on scroll
|
||||
let _scrollTimer = null;
|
||||
window.addEventListener('scroll', function() {
|
||||
clearTimeout(_scrollTimer);
|
||||
_scrollTimer = setTimeout(findAndOverlayStories, 300);
|
||||
}, { passive: true });
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kHideExploreTabCSS = """
|
||||
a[href="/explore/"],
|
||||
a[href="/explore"] {
|
||||
display: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
const String kHideReelsTabCSS = """
|
||||
a[href="/reels/"],
|
||||
a[href="/reels"] {
|
||||
display: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
const String kHideShopTabCSS = """
|
||||
a[href*="/shop"],
|
||||
a[href*="/shopping"] {
|
||||
display: none !important;
|
||||
}
|
||||
""";
|
||||
|
||||
// ─── Complete Section Disabling (CSS-based) ─────────────────────────────────
|
||||
|
||||
// Minimal mode - disables Reels and Explore entirely
|
||||
const String kMinimalModeCssScript = r'''
|
||||
(function() {
|
||||
const css = `
|
||||
/* Hide Reels tab */
|
||||
a[href="/reels/"], a[href="/reels"] { display: none !important; }
|
||||
/* Hide Explore tab */
|
||||
a[href="/explore/"], a[href="/explore"] { display: none !important; }
|
||||
/* Hide Create tab */
|
||||
a[href="/create/"], a[href="/create"] { display: none !important; }
|
||||
/* Hide Reels in feed */
|
||||
article a[href*="/reel/"] { display: none !important; }
|
||||
/* Hide Explore entry points */
|
||||
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
|
||||
`;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fg-minimal-mode';
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
})();
|
||||
''';
|
||||
|
||||
// Disable Reels entirely
|
||||
const String kDisableReelsEntirelyCssScript = r'''
|
||||
(function() {
|
||||
const css = `
|
||||
a[href="/reels/"], a[href="/reels"] { display: none !important; }
|
||||
article a[href*="/reel/"] { display: none !important; }
|
||||
`;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fg-disable-reels';
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
})();
|
||||
''';
|
||||
|
||||
// Disable Explore entirely
|
||||
const String kDisableExploreEntirelyCssScript = r'''
|
||||
(function() {
|
||||
const css = `
|
||||
a[href="/explore/"], a[href="/explore"] { display: none !important; }
|
||||
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
|
||||
`;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fg-disable-explore';
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ─── DM-embedded Reels Scroll Control ────────────────────────────────────────
|
||||
// Disables vertical scroll on reels opened from DM unless comment box or share modal is open
|
||||
const String kDmReelScrollLockScript = r'''
|
||||
(function() {
|
||||
// Track scroll lock state
|
||||
window.__fgDmReelScrollLocked = true;
|
||||
window.__fgDmReelCommentOpen = false;
|
||||
window.__fgDmReelShareOpen = false;
|
||||
|
||||
function lockScroll() {
|
||||
if (window.__fgDmReelScrollLocked) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
function unlockScroll() {
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
}
|
||||
|
||||
function updateScrollState() {
|
||||
// Only unlock if comment or share modal is open
|
||||
if (window.__fgDmReelCommentOpen || window.__fgDmReelShareOpen) {
|
||||
unlockScroll();
|
||||
} else if (window.__fgDmReelScrollLocked) {
|
||||
lockScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for comment box opening/closing
|
||||
function setupCommentObserver() {
|
||||
const commentBox = document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
|
||||
if (commentBox) {
|
||||
window.__fgDmReelCommentOpen = true;
|
||||
updateScrollState();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for share modal
|
||||
function setupShareObserver() {
|
||||
const shareModal = document.querySelector('div[role="dialog"][aria-label*="Share"], section[aria-label*="Share"]');
|
||||
if (shareModal) {
|
||||
window.__fgDmReelShareOpen = true;
|
||||
updateScrollState();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up MutationObserver to detect comment/share modals
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) {
|
||||
const ariaLabel = node.getAttribute('aria-label') || '';
|
||||
const role = node.getAttribute('role') || '';
|
||||
|
||||
// Check for comment box
|
||||
if (ariaLabel.toLowerCase().includes('comment') ||
|
||||
(role === 'dialog' && ariaLabel === '')) {
|
||||
// Check if it's a comment dialog
|
||||
setTimeout(function() {
|
||||
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
|
||||
updateScrollState();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Check for share modal
|
||||
if (ariaLabel.toLowerCase().includes('share')) {
|
||||
window.__fgDmReelShareOpen = true;
|
||||
updateScrollState();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mutation.removedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) {
|
||||
const ariaLabel = node.getAttribute('aria-label') || '';
|
||||
if (ariaLabel.toLowerCase().includes('comment')) {
|
||||
setTimeout(function() {
|
||||
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
|
||||
updateScrollState();
|
||||
}, 100);
|
||||
}
|
||||
if (ariaLabel.toLowerCase().includes('share')) {
|
||||
window.__fgDmReelShareOpen = false;
|
||||
updateScrollState();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial lock
|
||||
lockScroll();
|
||||
|
||||
// Expose functions for external control
|
||||
window.__fgSetDmReelScrollLock = function(locked) {
|
||||
window.__fgDmReelScrollLocked = locked;
|
||||
updateScrollState();
|
||||
};
|
||||
})();
|
||||
''';
|
||||
|
||||
// ─── JS-based (text-content detection, debounced) ─────────────────────────────
|
||||
|
||||
// Sponsored posts — scans for "Sponsored" text, debounced so it doesn't
|
||||
// cause scroll jank on Instagram's constantly-mutating feed DOM.
|
||||
const String kHideSponsoredPostsJS = r'''
|
||||
(function() {
|
||||
function hideSponsoredPosts() {
|
||||
try {
|
||||
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
|
||||
try {
|
||||
if (el.__fgSponsoredChecked) return; // skip already-processed elements
|
||||
const spans = el.querySelectorAll('span');
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const text = spans[i].textContent.trim();
|
||||
if (text === 'Sponsored' || text === 'Paid partnership') {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
return;
|
||||
}
|
||||
}
|
||||
el.__fgSponsoredChecked = true; // mark as checked (non-sponsored)
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideSponsoredPosts();
|
||||
|
||||
if (!window.__fgSponsoredObserver) {
|
||||
let _timer = null;
|
||||
window.__fgSponsoredObserver = new MutationObserver(() => {
|
||||
clearTimeout(_timer);
|
||||
_timer = setTimeout(hideSponsoredPosts, 300);
|
||||
});
|
||||
window.__fgSponsoredObserver.observe(
|
||||
document.documentElement,
|
||||
{ childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// Suggested posts — debounced same way.
|
||||
const String kHideSuggestedPostsJS = r'''
|
||||
(function() {
|
||||
function hideSuggestedPosts() {
|
||||
try {
|
||||
// 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();
|
||||
const matched = suggestedPatterns.some(pattern =>
|
||||
text === pattern || text.includes(pattern)
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
let parent = el.parentElement;
|
||||
// Traverse up to find the container section/article
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
const classList = parent.className || '';
|
||||
|
||||
// Hide articles, sections, lists, and common suggestion containers
|
||||
if (
|
||||
tag === 'article' ||
|
||||
tag === 'section' ||
|
||||
tag === 'li' ||
|
||||
classList.includes('xjx87jv0') || // Instagram suggestion container
|
||||
classList.includes('x1a8lsjc') // Reel suggestion container
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Also hide by attribute patterns
|
||||
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
|
||||
try {
|
||||
let parent = el;
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideSuggestedPosts();
|
||||
|
||||
if (!window.__fgSuggestedObserver) {
|
||||
let _timer = null;
|
||||
window.__fgSuggestedObserver = new MutationObserver(() => {
|
||||
clearTimeout(_timer);
|
||||
_timer = setTimeout(hideSuggestedPosts, 300);
|
||||
});
|
||||
window.__fgSuggestedObserver.observe(
|
||||
document.documentElement,
|
||||
{ childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// ─── DM Reel Blocker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Overlays a "Reels are disabled" card on reel preview cards inside DMs.
|
||||
///
|
||||
/// DM reel previews use pushState (SPA) not <a href> navigation, so the CSS
|
||||
/// display:none in kDisableReelsEntirelyCssScript doesn't remove the preview
|
||||
/// card from the thread. This script finds them structurally and covers them
|
||||
/// with a blocking overlay that also swallows all touch/click events.
|
||||
///
|
||||
/// Inject when disableReelsEntirely OR minimalMode is on.
|
||||
const String kDmReelBlockerJS = r'''
|
||||
(function() {
|
||||
if (window.__fgDmReelBlockerRunning) return;
|
||||
window.__fgDmReelBlockerRunning = true;
|
||||
|
||||
const BLOCKED_ATTR = 'data-fg-blocked';
|
||||
|
||||
function buildOverlay() {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute(BLOCKED_ATTR, '1');
|
||||
div.style.cssText = [
|
||||
'position:absolute',
|
||||
'inset:0',
|
||||
'z-index:99999',
|
||||
'display:flex',
|
||||
'flex-direction:column',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'background:rgba(0,0,0,0.85)',
|
||||
'border-radius:inherit',
|
||||
'pointer-events:all',
|
||||
'gap:8px',
|
||||
'cursor:default',
|
||||
].join(';');
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.textContent = '🚫';
|
||||
icon.style.cssText = 'font-size:28px;line-height:1';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Reels are disabled';
|
||||
label.style.cssText = [
|
||||
'color:#fff',
|
||||
'font-size:13px',
|
||||
'font-weight:600',
|
||||
'font-family:-apple-system,sans-serif',
|
||||
'text-align:center',
|
||||
'padding:0 12px',
|
||||
].join(';');
|
||||
|
||||
const sub = document.createElement('span');
|
||||
sub.textContent = 'Disable "Block Reels" in FocusGram settings';
|
||||
sub.style.cssText = [
|
||||
'color:rgba(255,255,255,0.5)',
|
||||
'font-size:11px',
|
||||
'font-family:-apple-system,sans-serif',
|
||||
'text-align:center',
|
||||
'padding:0 16px',
|
||||
].join(';');
|
||||
|
||||
div.appendChild(icon);
|
||||
div.appendChild(label);
|
||||
div.appendChild(sub);
|
||||
|
||||
// Swallow all interaction so the reel beneath cannot be triggered
|
||||
['click','touchstart','touchend','touchmove','pointerdown'].forEach(function(evt) {
|
||||
div.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}, { capture: true });
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function overlayContainer(container) {
|
||||
if (!container) return;
|
||||
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return; // already overlaid
|
||||
container.style.position = 'relative';
|
||||
container.style.overflow = 'hidden';
|
||||
container.appendChild(buildOverlay());
|
||||
}
|
||||
|
||||
function blockDmReels() {
|
||||
try {
|
||||
// Strategy 1: <a href*="/reel/"> links inside the DM thread
|
||||
document.querySelectorAll('a[href*="/reel/"]').forEach(function(link) {
|
||||
try {
|
||||
link.style.setProperty('pointer-events', 'none', 'important');
|
||||
overlayContainer(link.closest('div') || link.parentElement);
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Strategy 2: <video> inside DMs (reel cards without <a> wrapper)
|
||||
// Only targets videos inside the Direct thread or on /direct/ path
|
||||
document.querySelectorAll('video').forEach(function(video) {
|
||||
try {
|
||||
const inDm = !!video.closest('[aria-label="Direct"], [aria-label*="Direct"]');
|
||||
const isDmPath = window.location.pathname.includes('/direct/');
|
||||
if (!inDm && !isDmPath) return;
|
||||
|
||||
const container = video.closest('div[class]') || video.parentElement;
|
||||
if (!container) return;
|
||||
video.style.setProperty('pointer-events', 'none', 'important');
|
||||
overlayContainer(container);
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
blockDmReels();
|
||||
|
||||
let _t = null;
|
||||
new MutationObserver(function() {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(blockDmReels, 200);
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
''';
|
||||
@@ -1,587 +0,0 @@
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// WARNING: Do not add any network interception logic ("ghost mode") here.
|
||||
// All scripts in this file must be limited to UI behaviour, navigation helpers,
|
||||
// and local-only features that do not modify data sent to Meta's servers.
|
||||
|
||||
// ── CSS payloads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
|
||||
/// because Instagram's comment input sheet also uses that role and the
|
||||
/// CSS would paint a grey overlay on top of the typing area.
|
||||
const String kGlobalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
/// Activated via the body[path] attribute written by the path tracker script.
|
||||
const String kBlurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
/* Per-post unblur override (set by kTapToUnblurJS) */
|
||||
/* 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;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native.
|
||||
const String kDisableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
|
||||
const String kHideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs reel thumbnails in the feed AND reel preview cards sent in DMs.
|
||||
///
|
||||
/// Feed reels are wrapped in a[href*="/reel/"] — straightforward.
|
||||
/// DM reel previews are inline media cards NOT wrapped in a[href*="/reel/"],
|
||||
/// so they need separate selectors targeting img/video inside [aria-label="Direct"].
|
||||
/// Profile photos are excluded via :not([alt*="rofile"]) — covers both
|
||||
/// "profile" and "Profile" without case-sensitivity workarounds.
|
||||
const String kBlurReelsCSS = '''
|
||||
a[href*="/reel/"] img {
|
||||
filter: blur(12px) !important;
|
||||
}
|
||||
|
||||
[aria-label="Direct"] img:not([alt*="rofile"]):not([alt=""]),
|
||||
[aria-label="Direct"] video {
|
||||
filter: blur(12px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Allows users to unblur blurred media by tapping it.
|
||||
///
|
||||
/// Behaviour:
|
||||
/// - Only active when `window.__fgTapToUnblur === true`.
|
||||
/// - Only applies on Home feed (`/`) and Explore (`/explore*`) where FocusGram blurs.
|
||||
/// - First tap unblurs the post media and swallows the click (prevents opening).
|
||||
/// - Subsequent taps behave normally (Instagram opens the post as usual).
|
||||
const String kTapToUnblurJS = r'''
|
||||
(function fgTapToUnblur() {
|
||||
if (window.__fgTapToUnblurPatched) return;
|
||||
window.__fgTapToUnblurPatched = true;
|
||||
|
||||
function isBlurContext() {
|
||||
try {
|
||||
const p = (document.body && document.body.getAttribute('path')) || window.location.pathname || '';
|
||||
return p === '/' || p.indexOf('/explore') === 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findMediaFromTarget(t) {
|
||||
try {
|
||||
if (!t) return null;
|
||||
if (t.closest) {
|
||||
const direct = t.closest('img,video');
|
||||
if (direct) return direct;
|
||||
}
|
||||
// Walk up a few levels and look for a media element inside.
|
||||
let n = t;
|
||||
for (let i = 0; i < 6 && n; i++) {
|
||||
if (n.querySelector) {
|
||||
const m = n.querySelector('img,video');
|
||||
if (m) return m;
|
||||
}
|
||||
n = n.parentElement;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHost(media) {
|
||||
try {
|
||||
return media.closest('article') || media.closest('a') || media.parentElement;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function markUnblurred(host) {
|
||||
try {
|
||||
host.setAttribute('data-fg-unblurred', '1');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function isUnblurred(host) {
|
||||
try {
|
||||
return host && host.getAttribute && host.getAttribute('data-fg-unblurred') === '1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
media.style.setProperty('-webkit-filter', 'none', 'important');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
try {
|
||||
if (window.__fgTapToUnblur !== true) return;
|
||||
if (!isBlurContext()) return;
|
||||
const media = findMediaFromTarget(e.target);
|
||||
if (!media) return;
|
||||
const host = getHost(media);
|
||||
if (!host) return;
|
||||
|
||||
// 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);
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Removes the "Open in App" nag banner.
|
||||
const String kDismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Intercepts clicks on /reels/ links when no session is active and redirects
|
||||
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
|
||||
///
|
||||
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
|
||||
const String kStrictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
|
||||
/// via the `UrlChange` handler so reels can be blocked on SPA navigation.
|
||||
const String kTrackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('UrlChange', p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
|
||||
const String kThemeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
|
||||
function getTheme() {
|
||||
try {
|
||||
// 1. Check Instagram's specific classes
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
|
||||
// 2. Check body background color
|
||||
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'; // Fallback
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Prevents swipe-to-next-reel in the isolated DM reel player and when Reels
|
||||
/// are blocked by FocusGram's session controls.
|
||||
const String kReelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// 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';
|
||||
if (window.__fgBlockHomeFeedScroll === true &&
|
||||
(window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
return 'home_feed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLocked() {
|
||||
return lockMode() !== null;
|
||||
}
|
||||
|
||||
function allowInteractionTarget(t) {
|
||||
if (!t || !t.closest) return false;
|
||||
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
|
||||
if (t.closest(MODAL_SEL)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
// Mark the first DM reel as loaded on first swipe attempt
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
let __fgOrigHtmlOverflow = null;
|
||||
let __fgOrigBodyOverflow = null;
|
||||
|
||||
function applyOverflowLock() {
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
}
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (document.body) document.body.style.overflow = 'hidden';
|
||||
} else if (__fgOrigHtmlOverflow !== null) {
|
||||
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
|
||||
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
|
||||
__fgOrigHtmlOverflow = null;
|
||||
__fgOrigBodyOverflow = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
applyOverflowLock();
|
||||
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
// Give the first reel 3.5 s to buffer before activating the DM lock
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) {
|
||||
clearTimeout(window.__fgDmReelTimer);
|
||||
window.__fgDmReelTimer = null;
|
||||
}
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer =
|
||||
p.includes('/reel/') && !p.startsWith('/reels');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
applyOverflowLock();
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
|
||||
/// and Notifications icons, as well as the page title. Sends an event to
|
||||
/// Flutter whenever a new notification is detected.
|
||||
const String kBadgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
|
||||
const startedAt = Date.now();
|
||||
let initialised = false;
|
||||
let lastDmCount = 0;
|
||||
let lastNotifCount = 0;
|
||||
let lastTitleUnread = 0;
|
||||
|
||||
function parseBadgeCount(el) {
|
||||
if (!el) return 0;
|
||||
try {
|
||||
const raw = (el.innerText || el.textContent || '').trim();
|
||||
const n = parseInt(raw, 10);
|
||||
return isNaN(n) ? 1 : n;
|
||||
} catch (_) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function check() {
|
||||
try {
|
||||
// 1. Check Title for (N) indicator
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
|
||||
// 2. Scan for DM unread badge
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div',
|
||||
'a[href*="/direct/inbox/"] ._a9-v',
|
||||
].join(','));
|
||||
const currentDmCount = parseBadgeCount(dmBadge);
|
||||
|
||||
// 3. Scan for Notifications unread badge
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [style*="255, 48, 64"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]'
|
||||
].join(','));
|
||||
const currentNotifCount = parseBadgeCount(notifBadge);
|
||||
|
||||
// Establish baseline on first run and suppress false positives right after reload.
|
||||
if (!initialised) {
|
||||
lastDmCount = currentDmCount;
|
||||
lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
initialised = true;
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt < 6000) {
|
||||
lastDmCount = currentDmCount;
|
||||
lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentDmCount > lastDmCount) {
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
|
||||
}
|
||||
} else if (currentNotifCount > lastNotifCount) {
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
|
||||
}
|
||||
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
|
||||
}
|
||||
}
|
||||
|
||||
lastDmCount = currentDmCount;
|
||||
lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
// Initial check after some delay to let page settle
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 1000);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Forwards Web Notification events to the native Flutter channel.
|
||||
const String kNotificationBridgeJS = '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const startedAt = Date.now();
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
// Avoid false positives on reload / initial bootstrap.
|
||||
if (Date.now() - startedAt < 6000) {
|
||||
return new _N(title, opts);
|
||||
}
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramNotificationChannel',
|
||||
title + (opts && opts.body ? ': ' + opts.body : ''),
|
||||
);
|
||||
}
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
|
||||
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
|
||||
/// channel instead.
|
||||
const String kLinkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.flutter_inappwebview && u) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramShareChannel',
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }),
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
@@ -1,15 +0,0 @@
|
||||
/// JS to help Instagram's layout detect viewport changes when the Android
|
||||
/// soft keyboard appears in a WebView container.
|
||||
///
|
||||
/// It listens for resize events and re-dispatches an `orientationchange`
|
||||
/// event, which nudges Instagram's layout system out of the DM loading
|
||||
/// spinner state.
|
||||
const String kDmKeyboardFixJS = r'''
|
||||
// Fix: tell Instagram's layout system the viewport has changed after keyboard events
|
||||
// This resolves the loading state that appears on DM screens in WebView
|
||||
window.addEventListener('resize', function() {
|
||||
try {
|
||||
window.dispatchEvent(new Event('orientationchange'));
|
||||
} catch (_) {}
|
||||
});
|
||||
''';
|
||||
@@ -1,463 +0,0 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import '../focus_settings.dart';
|
||||
|
||||
/// Flutter sets these flags after settings load to enable ghost modes.
|
||||
/// Must be called from onWebViewCreated or on settings change.
|
||||
const String kSetGhostFlagsJS = '''
|
||||
(function(){
|
||||
// Placeholder — Flutter replaces these with actual setting values:
|
||||
// window.__fgPartialGhost = true/false;
|
||||
// window.__fgFullDmGhost = true/false;
|
||||
// window.__fgStoryGhost = true/false;
|
||||
// window.__fgGhostReady = true; // signals scripts can proceed
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIAL GHOST MODE — existing behavior
|
||||
// Blocks seen API patterns, WebSocket chat gateways, and uses
|
||||
// first-click gate for api/graphql on /direct/* (inbox loads, then block).
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kPartialGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgPartialGhostPatched) return;
|
||||
window.__fgPartialGhostPatched = true;
|
||||
|
||||
// ── Seen API patterns ──────────────────────────────────────
|
||||
var SEEN = [/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//];
|
||||
function isSeen(u) { for(var i=0;i<SEEN.length;i++){if(SEEN[i].test(u))return true;}return false; }
|
||||
|
||||
// ── First-click gate for api/graphql on /direct/* ──────────
|
||||
window.__fgDirectApiBlocked = false;
|
||||
document.addEventListener('click',function(){
|
||||
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
|
||||
},true);
|
||||
document.addEventListener('touchstart',function(){
|
||||
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
|
||||
},true);
|
||||
var _prevD=window.location.pathname.indexOf('/direct/')===0;
|
||||
setInterval(function(){
|
||||
var n=window.location.pathname.indexOf('/direct/')===0;
|
||||
if(n!==_prevD){_prevD=n;window.__fgDirectApiBlocked=false;}
|
||||
},300);
|
||||
|
||||
function partialEnabled() { return window.__fgPartialGhost===true; }
|
||||
function shouldBlock(u) {
|
||||
if (!partialEnabled()) return false;
|
||||
return window.location.pathname.indexOf('/direct/')===0 &&
|
||||
window.__fgDirectApiBlocked &&
|
||||
u.indexOf('/api/graphql')!==-1;
|
||||
}
|
||||
|
||||
// ── Fetch override (chain with previous fetch) ─────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch=function(i,init){
|
||||
var u=(typeof i==='string')?i:(i&&i.url)?i.url:'';
|
||||
if(partialEnabled()&&(isSeen(u)||shouldBlock(u))) return Promise.resolve(new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
return _prevFetch.call(window,i,init);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen=XMLHttpRequest.prototype.open,_prevSend=XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open=function(m,u){this.__fgU=u||'';return _prevOpen.apply(this,arguments);};
|
||||
XMLHttpRequest.prototype.send=function(b){
|
||||
if(partialEnabled()&&(isSeen(this.__fgU||'')||shouldBlock(this.__fgU||''))){
|
||||
var self=this;setTimeout(function(){
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
},5);return;
|
||||
}
|
||||
return _prevSend.apply(this,arguments);
|
||||
};
|
||||
|
||||
// ── Selective WS seen-message filter (no gouger) ───────────
|
||||
(function() {
|
||||
var _WS = window.WebSocket;
|
||||
function PartialWS(url, protocols) {
|
||||
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
var _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
var parsed = JSON.parse(data);
|
||||
if (parsed && (parsed.op === '4' || parsed.op === 'seen')) return;
|
||||
} catch(e) {}
|
||||
if (data.indexOf('"seen"') !== -1 && data.indexOf('"thread_id"') !== -1) return;
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
PartialWS.prototype = _WS.prototype;
|
||||
PartialWS.CONNECTING = _WS.CONNECTING;
|
||||
PartialWS.OPEN = _WS.OPEN;
|
||||
PartialWS.CLOSING = _WS.CLOSING;
|
||||
PartialWS.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PartialWS;
|
||||
})();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// FULL DM GHOST — blocks ALL api/graphql on /direct/* immediately
|
||||
// (inbox won't load, messages can't be sent)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kFullDmGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgFullDmGhostPatched) return;
|
||||
window.__fgFullDmGhostPatched = true;
|
||||
|
||||
// ── Smart path-based blocking ──────────────────────────────
|
||||
// /direct/inbox/ → allow (inbox loads)
|
||||
// /direct/t/* → block ALL api/graphql immediately
|
||||
// any /direct/* → block except /direct/inbox/
|
||||
function shouldBlockDmPath() {
|
||||
if (window.__fgFullDmGhost !== true) return false;
|
||||
var p = window.location.pathname;
|
||||
if (p.indexOf('/direct/') !== 0) return false;
|
||||
if (p === '/direct/inbox/' || p === '/direct/inbox') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── DM URL blocklist ───────────────────────────────────────
|
||||
var DM_URLS = [
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/mark_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/mark_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_visual_item_seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/visual_thread\\/[^/]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_audio_seen\\//,
|
||||
/\\/api\\/v1\\/live\\/[^/]+\\/join\\//,
|
||||
/\\/api\\/v1\\/live\\/[^/]+\\/get_join_requests\\//,
|
||||
/\\/api\\/v1\\/media\\/seen\\//,
|
||||
/\\/api\\/v1\\/feed\\/viewed_story\\//,
|
||||
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
|
||||
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/threads\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/direct_v2\\/visual_message\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/live\\/[\\w-]+\\/comment\\/seen\\//,
|
||||
/\\/api\\/v1\\/qe\\//,
|
||||
/\\/api\\/v1\\/launcher\\/sync\\//,
|
||||
/\\/api\\/v1\\/logging\\//,
|
||||
/\\/api\\/v1\\/fb_onetap_logging\\//,
|
||||
/\\/ajax\\/bz/,
|
||||
/\\/ajax\\/logging\\//,
|
||||
/\\/api\\/v1\\/stats\\//,
|
||||
/\\/api\\/v1\\/fbanalytics\\//,
|
||||
];
|
||||
|
||||
function matchUrl(url) {
|
||||
if (!url) return false;
|
||||
for (var i = 0; i < DM_URLS.length; i++) { if (DM_URLS[i].test(url)) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── DM GraphQL operations ──────────────────────────────────
|
||||
var DM_OPS = [
|
||||
'MarkDirectThreadItemSeen','markDirectThreadItemSeen',
|
||||
'DirectMarkItemSeen','DirectThreadMarkSeen',
|
||||
'MarkVisualMessageSeen','DirectMarkVisualItemSeen',
|
||||
'MarkAudioMessageSeen','AudioSeenMutation',
|
||||
'LiveJoinBroadcast','JoinLiveBroadcast','MarkLiveViewer',
|
||||
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
|
||||
'LogImpression','LogClick','FeedbackSeenMutation',
|
||||
];
|
||||
|
||||
function matchGraphQL(body) {
|
||||
if (!body) return false;
|
||||
var str = typeof body === 'string' ? body : String(body);
|
||||
for (var i = 0; i < DM_OPS.length; i++) { if (str.indexOf(DM_OPS[i]) !== -1) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGraphql(url) {
|
||||
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
|
||||
}
|
||||
|
||||
function shouldBlock(url, init) {
|
||||
// 1. Path-based: on /direct/t/* block ALL graphql
|
||||
if (shouldBlockDmPath() && isGraphql(url)) return true;
|
||||
// 2. URL blocklist match
|
||||
if (matchUrl(url)) return true;
|
||||
// 3. GraphQL body op-name match
|
||||
if (isGraphql(url) && init) {
|
||||
var bs = '';
|
||||
if (typeof init.body === 'string') bs = init.body;
|
||||
else if (init.body && init.body.toString) bs = init.body.toString();
|
||||
if (matchGraphQL(bs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
|
||||
|
||||
// ── Fetch override (chain) ─────────────────────────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch = function(i, init) {
|
||||
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
|
||||
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
|
||||
return _prevFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen = XMLHttpRequest.prototype.open;
|
||||
var _prevSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(m, u) { this.__fgDU = u || ''; return _prevOpen.apply(this, arguments); };
|
||||
XMLHttpRequest.prototype.send = function(b) {
|
||||
var u = this.__fgDU || '';
|
||||
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockDmPath())) {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
return _prevSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── SW killer ──────────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Beacon blocker ─────────────────────────────────────────
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon = function(url) { return true; };
|
||||
}
|
||||
|
||||
// ── MQTT WS intercept (typing / live viewer / seen) ────────
|
||||
// Instagram uses MQTT over WebSocket for real-time events.
|
||||
// '/t_fs' = foreground state, '/t_mt' = mark thread seen,
|
||||
// '/t_s' and '/t_se' = seen receipts, 'activity_indicator' = active status.
|
||||
(function() {
|
||||
var _WS = window.WebSocket;
|
||||
function DmGhostWS(url, protocols) {
|
||||
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
var _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
var bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
var packetType = bytes[0] & 0xF0;
|
||||
if (packetType === 0x30) {
|
||||
try {
|
||||
var decoded = new TextDecoder('utf-8').decode(bytes);
|
||||
if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('/t_mt') !== -1 ||
|
||||
decoded.indexOf('/t_s') !== -1 || decoded.indexOf('/t_se') !== -1 ||
|
||||
decoded.indexOf('activity_indicator') !== -1 ||
|
||||
decoded.indexOf('is_typing') !== -1 || decoded.indexOf('direct_typing') !== -1 ||
|
||||
decoded.indexOf('/live/viewer') !== -1 || decoded.indexOf('live_viewer_list') !== -1) {
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
} else if (typeof data === 'string') {
|
||||
if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 ||
|
||||
data.indexOf('is_typing') !== -1 || data.indexOf('mark_seen') !== -1 ||
|
||||
data.indexOf('mark_read') !== -1 || data.indexOf('receipt') !== -1) return;
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
DmGhostWS.prototype = _WS.prototype;
|
||||
DmGhostWS.CONNECTING = _WS.CONNECTING;
|
||||
DmGhostWS.OPEN = _WS.OPEN;
|
||||
DmGhostWS.CLOSING = _WS.CLOSING;
|
||||
DmGhostWS.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = DmGhostWS;
|
||||
})();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STORY GHOST — blocks api/graphql on homepage (/) and /stories/*
|
||||
// Allows viewing stories without sending seen indicators.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const String kStoryGhostJS = r'''
|
||||
(function() {
|
||||
if (window.__fgStoryGhostPatched) return;
|
||||
window.__fgStoryGhostPatched = true;
|
||||
|
||||
// ── Smart path-based blocking ──────────────────────────────
|
||||
// On /, /stories/*, /story/* → block ALL api/graphql
|
||||
// On /direct/inbox/ → allow (DMs need graphql to load messages)
|
||||
function shouldBlockByPath() {
|
||||
if (window.__fgStoryGhost !== true) return false;
|
||||
var p = window.location.pathname;
|
||||
// Don't block on DM pages
|
||||
if (p.indexOf('/direct/') === 0) return false;
|
||||
var isStory = p.indexOf('/stories/') === 0 || p.indexOf('/story/') === 0;
|
||||
var isHome = p === '/' || p === '';
|
||||
return isHome || isStory;
|
||||
}
|
||||
|
||||
// ── Story URL blocklist ────────────────────────────────────
|
||||
var STORY_URLS = [
|
||||
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
|
||||
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
|
||||
/\\/api\\/v1\\/feed\\/viewed_story\\//,
|
||||
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
|
||||
/\\/api\\/v1\\/media\\/seen\\//,
|
||||
];
|
||||
|
||||
function matchUrl(url) {
|
||||
if (!url) return false;
|
||||
for (var i = 0; i < STORY_URLS.length; i++) { if (STORY_URLS[i].test(url)) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Story GraphQL operations ───────────────────────────────
|
||||
var STORY_OPS = [
|
||||
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
|
||||
'FeedbackSeenMutation',
|
||||
];
|
||||
|
||||
function matchGraphQL(body) {
|
||||
if (!body) return false;
|
||||
var str = typeof body === 'string' ? body : String(body);
|
||||
for (var i = 0; i < STORY_OPS.length; i++) { if (str.indexOf(STORY_OPS[i]) !== -1) return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGraphql(url) {
|
||||
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
|
||||
}
|
||||
|
||||
function shouldBlock(url, init) {
|
||||
// 1. Path-based: on story pages block ALL graphql
|
||||
if (shouldBlockByPath() && isGraphql(url)) return true;
|
||||
// 2. URL blocklist match
|
||||
if (matchUrl(url)) return true;
|
||||
// 3. GraphQL body op-name match
|
||||
if (isGraphql(url) && init) {
|
||||
var bs = '';
|
||||
if (typeof init.body === 'string') bs = init.body;
|
||||
else if (init.body && init.body.toString) bs = init.body.toString();
|
||||
if (matchGraphQL(bs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
|
||||
|
||||
// ── Fetch override (chain) ─────────────────────────────────
|
||||
var _prevFetch = window.fetch;
|
||||
window.fetch = function(i, init) {
|
||||
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
|
||||
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
|
||||
return _prevFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── XHR override (chain) ───────────────────────────────────
|
||||
var _prevOpen = XMLHttpRequest.prototype.open;
|
||||
var _prevSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(m, u) { this.__fgSU = u || ''; return _prevOpen.apply(this, arguments); };
|
||||
XMLHttpRequest.prototype.send = function(b) {
|
||||
var u = this.__fgSU || '';
|
||||
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockByPath())) {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
Object.defineProperty(self,'readyState',{get:function(){return 4}});
|
||||
Object.defineProperty(self,'status',{get:function(){return 200}});
|
||||
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
|
||||
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
|
||||
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
|
||||
try{self.onload&&self.onload();}catch(e){}
|
||||
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
|
||||
}, 5);
|
||||
return;
|
||||
}
|
||||
return _prevSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── SW killer ──────────────────────────────────────────────
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
|
||||
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Beacon blocker ─────────────────────────────────────────
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon = function(url) { return true; };
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Builder — injects the right scripts based on settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
List<UserScript> buildUserScripts(FocusSettings settings) {
|
||||
final startScripts = <String>[];
|
||||
final endScripts = <String>[];
|
||||
|
||||
// Prepend flag values directly into the script so they survive page navigation.
|
||||
// (evaluateJavascript-set flags are destroyed when the JS context resets on load.)
|
||||
// DM Ghost uses the comprehensive Full DM approach (URL blocklist, GraphQL ops, SW killer, beacon, WS).
|
||||
// it should have worked, but sadly it didnt
|
||||
if (settings.ghostMode) {
|
||||
startScripts.add('window.__fgFullDmGhost=true;$kFullDmGhostJS');
|
||||
}
|
||||
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
|
||||
|
||||
// AT_DOCUMENT_END
|
||||
if (settings.noStories) endScripts.add(hideStoryTrayJS);
|
||||
if (settings.noReels) endScripts.add(hideReelsJS);
|
||||
if (settings.noDMs) endScripts.add(hideDMsJS);
|
||||
|
||||
final scripts = <UserScript>[];
|
||||
if (startScripts.isNotEmpty) {
|
||||
scripts.add(
|
||||
UserScript(
|
||||
source: startScripts.join('\n'),
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
forMainFrameOnly: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (endScripts.isNotEmpty) {
|
||||
scripts.add(
|
||||
UserScript(
|
||||
source: endScripts.join('\n'),
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
forMainFrameOnly: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
|
||||
// ── Existing non-ghost helpers (unchanged) ───────────────────
|
||||
|
||||
const String noAutoplayJS = '''
|
||||
document.addEventListener('play', function(e) {
|
||||
if (e.target.tagName === 'VIDEO') e.target.pause();
|
||||
}, true);
|
||||
''';
|
||||
|
||||
const String hideStoryTrayJS = '''
|
||||
(function(){var s=document.createElement('style');s.textContent='[data-pagelet="story_tray"]{display:none!important}';document.head.appendChild(s);})();
|
||||
''';
|
||||
|
||||
const String hideReelsJS = '''
|
||||
(function(){new MutationObserver(function(){document.querySelectorAll('a[href="/reels/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')});document.querySelectorAll('a[href="/explore/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')})}).observe(document.body,{childList:true,subtree:true});})();
|
||||
''';
|
||||
|
||||
const String hideDMsJS = '''
|
||||
(function(){var s=document.createElement('style');s.textContent='a[href="/direct/inbox/"]{display:none!important}';document.head.appendChild(s);})();
|
||||
''';
|
||||
@@ -1,48 +0,0 @@
|
||||
/// Grayscale style injector.
|
||||
/// Uses a <style> tag with !important so Instagram's CSS cannot override it.
|
||||
const String kGrayscaleJS = r'''
|
||||
(function fgGrayscale() {
|
||||
try {
|
||||
const ID = 'fg-grayscale';
|
||||
function inject() {
|
||||
let el = document.getElementById(ID);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = ID;
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.textContent = 'html { filter: grayscale(100%) !important; }';
|
||||
}
|
||||
inject();
|
||||
if (!window.__fgGrayscaleObserver) {
|
||||
window.__fgGrayscaleObserver = new MutationObserver(() => {
|
||||
if (!document.getElementById('fg-grayscale')) inject();
|
||||
});
|
||||
window.__fgGrayscaleObserver.observe(
|
||||
document.documentElement,
|
||||
{ childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Removes grayscale AND disconnects the observer so it cannot re-add it.
|
||||
/// Previously kGrayscaleOffJS only removed the style tag — the observer
|
||||
/// immediately re-injected it, requiring an app restart to actually go off.
|
||||
const String kGrayscaleOffJS = r'''
|
||||
(function() {
|
||||
try {
|
||||
// 1. Disconnect the observer FIRST so it cannot react to the removal
|
||||
if (window.__fgGrayscaleObserver) {
|
||||
window.__fgGrayscaleObserver.disconnect();
|
||||
window.__fgGrayscaleObserver = null;
|
||||
}
|
||||
// 2. Remove the style tag
|
||||
const el = document.getElementById('fg-grayscale');
|
||||
if (el) el.remove();
|
||||
// 3. Clear any inline filter that may have been set by older code
|
||||
document.documentElement.style.filter = '';
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
@@ -1,11 +0,0 @@
|
||||
const String kHapticBridgeScript = '''
|
||||
(function() {
|
||||
// Trigger native haptic feedback on double-tap (like gesture on posts)
|
||||
// Uses flutter_inappwebview's callHandler instead of postMessage
|
||||
document.addEventListener('dblclick', function(e) {
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('Haptic', 'light');
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
@@ -1,68 +0,0 @@
|
||||
// Document-start script — injected before Instagram's JS loads.
|
||||
const String kNativeFeelingScript = '''
|
||||
(function() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'fg-native-feel';
|
||||
style.textContent = `
|
||||
/* Hide all scrollbars */
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove blue tap highlight */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Disable text selection globally except inputs */
|
||||
* {
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
input, textarea, [contenteditable="true"] {
|
||||
-webkit-user-select: text !important;
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
/* Momentum scrolling */
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
/* Remove focus outlines */
|
||||
*:focus, *:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Fade images in */
|
||||
img {
|
||||
animation: igFadeIn 0.15s ease-in-out;
|
||||
}
|
||||
@keyframes igFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`;
|
||||
|
||||
if (document.head) {
|
||||
document.head.appendChild(style);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// Post-load script — call in onLoadStop only.
|
||||
// IMPORTANT: Do NOT add overscroll-behavior rules here — they lock the feed scroll.
|
||||
const String kNativeFeelingPostLoadScript = '''
|
||||
(function() {
|
||||
// Smooth anchor scrolling only — do NOT apply to all containers.
|
||||
document.documentElement.style.scrollBehavior = 'auto';
|
||||
})();
|
||||
''';
|
||||
@@ -1,118 +0,0 @@
|
||||
// Reel metadata extraction for history feature.
|
||||
// Extracts title and thumbnail URL from the page and sends to Flutter.
|
||||
|
||||
const String kReelMetadataExtractorScript = r'''
|
||||
(function() {
|
||||
// Track if we've already extracted for this URL to avoid duplicates
|
||||
window.__fgReelExtracted = window.__fgReelExtracted || false;
|
||||
window.__fgLastExtractedUrl = window.__fgLastExtractedUrl || '';
|
||||
|
||||
function extractAndSend() {
|
||||
const currentUrl = window.location.href;
|
||||
|
||||
// Skip if already extracted for this URL
|
||||
if (window.__fgReelExtracted && window.__fgLastExtractedUrl === currentUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reel page (Instagram uses /reels/ not /reel/)
|
||||
if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try multiple sources for metadata
|
||||
let title = '';
|
||||
let thumbnailUrl = '';
|
||||
|
||||
// 1. Try Open Graph tags
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
const ogImage = document.querySelector('meta[property="og:image"]');
|
||||
|
||||
if (ogTitle) title = ogTitle.content;
|
||||
if (ogImage) thumbnailUrl = ogImage.content;
|
||||
|
||||
// 2. Fallback to document title if no OG title
|
||||
if (!title && document.title) {
|
||||
title = document.title.replace(' on Instagram', '').trim();
|
||||
if (!title) title = 'Instagram Reel';
|
||||
}
|
||||
|
||||
// 3. Try JSON-LD structured data
|
||||
if (!thumbnailUrl) {
|
||||
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
jsonLdScripts.forEach(function(script) {
|
||||
try {
|
||||
const data = JSON.parse(script.textContent);
|
||||
if (data.image) {
|
||||
if (Array.isArray(data.image)) {
|
||||
thumbnailUrl = data.image[0];
|
||||
} else if (typeof data.image === 'string') {
|
||||
thumbnailUrl = data.image;
|
||||
} else if (data.image.url) {
|
||||
thumbnailUrl = data.image.url;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Try Twitter card as fallback
|
||||
if (!thumbnailUrl) {
|
||||
const twitterImage = document.querySelector('meta[name="twitter:image"]');
|
||||
if (twitterImage) thumbnailUrl = twitterImage.content;
|
||||
}
|
||||
|
||||
// Skip if no thumbnail found
|
||||
if (!thumbnailUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as extracted
|
||||
window.__fgReelExtracted = true;
|
||||
window.__fgLastExtractedUrl = currentUrl;
|
||||
|
||||
// Send to Flutter
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'ReelMetadata',
|
||||
JSON.stringify({
|
||||
url: currentUrl,
|
||||
title: title || 'Instagram Reel',
|
||||
thumbnailUrl: thumbnailUrl
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Run immediately in case metadata is already loaded
|
||||
extractAndSend();
|
||||
|
||||
// Set up MutationObserver to detect page changes and metadata loading
|
||||
if (!window.__fgReelObserver) {
|
||||
let debounceTimer = null;
|
||||
window.__fgReelObserver = new MutationObserver(function(mutations) {
|
||||
// Debounce to avoid excessive calls
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function() {
|
||||
extractAndSend();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
window.__fgReelObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Also listen for URL changes (SPA navigation)
|
||||
let lastUrl = location.href;
|
||||
setInterval(function() {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
window.__fgReelExtracted = false;
|
||||
window.__fgLastExtractedUrl = '';
|
||||
extractAndSend();
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
''';
|
||||
@@ -1,12 +0,0 @@
|
||||
/// JS to improve momentum scrolling behaviour inside the WebView, especially
|
||||
/// for content-heavy feeds like Reels.
|
||||
///
|
||||
/// Applies touch-style overflow scrolling hints to the root element.
|
||||
const String kScrollSmoothingJS = r'''
|
||||
(function fgScrollSmoothing() {
|
||||
try {
|
||||
document.documentElement.style.setProperty('-webkit-overflow-scrolling', 'touch');
|
||||
document.documentElement.style.setProperty('overflow-scrolling', 'touch');
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
@@ -1,31 +0,0 @@
|
||||
const String kSpaNavigationMonitorScript = '''
|
||||
(function() {
|
||||
// Monitor Instagram's SPA navigation and notify Flutter on every URL change.
|
||||
// Instagram uses history.pushState — onLoadStop won't fire for these transitions.
|
||||
// This is injected at document start so it wraps pushState before Instagram does.
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
function notifyUrlChange(url) {
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'UrlChange',
|
||||
url || window.location.href
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
history.pushState = function() {
|
||||
originalPushState.apply(this, arguments);
|
||||
setTimeout(() => notifyUrlChange(arguments[2]), 100);
|
||||
};
|
||||
|
||||
history.replaceState = function() {
|
||||
originalReplaceState.apply(this, arguments);
|
||||
setTimeout(() => notifyUrlChange(arguments[2]), 100);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', () => notifyUrlChange());
|
||||
})();
|
||||
''';
|
||||
@@ -1,263 +0,0 @@
|
||||
// UI element hiding for Instagram web.
|
||||
//
|
||||
// SCROLL LOCK WARNING:
|
||||
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
|
||||
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
|
||||
// calls inside these callbacks block the main thread = scroll jank.
|
||||
// All JS hiders below use a 300ms debounce so they run only after mutations settle.
|
||||
|
||||
// ─── CSS-based ────────────────────────────────────────────────────────────────
|
||||
|
||||
// FIX: Like count CSS.
|
||||
// Instagram's like BUTTON has aria-label="Like" (the verb) — NOT the count.
|
||||
// [role="button"][aria-label$=" likes"] never matches anything.
|
||||
// The COUNT lives in a[href*="/liked_by/"] (e.g. "1,234 likes" link).
|
||||
// We hide that link. The JS hider below catches React-rendered span variants.
|
||||
const String kHideLikeCountsCSS = '''
|
||||
a[href*="/liked_by/"],
|
||||
section a[href*="/liked_by/"] {
|
||||
display: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
const String kHideFollowerCountsCSS = '''
|
||||
a[href*="/followers/"] span,
|
||||
a[href*="/following/"] span {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// Stories bar CSS — multiple selectors for different Instagram DOM versions.
|
||||
// :has() is supported in WebKit (Instagram's engine). Targets the container,
|
||||
// not individual story items which is what [aria-label*="Stories"] matches.
|
||||
const String kHideStoriesBarCSS = '''
|
||||
[aria-label*="Stories"],
|
||||
[aria-label*="stories"],
|
||||
[role="list"]:has([aria-label*="tory"]),
|
||||
[role="listbox"]:has([aria-label*="tory"]),
|
||||
div[style*="overflow"][style*="scroll"]:has(canvas),
|
||||
section > div > div[style*="overflow-x"] {
|
||||
display: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
const String kHideExploreTabCSS = '''
|
||||
a[href="/explore/"],
|
||||
a[href="/explore"] {
|
||||
display: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
const String kHideReelsTabCSS = '''
|
||||
a[href="/reels/"],
|
||||
a[href="/reels"] {
|
||||
display: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
const String kHideShopTabCSS = '''
|
||||
a[href*="/shop"],
|
||||
a[href*="/shopping"] {
|
||||
display: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// ─── JS-based ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Like counts — JS fallback for React-rendered count spans not caught by CSS.
|
||||
// Scans for text matching "1,234 likes" / "12.3K views" patterns.
|
||||
const String kHideLikeCountsJS = r'''
|
||||
(function() {
|
||||
function hideLikeCounts() {
|
||||
try {
|
||||
// Hide liked_by links and their immediate parent wrapper
|
||||
document.querySelectorAll('a[href*="/liked_by/"]').forEach(function(el) {
|
||||
try {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
// Also hide the parent span/div that wraps the count text
|
||||
if (el.parentElement) {
|
||||
el.parentElement.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
// Scan spans for numeric like/view count text patterns
|
||||
document.querySelectorAll('span').forEach(function(el) {
|
||||
try {
|
||||
const text = el.textContent.trim();
|
||||
// Matches: "1,234 likes", "12.3K views", "1 like", "45 views", etc.
|
||||
if (/^[\d,.]+[KkMm]?\s+(like|likes|view|views)$/.test(text)) {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideLikeCounts();
|
||||
|
||||
if (!window.__fgLikeCountObserver) {
|
||||
let _t = null;
|
||||
window.__fgLikeCountObserver = new MutationObserver(() => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(hideLikeCounts, 300);
|
||||
});
|
||||
window.__fgLikeCountObserver.observe(
|
||||
document.documentElement, { childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// Stories bar JS — structural detection when CSS selectors don't match.
|
||||
// Two strategies:
|
||||
// 1. aria-label scan on role=list/listbox elements
|
||||
// 2. BoundingClientRect check: story circles are square, narrow (<120px), appear in a row
|
||||
const String kHideStoriesBarJS = r'''
|
||||
(function() {
|
||||
function hideStories() {
|
||||
try {
|
||||
// Strategy 1: aria-label on list containers
|
||||
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
|
||||
try {
|
||||
const label = (el.getAttribute('aria-label') || '').toLowerCase();
|
||||
if (label.includes('stor')) {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Strategy 2: BoundingClientRect — story circles are narrow square items in a row.
|
||||
// Look for a <ul> or <div role=list> whose first child is roughly square and < 120px wide.
|
||||
document.querySelectorAll('ul, [role="list"]').forEach(function(el) {
|
||||
try {
|
||||
const items = el.children;
|
||||
if (items.length < 3) return;
|
||||
const first = items[0].getBoundingClientRect();
|
||||
// Story item: small, roughly square (width ≈ height), near top of viewport
|
||||
if (
|
||||
first.width > 0 &&
|
||||
first.width < 120 &&
|
||||
Math.abs(first.width - first.height) < 20 &&
|
||||
first.top < 300
|
||||
) {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
// Also hide the section wrapping this if it has no article (pure stories row)
|
||||
const section = el.closest('section, div[class]');
|
||||
if (section && !section.querySelector('article')) {
|
||||
section.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Strategy 3: horizontal overflow container before any article in the feed
|
||||
document.querySelectorAll('main > div > div > div').forEach(function(container) {
|
||||
try {
|
||||
if (container.querySelector('article')) return;
|
||||
const inner = container.querySelector('div, ul');
|
||||
if (!inner) return;
|
||||
const s = window.getComputedStyle(inner);
|
||||
if (s.overflowX === 'scroll' || s.overflowX === 'auto') {
|
||||
container.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideStories();
|
||||
|
||||
if (!window.__fgStoriesObserver) {
|
||||
let _t = null;
|
||||
window.__fgStoriesObserver = new MutationObserver(() => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(hideStories, 300);
|
||||
});
|
||||
window.__fgStoriesObserver.observe(
|
||||
document.documentElement, { childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// Sponsored posts — scans article elements for "Sponsored" text child.
|
||||
// CSS cannot traverse from child text up to parent — JS only.
|
||||
const String kHideSponsoredPostsJS = r'''
|
||||
(function() {
|
||||
function hideSponsoredPosts() {
|
||||
try {
|
||||
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
|
||||
try {
|
||||
if (el.__fgSponsoredChecked) return;
|
||||
const spans = el.querySelectorAll('span');
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const text = spans[i].textContent.trim();
|
||||
if (text === 'Sponsored' || text === 'Paid partnership') {
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
return;
|
||||
}
|
||||
}
|
||||
el.__fgSponsoredChecked = true;
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
hideSponsoredPosts();
|
||||
|
||||
if (!window.__fgSponsoredObserver) {
|
||||
let _t = null;
|
||||
window.__fgSponsoredObserver = new MutationObserver(() => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(hideSponsoredPosts, 300);
|
||||
});
|
||||
window.__fgSponsoredObserver.observe(
|
||||
document.documentElement, { childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// Suggested posts — scans for heading text, walks up to parent article/section.
|
||||
const String kHideSuggestedPostsJS = r'''
|
||||
(function() {
|
||||
function hideSuggestedPosts() {
|
||||
try {
|
||||
document.querySelectorAll('span, h3, h4').forEach(function(el) {
|
||||
try {
|
||||
const text = el.textContent.trim();
|
||||
if (
|
||||
text === 'Suggested for you' ||
|
||||
text === 'Suggested posts' ||
|
||||
text === "You're all caught up"
|
||||
) {
|
||||
let parent = el.parentElement;
|
||||
for (let i = 0; i < 8 && 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(_) {}
|
||||
}
|
||||
|
||||
hideSuggestedPosts();
|
||||
|
||||
if (!window.__fgSuggestedObserver) {
|
||||
let _t = null;
|
||||
window.__fgSuggestedObserver = new MutationObserver(() => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(hideSuggestedPosts, 300);
|
||||
});
|
||||
window.__fgSuggestedObserver.observe(
|
||||
document.documentElement, { childList: true, subtree: true }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
@@ -1,355 +0,0 @@
|
||||
/// 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();
|
||||
})();
|
||||
''';
|
||||
@@ -1,430 +0,0 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,245 @@
|
||||
// ============================================================================
|
||||
// FocusGram — InjectionController
|
||||
// ============================================================================
|
||||
//
|
||||
// Builds all JavaScript and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// ── Ghost Mode Design ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Instead of blocking exact URLs (brittle — Instagram renames paths constantly),
|
||||
// we block by SEMANTIC KEYWORD GROUPS. A request is silenced if its URL contains
|
||||
// ANY keyword from the relevant group.
|
||||
//
|
||||
// Ghost Mode Semantic Groups (last verified: 2025-02)
|
||||
// ────────────────────────────────────────────────────
|
||||
// seenKeywords — story/DM seen receipts (any endpoint Instagram uses to
|
||||
// tell others you read/watched something)
|
||||
// typingKeywords — typing indicator REST calls + WS text frames
|
||||
// liveKeywords — live viewer heartbeat / join_request (presence on streams)
|
||||
// photoKeywords — disappearing / view-once DM photo seen receipts
|
||||
//
|
||||
// Adding new endpoints in the future: just append a keyword to the right group
|
||||
// in _ghostGroups below — no other code needs to change.
|
||||
//
|
||||
// ── Confirmed endpoint map ───────────────────────────────────────────────────
|
||||
// /api/v1/media/seen/ — story seen v1 (covered by "media/seen")
|
||||
// /api/v2/media/seen/ — story seen v2 (covered by "media/seen")
|
||||
// /stories/reel/seen — web story seen (covered by "reel/seen")
|
||||
// /api/v1/stories/reel/mark_seen/ — story mark (covered by "mark_seen")
|
||||
// /direct_v2/threads/…/seen/ — DM message read (covered by "/seen")
|
||||
// /api/v1/direct_v2/set_reel_seen/ — DM story (covered by "reel_seen")
|
||||
// /api/v1/direct_v2/mark_visual_item_seen/ — disappearing photos
|
||||
// /api/v1/live/…/heartbeat_and_get_viewer_count/ — live presence
|
||||
// /api/v1/live/…/join_request/ — live join
|
||||
// WS text frames with "typing", "direct_v2/typing", "activity_status"
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
import '../scripts/core_injection.dart' as scripts;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
|
||||
/// Central hub for all JavaScript and CSS injected into the Instagram WebView.
|
||||
class InjectionController {
|
||||
// ── User Agent ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// iOS UA ensures Instagram serves the full mobile UI (Reels, Stories, DMs).
|
||||
/// Without spoofing, instagram.com returns a stripped desktop-lite shell.
|
||||
static const String iOSUserAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
||||
'Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;'
|
||||
'FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;'
|
||||
'FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]';
|
||||
|
||||
static const String reelsMutationObserverJS =
|
||||
scripts.kReelsMutationObserverJS;
|
||||
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
|
||||
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
|
||||
// ── Ghost Mode keyword groups ────────────────────────────────────────────────
|
||||
|
||||
/// Semantic groups used by [buildGhostModeJS].
|
||||
///
|
||||
/// Each group is a list of URL substrings. A network request is suppressed
|
||||
/// if its URL contains ANY substring in the enabled groups.
|
||||
///
|
||||
/// To add future endpoints: append keywords here — nothing else changes.
|
||||
static const Map<String, List<String>> _ghostGroups = {
|
||||
// Any URL that records you having seen/read something
|
||||
'seen': ['/seen', '/mark_seen', 'reel_seen', 'reel/seen', 'media/seen'],
|
||||
// Typing indicator (REST + WebSocket text frames)
|
||||
'typing': ['set_typing_status', '/typing', 'activity_status'],
|
||||
// Live stream viewer join / heartbeat (you appear in viewer list)
|
||||
'live': ['/live/'],
|
||||
// Disappearing / view-once DM photos
|
||||
'dmPhotos': ['visual_item_seen'],
|
||||
};
|
||||
|
||||
// ── CSS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
|
||||
/// because Instagram's comment input sheet also uses that role and the
|
||||
/// CSS would paint a grey overlay on top of the typing area.
|
||||
static const String _globalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
/// Activated via the body[path] attribute written by [_trackPathJS].
|
||||
static const String _blurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native.
|
||||
static const String _disableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
|
||||
static const String _hideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// _blurExploreCSS removed — replaced by _blurHomeFeedAndExploreCSS above.
|
||||
|
||||
/// Blurs reel thumbnail images shown in the feed.
|
||||
static const String _blurReelsCSS = '''
|
||||
a[href*="/reel/"] img { filter: blur(12px) !important; }
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Removes the "Open in App" nag banner.
|
||||
static const String _dismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Replaces ONLY the Instagram wordmark SVG with "FocusGram" brand text.
|
||||
/// Specifically targets the top-bar logo SVG (aria-label="Instagram") while
|
||||
/// explicitly excluding SVG icons inside nav/tablist (home, notifications,
|
||||
/// create, reels, profile icons).
|
||||
static const String _brandingJS = r'''
|
||||
(function fgBranding() {
|
||||
// Only the wordmark: SVG with aria-label="Instagram" that is NOT inside
|
||||
// a [role="tablist"] (bottom nav) or a [role="navigation"] (nav bar).
|
||||
// Also targets the ._ac83 class which Instagram uses for its top wordmark.
|
||||
const WORDMARK_SEL = [
|
||||
'svg[aria-label="Instagram"]',
|
||||
'._ac83 svg[aria-label="Instagram"]',
|
||||
'h1[role="presentation"] svg',
|
||||
];
|
||||
const STYLE =
|
||||
'font-family:"Grand Hotel",cursive;font-size:26px;color:#fff;' +
|
||||
'vertical-align:middle;cursor:default;letter-spacing:.5px;display:inline-block;';
|
||||
|
||||
function isNavIcon(el) {
|
||||
// Exclude any SVG that lives inside a tablist, nav, or link with
|
||||
// non-home/non-root href (these are functional icons, not the wordmark).
|
||||
if (el.closest('[role="tablist"]')) return true;
|
||||
if (el.closest('[role="navigation"]')) return true;
|
||||
// The wordmark is always at the TOP of the page in a header/banner
|
||||
const header = el.closest('header, [role="banner"], [role="main"]');
|
||||
if (!header && el.closest('[role="button"]')) return true;
|
||||
// If the SVG has a meaningful role (img presenting an action icon), skip it
|
||||
const role = el.getAttribute('role');
|
||||
if (role && role !== 'img') return true;
|
||||
// If the parent <a> goes somewhere other than "/" it is a nav link
|
||||
const anchor = el.closest('a');
|
||||
if (anchor) {
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (href && href !== '/' && !href.startsWith('/?')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
WORDMARK_SEL.forEach(sel => document.querySelectorAll(sel).forEach(logo => {
|
||||
if (logo.dataset.fgBranded) return;
|
||||
if (isNavIcon(logo)) return;
|
||||
logo.dataset.fgBranded = 'true';
|
||||
const span = Object.assign(document.createElement('span'),
|
||||
{ textContent: 'FocusGram' });
|
||||
span.style.cssText = STYLE;
|
||||
logo.style.display = 'none';
|
||||
logo.parentNode.insertBefore(span, logo.nextSibling);
|
||||
}));
|
||||
}
|
||||
apply();
|
||||
new MutationObserver(apply)
|
||||
.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Intercepts clicks on /reels/ links when no session is active and redirects
|
||||
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
|
||||
///
|
||||
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
|
||||
static const String _strictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
|
||||
/// via `FocusGramPathChannel` so reels can be blocked on SPA navigation.
|
||||
static const String _trackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.FocusGramPathChannel) window.FocusGramPathChannel.postMessage(p);
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Injects a persistent `style` element and keeps it alive across SPA route
|
||||
/// changes by watching for it being removed from `head`.
|
||||
static String _buildMutationObserver(String cssContent) =>
|
||||
'''
|
||||
(function fgApplyStyles() {
|
||||
@@ -40,6 +264,9 @@ class InjectionController {
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
// ── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Returns JS that navigates to [path] only when not already on it.
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
@@ -48,57 +275,526 @@ class InjectionController {
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Session state ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Writes the current session-active flag into the WebView global scope.
|
||||
/// All injected scripts (Ghost Mode, scroll lock) read this flag.
|
||||
static String buildSessionStateJS(bool active) =>
|
||||
'window.__focusgramSessionActive = $active;';
|
||||
|
||||
// ── Ghost Mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns all URL keywords that should be blocked for the given feature flags.
|
||||
///
|
||||
/// Exposed as a separate method so unit tests can verify keyword selection
|
||||
/// independently of the full JS string.
|
||||
static List<String> resolveBlockedKeywords({
|
||||
required bool typingIndicator,
|
||||
required bool seenStatus,
|
||||
required bool stories,
|
||||
required bool dmPhotos,
|
||||
}) {
|
||||
final out = <String>[];
|
||||
if (seenStatus) out.addAll(_ghostGroups['seen']!);
|
||||
if (typingIndicator) out.addAll(_ghostGroups['typing']!);
|
||||
if (stories) out.addAll(_ghostGroups['live']!);
|
||||
if (dmPhotos) out.addAll(_ghostGroups['dmPhotos']!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Returns all WebSocket text-frame keywords to drop for the given flags.
|
||||
static List<String> resolveWsBlockedKeywords({
|
||||
required bool typingIndicator,
|
||||
}) {
|
||||
if (!typingIndicator) return const [];
|
||||
return List.unmodifiable(_ghostGroups['typing']!);
|
||||
}
|
||||
|
||||
/// Builds JavaScript that intercepts fetch, XHR, WebSocket, and sendBeacon
|
||||
/// traffic to suppress ALL activity receipts (seen, typing, live, DM photos).
|
||||
///
|
||||
/// All blocked requests return `{"status":"ok"}` with HTTP 200 so Instagram
|
||||
/// does not retry or display an error.
|
||||
///
|
||||
/// See [resolveBlockedKeywords] for the URL-keyword logic.
|
||||
static String buildGhostModeJS({
|
||||
required bool typingIndicator,
|
||||
required bool seenStatus,
|
||||
required bool stories,
|
||||
required bool dmPhotos,
|
||||
}) {
|
||||
if (!typingIndicator && !seenStatus && !stories && !dmPhotos) return '';
|
||||
|
||||
final blocked = resolveBlockedKeywords(
|
||||
typingIndicator: typingIndicator,
|
||||
seenStatus: seenStatus,
|
||||
stories: stories,
|
||||
dmPhotos: dmPhotos,
|
||||
);
|
||||
final wsBlocked = resolveWsBlockedKeywords(
|
||||
typingIndicator: typingIndicator,
|
||||
);
|
||||
|
||||
final urlsJson = blocked.map((u) => '"$u"').join(', ');
|
||||
final wsJson = wsBlocked.map((u) => '"$u"').join(', ');
|
||||
|
||||
return '''
|
||||
(function fgGhostMode() {
|
||||
if (window.__fgGhostModeDone) return;
|
||||
window.__fgGhostModeDone = true;
|
||||
|
||||
// URL substrings — any request whose URL contains one of these is silenced.
|
||||
const BLOCKED = [$urlsJson];
|
||||
// WebSocket text-frame keywords to drop (MQTT typing/presence).
|
||||
const WS_KEYS = [$wsJson];
|
||||
|
||||
function shouldBlock(url) {
|
||||
return typeof url === 'string' && BLOCKED.some(k => url.includes(k));
|
||||
}
|
||||
|
||||
function isDmVideoLocked(url) {
|
||||
if (typeof url !== 'string') return false;
|
||||
if (!url.includes('.mp4') && !url.includes('/v/t') && !url.includes('cdninstagram') && !url.includes('.dash')) return false;
|
||||
return window.__fgDmReelAlreadyLoaded === true;
|
||||
}
|
||||
|
||||
// ── fetch ──────────────────────────────────────────────────────────────
|
||||
const _oFetch = window.__fgOrigFetch || window.fetch;
|
||||
window.__fgOrigFetch = _oFetch;
|
||||
window.__fgGhostFetch = function(resource, init) {
|
||||
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
|
||||
// Ghost mode: block seen/typing receipts
|
||||
if (shouldBlock(url))
|
||||
return Promise.resolve(new Response('{"status":"ok"}',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
// DM isolation: block additional video segments after first reel loaded
|
||||
if (isDmVideoLocked(url))
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
return _oFetch.apply(this, arguments);
|
||||
};
|
||||
window.fetch = window.__fgGhostFetch;
|
||||
|
||||
// ── sendBeacon ─────────────────────────────────────────────────────────
|
||||
if (navigator.sendBeacon && !window.__fgBeaconPatched) {
|
||||
window.__fgBeaconPatched = true;
|
||||
const _oBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
if (shouldBlock(url)) return true;
|
||||
return _oBeacon(url, data);
|
||||
};
|
||||
}
|
||||
|
||||
// ── XHR ────────────────────────────────────────────────────────────────
|
||||
const _oOpen = window.__fgOrigXhrOpen || XMLHttpRequest.prototype.open;
|
||||
const _oSend = window.__fgOrigXhrSend || XMLHttpRequest.prototype.send;
|
||||
window.__fgOrigXhrOpen = _oOpen;
|
||||
window.__fgOrigXhrSend = _oSend;
|
||||
XMLHttpRequest.prototype.open = function(m, url) {
|
||||
this._fgUrl = url;
|
||||
this._fgBlock = shouldBlock(url);
|
||||
return _oOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
if (this._fgBlock) {
|
||||
Object.defineProperty(this, 'readyState', { get: () => 4, configurable: true });
|
||||
Object.defineProperty(this, 'status', { get: () => 200, configurable: true });
|
||||
Object.defineProperty(this, 'responseText', { get: () => '{"status":"ok"}', configurable: true });
|
||||
Object.defineProperty(this, 'response', { get: () => '{"status":"ok"}', configurable: true });
|
||||
setTimeout(() => {
|
||||
try { if (this.onreadystatechange) this.onreadystatechange(); } catch(_) {}
|
||||
try { if (this.onload) this.onload(); } catch(_) {}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// DM isolation: block additional video XHR fetches after first reel loaded
|
||||
if (this._fgUrl && isDmVideoLocked(this._fgUrl)) {
|
||||
setTimeout(() => { try { this.onload?.(); } catch(_) {} }, 0);
|
||||
return;
|
||||
}
|
||||
return _oSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── WebSocket — block text AND binary frames ───────────────────────────
|
||||
if (!window.__fgWsGhostDone) {
|
||||
window.__fgWsGhostDone = true;
|
||||
const _OWS = window.WebSocket;
|
||||
const ALL_SEEN = [$urlsJson];
|
||||
function containsKeyword(data) {
|
||||
if (typeof data === 'string') return ALL_SEEN.some(k => data.includes(k));
|
||||
try {
|
||||
let bytes;
|
||||
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
||||
else if (data instanceof Uint8Array) bytes = data;
|
||||
else return false;
|
||||
const text = String.fromCharCode.apply(null, bytes);
|
||||
return ALL_SEEN.some(k => text.includes(k));
|
||||
} catch(_) { return false; }
|
||||
}
|
||||
function FgWS(url, proto) {
|
||||
const ws = proto != null ? new _OWS(url, proto) : new _OWS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (containsKeyword(data)) return;
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
FgWS.prototype = _OWS.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k => FgWS[k] = _OWS[k]);
|
||||
window.WebSocket = FgWS;
|
||||
}
|
||||
|
||||
// Reapply every 3 s in case Instagram replaces window.fetch
|
||||
if (!window.__fgGhostReapplyInterval) {
|
||||
window.__fgGhostReapplyInterval = setInterval(() => {
|
||||
if (window.fetch !== window.__fgGhostFetch && window.__fgOrigFetch)
|
||||
window.fetch = window.__fgGhostFetch;
|
||||
}, 3000);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
// ── Theme Detector ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
|
||||
static const String _themeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
|
||||
function getTheme() {
|
||||
try {
|
||||
// 1. Check Instagram's specific classes
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
|
||||
// 2. Check body background color
|
||||
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'; // Fallback
|
||||
}
|
||||
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.FocusGramThemeChannel) {
|
||||
window.FocusGramThemeChannel.postMessage(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Reel scroll lock ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Prevents swipe-to-next-reel in the isolated DM reel player.
|
||||
///
|
||||
/// Lock is active when:
|
||||
/// `window.__focusgramIsolatedPlayer === true` (DM overlay)
|
||||
/// OR `window.__focusgramSessionActive === false` (no session)
|
||||
///
|
||||
/// Allow-list (these are never blocked):
|
||||
/// • buttons, anchors, [role=button], aria elements
|
||||
/// • dialogs, menus, modals, sheets (comment box, emoji picker, share sheet)
|
||||
/// • keyboard input inside comment / text fields
|
||||
/// Prevents swipe-to-next-reel in the isolated DM reel player.
|
||||
///
|
||||
/// Uses a document-level capture-phase touchmove listener so it fires BEFORE
|
||||
/// Instagram's scroll container can steal the gesture. The lock is active when
|
||||
/// `window.__focusgramIsolatedPlayer === true` (single reel from DM),
|
||||
/// OR `window.__focusgramSessionActive === false` (reels feed, no session).
|
||||
///
|
||||
/// The isolated player flag is also maintained here from the path tracker
|
||||
/// so it works for SPA navigations that don't trigger onPageFinished.
|
||||
static const String reelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const ALLOW_SEL = 'button,a,[role="button"],[aria-label],[aria-haspopup],input,textarea,span,h1,h2,h3';
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function isLocked() {
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
return window.__focusgramIsolatedPlayer === true ||
|
||||
window.__focusgramSessionActive === false ||
|
||||
isDmReel;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
// Allow vertical swipe if in a session and not on a DM/isolated path
|
||||
if (window.__focusgramSessionActive === true && !window.location.pathname.includes('/direct/')) return;
|
||||
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
|
||||
// Mark the first DM reel as loaded on first swipe attempt
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
// Give the first reel 3.5 s to buffer before activating the DM lock
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) {
|
||||
clearTimeout(window.__fgDmReelTimer);
|
||||
window.__fgDmReelTimer = null;
|
||||
}
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer =
|
||||
p.includes('/reel/') && !p.startsWith('/reels/');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Badge Monitor ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
|
||||
/// and Notifications icons, as well as the page title. Sends an event to
|
||||
/// Flutter whenever a new notification is detected.
|
||||
static const String _badgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
|
||||
let lastDmCount = 0;
|
||||
let lastNotifCount = 0;
|
||||
let lastTitleUnread = 0;
|
||||
|
||||
function check() {
|
||||
try {
|
||||
// 1. Check Title for (N) indicator
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
|
||||
// 2. Scan for DM unread badge
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', // New red dot sibling
|
||||
'a[href*="/direct/inbox/"] ._a9-v', // Modern common red badge class
|
||||
].join(','));
|
||||
const currentDmCount = dmBadge ? (parseInt(dmBadge.innerText) || 1) : 0;
|
||||
|
||||
// 3. Scan for Notifications unread badge
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [style*="255, 48, 64"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]'
|
||||
].join(','));
|
||||
const currentNotifCount = notifBadge ? (parseInt(notifBadge.innerText) || 1) : 0;
|
||||
|
||||
if (currentDmCount > lastDmCount) {
|
||||
window.FocusGramNotificationChannel?.postMessage('DM');
|
||||
} else if (currentNotifCount > lastNotifCount) {
|
||||
window.FocusGramNotificationChannel?.postMessage('Activity');
|
||||
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
|
||||
window.FocusGramNotificationChannel?.postMessage('Activity');
|
||||
}
|
||||
|
||||
lastDmCount = currentDmCount;
|
||||
lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
// Initial check after some delay to let page settle
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 3000);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Notification bridge ──────────────────────────────────────────────────────
|
||||
|
||||
/// Forwards Web Notification events to the native Flutter channel.
|
||||
static String get notificationBridgeJS => '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
if (window.FocusGramNotificationChannel)
|
||||
window.FocusGramNotificationChannel
|
||||
.postMessage(title + (opts && opts.body ? ': ' + opts.body : ''));
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Link sanitization ────────────────────────────────────────────────────────
|
||||
|
||||
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
|
||||
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
|
||||
/// channel instead.
|
||||
static const String linkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.FocusGramShareChannel && u) {
|
||||
window.FocusGramShareChannel.postMessage(
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Main injection builder ───────────────────────────────────────────────────
|
||||
|
||||
/// Builds the complete JS payload for a page load or session-state change.
|
||||
///
|
||||
/// Injection order matters (later scripts can depend on earlier ones):
|
||||
/// 1. Session flag — other scripts read `__focusgramSessionActive`
|
||||
/// 2. Path tracker — writes `body[path]` for CSS page targeting
|
||||
/// 3. CSS observer — keeps `<style>` alive across SPA navigations
|
||||
/// 4. Banner dismiss — removes "Open in App" nag
|
||||
/// 5. Branding — replaces Instagram logo with FocusGram
|
||||
/// 6. Reels JS blocker — click-interceptor (only when no session)
|
||||
/// 7. Ghost Mode — network interceptors (fetch / XHR / WS)
|
||||
/// 8. Link sanitizer — tracking param stripping
|
||||
static String buildInjectionJS({
|
||||
required bool sessionActive,
|
||||
required bool blurExplore,
|
||||
required bool blurReels,
|
||||
required bool tapToUnblur,
|
||||
required bool ghostTyping,
|
||||
required bool ghostSeen,
|
||||
required bool ghostStories,
|
||||
required bool ghostDmPhotos,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts,
|
||||
required bool hideSponsoredPosts,
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
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);
|
||||
|
||||
final css = StringBuffer()..writeln(_globalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(_disableSelectionCSS);
|
||||
if (!sessionActive) {
|
||||
// Hide reel feed content when no session active
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
css.writeln(_hideReelsFeedContentCSS);
|
||||
if (blurReels) css.writeln(_blurReelsCSS);
|
||||
}
|
||||
// blurExplore now also blurs home-feed posts ("Blur Posts and Explore")
|
||||
if (blurExplore) css.writeln(_blurHomeFeedAndExploreCSS);
|
||||
|
||||
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);
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
final ghost = buildGhostModeJS(
|
||||
typingIndicator: ghostTyping,
|
||||
seenStatus: ghostSeen,
|
||||
stories: ghostStories,
|
||||
dmPhotos: ghostDmPhotos,
|
||||
);
|
||||
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
||||
window.__fgTapToUnblur = $tapToUnblur;
|
||||
${scripts.kTrackPathJS}
|
||||
$_trackPathJS
|
||||
${_buildMutationObserver(css.toString())}
|
||||
${scripts.kDismissAppBannerJS}
|
||||
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
||||
${scripts.kReelsMutationObserverJS}
|
||||
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
|
||||
${scripts.kLinkSanitizationJS}
|
||||
${scripts.kThemeDetectorJS}
|
||||
${scripts.kBadgeMonitorJS}
|
||||
$_dismissAppBannerJS
|
||||
$_brandingJS
|
||||
${!sessionActive ? _strictReelsBlockJS : ''}
|
||||
$reelsMutationObserverJS
|
||||
$ghost
|
||||
$linkSanitizationJS
|
||||
$_themeDetectorJS
|
||||
$_badgeMonitorJS
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
import '../scripts/content_disabling.dart' as content_disabling;
|
||||
import '../scripts/video_downloader.dart' as video_downloader;
|
||||
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// WARNING: Do not add any network interception logic ("ghost mode") here.
|
||||
// All scripts in this file must be limited to UI behaviour, navigation helpers,
|
||||
// and local-only features that do not modify data sent to Meta's servers.
|
||||
|
||||
// ── CSS payloads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
const String kGlobalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
const String kBlurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native (only when disabled).
|
||||
const String kDisableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
const String kHideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const String kDismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kStrictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kTrackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('UrlChange', p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kThemeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
function getTheme() {
|
||||
try {
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (rgb && rgb.length >= 3) {
|
||||
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch(_) {}
|
||||
return 'dark';
|
||||
}
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kReelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
if (window.__fgDisableReelsEntirely === true) return 'disabled';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLocked() { return lockMode() !== null; }
|
||||
|
||||
function allowInteractionTarget(t) {
|
||||
if (!t || !t.closest) return false;
|
||||
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
|
||||
if (t.closest(MODAL_SEL)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
let __fgOrigHtmlOverflow = null;
|
||||
let __fgOrigBodyOverflow = null;
|
||||
|
||||
function applyOverflowLock() {
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
}
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (document.body) document.body.style.overflow = 'hidden';
|
||||
} else if (__fgOrigHtmlOverflow !== null) {
|
||||
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
|
||||
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
|
||||
__fgOrigHtmlOverflow = null;
|
||||
__fgOrigBodyOverflow = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
applyOverflowLock();
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) window.__fgDmReelAlreadyLoaded = true;
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; }
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
applyOverflowLock();
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kBadgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
const startedAt = Date.now();
|
||||
let initialised = false;
|
||||
let lastDmCount = 0, lastNotifCount = 0, lastTitleUnread = 0;
|
||||
|
||||
function parseBadgeCount(el) {
|
||||
if (!el) return 0;
|
||||
try {
|
||||
const raw = (el.innerText || el.textContent || '').trim();
|
||||
const n = parseInt(raw, 10);
|
||||
return isNaN(n) ? 1 : n;
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
function check() {
|
||||
try {
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'a[href*="/direct/inbox/"] ._a9-v',
|
||||
].join(','));
|
||||
const currentDmCount = parseBadgeCount(dmBadge);
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]',
|
||||
].join(','));
|
||||
const currentNotifCount = parseBadgeCount(notifBadge);
|
||||
|
||||
if (!initialised) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; initialised = true; return;
|
||||
}
|
||||
if (Date.now() - startedAt < 6000) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; return;
|
||||
}
|
||||
if (currentDmCount > lastDmCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
|
||||
} else if (currentNotifCount > lastNotifCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
|
||||
}
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 1000);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kNotificationBridgeJS = '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const startedAt = Date.now();
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
if (Date.now() - startedAt < 6000) return new _N(title, opts);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramNotificationChannel',
|
||||
title + (opts && opts.body ? ': ' + opts.body : ''),
|
||||
);
|
||||
}
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kLinkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.flutter_inappwebview && u) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramShareChannel',
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }),
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
/// Runs all post-load JavaScript injections based on current settings.
|
||||
Future<void> runAllPostLoadInjections(String url) async {
|
||||
if (_settingsService == null) return;
|
||||
|
||||
final settings = _settingsService!;
|
||||
final sessionActive = sessionManager.isSessionActive;
|
||||
|
||||
// Get settings values
|
||||
// Minimal mode controls all blocking - when enabled, it forces blur and disables
|
||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||
final tapToUnblur = settings.tapToUnblur;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
|
||||
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
|
||||
final hideSuggestedPosts = false;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
// 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 injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
blurExplore: blurExplore,
|
||||
blurReels: false, // Blur reels feature removed
|
||||
tapToUnblur: blurExplore && tapToUnblur,
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
blockHomeFeedScroll: blockHomeFeedScroll,
|
||||
);
|
||||
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (settings.isGrayscaleActiveNow) {
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: grayscale.kGrayscaleOffJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: ui_hider.kHideLikeCountsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Stories hiding functionality removed per user request
|
||||
// No stories overlay injection needed
|
||||
|
||||
// Inject video downloader UI when enabled
|
||||
if (settings.videoDownloadEnabled) {
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: video_downloader.kVideoDownloadJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
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,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class NotificationService {
|
||||
@@ -8,22 +9,18 @@ class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> init({bool requestPermissions = false}) async {
|
||||
Future<void> init() async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
const DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: requestPermissions,
|
||||
requestBadgePermission: requestPermissions,
|
||||
requestSoundPermission: requestPermissions,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
final InitializationSettings initializationSettings =
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsIOS,
|
||||
@@ -35,39 +32,6 @@ class NotificationService {
|
||||
// Handle notification tap
|
||||
},
|
||||
);
|
||||
|
||||
if (requestPermissions) {
|
||||
await requestPermissionsNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissionsNow() async {
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
|
||||
Future<void> _requestIOSPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
} catch (e) {
|
||||
// debugPrint('iOS permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestAndroidPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
} catch (e) {
|
||||
// debugPrint('Android permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNotification({
|
||||
@@ -104,69 +68,7 @@ class NotificationService {
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
// debugPrint('Notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a persistent (ongoing) notification that cannot be dismissed by the user
|
||||
Future<void> showPersistentNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails =
|
||||
AndroidNotificationDetails(
|
||||
'focusgram_persistent_channel',
|
||||
'FocusGram Persistent',
|
||||
channelDescription: 'Persistent notification while using FocusGram',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
showWhen: true,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentBadge: false,
|
||||
presentSound: false,
|
||||
);
|
||||
|
||||
const NotificationDetails platformDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
try {
|
||||
await _notificationsPlugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: platformDetails,
|
||||
);
|
||||
} catch (e) {
|
||||
// debugPrint('Persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a persistent notification
|
||||
Future<void> cancelPersistentNotification({required int id}) async {
|
||||
try {
|
||||
await _notificationsPlugin.cancel(id: id);
|
||||
} catch (e) {
|
||||
// debugPrint('Cancel persistent notification error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels all notifications
|
||||
Future<void> cancelAllNotifications() async {
|
||||
try {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
} catch (e) {
|
||||
// debugPrint('Cancel all notifications error: $e');
|
||||
debugPrint('Notification error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user