RELEASE: moved from beta to First stable release.

Check CHANGELOG.md for full changelog
This commit is contained in:
Ujwal
2026-02-27 04:14:40 +05:45
parent eecb823e62
commit 7992d65bc8
64 changed files with 6208 additions and 2752 deletions

36
.fdroid.yml Normal file
View 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
View 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
View File

@@ -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
View File

19
CHANGELOG.md Normal file
View 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
View File

@@ -1,87 +1,164 @@
<div align="center">
<img src="assets/images/focusgram.png" alt="FocusGram" width="96" height="96" />
# FocusGram
<<<<<<< Updated upstream
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE)
[![Flutter](https://img.shields.io/badge/Flutter-stable-blue?logo=flutter)](https://flutter.dev)
[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=300)](https://github.com/ujwal223/focusgram/releases)
=======
**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: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE)
[![Flutter](https://img.shields.io/badge/Flutter-3.38-blue?logo=flutter&logoColor=white)](https://flutter.dev)
[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=300)](https://github.com/ujwal223/focusgram/releases)
[![F-Droid](https://img.shields.io/badge/F--Droid-later-blue)](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 (115 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.

View File

@@ -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")
}
}

View File

@@ -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.**

View File

@@ -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" />

View File

@@ -1,3 +0,0 @@
package com.google.android.play.core.splitcompat;
import android.app.Application;
public class SplitCompatApplication extends Application {}

View File

@@ -1,4 +0,0 @@
package com.google.android.play.core.splitinstall;
public class SplitInstallException extends Exception {
public int getErrorCode() { return 0; }
}

View File

@@ -1,2 +0,0 @@
package com.google.android.play.core.splitinstall;
public interface SplitInstallManager {}

View File

@@ -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;
}
}

View File

@@ -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(); }
}
}

View File

@@ -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; }
}

View File

@@ -1,5 +0,0 @@
package com.google.android.play.core.splitinstall;
public interface SplitInstallStateUpdatedListener {
void onStateUpdate(SplitInstallSessionState state);
}

View File

@@ -1,4 +0,0 @@
package com.google.android.play.core.tasks;
public interface OnFailureListener {
void onFailure(Exception e);
}

View File

@@ -1,4 +0,0 @@
package com.google.android.play.core.tasks;
public interface OnSuccessListener<TResult> {
void onSuccess(TResult result);
}

View File

@@ -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
View 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:

View File

@@ -0,0 +1 @@
Same as1st version. just version pump

View 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.

View File

@@ -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.

View File

@@ -1 +1 @@
Distraction-free Instagram with controlled Reel access.
A digital wellness wrapper for Instagram.

View 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;
}

View 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,
});
}

View 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;
}
}

View 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,
),
);
}

View 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));
}
}

View 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();
}
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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

View 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 (_) {}
})();
''';

View 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 });
})();
''';

View 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);
})();
''';

View 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 (_) {}
});
''';

View 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 (_) {}
})();
''';

View 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);
})();
''';

View 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';
})();
''';

View 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);
})();
''';

View 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 (_) {}
})();
''';

View 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
View 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 }
);
}
})();
''';

View File

@@ -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}
''';
}
}

View 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
}
}
}
}

View File

@@ -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({

View 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();
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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);
},
);
});
}