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 + # 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) + +
--- -## 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. -Purple and Pink Pastel Simple Modern Payment Mobile App Presentation - - -### 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. +FocusGram screenshots --- -## 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() + .map((e) => ReelsHistoryEntry.fromJson(e.cast())) + .where((e) => e.url.isNotEmpty) + .toList(); + entries.sort((a, b) => b.visitedAt.compareTo(a.visitedAt)); + return entries; + } catch (_) { + return []; + } + } + + Future 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 deleteEntry(String id) async { + final entries = await getEntries(); + entries.removeWhere((e) => e.id == id); + await _save(entries); + } + + Future clearAll() async { + final prefs = await _getPrefs(); + await prefs.remove(_prefsKey); + } + + Future _save(List entries) async { + final prefs = await _getPrefs(); + final jsonList = entries.map((e) => e.toJson()).toList(); + await prefs.setString(_prefsKey, jsonEncode(jsonList)); + } +} + diff --git a/lib/features/screen_time/screen_time_screen.dart b/lib/features/screen_time/screen_time_screen.dart new file mode 100644 index 0000000..d0da8fe --- /dev/null +++ b/lib/features/screen_time/screen_time_screen.dart @@ -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( + builder: (context, service, _) { + final data = service.secondsByDate; + final todayKey = _todayKey(); + final todaySeconds = data[todayKey] ?? 0; + + final last7 = _lastNDays(7); + final barSpots = []; + 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 _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 bars, List 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 _confirmReset( + BuildContext context, + ScreenTimeService service, + ) async { + final first = await showDialog( + 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( + 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(); + } + } +} diff --git a/lib/features/update_checker/update_banner.dart b/lib/features/update_checker/update_banner.dart new file mode 100644 index 0000000..40fa7d3 --- /dev/null +++ b/lib/features/update_checker/update_banner.dart @@ -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 createState() => _UpdateBannerState(); +} + +class _UpdateBannerState extends State { + 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(); + } +} + diff --git a/lib/features/update_checker/update_checker_service.dart b/lib/features/update_checker/update_checker_service.dart new file mode 100644 index 0000000..9e14b18 --- /dev/null +++ b/lib/features/update_checker/update_checker_service.dart @@ -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 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 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 gitParts = cleanGit.split('-')[0].split('.'); + List 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; + } +} diff --git a/lib/main.dart b/lib/main.dart index 5f4aec1..172555d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { ); } - // 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 diff --git a/lib/screens/about_page.dart b/lib/screens/about_page.dart deleted file mode 100644 index 2c5db66..0000000 --- a/lib/screens/about_page.dart +++ /dev/null @@ -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 createState() => _AboutPageState(); -} - -class _AboutPageState extends State { - final String _currentVersion = '0.9.8-beta.2'; - bool _isChecking = false; - - Future _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 _launchURL(String url) async { - final uri = Uri.tryParse(url); - if (uri == null) return; - try { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } catch (_) {} - } -} diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index 4846edf..abe1f0c 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -1,20 +1,30 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:webview_flutter_android/webview_flutter_android.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../services/session_manager.dart'; import '../services/settings_service.dart'; import '../services/injection_controller.dart'; +import '../services/injection_manager.dart'; +import '../scripts/autoplay_blocker.dart'; +import '../scripts/native_feel.dart'; +import '../scripts/haptic_bridge.dart'; +import '../scripts/spa_navigation_monitor.dart'; +import '../scripts/content_disabling.dart'; +import '../services/screen_time_service.dart'; import '../services/navigation_guard.dart'; import '../services/focusgram_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../services/notification_service.dart'; +import '../features/update_checker/update_checker_service.dart'; import '../utils/discipline_challenge.dart'; import 'settings_page.dart'; +import '../features/loading/skeleton_screen.dart'; +import '../features/preloader/instagram_preloader.dart'; class MainWebViewPage extends StatefulWidget { const MainWebViewPage({super.key}); @@ -25,15 +35,25 @@ class MainWebViewPage extends StatefulWidget { class _MainWebViewPageState extends State with WidgetsBindingObserver { - late final WebViewController _controller; + InAppWebViewController? _controller; + late final PullToRefreshController _pullToRefreshController; + InjectionManager? _injectionManager; final GlobalKey<_EdgePanelState> _edgePanelKey = GlobalKey<_EdgePanelState>(); + bool _showSkeleton = + true; // true from the start so skeleton covers black Scaffold before WebView first paints bool _isLoading = true; Timer? _watchdog; + // FIX 4: Safety timer to clear stuck loading state + Timer? _loadingTimeout; bool _extensionDialogShown = false; bool _lastSessionActive = false; String _currentUrl = 'https://www.instagram.com/'; bool _hasError = false; bool _reelsBlockedOverlay = false; + bool _isPreloaded = false; + bool _minimalModeBannerDismissed = false; + DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0); + SkeletonType _skeletonType = SkeletonType.generic; /// Helper to determine if we are on a login/onboarding page. bool get _isOnOnboardingPage { @@ -51,13 +71,23 @@ class _MainWebViewPageState extends State void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _initPullToRefresh(); _initWebView(); _startWatchdog(); + // Check for updates on launch + context.read().checkForUpdates(); + WidgetsBinding.instance.addPostFrameCallback((_) { context.read().addListener(_onSessionChanged); context.read().addListener(_onSettingsChanged); _lastSessionActive = context.read().isSessionActive; + // Initialise structural snapshots so first change is detected correctly + final settings = context.read(); + _lastMinimalMode = settings.minimalModeEnabled; + _lastDisableReels = settings.disableReelsEntirely; + _lastDisableExplore = settings.disableExploreEntirely; + _lastBlockAutoplay = settings.blockAutoplay; }); FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged); @@ -67,15 +97,15 @@ class _MainWebViewPageState extends State final url = FocusGramRouter.pendingUrl.value; if (url != null && url.isNotEmpty) { FocusGramRouter.pendingUrl.value = null; - _controller.loadRequest(Uri.parse(url)); + _controller?.loadUrl(urlRequest: URLRequest(url: WebUri(url))); } } /// Sets the isolated reel player flag in the WebView so the scroll-lock /// knows it should block swipe-to-next-reel. Future _setIsolatedPlayer(bool active) async { - await _controller.runJavaScript( - 'window.__focusgramIsolatedPlayer = $active;', + await _controller?.evaluateJavascript( + source: 'window.__focusgramIsolatedPlayer = $active;', ); } @@ -84,7 +114,16 @@ class _MainWebViewPageState extends State final sm = context.read(); if (_lastSessionActive != sm.isSessionActive) { _lastSessionActive = sm.isSessionActive; - _applyInjections(); + + if (_lastSessionActive) { + HapticFeedback.mediumImpact(); + } else { + HapticFeedback.heavyImpact(); + } + + if (_controller != null && _injectionManager != null) { + _injectionManager!.runAllPostLoadInjections(_currentUrl); + } // If session became active and we were showing overlay, hide it if (_lastSessionActive && _reelsBlockedOverlay) { @@ -94,16 +133,60 @@ class _MainWebViewPageState extends State setState(() {}); } + // Debounce timer so rapid toggles don't spam reloads + Timer? _reloadDebounce; + + // Snapshot of structural settings β€” used to detect when a reload is needed + bool _lastMinimalMode = false; + bool _lastDisableReels = false; + bool _lastDisableExplore = false; + bool _lastBlockAutoplay = false; + void _onSettingsChanged() { if (!mounted) return; - _applyInjections(); - // Removed _controller.reload() to improve performance. JS injection now handles updates instantly. + final settings = context.read(); + + // 1. Apply all cosmetic changes immediately via injection + if (_controller != null) { + _controller!.evaluateJavascript( + source: 'window.__fgBlockAutoplay = ${settings.blockAutoplay};', + ); + } + if (_controller != null && _injectionManager != null) { + _injectionManager!.runAllPostLoadInjections(_currentUrl); + } + + // 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state) + setState(() {}); + + // 3. Detect structural changes that need a full reload. + // CSS injection alone can't undo Instagram's already-rendered React DOM. + final structuralChange = + settings.minimalModeEnabled != _lastMinimalMode || + settings.disableReelsEntirely != _lastDisableReels || + settings.disableExploreEntirely != _lastDisableExplore || + settings.blockAutoplay != _lastBlockAutoplay; + + _lastMinimalMode = settings.minimalModeEnabled; + _lastDisableReels = settings.disableReelsEntirely; + _lastDisableExplore = settings.disableExploreEntirely; + _lastBlockAutoplay = settings.blockAutoplay; + + if (structuralChange && _controller != null) { + // Debounce: if user toggles rapidly, only reload once they stop + _reloadDebounce?.cancel(); + _reloadDebounce = Timer(const Duration(milliseconds: 600), () { + if (mounted) _controller?.reload(); + }); + } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _watchdog?.cancel(); + _loadingTimeout?.cancel(); + _reloadDebounce?.cancel(); FocusGramRouter.pendingUrl.removeListener(_onPendingUrlChanged); context.read().removeListener(_onSessionChanged); context.read().removeListener(_onSettingsChanged); @@ -114,12 +197,15 @@ class _MainWebViewPageState extends State void didChangeAppLifecycleState(AppLifecycleState state) { if (!mounted) return; final sm = context.read(); + final screenTime = context.read(); if (state == AppLifecycleState.resumed) { sm.setAppForeground(true); + screenTime.startTracking(); } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { sm.setAppForeground(false); + screenTime.stopTracking(); } } @@ -134,6 +220,22 @@ class _MainWebViewPageState extends State }); } + // FIX 4: Cancel any existing loading timeout and start a fresh one. + // If onLoadStop or onReceivedError haven't fired after 12 seconds, + // force-clear the loading/skeleton state so the app never appears stuck. + void _resetLoadingTimeout() { + _loadingTimeout?.cancel(); + _loadingTimeout = Timer(const Duration(seconds: 12), () { + if (!mounted) return; + if (_isLoading || _showSkeleton) { + setState(() { + _isLoading = false; + _showSkeleton = false; + }); + } + }); + } + void _showSessionExpiredDialog(SessionManager sm) { showDialog( context: context, @@ -194,242 +296,45 @@ class _MainWebViewPageState extends State ); } - void _initWebView() { - final settings = context.read(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setUserAgent(InjectionController.iOSUserAgent) - ..setBackgroundColor(settings.isDarkMode ? Colors.black : Colors.white); - - if (_controller.platform is AndroidWebViewController) { - final androidController = - _controller.platform as AndroidWebViewController; - AndroidWebViewController.enableDebugging(false); - androidController.setMediaPlaybackRequiresUserGesture(false); - androidController.setOnShowFileSelector((params) async { - try { - final picker = ImagePicker(); - final acceptsVideo = params.acceptTypes.any( - (t) => t.contains('video'), - ); - final XFile? file = acceptsVideo - ? await picker.pickVideo(source: ImageSource.gallery) - : await picker.pickImage(source: ImageSource.gallery); - if (file != null) { - // WebView expects a content:// URI, not a raw filesystem path. - // XFile.path on Android is already a content:// URI string when - // picked from the gallery via image_picker >= 0.9, but if it - // starts with '/' we need to prefix it with 'file://'. - final path = file.path; - final uri = path.startsWith('/') ? 'file://$path' : path; - return [uri]; - } - } catch (_) {} - return []; - }); - } - - _controller - ..setNavigationDelegate( - NavigationDelegate( - onPageStarted: (url) { - if (mounted) { - setState(() { - _isLoading = !url.contains('#'); - _currentUrl = url; - // If navigating to reels and no session, block it - if (url.contains('/reels/') && - !context.read().isSessionActive) { - _reelsBlockedOverlay = true; - } else { - _reelsBlockedOverlay = false; - } - }); - } - }, - onPageFinished: (url) { - if (mounted) { - setState(() { - _isLoading = false; - _currentUrl = url; - }); - } - _applyInjections(); - _controller.runJavaScript(InjectionController.notificationBridgeJS); - - // Set isolated player flag: true only when a single reel is opened - // from a DM thread (URL contains /reel/ but we're coming from /direct/). - // When the user navigates away, clear the flag. - final isIsolatedReel = - url.contains('/reel/') && !url.contains('/reels/'); - _setIsolatedPlayer(isIsolatedReel); - }, - onNavigationRequest: (request) { - final uri = Uri.tryParse(request.url); - if (uri != null && - uri.host.contains('instagram.com') && - (request.url.contains('accounts/settings') || - request.url.contains('accounts/edit'))) { - return NavigationDecision.navigate; - } - - // Block reels feed if no session active - if (request.url.contains('/reels/') && - !context.read().isSessionActive) { - setState(() => _reelsBlockedOverlay = true); - return NavigationDecision.prevent; - } - - if (uri != null && - !uri.host.contains('instagram.com') && - !uri.host.contains('facebook.com') && - !uri.host.contains('cdninstagram.com') && - !uri.host.contains('fbcdn.net')) { - launchUrl(uri, mode: LaunchMode.externalApplication); - return NavigationDecision.prevent; - } - - final decision = NavigationGuard.evaluate(url: request.url); - if (decision.blocked) { - // Custom handling for reels in overlay instead of snackbar - if (request.url.contains('/reels/')) { - setState(() => _reelsBlockedOverlay = true); - return NavigationDecision.prevent; - } - return NavigationDecision.prevent; - } - - return NavigationDecision.navigate; - }, - onWebResourceError: (error) { - if (error.isForMainFrame == true && - (error.errorCode == -2 || error.errorCode == -6)) { - if (mounted) setState(() => _hasError = true); - } - }, - ), - ) - ..addJavaScriptChannel( - 'FocusGramNotificationChannel', - onMessageReceived: (message) { - final settings = context.read(); - final msg = message.message; - - // Check if it's a bridge payload (Title: Body) or a simple flag (DM/Activity) - String title = ''; - String body = ''; - bool isDM = false; - - if (msg.contains(': ')) { - final parts = msg.split(': '); - title = parts[0]; - body = parts.sublist(1).join(': '); - isDM = - title.toLowerCase().contains('message') || - title.toLowerCase().contains('direct'); - } else { - isDM = msg == 'DM'; - title = isDM ? 'Instagram Message' : 'Instagram Notification'; - body = isDM - ? 'Someone messaged you' - : 'New activity in notifications'; - } - - if (isDM && !settings.notifyDMs) return; - if (!isDM && !settings.notifyActivity) return; - - try { - NotificationService().showNotification( - id: DateTime.now().millisecond, - title: title, - body: body, - ); - } catch (_) {} - }, - ) - ..addJavaScriptChannel( - 'FocusGramShareChannel', - onMessageReceived: (message) { - try { - final data = message.message; - String url = data; - try { - final json = RegExp(r'"url":"([^"]+)"').firstMatch(data); - if (json != null) url = json.group(1) ?? data; - } catch (_) {} - Clipboard.setData(ClipboardData(text: url)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Link copied (tracking removed)'), - backgroundColor: const Color(0xFF1A1A2E), - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.fromLTRB(16, 0, 16, 20), - ), - ); - } - } catch (_) {} - }, - ) - ..addJavaScriptChannel( - 'FocusGramThemeChannel', - onMessageReceived: (message) { - context.read().setDarkMode( - message.message == 'dark', - ); - }, - ) - ..addJavaScriptChannel( - 'FocusGramPathChannel', - onMessageReceived: (message) { - if (!mounted) return; - final path = message.message; - final sm = context.read(); - if (path.startsWith('/reels') && !sm.isSessionActive) { - // SPA navigation landed on Reels without a session β€” gate it. - setState(() => _reelsBlockedOverlay = true); - // Navigate back to home feed so the overlay has content behind it. - _controller.runJavaScript( - 'if (window.location.pathname.startsWith("/reels")) window.location.href = "/";', - ); - } else if (_reelsBlockedOverlay && !path.startsWith('/reels')) { - setState(() => _reelsBlockedOverlay = false); - } - }, - ) - ..loadRequest(Uri.parse('https://www.instagram.com/accounts/login/')); + void _initPullToRefresh() { + _pullToRefreshController = PullToRefreshController( + settings: PullToRefreshSettings(color: Colors.blue), + onRefresh: () async { + await _controller?.reload(); + }, + ); } - void _applyInjections() { - if (!mounted) return; - if (_isOnOnboardingPage) return; + void _initWebView() { + // Preloader disabled β€” keepAlive WebView silently fails when app cold-starts, + // leaving _isPreloaded = true with no content, causing permanent black screen. + // The fresh load path is reliable; the ~300ms preload gain is not worth it. + _isPreloaded = false; - final sessionManager = context.read(); - final settings = context.read(); - final js = InjectionController.buildInjectionJS( - sessionActive: sessionManager.isSessionActive, - blurExplore: settings.blurExplore, - blurReels: settings.blurReels, - ghostTyping: settings.ghostTyping, - ghostSeen: settings.ghostSeen, - ghostStories: settings.ghostStories, - ghostDmPhotos: settings.ghostDmPhotos, - enableTextSelection: settings.enableTextSelection, - ); - _controller.runJavaScript(js); + setState(() { + _currentUrl = 'https://www.instagram.com/accounts/login/'; + }); + + // If not preloaded, controller will be created in onWebViewCreated + _injectionManager = null; + + // Nothing else to do here – configuration is on the InAppWebView widget } Future _signOut() async { - final manager = WebViewCookieManager(); - await manager.clearCookies(); - await _controller.clearCache(); + final cookieManager = CookieManager.instance(); + await cookieManager.deleteAllCookies(); + await InAppWebViewController.clearAllCache(); if (mounted) { setState(() { + _showSkeleton = true; _isLoading = true; _reelsBlockedOverlay = false; }); - await _controller.loadRequest( - Uri.parse('https://www.instagram.com/accounts/login/'), + await _controller?.loadUrl( + urlRequest: URLRequest( + url: WebUri('https://www.instagram.com/accounts/login/'), + ), ); } } @@ -513,12 +418,15 @@ class _MainWebViewPageState extends State color: Colors.white24, size: 14, ), - onTap: () { + onTap: () async { Navigator.pop(context); if (sm.startSession(mins)) { + HapticFeedback.mediumImpact(); setState(() => _reelsBlockedOverlay = false); - _controller.loadRequest( - Uri.parse('https://www.instagram.com/reels/'), + await _controller?.loadUrl( + urlRequest: URLRequest( + url: WebUri('https://www.instagram.com/reels/'), + ), ); } }, @@ -533,46 +441,44 @@ class _MainWebViewPageState extends State if (didPop) return; if (_reelsBlockedOverlay) { setState(() => _reelsBlockedOverlay = false); - _controller.goBack(); + await _controller?.goBack(); return; } - // Run history.back() in the WebView JS context first. - // This properly closes Instagram's comment sheet / modal overlay - // (which uses the History API pushState). If the webview itself - // can go back in its own page-level history, canGoBack() handles it. - // We use a JS promise to detect whether we actually navigated: - final didNavigate = await _controller - .runJavaScriptReturningResult( - '(function(){' - ' var before = window.location.href;' - ' history.back();' - ' return before;' - '})()', - ) - .then((_) => true) - .catchError((_) => false); - if (didNavigate) { - // history.back() was called β€” wait a frame to let the SPA handle it - // If the URL didn't change (e.g., no more history states), fall - // through to webview-level back or app exit. + final didNavigate = + await (_controller + ?.evaluateJavascript( + source: + '(function(){' + ' var before = window.location.href;' + ' history.back();' + ' return before;' + '})()', + ) + .then((_) => true) + .catchError((_) => false)) ?? + false; + if (didNavigate == true) { await Future.delayed(const Duration(milliseconds: 120)); return; } - if (await _controller.canGoBack()) { - await _controller.goBack(); + if (await (_controller?.canGoBack() ?? Future.value(false))) { + await _controller?.goBack(); } else { SystemNavigator.pop(); } }, child: Scaffold( - backgroundColor: context.watch().isDarkMode - ? Colors.black - : Colors.white, + // FIX 1: Use a solid color that matches the WebView background. + // When transparentBackground is false (see WebView settings), the + // WebView renders its own white/black background. Using black here + // matches the dark-mode WebView bg and prevents "flash of white". + backgroundColor: Colors.black, body: Stack( children: [ SafeArea( child: Column( children: [ + const _UpdateBanner(), if (!_isOnOnboardingPage) _BrandedTopBar( onFocusControlTap: () => @@ -625,7 +531,325 @@ class _MainWebViewPageState extends State ), ); } - return WebViewWidget(controller: _controller); + + final settings = context.read(); + + return Stack( + children: [ + InAppWebView( + keepAlive: InstagramPreloader.keepAlive, + initialUrlRequest: _isPreloaded + ? null + : URLRequest( + url: WebUri( + 'https://www.instagram.com/accounts/login/', + ), + ), + initialSettings: InAppWebViewSettings( + userAgent: InjectionController.iOSUserAgent, + mediaPlaybackRequiresUserGesture: + settings.blockAutoplay, + useHybridComposition: true, + cacheEnabled: true, + cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK, + domStorageEnabled: true, + databaseEnabled: true, + hardwareAcceleration: true, + // FIX 2: Set to false so the WebView renders + // its own opaque background. When true + black + // Scaffold, you see black until Instagram + // finishes painting β€” looks like a freeze/hang. + transparentBackground: false, + safeBrowsingEnabled: false, + disableContextMenu: false, + supportZoom: false, + allowsInlineMediaPlayback: true, + verticalScrollBarEnabled: false, + horizontalScrollBarEnabled: false, + ), + initialUserScripts: UnmodifiableListView([ + UserScript( + source: + 'window.__fgBlockAutoplay = ${settings.blockAutoplay};', + 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, + ), + if (settings.minimalModeEnabled) + UserScript( + source: kMinimalModeCssScript, + injectionTime: UserScriptInjectionTime + .AT_DOCUMENT_START, + ) + else ...[ + if (settings.disableReelsEntirely) + UserScript( + source: kDisableReelsEntirelyCssScript, + injectionTime: UserScriptInjectionTime + .AT_DOCUMENT_START, + ), + if (settings.disableExploreEntirely) + UserScript( + source: kDisableExploreEntirelyCssScript, + injectionTime: UserScriptInjectionTime + .AT_DOCUMENT_START, + ), + ], + UserScript( + source: kHapticBridgeScript, + injectionTime: + UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ]), + pullToRefreshController: _pullToRefreshController, + onWebViewCreated: (controller) async { + _controller = controller; + + // Capture settingsService before async gap to avoid BuildContext warning + final settingsService = context + .read(); + final prefs = + await SharedPreferences.getInstance(); + _injectionManager = InjectionManager( + controller: controller, + prefs: prefs, + sessionManager: sm, + ); + _injectionManager!.setSettingsService( + settingsService, + ); + + _registerJavaScriptHandlers(controller); + + // Start safety timeout β€” clears loading state + // if onLoadStop never fires (e.g. network stall). + _resetLoadingTimeout(); + }, + onLoadStart: (controller, url) { + if (!mounted) return; + final u = url?.toString() ?? ''; + final lower = u.toLowerCase(); + final isOnboardingUrl = + lower.contains('/accounts/login') || + lower.contains('/accounts/emailsignup') || + lower.contains('/accounts/signup') || + lower.contains('/legal/') || + lower.contains('/help/'); + setState(() { + _isLoading = true; + _lastMainFrameLoadStartedAt = DateTime.now(); + _currentUrl = u; + _hasError = false; + _showSkeleton = !isOnboardingUrl; + // Update skeleton type based on the URL being loaded + _skeletonType = getSkeletonTypeFromUrl(u); + }); + // FIX 4: Reset the safety timeout on each new load + _resetLoadingTimeout(); + }, + onLoadStop: (controller, url) async { + _pullToRefreshController.endRefreshing(); + if (!mounted) return; + + // FIX 4: Cancel the safety timeout β€” load completed normally + _loadingTimeout?.cancel(); + + final current = url?.toString() ?? ''; + setState(() { + _isLoading = false; + _currentUrl = current; + _hasError = false; + }); + + await _injectionManager + ?.runAllPostLoadInjections(current); + + await controller.evaluateJavascript( + source: + InjectionController.notificationBridgeJS, + ); + + final isIsolatedReel = + current.contains('/reel/') && + !current.contains('/reels/'); + await _setIsolatedPlayer(isIsolatedReel); + + await controller.evaluateJavascript( + source: kNativeFeelingPostLoadScript, + ); + + await Future.delayed( + const Duration(milliseconds: 100), + ); + if (mounted) { + setState(() => _showSkeleton = false); + } + }, + shouldOverrideUrlLoading: + (controller, navigationAction) async { + final url = + navigationAction.request.url + ?.toString() ?? + ''; + final uri = navigationAction.request.url; + final appSettings = context + .read(); + + final minimal = + appSettings.minimalModeEnabled; + final disableReels = + appSettings.disableReelsEntirely || + minimal; + final disableExplore = + appSettings.disableExploreEntirely || + minimal; + + bool isReelsUrl(String u) => + u.contains('/reel/') || + u.contains('/reels/'); + bool isExploreUrl(String u) => + u.contains('/explore/'); + + void showBlocked(String msg) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text(msg), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 20, + ), + ), + ); + } + + if (disableReels && isReelsUrl(url)) { + showBlocked('Reels are disabled'); + return NavigationActionPolicy.CANCEL; + } + + if (disableExplore && isExploreUrl(url)) { + showBlocked('Explore is disabled'); + return NavigationActionPolicy.CANCEL; + } + + if (uri != null && + uri.host.contains('instagram.com') && + (url.contains('accounts/settings') || + url.contains('accounts/edit'))) { + return NavigationActionPolicy.ALLOW; + } + + if (url.contains('/reels/') && + !context + .read() + .isSessionActive) { + setState( + () => _reelsBlockedOverlay = true, + ); + return NavigationActionPolicy.CANCEL; + } + + if (uri != null && + !uri.host.contains('instagram.com') && + !uri.host.contains('facebook.com') && + !uri.host.contains( + 'cdninstagram.com', + ) && + !uri.host.contains('fbcdn.net')) { + await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + return NavigationActionPolicy.CANCEL; + } + + final decision = NavigationGuard.evaluate( + url: url, + ); + if (decision.blocked) { + if (url.contains('/reels/')) { + setState( + () => _reelsBlockedOverlay = true, + ); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.CANCEL; + } + + return NavigationActionPolicy.ALLOW; + }, + onReceivedError: (controller, request, error) { + // FIX 5: Clear loading state on ANY main-frame + // error, not just HOST_LOOKUP and TIMEOUT. + // Previously, errors like CONNECTION_REFUSED or + // FAILED_URL_BLOCKED left _isLoading = true + // forever, causing the apparent "hang". + if (request.isForMainFrame == true) { + _loadingTimeout?.cancel(); + if (mounted) { + setState(() { + _isLoading = false; + _showSkeleton = false; + // Only show the full error screen for + // network-level failures, not blocked URLs + if (error.type == + WebResourceErrorType + .HOST_LOOKUP || + error.type == + WebResourceErrorType.TIMEOUT) { + _hasError = true; + } + }); + } + } + }, + ), + + if (_showSkeleton) + SkeletonScreen(skeletonType: _skeletonType), + + if (!_isOnOnboardingPage && + settings.minimalModeEnabled && + !_minimalModeBannerDismissed) + Positioned( + left: 12, + right: 12, + top: 12, + child: _MinimalModeBanner( + onDismiss: () { + HapticFeedback.lightImpact(); + setState( + () => _minimalModeBannerDismissed = true, + ); + }, + ), + ), + + // Instagram's native bottom nav is used directly. + // NativeBottomNav overlay removed β€” faster, looks native, + // and reels tap naturally hits shouldOverrideUrlLoading. + ], + ); }, ), ), @@ -636,17 +860,19 @@ class _MainWebViewPageState extends State _NoInternetScreen( onRetry: () { setState(() => _hasError = false); - _controller.reload(); + _controller?.reload(); }, ), if (_isLoading) Positioned( - top: 60 + MediaQuery.of(context).padding.top, + top: + (_isOnOnboardingPage ? 0 : 60) + + MediaQuery.of(context).padding.top, left: 0, right: 0, child: const _InstagramGradientProgressBar(), ), - _EdgePanel(key: _edgePanelKey, controller: _controller), + _EdgePanel(key: _edgePanelKey), if (_reelsBlockedOverlay) Positioned.fill( @@ -665,6 +891,9 @@ class _MainWebViewPageState extends State builder: (ctx, sm, _) { final onCooldown = sm.isCooldownActive; final quotaFinished = sm.dailyRemainingSeconds <= 0; + final reelsHardDisabled = + settings.disableReelsEntirely || + settings.minimalModeEnabled; return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -682,7 +911,9 @@ class _MainWebViewPageState extends State Text( quotaFinished ? 'Daily Quota Finished' - : 'Reels are Blocked', + : (reelsHardDisabled + ? 'Reels are Disabled' + : 'Reels are Blocked'), style: GoogleFonts.grandHotel( color: textMain, fontSize: 42, @@ -692,7 +923,9 @@ class _MainWebViewPageState extends State Text( quotaFinished ? 'You have reached your planned limit for today. Step away and focus on what matters most.' - : 'Start a planned reel session to access the feed. Use Instagram for connection, not distraction.', + : (reelsHardDisabled + ? 'Reels are disabled in your settings.' + : 'Start a planned reel session to access the feed. Use Instagram for connection, not distraction.'), textAlign: TextAlign.center, style: TextStyle( color: textDim, @@ -720,6 +953,15 @@ class _MainWebViewPageState extends State fontSize: 12, ), ), + ] else if (reelsHardDisabled) ...[ + Text( + 'You can re-enable Reels in Settings > Focus.', + textAlign: TextAlign.center, + style: TextStyle( + color: textSub, + fontSize: 12, + ), + ), ] else if (onCooldown) ...[ Container( padding: const EdgeInsets.symmetric( @@ -792,7 +1034,7 @@ class _MainWebViewPageState extends State TextButton( onPressed: () { setState(() => _reelsBlockedOverlay = false); - _controller.goBack(); + _controller?.goBack(); }, child: Text( 'Go Back', @@ -812,11 +1054,223 @@ class _MainWebViewPageState extends State ), ); } + + void _registerJavaScriptHandlers(InAppWebViewController controller) { + controller.addJavaScriptHandler( + handlerName: 'FocusGramNotificationChannel', + callback: (args) { + if (!mounted) return null; + final settings = context.read(); + final msg = (args.isNotEmpty ? args[0] : '') as String; + + if (DateTime.now().difference(_lastMainFrameLoadStartedAt).inSeconds < + 6) { + return null; + } + + String title = ''; + String body = ''; + bool isDM = false; + + if (msg.contains(': ')) { + final parts = msg.split(': '); + title = parts[0]; + body = parts.sublist(1).join(': '); + isDM = + title.toLowerCase().contains('message') || + title.toLowerCase().contains('direct'); + } else { + isDM = msg == 'DM'; + title = isDM ? 'Instagram Message' : 'Instagram Notification'; + body = isDM + ? 'Someone messaged you' + : 'New activity in notifications'; + } + + if (isDM && !settings.notifyDMs) return null; + if (!isDM && !settings.notifyActivity) return null; + + try { + NotificationService().showNotification( + id: DateTime.now().millisecond, + title: title, + body: body, + ); + } catch (_) {} + return null; + }, + ); + + controller.addJavaScriptHandler( + handlerName: 'FocusGramBlocked', + callback: (args) { + if (!mounted) return null; + final what = (args.isNotEmpty ? args[0] : '') as String? ?? ''; + final text = what == 'reels' + ? 'Reels are disabled' + : (what == 'explore' ? 'Explore is disabled' : 'Content disabled'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(text), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 20), + ), + ); + return null; + }, + ); + + controller.addJavaScriptHandler( + handlerName: 'FocusGramShareChannel', + callback: (args) { + if (!mounted) return; + try { + final data = (args.isNotEmpty ? args[0] : '') as String; + String url = data; + try { + final match = RegExp(r'"url":"([^"]+)"').firstMatch(data); + if (match != null) url = match.group(1) ?? data; + } catch (_) {} + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Link copied (tracking removed)'), + backgroundColor: Color(0xFF1A1A2E), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.fromLTRB(16, 0, 16, 20), + ), + ); + } catch (_) {} + }, + ); + + controller.addJavaScriptHandler( + handlerName: 'FocusGramThemeChannel', + callback: (args) { + final value = (args.isNotEmpty ? args[0] : '') as String; + context.read().setDarkMode(value == 'dark'); + }, + ); + + controller.addJavaScriptHandler( + handlerName: 'Haptic', + callback: (args) { + HapticFeedback.lightImpact(); + return null; + }, + ); + + controller.addJavaScriptHandler( + handlerName: 'UrlChange', + callback: (args) async { + final url = (args.isNotEmpty ? args[0] : '') as String? ?? ''; + await _injectionManager?.runAllPostLoadInjections(url); + if (!mounted) return; + setState(() { + _currentUrl = url; + // SPA navigations never fire onLoadStop β€” clear skeleton here + // so it doesn't stay visible forever (e.g. when navigating to DMs) + _showSkeleton = false; + _isLoading = false; + }); + + final settings = context.read(); + final minimal = settings.minimalModeEnabled; + final disableReels = settings.disableReelsEntirely || minimal; + final disableExplore = settings.disableExploreEntirely || minimal; + + final path = Uri.tryParse(url)?.path ?? url; + final isReels = path.startsWith('/reels') || path.startsWith('/reel/'); + final isExplore = path.startsWith('/explore'); + + // Block reel navigation that slipped through (e.g. DM-embedded reels) + if (disableReels && isReels) { + setState(() => _reelsBlockedOverlay = true); + await _controller?.goBack(); + return; + } + + if (_controller != null) { + if (disableExplore && isExplore) { + await _controller!.loadUrl( + urlRequest: URLRequest(url: WebUri('https://www.instagram.com/')), + ); + } + } + + // Update isolated player flag for DM-embedded reels + final isIsolatedReel = + path.contains('/reel/') && !path.startsWith('/reels'); + await _setIsolatedPlayer(isIsolatedReel); + + return null; + }, + ); + } +} + +// ─── Supporting widgets (unchanged) ────────────────────────────────────────── + +class _MinimalModeBanner extends StatelessWidget { + final VoidCallback onDismiss; + + const _MinimalModeBanner({required this.onDismiss}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white).withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isDark ? Colors.white12 : Colors.black12, + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.35 : 0.12), + blurRadius: 18, + spreadRadius: 2, + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Minimal mode β€” Feed & DMs only 🎯', + style: TextStyle( + color: isDark ? Colors.white : Colors.black, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + InkWell( + onTap: onDismiss, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.close, + size: 18, + color: isDark ? Colors.white70 : Colors.black54, + ), + ), + ), + ], + ), + ), + ); + } } class _EdgePanel extends StatefulWidget { - final WebViewController controller; - const _EdgePanel({super.key, required this.controller}); + const _EdgePanel({super.key}); @override State<_EdgePanel> createState() => _EdgePanelState(); } @@ -838,6 +1292,8 @@ class _EdgePanelState extends State<_EdgePanel> { final settings = context.watch(); final isDark = settings.isDarkMode; + final reelsHardDisabled = + settings.disableReelsEntirely || settings.minimalModeEnabled; final panelBg = isDark ? const Color(0xFF121212) : Colors.white; final textDim = isDark ? Colors.white70 : Colors.black87; final textSub = isDark ? Colors.white30 : Colors.black38; @@ -951,7 +1407,9 @@ class _EdgePanelState extends State<_EdgePanel> { isWarning: sm.isCooldownActive, isDark: isDark, ), - if (!sm.isSessionActive && sm.dailyRemainingSeconds > 0) ...[ + if (!reelsHardDisabled && + !sm.isSessionActive && + sm.dailyRemainingSeconds > 0) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -978,6 +1436,12 @@ class _EdgePanelState extends State<_EdgePanel> { ), ), ), + ] else if (reelsHardDisabled) ...[ + const SizedBox(height: 10), + Text( + 'Reels disabled in settings', + style: TextStyle(color: textSub, fontSize: 11), + ), ], const SizedBox(height: 32), Divider(color: isDark ? Colors.white10 : Colors.black12), @@ -1158,6 +1622,80 @@ class _InstagramGradientProgressBar extends StatelessWidget { } } +class _UpdateBanner extends StatelessWidget { + const _UpdateBanner(); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, update, _) { + if (!update.hasUpdate) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade700, Colors.blue.shade900], + ), + ), + child: Row( + children: [ + const Icon( + Icons.system_update_alt, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Update Available: ${update.updateInfo?.latestVersion ?? ''}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + InkWell( + onTap: () { + final url = update.updateInfo?.releaseUrl; + if (url != null && url.isNotEmpty) { + launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } + }, + child: const Text( + 'Download on GitHub β†’', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => update.dismissUpdate(), + icon: const Icon(Icons.close, color: Colors.white70, size: 18), + ), + ], + ), + ); + }, + ); + } +} + class _NoInternetScreen extends StatelessWidget { final VoidCallback onRetry; const _NoInternetScreen({required this.onRetry}); diff --git a/lib/screens/onboarding_page.dart b/lib/screens/onboarding_page.dart index e3d21b4..7eb495d 100644 --- a/lib/screens/onboarding_page.dart +++ b/lib/screens/onboarding_page.dart @@ -18,58 +18,61 @@ class _OnboardingPageState extends State { final PageController _pageController = PageController(); int _currentPage = 0; - final List _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(); + + final List 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 { 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 { 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 { ), ), const SizedBox(height: 32), + // CTA button Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: SizedBox( @@ -112,24 +117,38 @@ class _OnboardingPageState extends State { 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 { ), ), 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 { ), ), ), + // 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 { ); } - void _finish() { + void _finish(BuildContext context) { context.read().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 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, + ), + ], + ), + ); + } +} diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart index cadba89..e8b0a66 100644 --- a/lib/screens/reel_player_overlay.dart +++ b/lib/screens/reel_player_overlay.dart @@ -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 { - 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 { ), ], ), - 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; + }, + ), ); } } diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index e92971c..bd2abf4 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../services/session_manager.dart'; import '../services/settings_service.dart'; import '../services/focusgram_router.dart'; +import '../features/screen_time/screen_time_screen.dart'; import 'guardrails_page.dart'; -import 'about_page.dart'; + +// ─── Main Settings Page ─────────────────────────────────────────────────────── class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context) { - // Watching services ensures the UI rebuilds when settings or session state change. final sm = context.watch(); final settings = context.watch(); final isDark = settings.isDarkMode; @@ -19,7 +23,7 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: AppBar( title: const Text( - 'FocusGram', + 'Settings', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), centerTitle: true, @@ -30,48 +34,110 @@ class SettingsPage extends StatelessWidget { ), body: ListView( children: [ - // ── Stats row ─────────────────────────────────────────── _buildStatsRow(sm), - // ── Settings Subsections ────────────────────────────── - _buildSettingsTile( - context: context, - title: 'Guardrails', - subtitle: 'Daily limit, cooldown, and scheduled blocking', - icon: Icons.shield_outlined, - destination: const GuardrailsPage(), + const _SectionHeader(title: 'FOCUS & BLOCKING'), + _SubmoduleTile( + icon: Icons.block_rounded, + iconColor: Colors.redAccent, + title: 'Focus Mode', + subtitle: settings.minimalModeEnabled + ? 'Minimal mode on' + : settings.disableReelsEntirely + ? 'Reels fully disabled' + : 'Blocking, friction, media', + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const FocusSettingsPage()), + ), ), - _buildSettingsTile( - context: context, - title: 'Distraction Management', - subtitle: 'Blur explore and reel controls', - icon: Icons.visibility_off_outlined, - destination: const _DistractionSettingsPage(), + _SubmoduleTile( + icon: Icons.timer_outlined, + iconColor: Colors.blueAccent, + title: 'Time Control & Guardrails', + subtitle: 'Daily limit, cooldown, scheduled blocking', + enabled: + !(settings.disableReelsEntirely || settings.minimalModeEnabled), + disabledSubtitle: 'Reels are fully disabled', + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const GuardrailsPage()), + ), ), - _buildSettingsTile( - context: context, - title: 'Extras', - subtitle: 'Ghost mode, text selection and experimental features', - icon: Icons.extension_outlined, - destination: const _ExtrasSettingsPage(), + + const _SectionHeader(title: 'APPEARANCE'), + _SubmoduleTile( + icon: Icons.palette_outlined, + iconColor: Colors.purpleAccent, + title: 'Appearance', + subtitle: settings.grayscaleEnabled + ? 'Grayscale on' + : settings.grayscaleScheduleEnabled + ? 'Grayscale scheduled at ${settings.grayscaleScheduleTime}' + : 'Theme, grayscale', + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AppearancePage()), + ), ), - _buildSettingsTile( - context: context, - title: 'Notifications', - subtitle: 'Manage message and activity alerts', - icon: Icons.notifications_active_outlined, - destination: const _NotificationSettingsPage(), + + const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'), + _SubmoduleTile( + icon: Icons.lock_outline, + iconColor: Colors.tealAccent, + title: 'Privacy & Notifications', + subtitle: 'Link sanitization, session end alerts', + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PrivacyNotificationsPage(), + ), + ), ), - _buildSettingsTile( - context: context, - title: 'About', - subtitle: 'Developer info and GitHub', - icon: Icons.info_outline, - destination: const AboutPage(), + + const _SectionHeader(title: 'STATS & HISTORY'), + _SubmoduleTile( + icon: Icons.bar_chart_rounded, + iconColor: Colors.greenAccent, + title: 'Screen Time Dashboard', + subtitle: 'Daily & weekly usage', + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ScreenTimeScreen()), + ), + ), + + const _SectionHeader(title: 'ABOUT'), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) => ListTile( + title: const Text('Version'), + trailing: Text( + snapshot.data?.version ?? '…', + style: const TextStyle(color: Colors.grey), + ), + ), + ), + ListTile( + title: const Text('GitHub'), + trailing: const Icon(Icons.open_in_new, size: 14), + onTap: () => launchUrl( + Uri.parse('https://github.com/Ujwal223/FocusGram'), + mode: LaunchMode.externalApplication, + ), + ), + ListTile( + title: const Text('Legal Disclaimer'), + trailing: const Icon(Icons.info_outline, size: 14), + onTap: () => _showLegalDisclaimer(context), + ), + ListTile( + title: const Text('Open Source Licenses'), + trailing: const Icon(Icons.arrow_forward_ios, size: 14), + onTap: () => showLicensePage(context: context), ), const Divider(height: 40, indent: 16, endIndent: 16), - ListTile( leading: const Icon( Icons.settings_outlined, @@ -88,17 +154,15 @@ class SettingsPage extends StatelessWidget { size: 14, ), onTap: () { - // Bug 6 fix: navigate inside the WebView instead of external browser Navigator.pop(context); FocusGramRouter.pendingUrl.value = 'https://www.instagram.com/accounts/settings/?entrypoint=profile'; }, ), - const SizedBox(height: 40), Center( child: Text( - 'FocusGram Β· Built for discipline', + 'FocusGram Β· Built with πŸ’– by Ujwal Chapagain', style: TextStyle( color: isDark ? Colors.white12 : Colors.black12, fontSize: 12, @@ -111,25 +175,6 @@ class SettingsPage extends StatelessWidget { ); } - Widget _buildSettingsTile({ - required BuildContext context, - required String title, - required String subtitle, - required IconData icon, - required Widget destination, - }) { - return ListTile( - leading: Icon(icon, color: Colors.blue), - title: Text(title), - subtitle: Text(subtitle, style: const TextStyle(fontSize: 13)), - trailing: const Icon(Icons.arrow_forward_ios, size: 14), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => destination), - ), - ); - } - Widget _buildStatsRow(SessionManager sm) { return Container( margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), @@ -160,78 +205,47 @@ class SettingsPage extends StatelessWidget { ); } - Widget _statCell(String label, String value, Color color) { - return Column( - children: [ - Text( - value, - style: TextStyle( - color: color, - fontSize: 22, - fontWeight: FontWeight.bold, - ), + Widget _statCell(String label, String value, Color color) => Column( + children: [ + Text( + value, + style: TextStyle( + color: color, + fontSize: 22, + fontWeight: FontWeight.bold, ), - const SizedBox(height: 4), - Text(label, style: const TextStyle(color: Colors.grey, fontSize: 11)), - ], - ); - } + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 11)), + ], + ); Widget _dividerCell() => Container( width: 1, height: 36, color: Colors.blue.withValues(alpha: 0.1), ); -} -class _DistractionSettingsPage extends StatelessWidget { - const _DistractionSettingsPage(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - return Scaffold( - appBar: AppBar( - title: const Text( - 'Distraction Management', - style: TextStyle(fontSize: 17), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - SwitchListTile( - title: const Text('Blur Posts and Explore'), - subtitle: const Text( - 'Blurs images and videos on the home feed and Explore page', - style: TextStyle(fontSize: 13), - ), - value: settings.blurExplore, - onChanged: (v) => settings.setBlurExplore(v), - activeThumbColor: Colors.blue, + void _showLegalDisclaimer(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Legal Disclaimer'), + content: const SingleChildScrollView( + child: Text( + 'FocusGram is an independent, free, and open-source productivity tool ' + 'licensed under AGPL-3.0. Not affiliated with Meta or Instagram.\n\n' + 'How it works: FocusGram embeds a standard Android System WebView that loads instagram.com. \n' + 'All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta servers.\n\n' + 'All features are client-side only. We do not use private APIs, ' + 'intercept credentials, scrape, harvest or collect any user data.', + style: TextStyle(fontSize: 13, height: 1.4), ), - SwitchListTile( - title: const Text('Mindfulness Gate'), - subtitle: const Text( - 'Show breathing exercise before opening', - style: TextStyle(fontSize: 13), - ), - value: settings.showBreathGate, - onChanged: (v) => settings.setShowBreathGate(v), - activeThumbColor: Colors.blue, - ), - SwitchListTile( - title: const Text('Strict Changes (Word Challenge)'), - subtitle: const Text( - 'Requires 15-word typing challenge before lax changes', - style: TextStyle(fontSize: 13), - ), - value: settings.requireWordChallenge, - onChanged: (v) => settings.setRequireWordChallenge(v), - activeThumbColor: Colors.blue, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), ), ], ), @@ -239,209 +253,339 @@ class _DistractionSettingsPage extends StatelessWidget { } } -class _FrictionSliderTile extends StatefulWidget { +// ─── Focus Settings ─────────────────────────────────────────────────────────── + +class FocusSettingsPage extends StatelessWidget { + const FocusSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return Scaffold( + appBar: _subAppBar(context, 'Focus Mode'), + body: ListView( + children: [ + const _SectionHeader(title: 'BLOCKING'), + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.blue.withValues(alpha: 0.12)), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, size: 14, color: Colors.blueAccent), + SizedBox(width: 8), + Expanded( + child: Text( + 'Blocking changes apply immediately. The page reloads automatically in the background.', + style: TextStyle(fontSize: 11, color: Colors.blueAccent), + ), + ), + ], + ), + ), + _SwitchTile( + title: 'Minimal Mode', + subtitle: + 'Feed and DMs only β€” blocks Reels, Explore, Stories, Suggested', + value: settings.minimalModeEnabled, + onChanged: (v) async { + await settings.setMinimalModeEnabled(v); + HapticFeedback.selectionClick(); + }, + ), + _SwitchTile( + title: 'Disable Reels Entirely', + subtitle: 'Block all Reels with no session option', + value: settings.disableReelsEntirely, + enabled: !settings.minimalModeEnabled, + disabledSubtitle: 'Included in Minimal Mode', + onChanged: (v) => settings.setDisableReelsEntirely(v), + ), + + const _SectionHeader(title: 'FRICTION'), + _SwitchTile( + title: 'Mindfulness Gate', + subtitle: 'Breath / intention screen before opening Instagram', + value: settings.showBreathGate, + onChanged: (v) => settings.setShowBreathGate(v), + ), + _SwitchTile( + title: 'Strict Mode (Word Challenge)', + subtitle: 'Must type a phrase before starting a Reel session', + value: settings.requireWordChallenge, + onChanged: (v) => settings.setRequireWordChallenge(v), + ), + const _SectionHeader(title: 'MEDIA'), + _SwitchTile( + title: 'Block Autoplay Videos', + subtitle: 'Videos won\'t play until you tap them', + value: settings.blockAutoplay, + onChanged: (v) => settings.setBlockAutoplay(v), + ), + _SwitchTile( + title: 'Blur Feed & Explore', + subtitle: 'Blurs post thumbnails until tapped', + value: settings.blurExplore, + onChanged: (v) => settings.setBlurExplore(v), + ), + + const SizedBox(height: 40), + ], + ), + ); + } +} + +// ─── Appearance ─────────────────────────────────────────────────────────────── + +class AppearancePage extends StatelessWidget { + const AppearancePage({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return Scaffold( + appBar: _subAppBar(context, 'Appearance'), + body: ListView( + children: [ + const _SectionHeader(title: 'DISPLAY'), + _SwitchTile( + title: 'Grayscale Mode', + subtitle: + 'Makes Instagram black & white β€” reduces dopamine response', + value: settings.grayscaleEnabled, + onChanged: (v) => settings.setGrayscaleEnabled(v), + ), + const _SectionHeader(title: 'GRAYSCALE SCHEDULE'), + _SwitchTile( + title: 'Schedule Grayscale', + subtitle: 'Auto-enable grayscale at a set time each day', + value: settings.grayscaleScheduleEnabled, + onChanged: (v) => settings.setGrayscaleScheduleEnabled(v), + ), + if (settings.grayscaleScheduleEnabled) + ListTile( + leading: const Icon( + Icons.access_time, + color: Colors.blueAccent, + size: 20, + ), + title: const Text('Start Time'), + subtitle: const Text( + 'Grayscale activates at this time and stays on until midnight', + style: TextStyle(fontSize: 12), + ), + trailing: Text( + settings.grayscaleScheduleTime, + style: const TextStyle( + color: Colors.blueAccent, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + onTap: () async { + final parts = settings.grayscaleScheduleTime.split(':'); + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.parse(parts[0]), + minute: int.parse(parts[1]), + ), + ); + if (time != null) { + settings.setGrayscaleScheduleTime( + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', + ); + } + }, + ), + if (settings.grayscaleScheduleEnabled) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + settings.isGrayscaleActiveNow + ? '● Grayscale is active now' + : 'β—‹ Grayscale will activate at ${settings.grayscaleScheduleTime}', + style: TextStyle( + fontSize: 12, + color: settings.isGrayscaleActiveNow + ? Colors.greenAccent + : Colors.grey, + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } +} + +// ─── Privacy & Notifications ────────────────────────────────────────────────── + +class PrivacyNotificationsPage extends StatelessWidget { + const PrivacyNotificationsPage({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return Scaffold( + appBar: _subAppBar(context, 'Privacy & Notifications'), + body: ListView( + children: [ + const _SectionHeader(title: 'NOTIFICATIONS'), + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withValues(alpha: 0.15)), + ), + child: const Text( + 'FocusGram can show notifications when your Focus session ends. ' + 'Instagram\'s own notification system handles background alerts.', + style: TextStyle(fontSize: 12, height: 1.5), + ), + ), + _SwitchTile( + title: 'Session End Notification', + subtitle: 'Notify when Focus session time is up', + value: settings.notifySessionEnd, + onChanged: (v) => settings.setNotifySessionEnd(v), + ), + + const _SectionHeader(title: 'INSTA NOTIFICATIONS'), + _SwitchTile( + title: 'DM Notifications', + subtitle: 'Show notification when someone messages you', + value: settings.notifyDMs, + onChanged: (v) => settings.setNotifyDMs(v), + ), + _SwitchTile( + title: 'Activity Notifications', + subtitle: 'Likes, comments, follows and other activity', + value: settings.notifyActivity, + onChanged: (v) => settings.setNotifyActivity(v), + ), + + const SizedBox(height: 40), + ], + ), + ); + } +} + +// ─── Shared widgets ─────────────────────────────────────────────────────────── + +PreferredSizeWidget _subAppBar(BuildContext context, String title) => AppBar( + title: Text( + title, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.pop(context), + ), +); + +class _SubmoduleTile extends StatelessWidget { + final IconData icon; + final Color iconColor; final String title; final String subtitle; - final double value; - final double min; - final double max; - final int divisor; - final String warningText; - final Future Function(double) onConfirmed; + final String? disabledSubtitle; + final bool enabled; + final VoidCallback onTap; - const _FrictionSliderTile({ + const _SubmoduleTile({ + required this.icon, + required this.iconColor, required this.title, required this.subtitle, - required this.value, - required this.min, - required this.max, - required this.divisor, - required this.warningText, - required this.onConfirmed, + this.disabledSubtitle, + this.enabled = true, + required this.onTap, }); - @override - State<_FrictionSliderTile> createState() => _FrictionSliderTileState(); -} - -class _FrictionSliderTileState extends State<_FrictionSliderTile> { - late double _draftValue; - bool _pendingConfirm = false; - - @override - void initState() { - super.initState(); - _draftValue = widget.value; - } - - @override - void didUpdateWidget(_FrictionSliderTile old) { - super.didUpdateWidget(old); - // Keep draft in sync if external value changed (e.g. after reset) - if (!_pendingConfirm) _draftValue = widget.value; - } - @override Widget build(BuildContext context) { - final divisions = ((widget.max - widget.min) / widget.divisor).round(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - title: Text(widget.title), - subtitle: Text( - '${_draftValue.toInt()} min', - style: const TextStyle(fontSize: 13), - ), - trailing: _pendingConfirm - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - setState(() { - _draftValue = widget.value; - _pendingConfirm = false; - }); - }, - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - setState(() => _pendingConfirm = false); - await widget.onConfirmed(_draftValue); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text( - 'Apply', - style: TextStyle(fontSize: 12), - ), - ), - ], - ) - : null, + return ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: (enabled ? iconColor : Colors.grey).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - if (_pendingConfirm) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - widget.warningText, - style: TextStyle( - color: Colors.orangeAccent.withValues(alpha: 0.8), - fontSize: 12, - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Slider( - value: _draftValue, - min: widget.min, - max: widget.max, - divisions: divisions, - activeColor: _pendingConfirm ? Colors.orange : Colors.blue, - onChanged: (v) { - setState(() { - _draftValue = v; - // Show friction warning when moving to a larger (more permissive) value - _pendingConfirm = v > widget.value; - }); - }, - onChangeEnd: (v) { - // If decreasing (more strict), apply immediately without dialog - if (v <= widget.value) { - widget.onConfirmed(v); - setState(() => _pendingConfirm = false); - } - }, - ), + child: Icon(icon, color: enabled ? iconColor : Colors.grey, size: 20), + ), + title: Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: enabled ? null : Colors.grey, ), - ], + ), + subtitle: Text( + enabled ? subtitle : (disabledSubtitle ?? subtitle), + style: const TextStyle(fontSize: 12), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 14, + color: Colors.grey, + ), + onTap: enabled ? onTap : null, ); } } -class _ExtrasSettingsPage extends StatelessWidget { - const _ExtrasSettingsPage(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text('Extras', style: TextStyle(fontSize: 17)), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - const _SettingsSectionHeader(title: 'EXPERIMENT'), - ListTile( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const _GhostModeSettingsPage()), - ), - leading: Icon( - Icons.visibility_off_outlined, - color: isDark ? Colors.white70 : Colors.black87, - ), - title: const Text('Ghost Mode'), - subtitle: Text( - settings.anyGhostModeEnabled - ? 'Active β€” some receipts are hidden' - : 'Disabled', - style: TextStyle( - color: settings.anyGhostModeEnabled - ? Colors.blue - : (isDark ? Colors.white38 : Colors.black38), - fontSize: 13, - ), - ), - trailing: const Icon(Icons.chevron_right), - ), - SwitchListTile( - title: const Text('Enable Text Selection'), - subtitle: const Text( - 'Allows copying text from posts and captions', - style: TextStyle(fontSize: 13), - ), - value: settings.enableTextSelection, - onChanged: (v) => settings.setEnableTextSelection(v), - activeThumbColor: Colors.blue, - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Experimental features: Some features may break if Instagram updates their website.', - style: TextStyle( - color: isDark ? Colors.white24 : Colors.black26, - fontSize: 11, - ), - ), - ), - ], - ), - ); - } -} - -class _SettingsSectionHeader extends StatelessWidget { +class _SwitchTile extends StatelessWidget { final String title; - const _SettingsSectionHeader({required this.title}); + final String? subtitle; + final String? disabledSubtitle; + final bool value; + final bool enabled; + final ValueChanged onChanged; + + const _SwitchTile({ + required this.title, + this.subtitle, + this.disabledSubtitle, + required this.value, + this.enabled = true, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: Text( + title, + style: TextStyle(fontSize: 15, color: enabled ? null : Colors.grey), + ), + subtitle: (subtitle != null || (!enabled && disabledSubtitle != null)) + ? Text( + enabled ? (subtitle ?? '') : (disabledSubtitle ?? subtitle ?? ''), + style: const TextStyle(fontSize: 12), + ) + : null, + value: value, + onChanged: enabled ? onChanged : null, + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String title; + const _SectionHeader({required this.title}); @override Widget build(BuildContext context) { @@ -450,7 +594,7 @@ class _SettingsSectionHeader extends StatelessWidget { child: Text( title, style: const TextStyle( - color: Colors.blue, + color: Colors.grey, fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 1.2, @@ -459,243 +603,3 @@ class _SettingsSectionHeader extends StatelessWidget { ); } } - -class _GhostModeSettingsPage extends StatelessWidget { - const _GhostModeSettingsPage(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text('Ghost Mode', style: TextStyle(fontSize: 17)), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - 'Control which activity receipts are hidden from other users. ', - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black45, - fontSize: 12, - height: 1.4, - ), - ), - ), - const _SettingsSectionHeader(title: 'MESSAGING'), - SwitchListTile( - secondary: Icon( - Icons.keyboard_outlined, - color: isDark ? Colors.white54 : Colors.black54, - ), - title: const Text('Hide typing indicator'), - subtitle: Text( - "Others won't see the 'typing...' status when you write a message", - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black45, - fontSize: 12, - ), - ), - value: settings.ghostTyping, - onChanged: (v) => settings.setGhostTyping(v), - activeThumbColor: Colors.blue, - ), - Stack( - children: [ - AbsorbPointer( - child: Opacity( - opacity: 0.5, - child: SwitchListTile( - secondary: Icon( - Icons.done_all_rounded, - color: isDark ? Colors.white54 : Colors.black54, - ), - title: const Text('Hide seen status'), - subtitle: Text( - "Others won't see when you've read their DMs", - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black45, - fontSize: 12, - ), - ), - value: settings.ghostSeen, - onChanged: (v) => settings.setGhostSeen(v), - activeThumbColor: Colors.blue, - ), - ), - ), - Positioned( - right: 16, - top: 0, - bottom: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.blue, width: 0.5), - ), - child: const Text( - 'COMING SOON', - style: TextStyle( - color: Colors.blue, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - SwitchListTile( - secondary: Icon( - Icons.image_outlined, - color: isDark ? Colors.white54 : Colors.black54, - ), - title: const Text('Hide DM photo seen status'), - subtitle: Text( - 'Prevents Instagram from marking photos/videos in DMs as viewed', - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black45, - fontSize: 12, - ), - ), - value: settings.ghostDmPhotos, - onChanged: (v) => settings.setGhostDmPhotos(v), - activeThumbColor: Colors.blue, - ), - const _SettingsSectionHeader(title: 'STORIES'), - SwitchListTile( - secondary: Icon( - Icons.auto_stories_outlined, - color: isDark ? Colors.white54 : Colors.black54, - ), - title: const Text('Story ghost mode'), - subtitle: Text( - 'Watch stories without appearing in the viewer list', - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black45, - fontSize: 12, - ), - ), - value: settings.ghostStories, - onChanged: (v) => settings.setGhostStories(v), - activeThumbColor: Colors.blue, - ), - const SizedBox(height: 32), - ], - ), - ); - } -} - -class _NotificationSettingsPage extends StatelessWidget { - const _NotificationSettingsPage(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text('Notifications', style: TextStyle(fontSize: 17)), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - const Row( - children: [ - Icon( - Icons.info_outline, - color: Colors.blueAccent, - size: 20, - ), - SizedBox(width: 12), - Text( - 'Important Note', - style: TextStyle( - color: Colors.blueAccent, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - 'FocusGram monitors your session locally. For notifications to work, the app must be running in the background (minimized). If you force-close or swipe away the app from your task switcher, notifications will stop until you reopen it.', - style: TextStyle( - color: isDark ? Colors.white70 : Colors.black87, - fontSize: 13, - height: 1.4, - ), - ), - ], - ), - ), - SwitchListTile( - secondary: const Icon(Icons.mail_outline, color: Colors.blueAccent), - title: const Text('Direct Messages'), - subtitle: const Text( - 'Notify when you receive a new DM', - style: TextStyle(fontSize: 13), - ), - value: settings.notifyDMs, - onChanged: (v) => settings.setNotifyDMs(v), - activeThumbColor: Colors.blue, - ), - SwitchListTile( - secondary: const Icon( - Icons.favorite_border, - color: Colors.blueAccent, - ), - title: const Text('General Activity'), - subtitle: const Text( - 'Likes, mentions, and other interactions', - style: TextStyle(fontSize: 13), - ), - value: settings.notifyActivity, - onChanged: (v) => settings.setNotifyActivity(v), - activeThumbColor: Colors.blue, - ), - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Note: Push notifications are generated by the app local service by monitoring the web sessions. This does not rely on Instagram servers sending notifications to your device.', - style: TextStyle( - color: isDark ? Colors.white24 : Colors.black26, - fontSize: 11, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/scripts/autoplay_blocker.dart b/lib/scripts/autoplay_blocker.dart new file mode 100644 index 0000000..6659493 --- /dev/null +++ b/lib/scripts/autoplay_blocker.dart @@ -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