diff --git a/.fdroid.yml b/.fdroid.yml
new file mode 100644
index 0000000..b1b70d4
--- /dev/null
+++ b/.fdroid.yml
@@ -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
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..88666a0
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -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 }}"
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3907a8d..f3c45a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index e69de29..0000000
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..90f95c4
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index a5e9575..5a0e5c9 100644
--- a/README.md
+++ b/README.md
@@ -1,87 +1,164 @@
+
+
+

+
# FocusGram
+<<<<<<< Updated upstream
[](LICENSE)
[](https://flutter.dev)
[](https://github.com/ujwal223/focusgram/releases)
+=======
+**Use social media on your terms.**
+>>>>>>> Stashed changes
-**Take back your time.** FocusGram is a distraction-free client for Instagram on Android that hides Reels and Explore, so you can stay connected without getting lost in the scroll.
+[](LICENSE)
+[](https://flutter.dev)
+[](https://github.com/ujwal223/focusgram/releases)
+[](https://f-droid.org)
-[π Star on GitHub](https://github.com/Ujwal223/FocusGram) | [π₯ Download Latest APK](https://github.com/Ujwal223/FocusGram/releases)
+[Download APK](https://github.com/ujwal223/focusgram/releases) Β· [View Changelog](CHANGELOG.md) Β· [Report a Bug](https://github.com/ujwal223/focusgram/issues)
+
+
---
-## 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.
-
-
-
-### 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.
+
---
-## Master Your Usage
+## What it does
-FocusGram doesn't just block Reelsβit gives you tools to build better habits:
+**Focus tools**
-- β
**Controlled Reel Sessions**: Need to watch a Reel? Start a timed session (1 to 15 minutes). When the time is up, they're blocked again.
-- β
**Daily Limits**: Set a maximum amount of Reel time per day.
-- β
**Habit-Building Cooldowns**: Enforce a mandatory break between sessions to prevent bingeing.
+- Block Reels entirely, or allow them in timed sessions (1β15 min) with daily limits and cooldowns
+- Autoplay blocker β videos don't play until you tap them
+- Minimal Mode β strips everything down to Feed and DMs
+
+**Content filtering**
+
+- Hide the Explore tab, Reels tab, or Shop tab individually
+- Disable Explore and suggested content entirely
+- Disable Reels Entirely
+
+**Habit tools**
+
+- Screen Time Dashboard β daily usage, 7-day chart, weekly average
+- Grayscale Mode β reduces the visual pull of colour; can be scheduled by time of day
+- Session intentions β optionally set a reason before opening the app
+
+**The app itself**
+
+- Feels (almost) like a native app, not a browser.
+- No blank loading screen β content loads in the background before you get there
+- Instant updates via pull-to-refresh
+- Dark mode follows your system
---
## Installation
-### 1. From GitHub (Current)
-1. Go to the [Releases](https://github.com/Ujwal223/FocusGram/releases) page.
-2. Download the `focusgram-release.apk`.
-3. Open the file on your phone and allow "Install from unknown sources" if prompted.
+### Direct download
+1. Go to the [Releases](https://github.com/ujwal223/focusgram/releases) page
+2. Download `focusgram-release.apk`
+3. Open the file and allow "Install from unknown sources" if prompted
-### 2. From F-Droid (Soon)
-We are currently in the process of submitting FocusGram to the F-Droid store for easier updates.
+### F-Droid
+Submission is in progress. Updates will publish automatically once accepted.
---
-## Frequently Asked Questions
+## Privacy
-**Is my login safe?**
-Yes. FocusGram uses a standard system WebView. Your credentials go directly to Instagram/Meta's servers, just like in a mobile browser. We do not (and cannot) see your password.
+FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView β your login goes directly to Meta's servers, the same as any mobile browser.
+- No analytics
+- No crash reporting
+- No third-party SDKs
+- No data leaves your device
+- All settings and history are stored locally using Android's standard storage APIs
+
+---
+
+## Frequently asked questions
+
+**Will this get my account banned?**
+Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics.
+
+**Is this a mod of Instagram's app?**
+No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
**Why is it free?**
-FocusGram is Open Source software created by [Ujwal Chapagain](https://github.com/Ujwal223). It is built for everyone who wants a healthier relationship with social media.
+Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0.
---
-## Development & Technical Details
+## Building from source
-View Technical Info
+Technical details and build instructions
-### 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)
---
+## 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.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 22d0532..b08aa9a 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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")
}
}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index b5e0cd4..ea309ba 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -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.**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0a7cc0d..4a38ec5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,11 +1,13 @@
+
@@ -50,6 +52,12 @@
+
+
+
+
diff --git a/android/app/src/main/java/com/google/android/play/core/splitcompat/SplitCompatApplication.java b/android/app/src/main/java/com/google/android/play/core/splitcompat/SplitCompatApplication.java
deleted file mode 100644
index 3fa8893..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitcompat/SplitCompatApplication.java
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.google.android.play.core.splitcompat;
-import android.app.Application;
-public class SplitCompatApplication extends Application {}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallException.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallException.java
deleted file mode 100644
index 5991909..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallException.java
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.google.android.play.core.splitinstall;
-public class SplitInstallException extends Exception {
- public int getErrorCode() { return 0; }
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManager.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManager.java
deleted file mode 100644
index edb9d0e..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManager.java
+++ /dev/null
@@ -1,2 +0,0 @@
-package com.google.android.play.core.splitinstall;
-public interface SplitInstallManager {}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManagerFactory.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManagerFactory.java
deleted file mode 100644
index c89d0b8..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallManagerFactory.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallRequest.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallRequest.java
deleted file mode 100644
index d28de38..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallRequest.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.google.android.play.core.splitinstall;
-
-public class SplitInstallRequest {
- public static Builder newBuilder() { return new Builder(); }
- public static class Builder {
- public Builder addModule(String moduleName) { return this; }
- public SplitInstallRequest build() { return new SplitInstallRequest(); }
- }
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallSessionState.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallSessionState.java
deleted file mode 100644
index 96bcd30..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallSessionState.java
+++ /dev/null
@@ -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; }
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallStateUpdatedListener.java b/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallStateUpdatedListener.java
deleted file mode 100644
index 8085ffe..0000000
--- a/android/app/src/main/java/com/google/android/play/core/splitinstall/SplitInstallStateUpdatedListener.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package com.google.android.play.core.splitinstall;
-
-public interface SplitInstallStateUpdatedListener {
- void onStateUpdate(SplitInstallSessionState state);
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/tasks/OnFailureListener.java b/android/app/src/main/java/com/google/android/play/core/tasks/OnFailureListener.java
deleted file mode 100644
index a0b47f8..0000000
--- a/android/app/src/main/java/com/google/android/play/core/tasks/OnFailureListener.java
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.google.android.play.core.tasks;
-public interface OnFailureListener {
- void onFailure(Exception e);
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/tasks/OnSuccessListener.java b/android/app/src/main/java/com/google/android/play/core/tasks/OnSuccessListener.java
deleted file mode 100644
index 6935bc6..0000000
--- a/android/app/src/main/java/com/google/android/play/core/tasks/OnSuccessListener.java
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.google.android.play.core.tasks;
-public interface OnSuccessListener {
- void onSuccess(TResult result);
-}
diff --git a/android/app/src/main/java/com/google/android/play/core/tasks/Task.java b/android/app/src/main/java/com/google/android/play/core/tasks/Task.java
deleted file mode 100644
index 9bff913..0000000
--- a/android/app/src/main/java/com/google/android/play/core/tasks/Task.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.google.android.play.core.tasks;
-
-public abstract class Task {
- public abstract boolean isSuccessful();
- public abstract TResult getResult();
-}
diff --git a/assets/images/YGrQka.jpg b/assets/images/YGrQka.jpg
deleted file mode 100644
index bb68ea7..0000000
Binary files a/assets/images/YGrQka.jpg and /dev/null differ
diff --git a/assets/images/YGrivw.jpg b/assets/images/YGrivw.jpg
deleted file mode 100644
index 11ad150..0000000
Binary files a/assets/images/YGrivw.jpg and /dev/null differ
diff --git a/assets/images/YGrkqt.jpg b/assets/images/YGrkqt.jpg
deleted file mode 100644
index 94f4258..0000000
Binary files a/assets/images/YGrkqt.jpg and /dev/null differ
diff --git a/assets/images/YGrs4T.jpg b/assets/images/YGrs4T.jpg
deleted file mode 100644
index f08ab7d..0000000
Binary files a/assets/images/YGrs4T.jpg and /dev/null differ
diff --git a/assets/images/YGry0D.jpg b/assets/images/YGry0D.jpg
deleted file mode 100644
index 0da9d1d..0000000
Binary files a/assets/images/YGry0D.jpg and /dev/null differ
diff --git a/devtools_options.yaml b/devtools_options.yaml
new file mode 100644
index 0000000..fa0b357
--- /dev/null
+++ b/devtools_options.yaml
@@ -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:
diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt
new file mode 100644
index 0000000..a0b9d0b
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/2.txt
@@ -0,0 +1 @@
+Same as1st version. just version pump
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt
new file mode 100644
index 0000000..4f1c995
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3.txt
@@ -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.
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 02d72f4..ecb34fa 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -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.
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index e52caa3..285db2e 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-Distraction-free Instagram with controlled Reel access.
+A digital wellness wrapper for Instagram.
diff --git a/lib/features/loading/skeleton_screen.dart b/lib/features/loading/skeleton_screen.dart
new file mode 100644
index 0000000..e0ce254
--- /dev/null
+++ b/lib/features/loading/skeleton_screen.dart
@@ -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 createState() => _SkeletonScreenState();
+}
+
+class _SkeletonScreenState extends State
+ with SingleTickerProviderStateMixin {
+ late AnimationController _shimmerController;
+ late Animation _shimmerAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _shimmerController = AnimationController(
+ duration: const Duration(milliseconds: 1200),
+ vsync: this,
+ )..repeat();
+ _shimmerAnimation = Tween(
+ 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;
+}
diff --git a/lib/features/native_nav/native_bottom_nav.dart b/lib/features/native_nav/native_bottom_nav.dart
new file mode 100644
index 0000000..35c5b9c
--- /dev/null
+++ b/lib/features/native_nav/native_bottom_nav.dart
@@ -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,
+ });
+}
+
diff --git a/lib/features/preloader/instagram_preloader.dart b/lib/features/preloader/instagram_preloader.dart
new file mode 100644
index 0000000..57de8dd
--- /dev/null
+++ b/lib/features/preloader/instagram_preloader.dart
@@ -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 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;
+ }
+}
+
diff --git a/lib/features/reels_history/reels_history_screen.dart b/lib/features/reels_history/reels_history_screen.dart
new file mode 100644
index 0000000..3e5f70c
--- /dev/null
+++ b/lib/features/reels_history/reels_history_screen.dart
@@ -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 createState() => _ReelsHistoryScreenState();
+}
+
+class _ReelsHistoryScreenState extends State {
+ final _service = ReelsHistoryService();
+ late Future> _future;
+
+ @override
+ void initState() {
+ super.initState();
+ _future = _service.getEntries();
+ }
+
+ Future _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 _confirmClearAll() async {
+ final ok = await showDialog(
+ 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>(
+ future: _future,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final entries = snapshot.data ?? const [];
+
+ 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,
+ ),
+ );
+}
diff --git a/lib/features/reels_history/reels_history_service.dart b/lib/features/reels_history/reels_history_service.dart
new file mode 100644
index 0000000..67dec87
--- /dev/null
+++ b/lib/features/reels_history/reels_history_service.dart
@@ -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 toJson() => {
+ 'id': id,
+ 'url': url,
+ 'title': title,
+ 'thumbnailUrl': thumbnailUrl,
+ 'visitedAt': visitedAt.toUtc().toIso8601String(),
+ };
+
+ static ReelsHistoryEntry fromJson(Map 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 _getPrefs() async {
+ _prefs ??= await SharedPreferences.getInstance();
+ return _prefs!;
+ }
+
+ Future> getEntries() async {
+ final prefs = await _getPrefs();
+ final raw = prefs.getString(_prefsKey);
+ if (raw == null || raw.isEmpty) return [];
+ try {
+ final decoded = jsonDecode(raw) as List;
+ final entries = decoded
+ .whereType