mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-04-03 10:01:18 +02:00
RELEASE: moved from beta to First stable release.
Check CHANGELOG.md for full changelog
This commit is contained in:
36
.fdroid.yml
Normal file
36
.fdroid.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
Categories:
|
||||
- System
|
||||
- Productivity
|
||||
License: AGPL-3.0-only
|
||||
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||
|
||||
Summary: A wellness-focused Instagram web wrapper
|
||||
|
||||
Description: |-
|
||||
FocusGram is a digital wellness tool that loads instagram.com inside a secure Android WebView. It is designed to help you stay focused by filtering out distractions and enforcing session limits.
|
||||
|
||||
Key Features:
|
||||
- Client-side content filtering (blur explore, hide reels)
|
||||
- Customizable session timers and daily limits
|
||||
- Mindfulness breath gate before opening
|
||||
- No proprietary SDKs, analytics, or tracking
|
||||
- Fully FOSS and privacy-respecting
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/Ujwal223/FocusGram
|
||||
|
||||
Builds:
|
||||
- versionName: 1.0.0
|
||||
versionCode: 3
|
||||
commit: main
|
||||
subdir: .
|
||||
gradle:
|
||||
- yes
|
||||
scanned:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version v%v
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.0.0
|
||||
CurrentVersionCode: 3
|
||||
54
.github/workflows/codeql.yml
vendored
Normal file
54
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '15 14 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Build Android (for CodeQL)
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,6 +12,8 @@
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
PRD.md
|
||||
.agents/
|
||||
TODO.md
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
@@ -25,6 +27,7 @@ PRD.md
|
||||
#.vscode/
|
||||
RELEASE_GUIDE.md
|
||||
android/key.properties
|
||||
android/fdroid-config.properties
|
||||
android/app/*.jks
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
@@ -47,3 +50,5 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
.flutter/
|
||||
|
||||
0
.gitmodules
vendored
0
.gitmodules
vendored
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## FocusGram 1.0.0
|
||||
|
||||
First stable release.
|
||||
|
||||
### What's new
|
||||
- Minimal Mode — Feed and DMs only, everything else gone
|
||||
- Disable Reels / Disable Explore toggles
|
||||
- Autoplay blocker
|
||||
- Screen Time Dashboard with 7-day chart
|
||||
- Grayscale Mode with optional daily schedule
|
||||
- Removed the Browser Like Feel
|
||||
- Moved from webview_flutter to flutter_inappwebview
|
||||
- Changed UA
|
||||
|
||||
### Bug fixes
|
||||
- Message input bar no longer hidden behind keyboard in DMs
|
||||
- Fixed a bug where sending message was not possible.
|
||||
- Reels scrolling is now smooth
|
||||
- Perfomance Optimizations
|
||||
153
README.md
153
README.md
@@ -1,87 +1,164 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/images/focusgram.png" alt="FocusGram" width="96" height="96" />
|
||||
|
||||
# FocusGram
|
||||
|
||||
<<<<<<< Updated upstream
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
=======
|
||||
**Use social media on your terms.**
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
**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.
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
[](https://f-droid.org)
|
||||
|
||||
[🌟 Star on GitHub](https://github.com/Ujwal223/FocusGram) | [📥 Download Latest APK](https://github.com/Ujwal223/FocusGram/releases)
|
||||
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Why FocusGram?
|
||||
Most people don't want to quit Instagram. They want to check their messages, post a story, and leave — without losing an hour to Reels they never meant to watch.
|
||||
|
||||
Most people don't want to 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 an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use.
|
||||
|
||||
<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" />
|
||||
|
||||
|
||||
### 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.
|
||||
<img src="https://github.com/user-attachments/assets/a2da0c58-b7a1-4ac4-a5a7-0e30b751a111" alt="FocusGram screenshots" width="100%" />
|
||||
|
||||
---
|
||||
|
||||
## Master Your Usage
|
||||
## What it does
|
||||
|
||||
FocusGram doesn't just block Reels—it gives you tools to build better habits:
|
||||
**Focus tools**
|
||||
|
||||
- ✅ **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.
|
||||
- Block Reels entirely, or allow them in timed sessions (1–15 min) with daily limits and cooldowns
|
||||
- Autoplay blocker — videos don't play until you tap them
|
||||
- Minimal Mode — strips everything down to Feed and DMs
|
||||
|
||||
**Content filtering**
|
||||
|
||||
- Hide the Explore tab, Reels tab, or Shop tab individually
|
||||
- Disable Explore and suggested content entirely
|
||||
- Disable Reels Entirely
|
||||
|
||||
**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
|
||||
|
||||
**The app itself**
|
||||
|
||||
- Feels (almost) like a native app, not a browser.
|
||||
- No blank loading screen — content loads in the background before you get there
|
||||
- Instant updates via pull-to-refresh
|
||||
- Dark mode follows your system
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 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.
|
||||
### 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
|
||||
|
||||
### 2. From F-Droid (Soon)
|
||||
We are currently in the process of submitting FocusGram to the F-Droid store for easier updates.
|
||||
### F-Droid
|
||||
Submission is in progress. Updates will publish automatically once accepted.
|
||||
|
||||
---
|
||||
|
||||
## Frequently Asked Questions
|
||||
## Privacy
|
||||
|
||||
**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.
|
||||
FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView — your login goes directly to Meta's servers, the same as any mobile browser.
|
||||
|
||||
- No analytics
|
||||
- No crash reporting
|
||||
- No third-party SDKs
|
||||
- No data leaves your device
|
||||
- All settings and history are stored locally using Android's standard storage APIs
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Will this get my account banned?**
|
||||
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics.
|
||||
|
||||
**Is this a mod of Instagram's app?**
|
||||
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
|
||||
|
||||
**Why is it free?**
|
||||
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.
|
||||
Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0.
|
||||
|
||||
---
|
||||
|
||||
## Development & Technical Details
|
||||
## Building from source
|
||||
|
||||
<details>
|
||||
<summary>View Technical Info</summary>
|
||||
<summary>Technical details and build instructions</summary>
|
||||
|
||||
### Build from Source
|
||||
### Requirements
|
||||
- Flutter stable channel (3.38+)
|
||||
- Android SDK
|
||||
|
||||
### Build
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
### Permissions
|
||||
- `INTERNET`: To load Instagram.
|
||||
- `RECEIVE_BOOT_COMPLETED`: To keep your session timers and notifications accurate after a restart.
|
||||
### 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)
|
||||
|
||||
Nothing is modified server-side. The app never reads, intercepts, or stores Instagram content beyond what is explicitly listed (Reel URL, title, and thumbnail URL for the local history feature).
|
||||
|
||||
### Permissions
|
||||
| Permission | Reason |
|
||||
|---|---|
|
||||
| `INTERNET` | Load instagram.com |
|
||||
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
|
||||
|
||||
### Stack
|
||||
| | |
|
||||
|---|---|
|
||||
| Framework | Flutter (Dart) |
|
||||
| WebView | flutter_inappwebview (Apache 2.0) |
|
||||
| Storage | shared_preferences |
|
||||
| License | AGPL-3.0 |
|
||||
|
||||
### 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 Instagram's or Meta's private APIs
|
||||
- Intercept, read, log, or store user credentials, session data, or any 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 (C) 2025 Ujwal Chapagain
|
||||
Copyright © 2025 Ujwal Chapagain
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -44,8 +44,8 @@ android {
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = 2
|
||||
versionName = "0.9.8-beta.2"
|
||||
versionCode = 3
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -64,6 +64,12 @@ android {
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
exclude(group = "com.google.android.gms")
|
||||
exclude(group = "com.google.firebase")
|
||||
exclude(group = "com.google.android.datatransport")
|
||||
exclude(group = "com.google.android.play")
|
||||
exclude(group = "com.google.android.play", module = "core")
|
||||
exclude(group = "com.google.android.play", module = "core-common")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
android/app/proguard-rules.pro
vendored
5
android/app/proguard-rules.pro
vendored
@@ -1,3 +1,8 @@
|
||||
-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,11 +1,13 @@
|
||||
<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">
|
||||
|
||||
@@ -50,6 +52,12 @@
|
||||
</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" />
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.google.android.play.core.splitcompat;
|
||||
import android.app.Application;
|
||||
public class SplitCompatApplication extends Application {}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
public class SplitInstallException extends Exception {
|
||||
public int getErrorCode() { return 0; }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
public interface SplitInstallManager {}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
import android.content.Context;
|
||||
public class SplitInstallManagerFactory {
|
||||
public static SplitInstallManager create(Context context) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.google.android.play.core.splitinstall;
|
||||
|
||||
public interface SplitInstallStateUpdatedListener {
|
||||
void onStateUpdate(SplitInstallSessionState state);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
public interface OnFailureListener {
|
||||
void onFailure(Exception e);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
public interface OnSuccessListener<TResult> {
|
||||
void onSuccess(TResult result);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.google.android.play.core.tasks;
|
||||
|
||||
public abstract class Task<TResult> {
|
||||
public abstract boolean isSuccessful();
|
||||
public abstract TResult getResult();
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
1
fastlane/metadata/android/en-US/changelogs/2.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/2.txt
Normal file
@@ -0,0 +1 @@
|
||||
Same as1st version. just version pump
|
||||
7
fastlane/metadata/android/en-US/changelogs/3.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/3.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
New: Reels History, Minimal Mode, Disable Reels/Explore toggles, Autoplay Blocker, Screen Time Dashboard, Grayscale Mode, 8 content hide toggles.
|
||||
|
||||
Fixes: DM keyboard bug, Reels scroll lag.
|
||||
|
||||
Performance: Native nav bar, background preloading, skeleton screen, haptic feedback, smooth scrolling.
|
||||
|
||||
F-Droid: Removed all Google dependencies. No Play Services in APK.
|
||||
@@ -1,9 +1,10 @@
|
||||
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.
|
||||
FocusGram is a free and open-source Android app designed to help you regain control over your social media usage. It wraps the mobile version of Instagram in a secure WebView and injects features to minimize distractions.
|
||||
|
||||
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.
|
||||
Features:
|
||||
- **Focus Mode**: Blur explore posts and hide reel buttons.
|
||||
- **Guardrails**: Set daily usage limits and session cooldowns.
|
||||
- **Mindfulness**: A mandatory breathing exercise before entering the app.
|
||||
- **Privacy First**: No analytics, no tracking, and no proprietary Google Play Services requirements.
|
||||
- **Hybrid Composition**: Optimized WebView performance for smooth scrolling.
|
||||
|
||||
FocusGram is NOT an Instagram mod. It uses the standard web version of Instagram and applies client-side CSS and JS modifications only.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Distraction-free Instagram with controlled Reel access.
|
||||
A digital wellness wrapper for Instagram.
|
||||
|
||||
532
lib/features/loading/skeleton_screen.dart
Normal file
532
lib/features/loading/skeleton_screen.dart
Normal file
@@ -0,0 +1,532 @@
|
||||
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: [
|
||||
Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
167
lib/features/native_nav/native_bottom_nav.dart
Normal file
167
lib/features/native_nav/native_bottom_nav.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
72
lib/features/preloader/instagram_preloader.dart
Normal file
72
lib/features/preloader/instagram_preloader.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../../scripts/autoplay_blocker.dart';
|
||||
import '../../scripts/spa_navigation_monitor.dart';
|
||||
import '../../scripts/native_feel.dart';
|
||||
|
||||
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; // don't start twice
|
||||
|
||||
_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([
|
||||
UserScript(
|
||||
source: 'window.__fgBlockAutoplay = true;',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kAutoplayBlockerJS,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kSpaNavigationMonitorScript,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
UserScript(
|
||||
source: kNativeFeelingScript,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
),
|
||||
]),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
252
lib/features/reels_history/reels_history_screen.dart
Normal file
252
lib/features/reels_history/reels_history_screen.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
117
lib/features/reels_history/reels_history_service.dart
Normal file
117
lib/features/reels_history/reels_history_service.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
|
||||
const ReelsHistoryEntry({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.thumbnailUrl,
|
||||
required this.visitedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'title': title,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'visitedAt': visitedAt.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}) 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,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
307
lib/features/screen_time/screen_time_screen.dart
Normal file
307
lib/features/screen_time/screen_time_screen.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lib/features/update_checker/update_banner.dart
Normal file
130
lib/features/update_checker/update_banner.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
105
lib/features/update_checker/update_checker_service.dart
Normal file
105
lib/features/update_checker/update_checker_service.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
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,16 +1,22 @@
|
||||
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 '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 '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';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -23,9 +29,13 @@ void main() async {
|
||||
|
||||
final sessionManager = SessionManager();
|
||||
final settingsService = SettingsService();
|
||||
final screenTimeService = ScreenTimeService();
|
||||
|
||||
final updateChecker = UpdateCheckerService();
|
||||
|
||||
await sessionManager.init();
|
||||
await settingsService.init();
|
||||
await screenTimeService.init();
|
||||
await NotificationService().init();
|
||||
|
||||
runApp(
|
||||
@@ -33,10 +43,15 @@ void main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: sessionManager),
|
||||
ChangeNotifierProvider.value(value: settingsService),
|
||||
ChangeNotifierProvider.value(value: screenTimeService),
|
||||
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 {
|
||||
@@ -72,7 +87,8 @@ 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. App Session Picker (always)
|
||||
/// 4. If an app session is already active, resume it
|
||||
/// otherwise show App Session Picker
|
||||
/// 5. Main WebView
|
||||
class InitialRouteHandler extends StatefulWidget {
|
||||
const InitialRouteHandler({super.key});
|
||||
@@ -133,11 +149,16 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: App session picker
|
||||
// Step 4: App session picker / resume existing session
|
||||
if (!_appSessionStarted) {
|
||||
return AppSessionPickerScreen(
|
||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||
);
|
||||
if (sm.isAppSessionActive) {
|
||||
// User already has an active app session — don't ask intention again.
|
||||
_appSessionStarted = true;
|
||||
} else {
|
||||
return AppSessionPickerScreen(
|
||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Main app
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,58 +18,61 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
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,
|
||||
),
|
||||
];
|
||||
// Pages: Welcome, Session Management, 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;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsService>();
|
||||
|
||||
final List<Widget> slides = [
|
||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.auto_awesome,
|
||||
color: Colors.blue,
|
||||
title: 'Welcome to FocusGram',
|
||||
description:
|
||||
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
||||
),
|
||||
|
||||
// ── Page 1: Session Management ───────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.timer,
|
||||
color: Colors.orange,
|
||||
title: 'Session Management',
|
||||
description:
|
||||
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
||||
),
|
||||
|
||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.link,
|
||||
color: Colors.cyan,
|
||||
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,
|
||||
color: Colors.green,
|
||||
title: 'Stay Notified',
|
||||
description:
|
||||
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
||||
isPermissionPage: true,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
@@ -77,9 +80,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) => setState(() => _currentPage = index),
|
||||
itemCount: _pages.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_OnboardingSlide(data: _pages[index]),
|
||||
itemCount: _kTotalPages,
|
||||
itemBuilder: (context, index) => slides[index],
|
||||
),
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
@@ -87,11 +89,13 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
// Dot indicators
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
_pages.length,
|
||||
(index) => Container(
|
||||
_kTotalPages,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: _currentPage == index ? 12 : 8,
|
||||
height: 8,
|
||||
@@ -105,6 +109,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// CTA button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: SizedBox(
|
||||
@@ -112,24 +117,38 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
height: 56,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final data = _pages[_currentPage];
|
||||
final isLast = _currentPage == _kTotalPages - 1;
|
||||
final isLink = _currentPage == _kLinkPage;
|
||||
final isNotif = _currentPage == _kNotifPage;
|
||||
final isBlur = _currentPage == _kBlurPage;
|
||||
|
||||
String label;
|
||||
if (isLast) {
|
||||
label = 'Get Started';
|
||||
} else if (isLink) {
|
||||
label = 'Configure';
|
||||
} else if (isNotif) {
|
||||
label = 'Allow Notifications';
|
||||
} else if (isBlur) {
|
||||
label = 'Save & Continue';
|
||||
} else {
|
||||
label = 'Next';
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (data.isAppSettingsPage) {
|
||||
if (isLink) {
|
||||
await AppSettings.openAppSettings(
|
||||
type: AppSettingsType.settings,
|
||||
);
|
||||
} else if (data.isPermissionPage) {
|
||||
if (data.permission != null) {
|
||||
await data.permission!.request();
|
||||
}
|
||||
if (data.title == 'Stay Notified') {
|
||||
await NotificationService().init();
|
||||
}
|
||||
} else if (isNotif) {
|
||||
await Permission.notification.request();
|
||||
await NotificationService().init();
|
||||
}
|
||||
|
||||
if (_currentPage == _pages.length - 1) {
|
||||
_finish();
|
||||
if (!context.mounted) return;
|
||||
if (isLast) {
|
||||
_finish(context);
|
||||
} else {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -145,11 +164,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_currentPage == _pages.length - 1
|
||||
? 'Get Started'
|
||||
: (data.isAppSettingsPage
|
||||
? 'Configure'
|
||||
: 'Next'),
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -160,6 +175,15 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip button (available on all pages except last)
|
||||
if (_currentPage < _kTotalPages - 1)
|
||||
TextButton(
|
||||
onPressed: () => _finish(context),
|
||||
child: const Text(
|
||||
'Skip',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -168,48 +192,44 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _finish() {
|
||||
void _finish(BuildContext context) {
|
||||
context.read<SettingsService>().setFirstRunCompleted();
|
||||
widget.onFinish();
|
||||
}
|
||||
}
|
||||
|
||||
class OnboardingData {
|
||||
final String title;
|
||||
final String description;
|
||||
// ── Static info slide ──────────────────────────────────────────────────────────
|
||||
|
||||
class _StaticSlide extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String title;
|
||||
final String description;
|
||||
final bool isPermissionPage;
|
||||
final bool isAppSettingsPage;
|
||||
final Permission? permission;
|
||||
|
||||
OnboardingData({
|
||||
required this.title,
|
||||
required this.description,
|
||||
const _StaticSlide({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.title,
|
||||
required this.description,
|
||||
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.all(40),
|
||||
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(data.icon, size: 120, color: data.color),
|
||||
Icon(icon, size: 120, color: color),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
data.title,
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -219,7 +239,7 @@ class _OnboardingSlide extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
data.description,
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
@@ -232,3 +252,147 @@ class _OnboardingSlide extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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:webview_flutter/webview_flutter.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import '../services/injection_controller.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -15,58 +15,12 @@ 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
|
||||
@@ -114,7 +68,65 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
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,
|
||||
enableTextSelection: true,
|
||||
hideSuggestedPosts: false,
|
||||
hideSponsoredPosts: false,
|
||||
hideLikeCounts: false,
|
||||
hideFollowerCounts: false,
|
||||
hideStoriesBar: false,
|
||||
hideExploreTab: false,
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: 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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
113
lib/scripts/autoplay_blocker.dart
Normal file
113
lib/scripts/autoplay_blocker.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
/// JavaScript to block autoplaying videos on Instagram while still allowing
|
||||
/// explicit user-initiated playback.
|
||||
///
|
||||
/// This script:
|
||||
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
|
||||
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
|
||||
/// - Uses a short-lived per-element flag set by user clicks to allow play().
|
||||
/// - Strips the autoplay attribute from dynamically added <video> elements.
|
||||
const String kAutoplayBlockerJS = r'''
|
||||
(function fgAutoplayBlocker() {
|
||||
if (window.__fgAutoplayPatched) return;
|
||||
window.__fgAutoplayPatched = true;
|
||||
|
||||
// Toggleable at runtime from Flutter:
|
||||
// window.__fgBlockAutoplay = true/false
|
||||
if (typeof window.__fgBlockAutoplay === 'undefined') {
|
||||
window.__fgBlockAutoplay = true;
|
||||
}
|
||||
|
||||
const ALLOW_KEY = '__fgAllowPlayUntil';
|
||||
const ALLOW_WINDOW_MS = 1000;
|
||||
|
||||
function markAllow(video) {
|
||||
try {
|
||||
video[ALLOW_KEY] = Date.now() + ALLOW_WINDOW_MS;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function shouldAllow(video) {
|
||||
try {
|
||||
const until = video[ALLOW_KEY] || 0;
|
||||
return Date.now() <= until;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stripAutoplay(root) {
|
||||
try {
|
||||
if (window.__fgBlockAutoplay !== true) return;
|
||||
const all = root.querySelectorAll
|
||||
? root.querySelectorAll('video')
|
||||
: (root.tagName === 'VIDEO' ? [root] : []);
|
||||
all.forEach(v => {
|
||||
v.removeAttribute('autoplay');
|
||||
try { v.autoplay = false; } catch (_) {}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Initial pass
|
||||
try {
|
||||
document.querySelectorAll('video').forEach(v => stripAutoplay(v));
|
||||
} catch (_) {}
|
||||
|
||||
// MutationObserver for dynamically added videos
|
||||
try {
|
||||
const mo = new MutationObserver(ms => {
|
||||
if (window.__fgBlockAutoplay !== true) return;
|
||||
ms.forEach(m => {
|
||||
m.addedNodes.forEach(node => {
|
||||
if (!node || node.nodeType !== 1) return;
|
||||
if (node.tagName === 'VIDEO') {
|
||||
stripAutoplay(node);
|
||||
} else {
|
||||
stripAutoplay(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||
} catch (_) {}
|
||||
|
||||
// Allow play() shortly after a direct user click on a video.
|
||||
document.addEventListener('click', function(e) {
|
||||
try {
|
||||
const video = e.target && e.target.closest && e.target.closest('video');
|
||||
if (!video) return;
|
||||
markAllow(video);
|
||||
try { video.play(); } catch (_) {}
|
||||
} catch (_) {}
|
||||
}, true);
|
||||
|
||||
// Prototype override
|
||||
try {
|
||||
const origPlay = HTMLVideoElement.prototype.play;
|
||||
if (!origPlay) return;
|
||||
if (!window.__fgOrigVideoPlay) window.__fgOrigVideoPlay = origPlay;
|
||||
|
||||
HTMLVideoElement.prototype.play = function() {
|
||||
try {
|
||||
if (window.__fgBlockAutoplay !== true) {
|
||||
return origPlay.apply(this, arguments);
|
||||
}
|
||||
if (shouldAllow(this)) {
|
||||
return origPlay.apply(this, arguments);
|
||||
}
|
||||
// Block autoplay: resolve without actually starting playback.
|
||||
return Promise.resolve();
|
||||
} catch (_) {
|
||||
// If anything goes wrong, fall back to original behaviour to avoid
|
||||
// breaking Instagram's player.
|
||||
try {
|
||||
return origPlay.apply(this, arguments);
|
||||
} catch (_) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
473
lib/scripts/content_disabling.dart
Normal file
473
lib/scripts/content_disabling.dart
Normal file
@@ -0,0 +1,473 @@
|
||||
// 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 }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
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 {
|
||||
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 _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 });
|
||||
})();
|
||||
''';
|
||||
472
lib/scripts/core_injection.dart
Normal file
472
lib/scripts/core_injection.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
''';
|
||||
|
||||
// ── 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() {
|
||||
// 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) {
|
||||
// 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);
|
||||
// Apply lock for dm_reel or disabled modes when reel is present
|
||||
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) {
|
||||
// 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);
|
||||
})();
|
||||
''';
|
||||
16
lib/scripts/dm_keyboard_fix.dart
Normal file
16
lib/scripts/dm_keyboard_fix.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
/// 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 (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
48
lib/scripts/grayscale.dart
Normal file
48
lib/scripts/grayscale.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
/// 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
12
lib/scripts/haptic_bridge.dart
Normal file
12
lib/scripts/haptic_bridge.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
})();
|
||||
''';
|
||||
|
||||
68
lib/scripts/native_feel.dart
Normal file
68
lib/scripts/native_feel.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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';
|
||||
})();
|
||||
''';
|
||||
118
lib/scripts/reel_metadata_extractor.dart
Normal file
118
lib/scripts/reel_metadata_extractor.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
// 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
|
||||
if (!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);
|
||||
})();
|
||||
''';
|
||||
13
lib/scripts/scroll_smoothing.dart
Normal file
13
lib/scripts/scroll_smoothing.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
32
lib/scripts/spa_navigation_monitor.dart
Normal file
32
lib/scripts/spa_navigation_monitor.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
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());
|
||||
})();
|
||||
''';
|
||||
|
||||
263
lib/scripts/ui_hider.dart
Normal file
263
lib/scripts/ui_hider.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
// 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,245 +1,21 @@
|
||||
// ============================================================================
|
||||
// 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"
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
/// Central hub for all JavaScript and CSS injected into the Instagram WebView.
|
||||
import '../scripts/core_injection.dart' as scripts;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
|
||||
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) '
|
||||
'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]';
|
||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
// ── Ghost Mode keyword groups ────────────────────────────────────────────────
|
||||
static const String reelsMutationObserverJS =
|
||||
scripts.kReelsMutationObserverJS;
|
||||
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
|
||||
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
|
||||
|
||||
/// 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() {
|
||||
@@ -264,9 +40,6 @@ class InjectionController {
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
// ── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Returns JS that navigates to [path] only when not already on it.
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
@@ -275,526 +48,59 @@ 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 ghostTyping,
|
||||
required bool ghostSeen,
|
||||
required bool ghostStories,
|
||||
required bool ghostDmPhotos,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
required bool hideStoriesBar,
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(_globalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(_disableSelectionCSS);
|
||||
if (!sessionActive) {
|
||||
css.writeln(_hideReelsFeedContentCSS);
|
||||
if (blurReels) css.writeln(_blurReelsCSS);
|
||||
}
|
||||
// blurExplore now also blurs home-feed posts ("Blur Posts and Explore")
|
||||
if (blurExplore) css.writeln(_blurHomeFeedAndExploreCSS);
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
|
||||
final ghost = buildGhostModeJS(
|
||||
typingIndicator: ghostTyping,
|
||||
seenStatus: ghostSeen,
|
||||
stories: ghostStories,
|
||||
dmPhotos: ghostDmPhotos,
|
||||
);
|
||||
if (!sessionActive) {
|
||||
// Hide reel feed content when no session active
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
}
|
||||
|
||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||
// Previously it was inside that block alongside display:none on the parent —
|
||||
// you cannot blur children of a display:none element, making it dead code.
|
||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||
|
||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
if (hideStoriesBar) css.writeln(ui_hider.kHideStoriesBarCSS);
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
$_trackPathJS
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
$_dismissAppBannerJS
|
||||
$_brandingJS
|
||||
${!sessionActive ? _strictReelsBlockJS : ''}
|
||||
$reelsMutationObserverJS
|
||||
$ghost
|
||||
$linkSanitizationJS
|
||||
$_themeDetectorJS
|
||||
$_badgeMonitorJS
|
||||
${scripts.kDismissAppBannerJS}
|
||||
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
||||
${scripts.kReelsMutationObserverJS}
|
||||
${scripts.kLinkSanitizationJS}
|
||||
${scripts.kThemeDetectorJS}
|
||||
${scripts.kBadgeMonitorJS}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
505
lib/services/injection_manager.dart
Normal file
505
lib/services/injection_manager.dart
Normal file
@@ -0,0 +1,505 @@
|
||||
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;
|
||||
|
||||
// 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 ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
final SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
});
|
||||
|
||||
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
|
||||
final blurExplore = settings.blurExplore;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
final hideSuggestedPosts = settings.hideSuggestedPosts;
|
||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
final hideExploreTab = settings.hideExploreTab;
|
||||
final hideReelsTab = settings.hideReelsTab;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
blurExplore: blurExplore,
|
||||
blurReels: false, // Blur reels feature removed
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
hideStoriesBar: false, // Story blocking removed
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide suggested posts JS when enabled
|
||||
if (hideSuggestedPosts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSuggestedPostsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,18 @@ class NotificationService {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const DarwinInitializationSettings initializationSettingsIOS =
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
);
|
||||
|
||||
const InitializationSettings initializationSettings =
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsIOS,
|
||||
@@ -32,6 +36,34 @@ class NotificationService {
|
||||
// Handle notification tap
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
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({
|
||||
|
||||
107
lib/services/screen_time_service.dart
Normal file
107
lib/services/screen_time_service.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tracks total in-app screen time per day.
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
class ScreenTimeService extends ChangeNotifier {
|
||||
static const String prefKey = 'screen_time_data';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
Map<String, int> _secondsByDate = {};
|
||||
Timer? _ticker;
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final raw = _prefs?.getString(prefKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_secondsByDate = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Prune entries older than 30 days
|
||||
final now = DateTime.now();
|
||||
final cutoff = now.subtract(const Duration(days: 30));
|
||||
_secondsByDate.removeWhere((key, value) {
|
||||
try {
|
||||
final d = DateTime.parse(key);
|
||||
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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')}';
|
||||
}
|
||||
|
||||
void startTracking() {
|
||||
if (_tracking) return;
|
||||
_tracking = true;
|
||||
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!_tracking) return;
|
||||
final key = _todayKey();
|
||||
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
|
||||
// Persist every 10 seconds to reduce writes.
|
||||
if (_secondsByDate[key]! % 10 == 0) {
|
||||
_save();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
if (!_tracking) return;
|
||||
_tracking = false;
|
||||
_save();
|
||||
}
|
||||
|
||||
Future<void> resetAll() async {
|
||||
_secondsByDate.clear();
|
||||
await _prefs?.remove(prefKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,37 @@ class SettingsService extends ChangeNotifier {
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
static const _keyIsFirstRun = 'set_is_first_run';
|
||||
|
||||
// Granular Ghost Mode keys
|
||||
static const _keyGhostTyping = 'set_ghost_typing';
|
||||
static const _keyGhostSeen = 'set_ghost_seen';
|
||||
static const _keyGhostStories = 'set_ghost_stories';
|
||||
static const _keyGhostDmPhotos = 'set_ghost_dm_photos';
|
||||
// Focus / playback
|
||||
static const _keyBlockAutoplay = 'block_autoplay';
|
||||
|
||||
// Grayscale mode
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleScheduleEnabled = 'grayscale_schedule_enabled';
|
||||
static const _keyGrayscaleScheduleTime = 'grayscale_schedule_time';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideStoriesBar = 'hide_stories_bar';
|
||||
static const _keyHideExploreTab = 'hide_explore_tab';
|
||||
static const _keyHideReelsTab = 'hide_reels_tab';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Complete section disabling / Minimal mode
|
||||
static const _keyDisableReelsEntirely = 'disable_reels_entirely';
|
||||
static const _keyDisableExploreEntirely = 'disable_explore_entirely';
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
// Reels History
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
|
||||
// Privacy keys
|
||||
static const _keySanitizeLinks = 'set_sanitize_links';
|
||||
static const _keyNotifyDMs = 'set_notify_dms';
|
||||
static const _keyNotifyActivity = 'set_notify_activity';
|
||||
|
||||
// Legacy key for migration
|
||||
static const _keyGhostModeLegacy = 'set_ghost_mode';
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
@@ -38,16 +56,32 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
// Granular Ghost Mode defaults (all on)
|
||||
bool _ghostTyping = true;
|
||||
bool _ghostSeen = true;
|
||||
bool _ghostStories = true;
|
||||
bool _ghostDmPhotos = true;
|
||||
bool _blockAutoplay = true;
|
||||
|
||||
// Privacy defaults
|
||||
bool _grayscaleEnabled = false;
|
||||
bool _grayscaleScheduleEnabled = false;
|
||||
String _grayscaleScheduleTime = '21:00'; // 9:00 PM default
|
||||
|
||||
bool _hideSuggestedPosts = false;
|
||||
bool _hideSponsoredPosts = false;
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideStoriesBar = false;
|
||||
bool _hideExploreTab = false;
|
||||
bool _hideReelsTab = false;
|
||||
bool _hideShopTab = false;
|
||||
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
// Privacy defaults - notifications OFF by default
|
||||
bool _sanitizeLinks = true;
|
||||
bool _notifyDMs = true;
|
||||
bool _notifyActivity = true;
|
||||
bool _notifyDMs = false;
|
||||
bool _notifyActivity = false;
|
||||
bool _notifySessionEnd = false;
|
||||
|
||||
List<String> _enabledTabs = [
|
||||
'Home',
|
||||
@@ -68,18 +102,49 @@ class SettingsService extends ChangeNotifier {
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
|
||||
// Granular Ghost Mode getters
|
||||
bool get ghostTyping => _ghostTyping;
|
||||
bool get ghostSeen => _ghostSeen;
|
||||
bool get ghostStories => _ghostStories;
|
||||
bool get ghostDmPhotos => _ghostDmPhotos;
|
||||
bool get blockAutoplay => _blockAutoplay;
|
||||
bool get notifyDMs => _notifyDMs;
|
||||
bool get notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
|
||||
/// True if ANY ghost mode setting is enabled (for injection logic).
|
||||
bool get anyGhostModeEnabled =>
|
||||
_ghostTyping || _ghostSeen || _ghostStories || _ghostDmPhotos;
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
bool get grayscaleScheduleEnabled => _grayscaleScheduleEnabled;
|
||||
String get grayscaleScheduleTime => _grayscaleScheduleTime;
|
||||
|
||||
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
bool get hideStoriesBar => _hideStoriesBar;
|
||||
bool get hideExploreTab => _hideExploreTab;
|
||||
bool get hideReelsTab => _hideReelsTab;
|
||||
bool get hideShopTab => _hideShopTab;
|
||||
|
||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
|
||||
/// True if grayscale should currently be applied, considering the manual
|
||||
/// toggle and the optional schedule.
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (!_grayscaleScheduleEnabled) return false;
|
||||
try {
|
||||
final parts = _grayscaleScheduleTime.split(':');
|
||||
if (parts.length != 2) return false;
|
||||
final h = int.parse(parts[0]);
|
||||
final m = int.parse(parts[1]);
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
final startMinutes = h * 60 + m;
|
||||
// Active from the configured time until midnight.
|
||||
return currentMinutes >= startMinutes;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy getters
|
||||
bool get sanitizeLinks => _sanitizeLinks;
|
||||
@@ -93,31 +158,34 @@ class SettingsService extends ChangeNotifier {
|
||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||
|
||||
// Migrate legacy ghostMode key -> all granular keys
|
||||
final legacyGhostMode = _prefs!.getBool(_keyGhostModeLegacy);
|
||||
if (legacyGhostMode != null) {
|
||||
// Seed all four granular keys with the legacy value
|
||||
_ghostTyping = legacyGhostMode;
|
||||
_ghostSeen = legacyGhostMode;
|
||||
_ghostStories = legacyGhostMode;
|
||||
_ghostDmPhotos = legacyGhostMode;
|
||||
// Save granular keys and remove legacy key
|
||||
await _prefs!.setBool(_keyGhostTyping, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostSeen, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostStories, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostDmPhotos, legacyGhostMode);
|
||||
await _prefs!.remove(_keyGhostModeLegacy);
|
||||
} else {
|
||||
_ghostTyping = _prefs!.getBool(_keyGhostTyping) ?? true;
|
||||
_ghostSeen = _prefs!.getBool(_keyGhostSeen) ?? true;
|
||||
_ghostStories = _prefs!.getBool(_keyGhostStories) ?? true;
|
||||
_ghostDmPhotos = _prefs!.getBool(_keyGhostDmPhotos) ?? true;
|
||||
}
|
||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||
_grayscaleScheduleEnabled =
|
||||
_prefs!.getBool(_keyGrayscaleScheduleEnabled) ?? false;
|
||||
_grayscaleScheduleTime =
|
||||
_prefs!.getString(_keyGrayscaleScheduleTime) ?? '21:00';
|
||||
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideStoriesBar = _prefs!.getBool(_keyHideStoriesBar) ?? false;
|
||||
_hideExploreTab = _prefs!.getBool(_keyHideExploreTab) ?? false;
|
||||
_hideReelsTab = _prefs!.getBool(_keyHideReelsTab) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
_disableReelsEntirely = _prefs!.getBool(_keyDisableReelsEntirely) ?? false;
|
||||
_disableExploreEntirely =
|
||||
_prefs!.getBool(_keyDisableExploreEntirely) ?? false;
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||
|
||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? true;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
|
||||
|
||||
_enabledTabs =
|
||||
(_prefs!.getStringList(_keyEnabledTabs) ??
|
||||
@@ -179,6 +247,102 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlockAutoplay(bool v) async {
|
||||
_blockAutoplay = v;
|
||||
await _prefs?.setBool(_keyBlockAutoplay, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleEnabled(bool v) async {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleScheduleEnabled(bool v) async {
|
||||
_grayscaleScheduleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleScheduleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleScheduleTime(String hhmm) async {
|
||||
_grayscaleScheduleTime = hhmm;
|
||||
await _prefs?.setString(_keyGrayscaleScheduleTime, hhmm);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSuggestedPosts(bool v) async {
|
||||
_hideSuggestedPosts = v;
|
||||
await _prefs?.setBool(_keyHideSuggestedPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideFollowerCounts(bool v) async {
|
||||
_hideFollowerCounts = v;
|
||||
await _prefs?.setBool(_keyHideFollowerCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideStoriesBar(bool v) async {
|
||||
_hideStoriesBar = v;
|
||||
await _prefs?.setBool(_keyHideStoriesBar, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideExploreTab(bool v) async {
|
||||
_hideExploreTab = v;
|
||||
await _prefs?.setBool(_keyHideExploreTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideReelsTab(bool v) async {
|
||||
_hideReelsTab = v;
|
||||
await _prefs?.setBool(_keyHideReelsTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setDisableReelsEntirely(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool(_keyDisableReelsEntirely, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setDisableExploreEntirely(bool v) async {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool(_keyDisableExploreEntirely, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
_minimalModeEnabled = v;
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setReelsHistoryEnabled(bool v) async {
|
||||
_reelsHistoryEnabled = v;
|
||||
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDarkMode(bool dark) {
|
||||
if (_isDarkMode != dark) {
|
||||
_isDarkMode = dark;
|
||||
@@ -186,31 +350,6 @@ class SettingsService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Granular Ghost Mode setters
|
||||
Future<void> setGhostTyping(bool v) async {
|
||||
_ghostTyping = v;
|
||||
await _prefs?.setBool(_keyGhostTyping, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostSeen(bool v) async {
|
||||
_ghostSeen = v;
|
||||
await _prefs?.setBool(_keyGhostSeen, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostStories(bool v) async {
|
||||
_ghostStories = v;
|
||||
await _prefs?.setBool(_keyGhostStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostDmPhotos(bool v) async {
|
||||
_ghostDmPhotos = v;
|
||||
await _prefs?.setBool(_keyGhostDmPhotos, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setSanitizeLinks(bool v) async {
|
||||
_sanitizeLinks = v;
|
||||
await _prefs?.setBool(_keySanitizeLinks, v);
|
||||
@@ -229,6 +368,12 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleTab(String tab) async {
|
||||
if (_enabledTabs.contains(tab)) {
|
||||
if (_enabledTabs.length > 1) {
|
||||
|
||||
@@ -8,9 +8,9 @@ AutoName: FocusGram
|
||||
RepoType: git
|
||||
Repo: https://github.com/Ujwal223/FocusGram
|
||||
Builds:
|
||||
- versionName: 0.9.8-beta.2
|
||||
versionCode: 2
|
||||
commit: v0.9.8-beta.2
|
||||
- versionName: 1.0.0
|
||||
versionCode: 3
|
||||
commit: v1.0.0
|
||||
submodules: true
|
||||
output: build/app/outputs/flutter-apk/app-release.apk
|
||||
rm:
|
||||
@@ -35,5 +35,5 @@ Builds:
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||
CurrentVersion: 0.9.8-beta.2
|
||||
CurrentVersionCode: 2
|
||||
CurrentVersion: 1.0.0
|
||||
CurrentVersionCode: 3
|
||||
|
||||
128
pubspec.lock
128
pubspec.lock
@@ -145,6 +145,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -201,11 +209,83 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+5"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_inappwebview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -512,6 +592,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -877,38 +973,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.13.1"
|
||||
webview_flutter_android:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.11"
|
||||
webview_flutter_platform_interface:
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "108bd85d0ff20bff1e8b52a040f5c19b6b9fc4a78fdf3160534ff5a11a82e267"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.23.7"
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
14
pubspec.yaml
14
pubspec.yaml
@@ -1,8 +1,8 @@
|
||||
name: focusgram
|
||||
description: "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. "
|
||||
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.9.8-beta.2+2
|
||||
version: 1.0.0+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
@@ -11,10 +11,8 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView engine — latest stable
|
||||
webview_flutter: ^4.13.1
|
||||
# Android-specific WebView platform (explicit dep required for direct import)
|
||||
webview_flutter_android: ^4.3.0
|
||||
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# Local key-value persistence — latest stable
|
||||
shared_preferences: ^2.5.4
|
||||
@@ -30,6 +28,7 @@ dependencies:
|
||||
|
||||
# URL launcher for About page links — latest stable
|
||||
url_launcher: ^6.3.2
|
||||
package_info_plus: ^8.1.2
|
||||
# Handling Instagram deep links — latest stable
|
||||
app_links: ^6.3.2
|
||||
# Open system settings — latest stable
|
||||
@@ -41,6 +40,9 @@ dependencies:
|
||||
image_picker: ^1.1.2
|
||||
flutter_windowmanager_plus: ^1.0.1
|
||||
|
||||
# Charts for on-device screen time dashboard (MIT)
|
||||
fl_chart: ^0.69.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
// test/services/injection_controller_test.dart
|
||||
//
|
||||
// Tests for InjectionController — JS/CSS builder, Ghost Mode keyword resolver,
|
||||
// and JS string generation.
|
||||
//
|
||||
// Run with: flutter test test/services/injection_controller_test.dart
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focusgram/services/injection_controller.dart';
|
||||
|
||||
void main() {
|
||||
// ── resolveBlockedKeywords ───────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.resolveBlockedKeywords', () {
|
||||
test('returns empty list when all flags are false', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: false,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(kws, isEmpty);
|
||||
});
|
||||
|
||||
test('includes seen keywords when seenStatus is true', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: false,
|
||||
seenStatus: true,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(
|
||||
kws,
|
||||
containsAll(['/seen', 'media/seen', 'reel/seen', '/mark_seen']),
|
||||
);
|
||||
});
|
||||
|
||||
test('includes typing keywords when typingIndicator is true', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: true,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(kws, containsAll(['set_typing_status', '/typing']));
|
||||
});
|
||||
|
||||
test('includes live keywords when stories is true', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: false,
|
||||
seenStatus: false,
|
||||
stories: true,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(kws, contains('/live/'));
|
||||
});
|
||||
|
||||
test('includes visual_item_seen when dmPhotos is true', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: false,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: true,
|
||||
);
|
||||
expect(kws, contains('visual_item_seen'));
|
||||
});
|
||||
|
||||
test('all flags true — returns all groups combined', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: true,
|
||||
seenStatus: true,
|
||||
stories: true,
|
||||
dmPhotos: true,
|
||||
);
|
||||
// Must contain at least one keyword from every group
|
||||
expect(
|
||||
kws,
|
||||
containsAll([
|
||||
'/seen',
|
||||
'set_typing_status',
|
||||
'/live/',
|
||||
'visual_item_seen',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('no duplicates in result (seen + typing + stories + dmPhotos)', () {
|
||||
final kws = InjectionController.resolveBlockedKeywords(
|
||||
typingIndicator: true,
|
||||
seenStatus: true,
|
||||
stories: true,
|
||||
dmPhotos: true,
|
||||
);
|
||||
final unique = kws.toSet();
|
||||
expect(kws.length, unique.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveWsBlockedKeywords ─────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.resolveWsBlockedKeywords', () {
|
||||
test('returns empty list when typingIndicator is false', () {
|
||||
expect(
|
||||
InjectionController.resolveWsBlockedKeywords(typingIndicator: false),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns non-empty list when typingIndicator is true', () {
|
||||
final kws = InjectionController.resolveWsBlockedKeywords(
|
||||
typingIndicator: true,
|
||||
);
|
||||
expect(kws, isNotEmpty);
|
||||
expect(kws, contains('activity_status'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildGhostModeJS ─────────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.buildGhostModeJS', () {
|
||||
test('returns empty string when all flags are false', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: false,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js.trim(), isEmpty);
|
||||
});
|
||||
|
||||
test('generated JS contains seen keywords when seenStatus=true', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: false,
|
||||
seenStatus: true,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js, contains('/seen'));
|
||||
expect(js, contains('media/seen'));
|
||||
});
|
||||
|
||||
test('generated JS contains typing keywords when typingIndicator=true', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: true,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js, contains('set_typing_status'));
|
||||
});
|
||||
|
||||
test('generated JS contains live keyword when stories=true', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: false,
|
||||
seenStatus: false,
|
||||
stories: true,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js, contains('/live/'));
|
||||
});
|
||||
|
||||
test('generated JS contains BLOCKED array and shouldBlock function', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: false,
|
||||
seenStatus: true,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js, contains('BLOCKED'));
|
||||
expect(js, contains('shouldBlock'));
|
||||
});
|
||||
|
||||
test('generated JS wraps XHR and fetch', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: true,
|
||||
seenStatus: true,
|
||||
stories: true,
|
||||
dmPhotos: true,
|
||||
);
|
||||
expect(js, contains('window.fetch'));
|
||||
expect(js, contains('XMLHttpRequest.prototype.open'));
|
||||
expect(js, contains('XMLHttpRequest.prototype.send'));
|
||||
});
|
||||
|
||||
test('WS patch is included when typingIndicator=true', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: true,
|
||||
seenStatus: false,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
expect(js, contains('WebSocket'));
|
||||
});
|
||||
|
||||
test('WS patch is NOT included when typingIndicator=false', () {
|
||||
final js = InjectionController.buildGhostModeJS(
|
||||
typingIndicator: false,
|
||||
seenStatus: true,
|
||||
stories: false,
|
||||
dmPhotos: false,
|
||||
);
|
||||
// WS_KEYS will be empty array; the `if (WS_KEYS.length > 0)` guard
|
||||
// prevents the WS override from running — but the string may still be present.
|
||||
// At minimum, WS_KEYS should be empty in the output.
|
||||
expect(js, contains('WS_KEYS = []'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildSessionStateJS ──────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.buildSessionStateJS', () {
|
||||
test('returns true assignment when active', () {
|
||||
expect(
|
||||
InjectionController.buildSessionStateJS(true),
|
||||
contains('__focusgramSessionActive = true'),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false assignment when inactive', () {
|
||||
expect(
|
||||
InjectionController.buildSessionStateJS(false),
|
||||
contains('__focusgramSessionActive = false'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── softNavigateJS ───────────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.softNavigateJS', () {
|
||||
test('contains the target path', () {
|
||||
final js = InjectionController.softNavigateJS('/direct/inbox/');
|
||||
expect(js, contains('/direct/inbox/'));
|
||||
});
|
||||
|
||||
test('contains location.href assignment', () {
|
||||
final js = InjectionController.softNavigateJS('/explore/');
|
||||
expect(js, contains('location.href'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildInjectionJS ─────────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.buildInjectionJS', () {
|
||||
InjectionController.buildInjectionJS; // reference check
|
||||
|
||||
test('contains session state flag', () {
|
||||
final js = _buildFull();
|
||||
expect(js, contains('__focusgramSessionActive'));
|
||||
});
|
||||
|
||||
test('contains path tracker when assembled', () {
|
||||
final js = _buildFull();
|
||||
expect(js, contains('fgTrackPath'));
|
||||
});
|
||||
|
||||
test('includes reels block JS when session is not active', () {
|
||||
final js = InjectionController.buildInjectionJS(
|
||||
sessionActive: false,
|
||||
blurExplore: false,
|
||||
blurReels: false,
|
||||
ghostTyping: false,
|
||||
ghostSeen: false,
|
||||
ghostStories: false,
|
||||
ghostDmPhotos: false,
|
||||
enableTextSelection: false,
|
||||
);
|
||||
expect(js, contains('fgReelsBlock'));
|
||||
});
|
||||
|
||||
test('does NOT include reels block JS when session is active', () {
|
||||
final js = InjectionController.buildInjectionJS(
|
||||
sessionActive: true,
|
||||
blurExplore: false,
|
||||
blurReels: false,
|
||||
ghostTyping: false,
|
||||
ghostSeen: false,
|
||||
ghostStories: false,
|
||||
ghostDmPhotos: false,
|
||||
enableTextSelection: false,
|
||||
);
|
||||
expect(js, isNot(contains('fgReelsBlock')));
|
||||
});
|
||||
|
||||
test('always includes link sanitizer', () {
|
||||
final js = InjectionController.buildInjectionJS(
|
||||
sessionActive: false,
|
||||
blurExplore: false,
|
||||
blurReels: false,
|
||||
ghostTyping: false,
|
||||
ghostSeen: false,
|
||||
ghostStories: false,
|
||||
ghostDmPhotos: false,
|
||||
enableTextSelection: false,
|
||||
);
|
||||
// linkSanitizationJS is now always injected (not togglable)
|
||||
expect(js, contains('fgSanitize'));
|
||||
});
|
||||
|
||||
test('returns non-empty string in all cases', () {
|
||||
expect(_buildFull().trim(), isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── iOSUserAgent sanity ──────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.iOSUserAgent', () {
|
||||
test('contains iPhone identifier', () {
|
||||
expect(InjectionController.iOSUserAgent, contains('iPhone'));
|
||||
});
|
||||
|
||||
test('contains FBAN (Instagram app identifier)', () {
|
||||
expect(InjectionController.iOSUserAgent, contains('FBAN'));
|
||||
});
|
||||
|
||||
test('is non-empty', () {
|
||||
expect(InjectionController.iOSUserAgent, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── notificationBridgeJS ─────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.notificationBridgeJS', () {
|
||||
test('contains Notification bridge guard', () {
|
||||
expect(
|
||||
InjectionController.notificationBridgeJS,
|
||||
contains('fgNotifBridged'),
|
||||
);
|
||||
});
|
||||
|
||||
test('patches window.Notification', () {
|
||||
expect(
|
||||
InjectionController.notificationBridgeJS,
|
||||
contains('window.Notification'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── linkSanitizationJS ───────────────────────────────────────────────────
|
||||
|
||||
group('InjectionController.linkSanitizationJS', () {
|
||||
test('strips igsh param', () {
|
||||
expect(InjectionController.linkSanitizationJS, contains('igsh'));
|
||||
});
|
||||
|
||||
test('strips utm params', () {
|
||||
expect(InjectionController.linkSanitizationJS, contains('utm_source'));
|
||||
});
|
||||
|
||||
test('strips fbclid', () {
|
||||
expect(InjectionController.linkSanitizationJS, contains('fbclid'));
|
||||
});
|
||||
|
||||
test('patches navigator.share', () {
|
||||
expect(
|
||||
InjectionController.linkSanitizationJS,
|
||||
contains('navigator.share'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper to create a fully-featured injection JS for common assertions.
|
||||
String _buildFull() => InjectionController.buildInjectionJS(
|
||||
sessionActive: false,
|
||||
blurExplore: true,
|
||||
blurReels: true,
|
||||
ghostTyping: true,
|
||||
ghostSeen: true,
|
||||
ghostStories: true,
|
||||
ghostDmPhotos: true,
|
||||
enableTextSelection: false,
|
||||
);
|
||||
@@ -1,250 +0,0 @@
|
||||
// test/services/settings_service_test.dart
|
||||
//
|
||||
// Tests for SettingsService — default values, setters, tab management,
|
||||
// and the legacy GhostMode key migration.
|
||||
//
|
||||
// Note: SettingsService requires SharedPreferences. We use
|
||||
// SharedPreferences.setMockInitialValues({}) to avoid platform channel calls.
|
||||
//
|
||||
// Run with: flutter test test/services/settings_service_test.dart
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:focusgram/services/settings_service.dart';
|
||||
|
||||
/// Helper: create an initialised SettingsService with a clean prefs slate.
|
||||
Future<SettingsService> makeService() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final svc = SettingsService();
|
||||
await svc.init();
|
||||
return svc;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
// ── Default values ──────────────────────────────────────────────────────
|
||||
|
||||
group('SettingsService — defaults', () {
|
||||
test('blurExplore defaults to true', () async {
|
||||
expect((await makeService()).blurExplore, isTrue);
|
||||
});
|
||||
|
||||
test('blurReels defaults to false', () async {
|
||||
expect((await makeService()).blurReels, isFalse);
|
||||
});
|
||||
|
||||
test('requireLongPress defaults to true', () async {
|
||||
expect((await makeService()).requireLongPress, isTrue);
|
||||
});
|
||||
|
||||
test('showBreathGate defaults to true', () async {
|
||||
expect((await makeService()).showBreathGate, isTrue);
|
||||
});
|
||||
|
||||
test('requireWordChallenge defaults to true', () async {
|
||||
expect((await makeService()).requireWordChallenge, isTrue);
|
||||
});
|
||||
|
||||
test('enableTextSelection defaults to false', () async {
|
||||
expect((await makeService()).enableTextSelection, isFalse);
|
||||
});
|
||||
|
||||
test('ghostTyping defaults to true', () async {
|
||||
expect((await makeService()).ghostTyping, isTrue);
|
||||
});
|
||||
|
||||
test('ghostSeen defaults to true', () async {
|
||||
expect((await makeService()).ghostSeen, isTrue);
|
||||
});
|
||||
|
||||
test('ghostStories defaults to true', () async {
|
||||
expect((await makeService()).ghostStories, isTrue);
|
||||
});
|
||||
|
||||
test('ghostDmPhotos defaults to true', () async {
|
||||
expect((await makeService()).ghostDmPhotos, isTrue);
|
||||
});
|
||||
|
||||
test('sanitizeLinks defaults to true', () async {
|
||||
expect((await makeService()).sanitizeLinks, isTrue);
|
||||
});
|
||||
|
||||
test('isFirstRun defaults to true', () async {
|
||||
expect((await makeService()).isFirstRun, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'anyGhostModeEnabled is true when all ghost settings are true',
|
||||
() async {
|
||||
expect((await makeService()).anyGhostModeEnabled, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Setters ─────────────────────────────────────────────────────────────
|
||||
|
||||
group('SettingsService — setters persist and notify', () {
|
||||
test('setBlurExplore changes value and notifies', () async {
|
||||
final svc = await makeService();
|
||||
bool notified = false;
|
||||
svc.addListener(() => notified = true);
|
||||
await svc.setBlurExplore(false);
|
||||
expect(svc.blurExplore, isFalse);
|
||||
expect(notified, isTrue);
|
||||
});
|
||||
|
||||
test('setBlurReels persists', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setBlurReels(true);
|
||||
expect(svc.blurReels, isTrue);
|
||||
});
|
||||
|
||||
test('setRequireLongPress persists', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setRequireLongPress(false);
|
||||
expect(svc.requireLongPress, isFalse);
|
||||
});
|
||||
|
||||
test('setGhostTyping turns off ghost typing', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostTyping(false);
|
||||
expect(svc.ghostTyping, isFalse);
|
||||
});
|
||||
|
||||
test('setGhostSeen turns off ghost seen', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostSeen(false);
|
||||
expect(svc.ghostSeen, isFalse);
|
||||
});
|
||||
|
||||
test('setGhostStories turns off ghost stories', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostStories(false);
|
||||
expect(svc.ghostStories, isFalse);
|
||||
});
|
||||
|
||||
test('setGhostDmPhotos turns off ghost dm photos', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostDmPhotos(false);
|
||||
expect(svc.ghostDmPhotos, isFalse);
|
||||
});
|
||||
|
||||
test('setSanitizeLinks persists', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setSanitizeLinks(false);
|
||||
expect(svc.sanitizeLinks, isFalse);
|
||||
});
|
||||
|
||||
test('setFirstRunCompleted sets isFirstRun to false', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setFirstRunCompleted();
|
||||
expect(svc.isFirstRun, isFalse);
|
||||
});
|
||||
|
||||
test('setEnableTextSelection persists', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setEnableTextSelection(true);
|
||||
expect(svc.enableTextSelection, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── anyGhostModeEnabled ──────────────────────────────────────────────────
|
||||
|
||||
group('SettingsService.anyGhostModeEnabled', () {
|
||||
test('is false when all ghost flags are off', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostTyping(false);
|
||||
await svc.setGhostSeen(false);
|
||||
await svc.setGhostStories(false);
|
||||
await svc.setGhostDmPhotos(false);
|
||||
expect(svc.anyGhostModeEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('is true when only one ghost flag is on', () async {
|
||||
final svc = await makeService();
|
||||
await svc.setGhostTyping(false);
|
||||
await svc.setGhostSeen(false);
|
||||
await svc.setGhostStories(false);
|
||||
await svc.setGhostDmPhotos(true); // only dmPhotos on
|
||||
expect(svc.anyGhostModeEnabled, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tab management ───────────────────────────────────────────────────────
|
||||
|
||||
group('SettingsService — tab management', () {
|
||||
test('default tabs include Home, Reels, Messages, Profile', () async {
|
||||
final svc = await makeService();
|
||||
expect(
|
||||
svc.enabledTabs,
|
||||
containsAll(['Home', 'Reels', 'Messages', 'Profile']),
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleTab removes an enabled tab', () async {
|
||||
final svc = await makeService();
|
||||
final before = List<String>.from(svc.enabledTabs);
|
||||
await svc.toggleTab('Reels');
|
||||
expect(svc.enabledTabs, isNot(contains('Reels')));
|
||||
expect(svc.enabledTabs.length, before.length - 1);
|
||||
});
|
||||
|
||||
test('toggleTab adds a tab back when toggled again', () async {
|
||||
final svc = await makeService();
|
||||
await svc.toggleTab('Reels');
|
||||
await svc.toggleTab('Reels');
|
||||
expect(svc.enabledTabs, contains('Reels'));
|
||||
});
|
||||
|
||||
test('toggleTab does not remove the last remaining tab', () async {
|
||||
final svc = await makeService();
|
||||
final tabs = List<String>.from(svc.enabledTabs);
|
||||
for (final t in tabs.sublist(0, tabs.length - 1)) {
|
||||
await svc.toggleTab(t);
|
||||
}
|
||||
final last = svc.enabledTabs.first;
|
||||
await svc.toggleTab(last); // try to remove the last one
|
||||
expect(svc.enabledTabs.length, 1); // still 1
|
||||
});
|
||||
|
||||
test('reorderTab moves item correctly — no tabs are lost', () async {
|
||||
final svc = await makeService();
|
||||
final original = List<String>.from(svc.enabledTabs);
|
||||
await svc.reorderTab(0, 1);
|
||||
expect(svc.enabledTabs.toSet(), original.toSet());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Legacy Ghost Mode migration ──────────────────────────────────────────
|
||||
|
||||
group('SettingsService — legacy ghost mode migration', () {
|
||||
test(
|
||||
'migrates legacy ghost_mode=true to all four granular flags',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'set_ghost_mode': true});
|
||||
final svc = SettingsService();
|
||||
await svc.init();
|
||||
expect(svc.ghostTyping, isTrue);
|
||||
expect(svc.ghostSeen, isTrue);
|
||||
expect(svc.ghostStories, isTrue);
|
||||
expect(svc.ghostDmPhotos, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'migrates legacy ghost_mode=false to all four granular flags off',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({'set_ghost_mode': false});
|
||||
final svc = SettingsService();
|
||||
await svc.init();
|
||||
expect(svc.ghostTyping, isFalse);
|
||||
expect(svc.ghostSeen, isFalse);
|
||||
expect(svc.ghostStories, isFalse);
|
||||
expect(svc.ghostDmPhotos, isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user