diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..39cb507 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,65 @@ +name: "CodeQL Analysis & Code Scanning" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '30 4 * * 1' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (Kotlin & Java) + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@v4 + + - name: Install Android Platform & Build Tools + shell: bash + run: | + set -euo pipefail + sdkmanager \ + "platform-tools" \ + "platforms;android-35" \ + "build-tools;35.0.0" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + cache-read-only: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: 'java-kotlin' + queries: +security-extended,security-and-quality + + - name: Build Application for Analysis + shell: bash + run: | + chmod +x gradlew + ./gradlew :app:assembleDebug --no-daemon + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:java-kotlin" diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index b0a974c..8bb01b1 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -50,25 +50,23 @@ jobs: - name: Grant execute permission for gradlew shell: bash - run: chmod +x Ported/gradlew + run: chmod +x gradlew - name: Run Unit Tests - working-directory: Ported run: ./gradlew :app:testDebugUnitTest --no-daemon - name: Assemble Release APKs - working-directory: Ported run: ./gradlew :app:assembleRelease --no-daemon - name: Display APK Sizes shell: bash run: | echo "=== Built Release APKs ===" - ls -lh Ported/app/build/outputs/apk/release/*.apk + ls -lh app/build/outputs/apk/release/*.apk - name: Upload Release APKs uses: actions/upload-artifact@v4 with: name: focusgram-native-release-apks - path: Ported/app/build/outputs/apk/release/*.apk + path: app/build/outputs/apk/release/*.apk if-no-files-found: error diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index b6a4862..83553b8 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -1,13 +1,19 @@ name: Build APK and Create GitHub Release + on: workflow_dispatch: inputs: version: - description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading." + description: "Release version without leading v. Example: 2.1.0. Leave empty to read the top CHANGELOG heading." required: false type: string + push: + tags: + - "v*.*.*" + permissions: contents: write + jobs: release: runs-on: ubuntu-latest @@ -16,18 +22,16 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Java 17 - uses: actions/setup-java@v5 + + - name: Set up Java 21 + uses: actions/setup-java@v4 with: distribution: temurin - java-version: "17" + java-version: "21" + - name: Set up Android SDK uses: android-actions/setup-android@v4 - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true + - name: Install required Android SDK packages shell: bash run: | @@ -35,61 +39,86 @@ jobs: sdkmanager \ "platform-tools" \ "platforms;android-35" \ - "build-tools;34.0.0" \ - "build-tools;35.0.0" \ - - name: Get dependencies - run: flutter pub get + "build-tools;35.0.0" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + cache-read-only: false + + - name: Grant execute permission for gradlew + shell: bash + run: chmod +x gradlew + - name: Resolve version and tag id: meta shell: bash run: | set -euo pipefail - INPUT_VERSION="${{ github.event.inputs.version }}" - if [[ -n "${INPUT_VERSION}" ]]; then - VERSION="${INPUT_VERSION#v}" + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + elif [[ -n "${{ github.event.inputs.version }}" ]]; then + VERSION="${{ github.event.inputs.version }}" + VERSION="${VERSION#v}" + TAG="v${VERSION}" else VERSION="$(python3 .github/scripts/get_version.py)" + TAG="v${VERSION}" fi - TAG="v${VERSION}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + - name: Extract release notes from CHANGELOG.md shell: bash env: VERSION: ${{ steps.meta.outputs.version }} run: python3 .github/scripts/get_notes.py - - name: Build release APK - run: flutter build apk --release - - name: Rename APK - run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk - - name: Upload APK artifact + + - name: Run Unit Tests + run: ./gradlew :app:testDebugUnitTest --no-daemon + + - name: Build release APKs (with ABI splits) + run: ./gradlew :app:assembleRelease --no-daemon + + - name: Display Generated APKs + shell: bash + run: | + echo "=== Built Release APKs ===" + ls -lh app/build/outputs/apk/release/*.apk + + - name: Upload APK artifacts uses: actions/upload-artifact@v4 with: name: focusgram-apk-${{ steps.meta.outputs.tag }} - path: build/app/outputs/flutter-apk/focusgram-release.apk + path: app/build/outputs/apk/release/*.apk if-no-files-found: error - - name: Create Git tag + + - name: Create Git tag (workflow_dispatch only) shell: bash + if: github.event_name == 'workflow_dispatch' env: TAG: ${{ steps.meta.outputs.tag }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag already exists on remote: ${TAG}" - exit 1 + if ! git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + else + echo "Tag ${TAG} already exists on remote." fi - git tag -a "${TAG}" -m "Release ${TAG}" - git push origin "${TAG}" + - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.meta.outputs.tag }} name: FocusGram ${{ steps.meta.outputs.tag }} body_path: release_notes.md - files: build/app/outputs/flutter-apk/focusgram-release.apk + files: app/build/outputs/apk/release/*.apk draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 89b4097..e90bddc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # ============================================================================== -# FocusGram Root .gitignore -# Covers both Flutter Legacy app and Native Kotlin app in Ported/ +# FocusGram Native Android (Kotlin + GeckoView + Jetpack Compose) .gitignore # ============================================================================== # ------------------------------------------------------------------------------ @@ -29,7 +28,7 @@ Thumbs.db .atom/ .history .svn/ -.swiftpm/ +.navigation/ # ------------------------------------------------------------------------------ # 2. Secrets, Environment, and Keystores @@ -42,11 +41,10 @@ Thumbs.db *.key key.properties **/key.properties -android/key.properties upload-keystore.jks # ------------------------------------------------------------------------------ -# 3. Android & Gradle Build Artifacts (Root and Ported/) +# 3. Android & Gradle Build Artifacts # ------------------------------------------------------------------------------ # Gradle & Android user cache files .gradle/ @@ -66,12 +64,7 @@ local.properties # Build directories & outputs build/ **/build/ -/build/ -/android/app/debug -/android/app/profile -/android/app/release -Ported/build/ -Ported/app/build/ +app/build/ *.apk *.aab *.ap_ @@ -86,31 +79,8 @@ captures/ .buildlog/ # ------------------------------------------------------------------------------ -# 4. Flutter & Dart Artifacts (Preserved Legacy App) +# 4. Multi-Agent Governance & Documentation (Keep untracked from Git if needed) # ------------------------------------------------------------------------------ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub/ -.pub-cache/ -/coverage/ -.flutter/ -**/doc/api/ -**/ios/Flutter/.last_build_id -app.*.symbols -app.*.map.json - -# ------------------------------------------------------------------------------ -# 5. Project Migration & Local Tools (Internal / Scratch) -# ------------------------------------------------------------------------------ -migrate_working_dir/ -PRD.md -.reasonix/ .agents/ system/ Documents/ -Ghostify/ -Ghostify -inspo/ -Instagram-Downloader/ - diff --git a/.metadata b/.metadata deleted file mode 100644 index 00b74be..0000000 --- a/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: android - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d47183..d6b13d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,44 @@ -## FocusGram 2.1.0 +## FocusGram 3.0.0 ### What's new -- NEW: Startup Page - choose which page to launch on app launch. -- NEW: App lock and DM Lock. -- NEW: Bait me button in Focus Control. -- NEW: Interactive Level based system for unlocking features. -- NEW: Effort Friction Mode. -- NEW: Strict and fully working Ghost Mode. +- NEW: AD BLOCKER!! +- NEW: Custom App Icons. +- NEW: Lock Specific Chats. +- NEW: Block Reels Scrolling in DM Feed. +- NEW: Block Video Autoplay. +- NEW: Search Story Viewer List. +- NEW: Custom Fonts in Bio. +- NEW: Floating bottom Navigation Bar. +- NEW: Remove Stories Tray. +- NEW: Improved Ghost Mode. +- NEW: Compelete UI Revamp. +- NEW: Fingerprint Support +- NEW: Live Notifications That shows session timer. +- NEW: Reels Counter (Track how many and how long have you been watching Reels). +- NEW: Reels History. + and more.... ### Bug fixes -- Fixed: Greyscale mode used to turn off when app was restarted. -- Fixed: Images in posts containing multiple images werent getting unblurred when tapped. -- Fixed: You could send message as "Ghost" in GHost mode (Ghost's cant talk with real people ๐Ÿคช). -- Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs. -- Fixed: Download media button (rarely) opened random media rather than desired one. -- Fixed: Reel Session could be started despite quota being finished. -- Perfomance Optimizations -- A lof of other Minor fixes. +- Removed: Leveling System, Credits, Baitme etc. +- Removed: Top bar with Focusgram Logo. +- Fixed: You couldn't send messages when in Ghost mode. +- Fixed: Captions & Audio Banner were Cutted off. +- Fixed: Blur state of the homepage and reels couldn't be changed once set. +- Fixed: Story tray wasn't being hidden after enabling the option (feature didn't exist but its reference did in some descriptions) +- Fixed: App felt Laggy +- Fixed: A lot of other bugs & added few more to fix later. + +### Uncloned Instagram Package Edition + +Looking to replace the official Instagram app directly on your phone? Download the uncloned edition (`com.instagram.android` package name) directly from our distribution channel: +- ๐Ÿ”— **Download Uncloned Edition (`com.instagram.android`):** [https://focusgram-app.netlify.app/](https://focusgram-app.netlify.app/) + +[Note: this webpage contains ads] + +FEW CHANGES IN USER INTERACTION: +1. Long Press the Profile Icon to open Focusgram Settings (The app looks a LOT CLeaner) +2. Double tap any icon on nav bar to refresh the page. +If more Confusions, Don't hesitate to create an Issue. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4e578e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to FocusGram + +Thank you for your interest in contributing to FocusGram! FocusGram is a free, open-source digital wellness tool built under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. + +--- + +## Architecture Principles + +Every pull request must uphold these non-negotiable rules: + +1. **Local-Only Operation:** + FocusGram never sends user data off-device. No telemetry, no analytics, no third-party network SDKs, and no remote crash loggers. +2. **The Login Flow is a Black Box:** + Never write code or content scripts that read, intercept, or modify authentication form fields, credentials, cookies, or 2FA tokens. +3. **No Automation of Instagram Actions:** + FocusGram is an intentionality tool, not an automation bot. Do not contribute auto-likers, auto-followers, auto-scrollers, or mass-DM tools. +4. **Clean Web Sandboxing:** + Web content manipulation is strictly client-side via Mozilla GeckoView content scripts (`app/src/main/assets/scripts/` or `app/src/main/assets/extensions/ublock/`). Never inline large raw JavaScript strings directly in Kotlin source files. +5. **FOSS Dependency Licensing:** + All dependencies must be compatible with AGPL-3.0 (e.g. Apache 2.0, MIT, BSD, MPL 2.0, LGPL/GPL). + +--- + +## Development Environment Setup + +### Prerequisites + +- **OS:** Linux, macOS, or Windows +- **JDK:** OpenJDK 21 (Temurin or Adoptium recommended) +- **Android SDK:** API Level 26 minimum, API 35 target, Build-Tools 35.0.0 +- **Build System:** Gradle (bundled wrapper `./gradlew`) + +### Build Commands + +```bash +# Clone the repository +git clone https://github.com/Ujwal223/FocusGram.git +cd FocusGram + +# Run unit tests +./gradlew :app:testDebugUnitTest + +# Build debug APK +./gradlew :app:assembleDebug + +# Build standard release APKs (with ABI splits) +./gradlew :app:assembleRelease + +# Build uncloned Instagram package edition (com.instagram.android) +./gradlew :app:assembleRelease -PuseInstaPackage=true + +# Or use the interactive build script +./build-release.sh +``` + +--- + +## Code Style & Standards + +- **Kotlin:** Idiomatic Kotlin 2.0+ following official Kotlin code style. +- **Compose:** Jetpack Compose Material 3 using Unidirectional Data Flow (UDF), `StateFlow`, and coroutines. +- **Storage:** Use Jetpack DataStore Preferences for simple preferences and AndroidX Room for relational persistence (session snapshots, screen time records, reel history). +- **Comments:** Follow maintainer-voice commenting. Explain *why* something is designed the way it is, warn about platform traps, and delete restatements of code. Avoid decorative ASCII art or divider lines. + +--- + +## Submitting Pull Requests + +1. Fork the repository and create a branch with a descriptive name (`git checkout -b feature/your-feature`). +2. Run `./gradlew :app:testDebugUnitTest` to ensure all tests pass. +3. Keep diffs minimal, focused, and free of drive-by refactors. +4. Open a Pull Request referencing any relevant issues. diff --git a/Ported/.gitignore b/Ported/.gitignore deleted file mode 100644 index 9dd8e46..0000000 --- a/Ported/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -# ============================================================================== -# FocusGram Native Android (Ported/) .gitignore -# ============================================================================== - -# Gradle & Android user cache -.gradle/ -.android/ -build/ -app/build/ - -# Machine-specific SDK & local configurations -local.properties - -# Android Studio / IntelliJ -.idea/ -*.iml -.navigation/ -captures/ -.cxx/ - -# Keystores & Signing Secrets -*.jks -*.keystore -*.key -key.properties - -# OS & Logs -.DS_Store -Thumbs.db -*.log -*.hprof - -# Others -Ghostify/ -Instagram-Downloader/ diff --git a/Ported/app/src/main/assets/scripts/reel_metadata_extractor.js b/Ported/app/src/main/assets/scripts/reel_metadata_extractor.js deleted file mode 100644 index 450686b..0000000 --- a/Ported/app/src/main/assets/scripts/reel_metadata_extractor.js +++ /dev/null @@ -1,167 +0,0 @@ -/** - * FocusGram โ€” Reels Metadata Extractor UserScript - * - * Extracts author, title, and thumbnail URL for watched Reels directly from client-side DOM - * (Open Graph meta tags, JSON-LD structured data, document titles). - * - * LEGAL & ARCHITECTURE COMPLIANCE: - * - 100% Client-side DOM parsing only. - * - ZERO video media is downloaded or cached to disk. - * - ZERO network calls or external API calls. - * - All metadata remains strictly on-device in Room SQLite. - */ - -'use strict'; - -(function () { - if (window.__fgReelExtractorInitialized) return; - window.__fgReelExtractorInitialized = true; - - let lastExtractedUrl = ''; - - function extractMetadata() { - const currentUrl = window.location.href; - - // Verify this is an Instagram Reel URL - if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) { - return null; - } - - // Avoid re-extracting for the identical URL - if (lastExtractedUrl === currentUrl) { - return null; - } - - let title = ''; - let thumbnailUrl = ''; - let author = ''; - - // 1. Check Open Graph tags - const ogTitle = document.querySelector('meta[property="og:title"]'); - const ogImage = document.querySelector('meta[property="og:image"]'); - if (ogTitle && ogTitle.content) title = ogTitle.content.trim(); - if (ogImage && ogImage.content) thumbnailUrl = ogImage.content.trim(); - - // 2. Fallback to document title - if (!title && document.title) { - title = document.title.replace(' on Instagram', '').replace('โ€ข Instagram photos and videos', '').trim(); - } - - // Parse author if available in title (format: "Author (@handle) on Instagram: ...") - if (title) { - const authorMatch = title.match(/^([^(@:]+)(?:\s*\((@[^)]+)\))?/); - if (authorMatch) { - author = (authorMatch[2] || authorMatch[1]).trim(); - } - } - - // 3. Fallback to JSON-LD structured data - if (!thumbnailUrl) { - const jsonLdElements = document.querySelectorAll('script[type="application/ld+json"]'); - jsonLdElements.forEach(script => { - try { - const data = JSON.parse(script.textContent); - if (data && data.image) { - if (Array.isArray(data.image) && data.image.length > 0) { - thumbnailUrl = data.image[0]; - } else if (typeof data.image === 'string') { - thumbnailUrl = data.image; - } else if (data.image.url) { - thumbnailUrl = data.image.url; - } - } - if (!title && data.caption) { - title = data.caption; - } - } catch (_) {} - }); - } - - // 4. Fallback to Twitter card image - if (!thumbnailUrl) { - const twitterImage = document.querySelector('meta[name="twitter:image"]'); - if (twitterImage && twitterImage.content) { - thumbnailUrl = twitterImage.content.trim(); - } - } - - // 5. Fallback to video element poster and DOM author - if (!thumbnailUrl) { - const videoEl = document.querySelector('video[poster]') || document.querySelector('video'); - if (videoEl && videoEl.getAttribute('poster')) { - thumbnailUrl = videoEl.getAttribute('poster'); - } - } - if (!author) { - const authorEl = document.querySelector('a[role="link"][tabindex="0"] span') || - document.querySelector('header a') || - document.querySelector('div[role="dialog"] header a'); - if (authorEl && authorEl.textContent) { - author = authorEl.textContent.trim(); - } - } - - // If neither title nor thumbnail is ready, DOM may still be rendering - if (!thumbnailUrl && !title) { - return null; - } - - lastExtractedUrl = currentUrl; - - return { - url: currentUrl, - title: title || 'Instagram Reel', - author: author || 'Instagram Creator', - thumbnailUrl: thumbnailUrl || '', - timestamp: Date.now() - }; - } - - function extractAndBroadcast() { - const data = extractMetadata(); - if (!data) return; - - // Dispatch DOM event for native page bridge / observers - try { - window.dispatchEvent(new CustomEvent('focusgram:reelmetadata', { - detail: data - })); - } catch (_) {} - - // Send via WebExtension messaging if available - try { - if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { - browser.runtime.sendMessage({ - type: 'ReelMetadata', - payload: data - }).catch(() => {}); - } - } catch (_) {} - - console.log('[FocusGram] Reel metadata extracted on-device:', data.url); - } - - // Initial check - extractAndBroadcast(); - - // Listen to route changes from spa_navigation_monitor - window.addEventListener('focusgram:routechange', () => { - setTimeout(extractAndBroadcast, 400); - }); - - // Observe DOM mutations for asynchronously loaded OG meta tags - let debounceTimer = null; - const observer = new MutationObserver(() => { - if (window.location.href.includes('/reel')) { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(extractAndBroadcast, 500); - } - }); - - observer.observe(document.documentElement || document.body, { - childList: true, - subtree: true - }); - - console.log('[FocusGram] Reels Metadata Extractor active.'); -})(); diff --git a/Ported/app/src/main/assets/scripts/stealth_instagram.js b/Ported/app/src/main/assets/scripts/stealth_instagram.js deleted file mode 100644 index 09b9e13..0000000 --- a/Ported/app/src/main/assets/scripts/stealth_instagram.js +++ /dev/null @@ -1,227 +0,0 @@ -/** - * FocusGram โ€” Stealth Privacy Engine - * - * Intercepts client-side network calls and DOM visibility signals on instagram.com to provide: - * 1. Anonymous Story Viewing (suppressing story view and reel seen mutations) - * 2. Incognito Direct Messages (blocking read receipts and seen acknowledgements) - * 3. Invisible Typing (suppressing typing indicators in chats) - * 4. Visibility spoofing for story viewer containers - */ - -'use strict'; - -(function () { - if (window.__FG_STEALTH_INITIALIZED__) return; - window.__FG_STEALTH_INITIALIZED__ = true; - - const DEFAULT_CONFIG = { - enabled: false, - igStory: true, - igSeen: true, - igTyping: true - }; - - window.__FOCUSGRAM_GHOST_CONFIG__ = Object.assign({}, DEFAULT_CONFIG, window.__FOCUSGRAM_GHOST_CONFIG__ || {}); - - const PATTERNS = { - igTyping: [ - "indicate_activity", - "typing_indicator", - "activity_indicator", - "is_typing", - "direct_v2/threads/broadcast/typing", - "direct_v2/threads/typing", - "sendtypingindicator", - "send_typing_indicator", - "typing_on", - "is_composing", - "update_activity_status", - "activity_status" - ], - igSeen: [ - "mark_read", - "mark_seen", - "thread_seen", - "directmarkasseen", - "markasseen", - "directthreadmarkitemsseen", - "polarisdirectmarkasseenmutation", - "directseenmutation", - "usepolarismarkthreadseenmutation", - "useigdmarkthreadasreadmutation" - ], - igStory: [ - "storiesupdateseenmutation", - "polarisstoriesseenmutation", - "usepolarisstoriesv3seenmutation", - "reelmediaseen", - "storiesupdateseen", - "seenstoriesupdatemutation", - "polarisapireelseenmutation", - "xdt_mark_story_reel_seen", - "26997980659837802", - "polarisapiforcestoryseenmutation", - "xdt_api__v1__stories__reel__seen", - "9647304595318258", - "api/v1/stories/reel/seen", - "stories/reel/seen", - "mark_story_seen", - "update_seen_for_reel", - "reel_seen", - "stories_update_seen", - "mark_story_read" - ] - }; - - function shouldBlock(data, url, method) { - const config = window.__FOCUSGRAM_GHOST_CONFIG__; - if (!config || !config.enabled) return null; - - const urlString = String(url || "").toLowerCase(); - const bodyText = (typeof data === "string" ? data : "").toLowerCase(); - const text = `${bodyText} ${urlString}`; - - // Never block actual message sending or media uploads - if (urlString.includes("threads/broadcast/text") || - urlString.includes("direct_v2/threads/broadcast") || - urlString.includes("broadcast/media") || - urlString.includes("action=send_message") || - urlString.includes("configure")) { - return null; - } - - if (config.igTyping && PATTERNS.igTyping.some(p => text.includes(p))) { - return "IG_TYPING"; - } - - if (config.igStory && PATTERNS.igStory.some(p => text.includes(p))) { - return "IG_STORY"; - } - - if (config.igSeen && PATTERNS.igSeen.some(p => text.includes(p))) { - return "IG_SEEN"; - } - - return null; - } - - function createMockPayload(blockType, url, bodyText) { - const combined = `${bodyText} ${url}`.toLowerCase(); - const isGraphQL = combined.includes("/graphql") || combined.includes("fb_api_req_friendly_name") || combined.includes("doc_id"); - - if (!isGraphQL) { - return { status: "ok", blocked: blockType }; - } - - if (blockType === "IG_STORY") { - if (combined.includes("xdt_api__v1__stories__reel__seen") || - combined.includes("polarisapiforcestoryseenmutation") || - combined.includes("9647304595318258")) { - return { - data: { - xdt_api__v1__stories__reel__seen: { - __typename: "XDTEmptyRecord" - } - } - }; - } - return { - data: { - xdt_mark_story_reel_seen: { - __typename: "XDTMarkSeenResponse" - } - } - }; - } - - return { data: {} }; - } - - // 1. Intercept Fetch API - const originalFetch = window.fetch; - window.fetch = async function (input, init) { - const url = typeof input === "string" ? input : (input && input.url ? input.url : ""); - const method = (init && init.method) || (input && input.method) || "GET"; - const body = (init && init.body) || ""; - - let bodyText = ""; - try { - if (typeof body === "string") { - bodyText = body; - } else if (body instanceof URLSearchParams) { - bodyText = body.toString(); - } - } catch (_) {} - - const blockType = shouldBlock(bodyText, url, method); - if (blockType) { - console.log(`[FocusGram Stealth] Filtered fetch: ${blockType} -> ${url}`); - const payload = createMockPayload(blockType, url, bodyText); - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - - return originalFetch.apply(this, arguments); - }; - - // 2. Intercept XMLHttpRequest - const originalXhrOpen = XMLHttpRequest.prototype.open; - const originalXhrSend = XMLHttpRequest.prototype.send; - - XMLHttpRequest.prototype.open = function (method, url) { - this._fgStealthUrl = url; - this._fgStealthMethod = method; - return originalXhrOpen.apply(this, arguments); - }; - - XMLHttpRequest.prototype.send = function (body) { - let bodyText = ""; - try { - if (typeof body === "string") bodyText = body; - } catch (_) {} - - const blockType = shouldBlock(bodyText, this._fgStealthUrl, this._fgStealthMethod); - if (blockType) { - console.log(`[FocusGram Stealth] Filtered XHR: ${blockType} -> ${this._fgStealthUrl}`); - const payload = createMockPayload(blockType, this._fgStealthUrl, bodyText); - Object.defineProperty(this, 'readyState', { value: 4 }); - Object.defineProperty(this, 'status', { value: 200 }); - Object.defineProperty(this, 'responseText', { value: JSON.stringify(payload) }); - setTimeout(() => { - this.dispatchEvent(new Event('readystatechange')); - this.dispatchEvent(new Event('load')); - }, 10); - return; - } - - return originalXhrSend.apply(this, arguments); - }; - - // 3. Visibility and Focus Spoofing (Prevents story seen triggers during passive browsing) - const originalHasFocus = document.hasFocus.bind(document); - document.hasFocus = function () { - const config = window.__FOCUSGRAM_GHOST_CONFIG__; - if (config && config.enabled && config.igStory && window.location.pathname.startsWith('/stories/')) { - return false; - } - return originalHasFocus(); - }; - - // 4. Listen for realtime configuration updates - window.addEventListener("message", (event) => { - if (!event.data || event.data.source !== "FOCUSGRAM_HOST") return; - if (event.data.type === "FOCUSGRAM_UPDATE" || event.data.type === "STEALTH_UPDATE") { - const ghost = event.data.ghost || event.data.config || {}; - Object.assign(window.__FOCUSGRAM_GHOST_CONFIG__, { - enabled: ghost.enabled !== undefined ? ghost.enabled : window.__FOCUSGRAM_GHOST_CONFIG__.enabled, - igStory: ghost.igStory !== undefined ? ghost.igStory : window.__FOCUSGRAM_GHOST_CONFIG__.igStory, - igSeen: ghost.igSeen !== undefined ? ghost.igSeen : window.__FOCUSGRAM_GHOST_CONFIG__.igSeen, - igTyping: ghost.igTyping !== undefined ? ghost.igTyping : window.__FOCUSGRAM_GHOST_CONFIG__.igTyping - }); - } - }); - - console.log("[FocusGram] Stealth Privacy Engine active."); -})(); diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt deleted file mode 100644 index 932bd07..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.ujwal.focusgram.feature.diagnostics - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ujwal.focusgram.core.datastore.SettingsRepository -import com.ujwal.focusgram.core.model.FocusSettings -import com.ujwal.focusgram.core.ui.components.FocusGramCard -import com.ujwal.focusgram.core.ui.components.FocusGramSectionHeader -import com.ujwal.focusgram.core.ui.components.FocusGramTopBar -import com.ujwal.focusgram.core.ui.theme.AlertRed -import com.ujwal.focusgram.core.ui.theme.DarkCard -import com.ujwal.focusgram.core.ui.theme.DarkCardElevated -import com.ujwal.focusgram.core.ui.theme.FocusGold -import com.ujwal.focusgram.core.ui.theme.FocusGreen -import com.ujwal.focusgram.core.ui.theme.InstagramBlue -import com.ujwal.focusgram.core.ui.theme.PureBlack -import com.ujwal.focusgram.core.ui.theme.TextPrimary -import com.ujwal.focusgram.core.ui.theme.TextSecondary -import kotlinx.coroutines.launch - -/** - * DebugMenuScreen - * Hidden developer diagnostics panel: - * - Level & XP override - * - Session credit balances mock - * - Hard state reset - */ -@Composable -fun DebugMenuScreen( - repository: SettingsRepository, - onBack: () -> Unit, - modifier: Modifier = Modifier -) { - val settings by repository.settingsFlow.collectAsState(initial = FocusSettings()) - val scope = rememberCoroutineScope() - - Column( - modifier = modifier - .fillMaxSize() - .background(PureBlack) - .padding(WindowInsets.statusBars.asPaddingValues()) - ) { - FocusGramTopBar( - title = "Developer Diagnostics", - onBack = onBack - ) - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - item { - FocusGramCard( - modifier = Modifier.fillMaxWidth(), - backgroundColor = DarkCardElevated - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Current State", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = FocusGold - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = "Level: ${settings.userLevel} ยท XP: ${settings.userXP}\n" + - "Reels Min: ${settings.reelsMinutes} ยท Insta Min: ${settings.instaMinutes}\n" + - "Ads Today: ${settings.adsWatchedToday} ยท AppLock: ${settings.appLockEnabled}", - style = MaterialTheme.typography.bodyMedium, - color = TextPrimary - ) - } - } - } - - item { - FocusGramSectionHeader(title = "Level & XP Grants") - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = { scope.launch { repository.addXP(20) } }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue) - ) { - Text("+20 XP") - } - Button( - onClick = { scope.launch { repository.addXP(100) } }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = FocusGreen) - ) { - Text("+100 XP (Lvl Up)") - } - } - } - - item { - FocusGramSectionHeader(title = "Credit Balances") - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = { scope.launch { repository.addReelsMinutes(15) } }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = FocusGold) - ) { - Text("+15m Reels") - } - Button( - onClick = { scope.launch { repository.addInstaMinutes(30) } }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue) - ) { - Text("+30m Insta") - } - } - } - - item { - FocusGramSectionHeader(title = "Danger Zone") - } - - item { - OutlinedButton( - onClick = { - scope.launch { - repository.deductReelsMinute() - repository.deductInstaMinute() - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Text("Drain 1 Minute Each", color = AlertRed) - } - } - - item { - Spacer(modifier = Modifier.height(30.dp)) - } - } - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt deleted file mode 100644 index e983a4d..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.ujwal.focusgram.feature.history - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.util.concurrent.Executors - -data class ReelEntry( - val code: String, - val author: String, // may be empty if no source had it - val caption: String, // empty is normal, many reels have no caption - val lastSeen: Long, // epoch millis - val views: Int, -) { - val url: String get() = "https://www.instagram.com/reel/${code.removePrefix("reel_")}/" -} - -class ReelHistory(private val file: File) { - private val lock = Any() - private val io = Executors.newSingleThreadExecutor() - private val sessionCodes = HashSet() - private var entries: List = load() // newest first - - var onNewReelWatched: ((String) -> Unit)? = null - - private fun getTodayStartMillis(): Long { - val cal = java.util.Calendar.getInstance().apply { - set(java.util.Calendar.HOUR_OF_DAY, 0) - set(java.util.Calendar.MINUTE, 0) - set(java.util.Calendar.SECOND, 0) - set(java.util.Calendar.MILLISECOND, 0) - } - return cal.timeInMillis - } - - private fun calculateTodayCount(list: List): Int { - val start = getTodayStartMillis() - return list.count { it.lastSeen >= start } - } - - private val _items = MutableStateFlow(entries) - val items: StateFlow> = _items - - private val _sessionCount = MutableStateFlow(0) - val sessionCount: StateFlow = _sessionCount // unique reels since app start - - private val _todayCount = MutableStateFlow(calculateTodayCount(entries)) - val todayCount: StateFlow = _todayCount // unique reels watched today - - companion object { - private val IGNORED_CODES = setOf( - "home", "explore", "reels", "reel", "direct", "accounts", - "stories", "p", "about", "legal", "videos", "audio", "clips", - "trending", "foryou", "following", "live" - ) - private val GENERIC_AUTHORS = setOf( - "home", "explore", "reels", "reel", "direct", "instagram", - "instagram creator", "instagram user", "instagram video", "creator", "user", "" - ) - private val GENERIC_CAPTIONS = setOf( - "instagram reel", "reel", "reels", "instagram", "instagram video", "video", "" - ) - } - - private fun sanitizeCaption(rawCaption: String, cleanAuthor: String): String { - val clean = rawCaption.trim() - val lower = clean.lowercase() - val isGeneric = lower.isBlank() || - lower in GENERIC_CAPTIONS || - lower.startsWith("instagram reel") || - lower.startsWith("reel by @") || - lower.startsWith("reel by ") || - (cleanAuthor.isNotBlank() && lower == cleanAuthor.lowercase()) || - lower.endsWith("ยท original audio") || - lower.endsWith("original audio") - return if (isGeneric) "" else clean - } - - private fun isCaptionInvalid(caption: String, author: String): Boolean { - return sanitizeCaption(caption, author).isBlank() - } - - fun onWatched(code: String, author: String, caption: String): Int = synchronized(lock) { - val cleanCode = code.trim().removePrefix("/").removeSuffix("/") - val lowerCode = cleanCode.lowercase() - if (cleanCode.length < 5 || lowerCode in IGNORED_CODES) { - return _sessionCount.value - } - val finalCode = if (cleanCode.startsWith("reel_")) cleanCode.removePrefix("reel_") else cleanCode - if (finalCode.length < 5 || finalCode.lowercase() in IGNORED_CODES) { - return _sessionCount.value - } - - val cleanAuthor = author.trim().removePrefix("@") - val lowerAuthor = cleanAuthor.lowercase() - val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram") - val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor - - val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor) - - val isNew = sessionCodes.add(finalCode) - val old = entries.firstOrNull { it.code == finalCode } - - val resolvedAuthor = sanitizedAuthor.ifEmpty { old?.author ?: "" } - val resolvedCaption = sanitizedCaption.ifEmpty { - val oldCaption = old?.caption ?: "" - if (!isCaptionInvalid(oldCaption, resolvedAuthor)) oldCaption else "" - } - - val updated = ReelEntry( - code = finalCode, - author = resolvedAuthor, - caption = resolvedCaption, - lastSeen = System.currentTimeMillis(), - views = (old?.views ?: 0) + 1, - ) - commit(listOf(updated) + entries.filter { it.code != finalCode }) - _sessionCount.value = sessionCodes.size - if (isNew) { - onNewReelWatched?.invoke(finalCode) - } - sessionCodes.size - } - - fun onMeta(code: String, author: String, caption: String) { - synchronized(lock) { - val cleanCode = if (code.startsWith("reel_")) code.removePrefix("reel_") else code - if (entries.none { it.code == cleanCode }) return - - val cleanAuthor = author.trim().removePrefix("@") - val lowerAuthor = cleanAuthor.lowercase() - val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram") - val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor - - val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor) - - commit(entries.map { - if (it.code == cleanCode) { - val resolvedAuthor = sanitizedAuthor.ifEmpty { it.author } - val currentCaptionInvalid = isCaptionInvalid(it.caption, resolvedAuthor) - val resolvedCaption = if (sanitizedCaption.isNotEmpty()) { - sanitizedCaption - } else if (!currentCaptionInvalid) { - it.caption - } else { - "" - } - it.copy( - author = resolvedAuthor, - caption = resolvedCaption - ) - } else it - }) - } - } - - fun delete(code: String) = synchronized(lock) { commit(entries.filter { it.code != code }) } - - fun clear() = synchronized(lock) { commit(emptyList()) } - - fun resetSession() = synchronized(lock) { - sessionCodes.clear() - _sessionCount.value = 0 - } - - private fun commit(list: List) { - entries = list.take(500) - _items.value = entries - _todayCount.value = calculateTodayCount(entries) - val snapshot = entries - io.execute { save(snapshot) } - } - - private fun save(list: List) { - try { - val arr = JSONArray() - list.forEach { - arr.put( - JSONObject() - .put("code", it.code) - .put("author", it.author) - .put("caption", it.caption) - .put("lastSeen", it.lastSeen) - .put("views", it.views) - ) - } - val tmp = File(file.parentFile, file.name + ".tmp") - tmp.writeText(arr.toString()) - tmp.renameTo(file) - } catch (_: Exception) {} - } - - private fun load(): List = try { - if (!file.exists()) emptyList() else { - val arr = JSONArray(file.readText()) - (0 until arr.length()).map { - val o = arr.getJSONObject(it) - ReelEntry( - o.getString("code"), - o.optString("author"), - o.optString("caption"), - o.optLong("lastSeen"), - o.optInt("views", 1) - ) - } - } - } catch (_: Exception) { - emptyList() - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt deleted file mode 100644 index ed294da..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.ujwal.focusgram.feature.screentime - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat -import android.content.pm.ServiceInfo -import com.ujwal.focusgram.FocusGramApp -import com.ujwal.focusgram.MainActivity -import com.ujwal.focusgram.R -import com.ujwal.focusgram.core.database.ScreenTimeRecord -import com.ujwal.focusgram.core.database.SessionRecord -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * ScreenTimeService - * Android Foreground Service tracking active Instagram usage, updating persistent notifications, - * draining credit minutes, and triggering session limit gates. - */ -class ScreenTimeService : Service() { - - private val serviceScope = CoroutineScope(Dispatchers.Default) - private var trackingJob: Job? = null - - private var sessionStartTime = 0L - private var sessionSeconds = 0 - private var sessionType = "INSTAGRAM" // "INSTAGRAM" or "REELS" - private var isReelsSession = false - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...") - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceCompat.startForeground( - this, - NOTIFICATION_ID, - initialNotification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - ) - } else { - startForeground(NOTIFICATION_ID, initialNotification) - } - } catch (e: Exception) { - Log.e(TAG, "Primary startForeground failed in onCreate: ${e.message}", e) - try { - startForeground(NOTIFICATION_ID, initialNotification) - } catch (fallbackEx: Exception) { - Log.e(TAG, "Fallback startForeground failed: ${fallbackEx.message}", fallbackEx) - stopSelf() - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_START -> { - isReelsSession = intent.getBooleanExtra(EXTRA_IS_REELS, false) - sessionType = if (isReelsSession) "REELS" else "INSTAGRAM" - startTracking() - } - ACTION_UPDATE_ROUTE -> { - val wasReels = isReelsSession - isReelsSession = intent.getBooleanExtra(EXTRA_IS_REELS, false) - sessionType = if (isReelsSession) "REELS" else "INSTAGRAM" - if (wasReels != isReelsSession && trackingJob?.isActive == true) { - val minutes = sessionSeconds / 60 - val secs = sessionSeconds % 60 - val timeStr = String.format("%02d:%02d", minutes, secs) - val title = if (isReelsSession) "Reels Focus: $timeStr" else "Instagram Focus: $timeStr" - updateNotification(title, "Discipline active. Stay intentional.") - } - } - ACTION_STOP -> { - stopTracking() - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - @Suppress("DEPRECATION") - stopForeground(true) - } - } catch (_: Exception) {} - stopSelf() - } - } - return START_NOT_STICKY - } - - private fun startTracking() { - if (trackingJob?.isActive == true) return - - sessionStartTime = System.currentTimeMillis() - sessionSeconds = 0 - - updateNotification("Focus Session Active", "Tracking active session time...") - - trackingJob = serviceScope.launch { - val app = FocusGramApp.instance - val db = app.database - val settingsRepo = app.settingsRepository - val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) - - var secondsAccumulator = 0 - - while (isActive) { - delay(1000L) - sessionSeconds++ - secondsAccumulator++ - - // Update notification every 5 seconds - if (sessionSeconds % 5 == 0) { - val minutes = sessionSeconds / 60 - val secs = sessionSeconds % 60 - val timeStr = String.format("%02d:%02d", minutes, secs) - val title = if (isReelsSession) "Reels Focus: $timeStr" else "Instagram Focus: $timeStr" - updateNotification(title, "Discipline active. Stay intentional.") - } - - // Every 60 seconds: Drain credits & check daily limits - if (secondsAccumulator >= 60) { - // Drain credit if applicable - if (isReelsSession) { - settingsRepo.deductReelsMinute() - } else { - settingsRepo.deductInstaMinute() - } - - // Check daily limit - val settings = settingsRepo.settingsFlow.first() - val today = dateFormat.format(Date()) - val todayRecord = db.screenTimeDao().getForDateSync(today) - val todayTotalSeconds = todayRecord?.seconds ?: 0 - val dailyLimitSeconds = settings.dailyLimitMinutes * 60 - - if (dailyLimitSeconds in 1..todayTotalSeconds) { - Log.i(TAG, "Daily screen time limit reached ($dailyLimitSeconds s). Triggering session limit broadcast.") - val limitIntent = Intent(ACTION_LIMIT_EXCEEDED).apply { - setPackage(packageName) - } - sendBroadcast(limitIntent) - } - - secondsAccumulator = 0 - } - } - } - } - - private fun stopTracking() { - trackingJob?.cancel() - trackingJob = null - - val endTime = System.currentTimeMillis() - val duration = ((endTime - sessionStartTime) / 1000).toInt() - - if (duration > 5) { - serviceScope.launch { - try { - val app = FocusGramApp.instance - // Insert completed session record - app.database.sessionRecordDao().insert( - SessionRecord( - sessionType = sessionType, - startTime = sessionStartTime, - endTime = endTime, - durationSeconds = duration, - completed = true - ) - ) - } catch (e: Exception) { - Log.e(TAG, "Error saving session record on stop: ${e.message}", e) - } - } - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "FocusGram Session Timer", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Shows active Instagram usage session duration and limits." - setShowBadge(false) - } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) - } - } - - private fun buildNotification(title: String, content: String): Notification { - val launchIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - val pendingIntent = PendingIntent.getActivity( - this, - 0, - launchIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(title) - .setContentText(content) - .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(pendingIntent) - .setOngoing(true) - .setSilent(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - private fun updateNotification(title: String, content: String) { - try { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, buildNotification(title, content)) - } catch (e: Exception) { - Log.w(TAG, "Failed to update notification: ${e.message}") - } - } - - override fun onDestroy() { - stopTracking() - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? = null - - companion object { - private const val TAG = "ScreenTimeService" - const val CHANNEL_ID = "focusgram_session_timer" - const val NOTIFICATION_ID = 1001 - - const val ACTION_START = "com.ujwal.focusgram.action.START_SESSION" - const val ACTION_STOP = "com.ujwal.focusgram.action.STOP_SESSION" - const val ACTION_UPDATE_ROUTE = "com.ujwal.focusgram.action.UPDATE_ROUTE" - const val ACTION_LIMIT_EXCEEDED = "com.ujwal.focusgram.action.LIMIT_EXCEEDED" - const val EXTRA_IS_REELS = "extra_is_reels" - - fun startSession(context: Context, isReels: Boolean = false) { - try { - val intent = Intent(context, ScreenTimeService::class.java).apply { - action = ACTION_START - putExtra(EXTRA_IS_REELS, isReels) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to startSession: ${e.message}", e) - } - } - - fun updateRoute(context: Context, isReels: Boolean) { - val intent = Intent(context, ScreenTimeService::class.java).apply { - action = ACTION_UPDATE_ROUTE - putExtra(EXTRA_IS_REELS, isReels) - } - try { - context.startService(intent) - } catch (e: Exception) { - Log.w(TAG, "Failed to send route update to ScreenTimeService: ${e.message}") - } - } - - fun stopSession(context: Context) { - val intent = Intent(context, ScreenTimeService::class.java).apply { - action = ACTION_STOP - } - context.startService(intent) - } - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitEngine.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitEngine.kt deleted file mode 100644 index 07776b2..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitEngine.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.ujwal.focusgram.feature.wellness - -import android.content.Context -import android.util.Log -import com.ujwal.focusgram.core.datastore.SettingsRepository -import java.util.Random - -/** - * BaitOutcome - * Possible outcomes of activating the gamified "Bait Me" button. - */ -enum class BaitOutcome(val label: String, val subtext: String) { - OPEN_AD_SITE_AND_RESET("๐Ÿ’ธ Session Reset!", "All session credits have been reset. Better luck next time."), - ADD_TEN_MINUTES("โฐ +10 Minutes!", "You earned 10 extra minutes. Use them wisely!"), - REDUCE_SESSION_TIME("โณ Time Deducted!", "The Bait Me took 1 to 5 minutes away!"), - INCREASE_COOLDOWN("๐ŸงŠ Cooldown Increased!", "Cooldown period extended by 10 minutes."), - END_REEL_SESSION("๐ŸŽฌ Reel Session Ended!", "Your reel session has been cut short."), - END_APP_SESSION("๐Ÿ“ฑ App Session Ended!", "Your Instagram session has been ended.") -} - -/** - * BaitEngine - * Weighted random gamification engine that rewards or penalizes risky app interactions. - */ -class BaitEngine( - private val context: Context, - private val settingsRepository: SettingsRepository -) { - private val random = Random() - private var lastActivationTime = 0L - - val isOnCooldown: Boolean - get() = (System.currentTimeMillis() - lastActivationTime) < COOLDOWN_MILLIS - - val remainingCooldownMinutes: Int - get() { - val elapsed = System.currentTimeMillis() - lastActivationTime - val remaining = (COOLDOWN_MILLIS - elapsed) / (1000 * 60) - return remaining.toInt().coerceAtLeast(0) - } - - fun roll(): BaitOutcome { - val r = random.nextInt(100) - return when { - r < 30 -> BaitOutcome.OPEN_AD_SITE_AND_RESET - r < 50 -> BaitOutcome.ADD_TEN_MINUTES - r < 65 -> BaitOutcome.REDUCE_SESSION_TIME - r < 80 -> BaitOutcome.INCREASE_COOLDOWN - r < 90 -> BaitOutcome.END_REEL_SESSION - else -> BaitOutcome.END_APP_SESSION - } - } - - suspend fun activate( - onResetSession: () -> Unit, - onEndSession: () -> Unit, - onOpenUrl: (String) -> Unit - ): BaitOutcome { - val outcome = roll() - lastActivationTime = System.currentTimeMillis() - - Log.i(TAG, "Bait Me activated. Rolled outcome: ${outcome.name}") - - when (outcome) { - BaitOutcome.OPEN_AD_SITE_AND_RESET -> { - onResetSession() - onOpenUrl(AD_WEBSITE_URL) - } - BaitOutcome.ADD_TEN_MINUTES -> { - settingsRepository.addInstaMinutes(10) - } - BaitOutcome.REDUCE_SESSION_TIME -> { - val deduction = 1 + random.nextInt(5) - for (i in 0 until deduction) { - settingsRepository.deductInstaMinute() - } - } - BaitOutcome.INCREASE_COOLDOWN -> { - settingsRepository.setCooldownMinutes(25) - } - BaitOutcome.END_REEL_SESSION -> { - onResetSession() - } - BaitOutcome.END_APP_SESSION -> { - onEndSession() - } - } - - return outcome - } - - companion object { - private const val TAG = "BaitEngine" - private const val COOLDOWN_MILLIS = 30 * 60 * 1000L // 30 minutes - - const val AD_WEBSITE_URL = - "https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13" - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeButton.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeButton.kt deleted file mode 100644 index 61050d3..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeButton.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.ujwal.focusgram.feature.wellness - -import android.view.HapticFeedbackConstants -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ujwal.focusgram.core.ui.theme.AlertRed -import com.ujwal.focusgram.core.ui.theme.DarkCard -import com.ujwal.focusgram.core.ui.theme.FocusGold -import com.ujwal.focusgram.core.ui.theme.FocusGreen -import com.ujwal.focusgram.core.ui.theme.PureBlack -import com.ujwal.focusgram.core.ui.theme.TextMuted -import com.ujwal.focusgram.core.ui.theme.TextPrimary -import com.ujwal.focusgram.core.ui.theme.TextSecondary -import kotlinx.coroutines.launch - -/** - * BaitMeButton - * Floating gamified overlay button with rotation animation, haptics, and cooldown badge. - */ -@Composable -fun BaitMeButton( - baitEngine: BaitEngine, - userLevel: Int, - onResetSession: () -> Unit, - onEndSession: () -> Unit, - onOpenUrl: (String) -> Unit, - modifier: Modifier = Modifier -) { - if (userLevel < 3) return // Unlocks at Level 3 - - val scope = rememberCoroutineScope() - val rotation = remember { Animatable(0f) } - var isSpinning by remember { mutableStateOf(false) } - var activeOutcome by remember { mutableStateOf(null) } - val view = LocalView.current - val isOnCooldown = baitEngine.isOnCooldown - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - Box(contentAlignment = Alignment.Center) { - Box( - modifier = Modifier - .size(46.dp) - .clip(CircleShape) - .background( - if (isOnCooldown) DarkCard else FocusGold.copy(alpha = 0.18f) - ) - .border( - 2.dp, - if (isOnCooldown) TextMuted else FocusGold, - CircleShape - ) - .rotate(rotation.value) - .clickable(enabled = !isOnCooldown && !isSpinning) { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - isSpinning = true - scope.launch { - rotation.animateTo( - targetValue = rotation.value + 1080f, - animationSpec = tween(durationMillis = 1200) - ) - val outcome = baitEngine.activate( - onResetSession = onResetSession, - onEndSession = onEndSession, - onOpenUrl = onOpenUrl - ) - isSpinning = false - activeOutcome = outcome - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "Bait Me", - tint = if (isOnCooldown) TextMuted else FocusGold, - modifier = Modifier.size(22.dp) - ) - } - - // Cooldown indicator badge - if (isOnCooldown) { - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .clip(RoundedCornerShape(6.dp)) - .background(PureBlack) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) { - Text( - text = "${baitEngine.remainingCooldownMinutes}m", - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 8.sp, - fontWeight = FontWeight.Bold - ), - color = FocusGold - ) - } - } - } - - Spacer(modifier = Modifier.height(2.dp)) - - Text( - text = "Bait Me", - style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), - color = if (isOnCooldown) TextMuted else TextSecondary - ) - } - - // Outcome Result Dialog - activeOutcome?.let { outcome -> - AlertDialog( - onDismissRequest = { activeOutcome = null }, - title = { - Text( - text = outcome.label, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - color = if (outcome == BaitOutcome.ADD_TEN_MINUTES) FocusGreen else AlertRed - ) - }, - text = { - Text( - text = outcome.subtext, - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary - ) - }, - containerColor = DarkCard, - confirmButton = { - Button( - onClick = { activeOutcome = null }, - colors = ButtonDefaults.buttonColors(containerColor = FocusGold) - ) { - Text("OK", color = PureBlack, fontWeight = FontWeight.Bold) - } - } - ) - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeFullScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeFullScreen.kt deleted file mode 100644 index ad14d83..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BaitMeFullScreen.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.ujwal.focusgram.feature.wellness - -import android.view.HapticFeedbackConstants -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ujwal.focusgram.core.ui.components.FocusGramCard -import com.ujwal.focusgram.core.ui.theme.AlertRed -import com.ujwal.focusgram.core.ui.theme.DarkCard -import com.ujwal.focusgram.core.ui.theme.DarkCardElevated -import com.ujwal.focusgram.core.ui.theme.FocusGold -import com.ujwal.focusgram.core.ui.theme.FocusGreen -import com.ujwal.focusgram.core.ui.theme.PureBlack -import com.ujwal.focusgram.core.ui.theme.TextMuted -import com.ujwal.focusgram.core.ui.theme.TextPrimary -import com.ujwal.focusgram.core.ui.theme.TextSecondary -import kotlinx.coroutines.launch - -/** - * BaitMeFullScreen - * Fullscreen gamification spin wheel screen. - */ -@Composable -fun BaitMeFullScreen( - baitEngine: BaitEngine, - onClose: () -> Unit, - onResetSession: () -> Unit, - onEndSession: () -> Unit, - onOpenUrl: (String) -> Unit, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val rotation = remember { Animatable(0f) } - var isSpinning by remember { mutableStateOf(false) } - var done by remember { mutableStateOf(false) } - var lastOutcome by remember { mutableStateOf(null) } - val view = LocalView.current - - Box( - modifier = modifier - .fillMaxSize() - .background(PureBlack) - .padding(28.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = if (done) "๐ŸŽฒ Result!" else "๐ŸŽฒ Bait Me Wheel", - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), - color = TextPrimary - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = if (done) { - lastOutcome?.subtext ?: "" - } else { - "Test your discipline luck. Risk session minutes for higher rewards!" - }, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary - ) - - Spacer(modifier = Modifier.height(36.dp)) - - // Big animated wheel icon - Box( - modifier = Modifier - .size(130.dp) - .clip(CircleShape) - .background( - if (done) FocusGreen.copy(alpha = 0.15f) else FocusGold.copy(alpha = 0.15f) - ) - .border( - 3.dp, - if (done) FocusGreen else FocusGold, - CircleShape - ) - .rotate(rotation.value), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (done) Icons.Default.CheckCircle else Icons.Default.Star, - contentDescription = null, - tint = if (done) FocusGreen else FocusGold, - modifier = Modifier.size(60.dp) - ) - } - - Spacer(modifier = Modifier.height(36.dp)) - - // Outcome card if completed - if (done && lastOutcome != null) { - FocusGramCard( - modifier = Modifier.fillMaxWidth(), - backgroundColor = DarkCardElevated - ) { - Column( - modifier = Modifier.padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = lastOutcome!!.label, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - color = if (lastOutcome == BaitOutcome.ADD_TEN_MINUTES) FocusGreen else AlertRed - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = lastOutcome!!.subtext, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - - // Main CTA Button - Button( - onClick = { - if (done) { - onClose() - } else if (!isSpinning) { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - isSpinning = true - scope.launch { - rotation.animateTo( - targetValue = rotation.value + 1800f, - animationSpec = tween(durationMillis = 1800) - ) - val outcome = baitEngine.activate( - onResetSession = onResetSession, - onEndSession = onEndSession, - onOpenUrl = onOpenUrl - ) - isSpinning = false - done = true - lastOutcome = outcome - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } - } - }, - modifier = Modifier - .fillMaxWidth() - .height(54.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (done) FocusGreen else FocusGold - ), - shape = RoundedCornerShape(16.dp), - enabled = !isSpinning - ) { - Text( - text = when { - isSpinning -> "Rolling..." - done -> "Done โ€” Close" - else -> "๐ŸŽฒ Spin the Wheel!" - }, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = PureBlack - ) - } - - if (!done) { - Spacer(modifier = Modifier.height(14.dp)) - TextButton(onClick = onClose) { - Text("Not now", color = TextMuted) - } - } - } - } -} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CreditStore.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CreditStore.kt deleted file mode 100644 index 48a5839..0000000 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CreditStore.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.ujwal.focusgram.feature.wellness - -import com.ujwal.focusgram.core.datastore.SettingsRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * CreditStore - * Manages earned focus time balances (reels and Instagram minutes) - * acquired through mindful discipline challenges and rewarded ads. - */ -class CreditStore(private val settingsRepository: SettingsRepository) { - - val reelsMinutes: Flow = settingsRepository.settingsFlow.map { it.reelsMinutes } - val instaMinutes: Flow = settingsRepository.settingsFlow.map { it.instaMinutes } - val adsWatchedToday: Flow = settingsRepository.settingsFlow.map { it.adsWatchedToday } - val adsWatchedAllTime: Flow = settingsRepository.settingsFlow.map { it.adsWatchedAllTime } - val userLevel: Flow = settingsRepository.settingsFlow.map { it.userLevel } - val userXP: Flow = settingsRepository.settingsFlow.map { it.userXP } - - val canWatchAdToday: Flow = settingsRepository.settingsFlow.map { - val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) - if (it.lastAdDate != today) true else it.adsWatchedToday < MAX_DAILY_ADS - } - - suspend fun recordRewardedAd(isReels: Boolean = false) { - val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) - if (isReels) { - settingsRepository.addReelsMinutes(MINUTES_PER_AD) - } else { - settingsRepository.addInstaMinutes(MINUTES_PER_AD) - } - settingsRepository.recordRewardedAd(todayKey = today, minutesEarned = 0) - settingsRepository.addXP(XP_PER_AD) - } - - suspend fun deductActiveMinute(isReels: Boolean) { - if (isReels) { - settingsRepository.deductReelsMinute() - } else { - settingsRepository.deductInstaMinute() - } - } - - companion object { - const val MINUTES_PER_AD = 2 - const val MAX_DAILY_ADS = 5 - const val XP_PER_AD = 25 - } -} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BaitEngineTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/BaitEngineTest.kt deleted file mode 100644 index aff7b53..0000000 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BaitEngineTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.ujwal.focusgram - -import com.ujwal.focusgram.feature.wellness.BaitOutcome -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * BaitEngineTest - * Validates weighted random mechanics and outcome labels. - */ -class BaitEngineTest { - - @Test - fun testBaitOutcomeValues() { - val outcomes = BaitOutcome.entries - assertEquals(6, outcomes.size) - - outcomes.forEach { outcome -> - assertNotNull(outcome.label) - assertTrue(outcome.label.isNotEmpty()) - assertNotNull(outcome.subtext) - assertTrue(outcome.subtext.isNotEmpty()) - } - } - - @Test - fun testPositiveOutcomesExist() { - val positive = BaitOutcome.ADD_TEN_MINUTES - assertTrue(positive.label.contains("+10 Minutes")) - } -} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/CreditStoreTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/CreditStoreTest.kt deleted file mode 100644 index 13b8860..0000000 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/CreditStoreTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.ujwal.focusgram - -import com.ujwal.focusgram.feature.wellness.CreditStore -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * CreditStoreTest - * Validates economy constants, credit calculations, and daily limits for rewarded ads. - */ -class CreditStoreTest { - - @Test - fun testEconomyConstants() { - assertEquals(2, CreditStore.MINUTES_PER_AD) - assertEquals(5, CreditStore.MAX_DAILY_ADS) - assertEquals(25, CreditStore.XP_PER_AD) - } - - @Test - fun testDailyAdLimitPredicate() { - val today = "2026-10-10" - - // If lastAdDate is not today, user can watch ad regardless of count - val differentDayLastAd = "2026-10-09" - val canWatchNewDay = differentDayLastAd != today || 5 < CreditStore.MAX_DAILY_ADS - assertTrue(canWatchNewDay) - - // If today and under max limit (e.g. 4 ads watched), user can watch - val canWatchUnderLimit = today != today || 4 < CreditStore.MAX_DAILY_ADS - assertTrue(canWatchUnderLimit) - - // If today and at max limit (5 ads watched), user cannot watch - val canWatchAtLimit = today != today || 5 < CreditStore.MAX_DAILY_ADS - assertFalse(canWatchAtLimit) - } - - @Test - fun testMinutesAccumulationMath() { - var reelsMinutes = 0 - var instaMinutes = 0 - - // Watch 3 ads for reels (+2 mins each) - repeat(3) { - reelsMinutes += CreditStore.MINUTES_PER_AD - } - assertEquals(6, reelsMinutes) - - // Watch 2 ads for Instagram (+2 mins each) - repeat(2) { - instaMinutes += CreditStore.MINUTES_PER_AD - } - assertEquals(4, instaMinutes) - - // Consume 1 minute from each - reelsMinutes = (reelsMinutes - 1).coerceAtLeast(0) - instaMinutes = (instaMinutes - 1).coerceAtLeast(0) - - assertEquals(5, reelsMinutes) - assertEquals(3, instaMinutes) - } -} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt deleted file mode 100644 index 9b8cc3e..0000000 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.ujwal.focusgram - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * EffortFrictionGateTest - * Validates typing discipline challenge mechanics, word counting, and completion criteria. - */ -class EffortFrictionGateTest { - - private val sampleMantra = - "I choose to control my focus and not let algorithmic feeds govern my attention. Real life happens outside this screen, and my precious time deserves deliberate purpose and unwavering disciplined presence." - - @Test - fun testSampleMantraWordCount() { - val words = sampleMantra.trim().split("\\s+".toRegex()) - assertTrue("Mantra should have at least 25 words", words.size >= 25) - } - - @Test - fun testWordCountProgressCalculation() { - val targetCount = 30 - - val input10Words = "one two three four five six seven eight nine ten" - val words10 = input10Words.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } - assertEquals(10, words10.size) - - val progress = (words10.size.toFloat() / targetCount).coerceIn(0f, 1f) - assertEquals(10f / 30f, progress, 0.001f) - - val isTargetReached = words10.size >= targetCount - assertFalse(isTargetReached) - } - - @Test - fun testCompletionCondition() { - val targetCount = 30 - val fullInput = (1..35).joinToString(" ") { "word$it" } - val words = fullInput.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } - - assertTrue(words.size >= targetCount) - } -} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt deleted file mode 100644 index 1789254..0000000 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.ujwal.focusgram - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * NavigationContainmentTest - * Validates domain whitelisting, authentication domain support, and external escaping defense. - */ -class NavigationContainmentTest { - - private val allowedHosts = listOf( - "instagram.com", - "instagr.am", - "cdninstagram.com", - "fbcdn.net", - "facebook.com", - "m.facebook.com", - "accountscenter.instagram.com", - "accountscenter.facebook.com" - ) - - private fun isAllowedHost(host: String): Boolean { - val lower = host.lowercase() - return allowedHosts.any { allowed -> - lower == allowed || lower.endsWith(".$allowed") - } - } - - @Test - fun testAllowedInstagramHosts() { - assertTrue(isAllowedHost("instagram.com")) - assertTrue(isAllowedHost("www.instagram.com")) - assertTrue(isAllowedHost("help.instagram.com")) - assertTrue(isAllowedHost("about.instagram.com")) - assertTrue(isAllowedHost("instagr.am")) - assertTrue(isAllowedHost("scontent.cdninstagram.com")) - assertTrue(isAllowedHost("static.xx.fbcdn.net")) - } - - @Test - fun testAllowedAuthHosts() { - assertTrue(isAllowedHost("facebook.com")) - assertTrue(isAllowedHost("m.facebook.com")) - assertTrue(isAllowedHost("accountscenter.instagram.com")) - assertTrue(isAllowedHost("accountscenter.facebook.com")) - } - - @Test - fun testDisallowedExternalHosts() { - assertFalse(isAllowedHost("google.com")) - assertFalse(isAllowedHost("youtube.com")) - assertFalse(isAllowedHost("tiktok.com")) - assertFalse(isAllowedHost("malicious-fake-instagram.com")) - assertFalse(isAllowedHost("notinstagram.com")) - assertFalse(isAllowedHost("x.com")) - } - - @Test - fun testInternalSchemesAllowed() { - val internalSchemes = listOf("resource", "about", "data", "blob", "javascript") - internalSchemes.forEach { scheme -> - val isInternal = scheme == "resource" || scheme == "about" || scheme == "data" || scheme == "blob" || scheme == "javascript" - assertTrue(isInternal) - } - } -} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt deleted file mode 100644 index 53ae63e..0000000 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.ujwal.focusgram - -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File - -/** - * UserScriptAssetsTest - * Verifies that all required client-side injection scripts exist in assets/scripts/ - * and contain valid code structure. - */ -class UserScriptAssetsTest { - - private val expectedScripts = listOf( - "content_hider.js", - "autoplay_blocker.js", - "grayscale.js", - "spa_navigation_monitor.js", - "native_feel.js", - "reel_metadata_extractor.js" - ) - - @Test - fun testAllUserScriptsExistAndNonEmpty() { - // Path relative to execution root or app directory - val baseAssetsDir = File("src/main/assets/scripts") - val altAssetsDir = File("app/src/main/assets/scripts") - val assetsDir = if (baseAssetsDir.exists()) baseAssetsDir else altAssetsDir - - assertTrue("assets/scripts directory must exist", assetsDir.exists()) - - expectedScripts.forEach { scriptName -> - val scriptFile = File(assetsDir, scriptName) - assertTrue("Script file $scriptName must exist", scriptFile.exists()) - assertTrue("Script file $scriptName must not be empty", scriptFile.length() > 0) - - val content = scriptFile.readText() - assertTrue("$scriptName should contain function or IIFE", content.contains("function") || content.contains("=>")) - } - } -} diff --git a/README.md b/README.md index 1428df3..05cee90 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,195 @@ +
+ [ This APK Doesn't require the "Advanced Flow" to install on Android Devices. ] +
-FocusGram +FocusGram Logo # FocusGram -**Use social media on your terms.** +**Digital wellness & intentionality layer for Instagram on Android** -[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.1.0-white)](https://flutter.dev) -[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=30)](https://github.com/ujwal223/focusgram/releases) +An open-source Android application built with 100% native Kotlin and Jetpack Compose that loads Instagram inside Mozilla GeckoView with embedded WebExtensions (uBlock Origin), client-side content filters, and hardware-backed wellness controls. - - Download FocusGram on Uptodown - - -[Download APK](https://github.com/ujwal223/focusgram/releases) ยท [View Changelog](CHANGELOG.md) ยท [Report a Bug](https://github.com/ujwal223/focusgram/issues/new) +[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg?style=for-the-badge)](LICENSE) +[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?style=for-the-badge&logo=android&logoColor=white)](https://developer.android.com) +[![Release](https://img.shields.io/github/v/release/Ujwal223/FocusGram?style=for-the-badge&color=0969da)](https://github.com/Ujwal223/FocusGram/releases) +[![Downloads](https://img.shields.io/github/downloads/Ujwal223/FocusGram/total?style=for-the-badge&color=fa8900)](https://github.com/Ujwal223/FocusGram/releases) +[![Uptodown](https://img.shields.io/badge/Uptodown-Download-00A859?style=for-the-badge&logo=android&logoColor=white)](https://focusgram.en.uptodown.com/android) +[![Support](https://img.shields.io/badge/Support-BuyMeMomo-FFDD00.svg?style=for-the-badge&logo=buymeacoffee&logoColor=black)](https://buymemomo.com/ujwal) +
---- - -Most people don't want to completely quit Instagram but control its usage (i.e They want to check their messages, post a story, and leave) without losing many hours to Reels and distracting content they never meant to watch. - -FocusGram is an Android-only app that loads the Instagram website with the distracting parts removed and with Extra features. No private APIs. No data collection. Just a cleaner way to use a platform you already use. - -> FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo ๐Ÿ‘‰๐Ÿ‘ˆ. -> -> [![Buy Me a Momo](https://img.shields.io/badge/-%F0%9F%A5%9F%20Buy%20Me%20a%20Momo-FF6B35?style=for-the-badge&labelColor=1a1a1a)](https://buymemomo.com/ujwal) - -FocusGram App Screenshots --- -## What it does +## The Problem & The Solution -**Focus tools** +

+ FocusGram App Demo +

-- Block Reels entirely, or allow them in timed sessions (1โ€“30 min) with daily limits and cooldowns -- Minimal Mode strips everything down to Feed and DMs -- Hide ALL feed posts entirely. +Modern social platforms are engineered around algorithmic feedback loops, autoplaying short-form videos, and recommendation feeds designed to maximize screen time at the expense of intentionality. Most users do not want to quit Instagram entirelyโ€”they want to message friends, reply to stories, or share moments without losing hours to an endless Reel rabbit hole. -**Content filtering** +**FocusGram solves this at the browser engine level:** +Instead of modifying Instagram's proprietary binary or relying on unofficial reverse-engineered APIs, FocusGram embeds a sandboxed **Mozilla GeckoView browser engine** (`org.mozilla.geckoview:geckoview-omni`) paired with an embedded **uBlock Origin** WebExtension. All ad-blocking, element suppression, and session guardrails operate strictly client-side on the rendered web DOM. -- Hide the Explore tab or Reels tab individually -- Disable Explore and blur posts, videos on feed entirely -- Click to unblur feed posts -- Disable Reels entirely -- Disable scrolling of home feed - -**Habit tools** - -- Screen Time Dashboard: daily usage, 7-day chart, weekly average -- Grayscale Mode: reduces the visual pull of colour; can be scheduled by time of day -- Session intentions: optionally set a reason before opening the app -- Reel & App Quota: Allocate only certain time for reels and/or instagram - -**Other Features** - -- Lock the app and/or your private messages. -- See other's message without sending seen indicator* -- Choose which page to launch when app is opened. -- Choose pause time before opening app (mindfulness gate). -- Save media on your local device. ---- - -## Installation - -### Direct download -1. Go to the [Releases](https://github.com/ujwal223/focusgram/releases) page -2. Download `focusgram-release.apk` -3. Open the file and allow "Install from unknown sources" if prompted - -### Uptodown -1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page -2. Click "Get the Latest Version" -3. Click "Download" -4. Open the file and allow "Install from unknown sources" if prompted - ---- - -## Privacy - -FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView and your login goes directly to Meta's servers, the same as any mobile browser. - -Our app has: -- No analytics -- No crash reporting -- No third-party SDKs -- No Logging -- No data leaves your device - ---- - -## 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. - -**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. - -**How do i support this project?**
-You can support this project by donating here: [Donate](https://buymemomo.com/ujwal) - ---- - -## Building from source - -
-Technical details and build instructions - -### Requirements -- Flutter stable channel (3.38+) -- Android SDK -- NDK 28.2.12676356 -- Android SDK cmdline tools 20 -- Android build tools 34 and 35 -- JDK 17 (Eclipse Adoptium 17.0.17+) - -### Build -```bash -flutter pub get -flutter build apk --release +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ FocusGram Android Shell โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Wellness โ”‚ Guardrails โ”‚ App Lock โ”‚ +โ”‚ [Screen Time] โ”‚ [Timed Reel Session] โ”‚ [Biometrics] โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Jetpack Compose Native UI Shell โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Mozilla GeckoView Sandboxed Browser Engine โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Embedded uBlock Origin Extension + Content UserScripts โ”‚ โ”‚ +โ”‚ โ”‚ (Ad blocking, Autoplay suppression, Element blurring) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ + Meta Official Web Servers (HTTPS) ``` -### Architecture -FocusGram uses a standard Android System WebView to load `instagram.com`. All features are implemented client-side via: -- JavaScript injection (autoplay blocking, metadata extraction, SPA navigation monitoring) -- CSS injection (element hiding, grayscale, scroll behaviour) -- URL interception via NavigationDelegate (Reels blocking, Explore blocking) +--- -### Stack +## Core Capabilities -| | | -|---|---| -| Framework | Flutter (Dart) | -| WebView | flutter_inappwebview (Apache 2.0) | -| Storage | shared_preferences | -| License | AGPL-3.0 | - -
+ + + + + + + + + +
+

Focus Guardrails

+
    +
  • Timed Reel Sessions: Watch Reels in structured blocks (1โ€“30 min) before a gentle pause.
  • +
  • Daily Screen Time Limits: Set strict daily quotas for overall usage.
  • +
  • Mindfulness Breath Gate: A 10-second reflection pause before entering the app.
  • +
  • Cooldown Periods: Enforce mandatory break intervals between sessions.
  • +
+
+

Calm Feeds & Content Control

+
    +
  • Blur Explore & Feed: Keep recommendation grids blurred until tapped.
  • +
  • Autoplay Blocker: Prevents intrusive video playback from hijacking attention.
  • +
  • Minimal Mode: Strips unnecessary clutter down to essential DMs and Feed.
  • +
  • Grayscale Filter: Eliminates vibrant dopamine colors on demand or on schedule.
  • +
+
+

Privacy & Security Locks

+
    +
  • Biometric & PIN App Lock: Secure the entire app behind Android Keystore authentication.
  • +
  • Individual Chat Lock: Lock specific sensitive direct message conversations with separate PINs.
  • +
  • Link Sanitizer: Strips fbclid and tracking parameters before sharing.
  • +
  • Zero Data Transmission: No telemetry, no analytics, no third-party network SDKs.
  • +
+
+

Native Android Experience

+
    +
  • Modern Apple-Style Onboarding: Elegant guided setup explaining permissions and boundaries.
  • +
  • Update Checker: In-app GitHub release notifications with one-tap downloads.
  • +
  • Dynamic Launcher Icons: Custom app icons including OLED Pitch Black, Abstract, and Classic.
  • +
  • Reel History & Local Snapshots: Save and revisit content stored completely offline in Room DB.
  • +
+
--- -## Legal disclaimer +## Architecture -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. +FocusGram is built natively for Android using standard FOSS components: -**How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com`; the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers. - -**What we do not do:** -- Use/Alter Instagram's or Meta's private APIs -- Intercept, read, log, or store user credentials, session data, or any sensitive content -- Modify any server-side Meta or Instagram services -- Scrape, harvest, or collect any user data -- Claim ownership of any Meta or Instagram trademarks, logos, or intellectual property โ€” any branding visible within the app is served directly from `instagram.com` and remains the property of Meta Platforms, Inc. - -Using FocusGram is functionally equivalent to accessing Instagram through a mobile web browser with a content blocker extension. By using FocusGram, you acknowledge that you remain bound by Instagram's own Terms of Service. - -For legal concerns, contact `notujwal@proton.me` before taking any other action. +| Layer | Implementation | Purpose | +| :--- | :--- | :--- | +| **User Interface** | Jetpack Compose (Material 3) | Declarative UI, Cupertino-style controls, OLED black styling | +| **Web Engine** | Mozilla GeckoView Omni (`MPL-2.0`) | Full web standards compliance, standards-isolated browsing sandbox | +| **Ad Blocking** | Bundled uBlock Origin WebExtension | Hardware-accelerated cosmetic and network ad filtering | +| **Local Storage** | AndroidX Room & DataStore | Relational database for screen time & DataStore preferences | +| **Security** | AndroidX Security Crypto & Biometrics | Keystore-backed AES-256 MasterKey and fingerprint authentication | +| **Concurrency** | Kotlin Coroutines & Flow | Asynchronous I/O and reactive state pipelines | --- -## License +## Quick Start -Copyright ยฉ 2025-2026 Ujwal Chapagain +### Installation -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. +- **Direct APK**: Download the latest release from [GitHub Releases](https://github.com/Ujwal223/FocusGram/releases). Available in architecture splits (`arm64-v8a`, `armeabi-v7a`, `x86_64`) and Universal APK. +- **Uptodown**: Download verified builds on [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android). +- **Uncloned Edition (`com.instagram.android`)**: Replace the official Instagram app directly on your device via our [Uncloned Distribution Channel](https://ujwal223.github.io/FocusGram/uncloned/). -FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) under AGPL-3.0, Thanks for Reading README. +--- + +### Building from Source + +#### Prerequisites +- Android 8.0+ (API Level 26+) +- JDK 21 (Temurin / Adoptium recommended) +- Android SDK Command-line Tools (Build-Tools 35.0.0) + +#### Build Commands + +```bash +# Clone repository +git clone https://github.com/Ujwal223/FocusGram.git +cd FocusGram + +# Run unit tests +./gradlew :app:testDebugUnitTest + +# Build debug APK +./gradlew :app:assembleDebug + +# Build standard release APKs (Universal & ABI splits) +./gradlew :app:assembleRelease + +# Build uncloned edition with official package name (com.instagram.android) +./gradlew :app:assembleRelease -PuseInstaPackage=true + +# Or launch the interactive build selector +./build-release.sh +``` + +--- + +## Security & Privacy Boundary + +> [!IMPORTANT] +> **Why FocusGram Presents Instagram's Web Login Screen:** +> Meta does not offer public OAuth or user-generated app passwords for third-party consumer apps. To connect to Instagram securely without risking account bans or scraping violations, FocusGram loads the official web login directly from `https://www.instagram.com/` inside an isolated Mozilla GeckoView browser session. + +- **Zero Credential Interception**: The login interface is rendered inside GeckoView as a sealed black box. FocusGram's Kotlin codebase cannot inspect, intercept, or log passwords or 2FA codes. +- **Strictly Local Storage**: All settings, screen time stats, and session records stay encrypted on your phone. +- **No Third-Party SDKs**: Zero Google Firebase, zero analytics, zero crash reporting services, and zero network calls from Kotlin to external servers. + +For detailed security architecture, threat model explanations, and vulnerability disclosure instructions, see **[SECURITY.md](SECURITY.md)**. +For contribution guidelines and development standards, see **[CONTRIBUTING.md](CONTRIBUTING.md)**. + +--- + +## Frequently Asked Questions + +**Will this get my Instagram account banned?** +No. FocusGram's web traffic is identical to accessing Instagram inside a standard Firefox or Chrome browser on Android. FocusGram does not use Instagram's private internal API, does not automate actions, and does not scrape content. + +**Can I use FocusGram alongside the official Instagram app?** +Yes! The default release uses package name `com.ujwal.focusgram` so it installs side-by-side with Instagram. If you wish to replace the official app completely so system links open in FocusGram automatically, build or download the Uncloned Edition (`com.instagram.android`). + +--- + +## License & Support + +Copyright ยฉ 2025โ€“2026 [Ujwal Chapagain](https://github.com/Ujwal223). +Licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE). + +If FocusGram has helped you regain control of your attention, support continuous open-source development: + +[![Buy Me a Momo](https://img.shields.io/badge/-%F0%9F%A5%9F%20Buy%20Me%20a%20Momo-FF6B35?style=for-the-badge&labelColor=1a1a1a)](https://buymemomo.com/ujwal) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dc7bdf9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security Policy + +FocusGram takes user security and privacy seriously. Because FocusGram operates as an intentional digital wellness layer on top of Instagram, preserving trust and technical boundaries is critical. + +--- + +## Supported Versions + +Only the latest major/minor release receives active security patches. + +| Version | Supported | +| ------- | ------------------ | +| 3.x.x | โ˜‘๏ธ | +| < 2.1.0 | x | + +--- + +## Authentication Architecture & Threat Model + +### Why FocusGram Presents Instagram's Web Login Screen + +Users often ask why FocusGram shows a login screen requesting their primary Instagram credentials instead of a standard OAuth login or a scoped app-specific password. + +1. **Meta Does Not Provide Consumer OAuth or App Passwords:** + Meta does not allow third-party consumer apps to access feeds, stories, or direct messages through an official OAuth API. Furthermore, Meta does not offer user-generated app-specific passwords or personal API tokens for Instagram accounts. +2. **The GeckoView Sandbox (Black Box Isolation):** + When you log in to Instagram inside FocusGram, the login page is loaded directly from official Meta servers (`https://www.instagram.com/accounts/login/`) inside an isolated **Mozilla GeckoView browser engine**. +3. **Zero Credential Access:** + FocusGram's Kotlin codebase does not intercept, inspect, listen to, or log form inputs, password fields, or two-factor authentication (2FA) tokens. The login flow is a sealed black box: your credentials flow directly between your device's browser engine and Meta's servers over TLS (HTTPS). +4. **Session Cookie Isolation:** + Authentication cookies are stored inside Mozilla GeckoView's isolated sandboxed profile directory on your device. FocusGram does not transmit or synchronize these cookies to any external server. + +--- + +## Core Security Invariants + +1. **Zero External Data Transmission:** + FocusGram contains no analytics SDKs, no tracking beacons, no crash reporting services, and no developer telemetry. Android Kotlin code makes zero network requests to external servers. All web traffic flows strictly through GeckoView to Meta's official domains. +2. **Client-Side Scripting Boundaries:** + Content modifications (such as hiding Explore or blurring Reels) occur via strictly scoped client-side user scripts and WebExtensions (bundled uBlock Origin). Content scripts are strictly restricted to layout and presentation adjustments; they never touch password inputs, authentication headers, or private user credentials. +3. **Hardware-Backed Local Storage:** + Biometric credentials and PINs configured for App Lock and Chat Lock are verified locally using AndroidX BiometricPrompt and Android Keystore (AES-256-GCM). PIN hashes never leave the device. +4. **Link Sanitization:** + FocusGram strips tracking parameters (such as `fbclid`, `igsh`, and UTM parameters) before links are shared or opened externally. + +--- + +## Reporting a Vulnerability + +If you discover a security vulnerability or privacy flaw within FocusGram: + +1. **Do not open a public GitHub issue.** +2. Report the vulnerability privately via **[GitHub Private Vulnerability Reporting](https://github.com/Ujwal223/FocusGram/security/advisories/new)** or by emailing `notujwal@proton.me`. +3. Provide a clear description of the vulnerability, reproduction steps, and potential impact. +4. We will acknowledge receipt within 48 hours and work with you to coordinate a release before public disclosure. + +### NOTE: Focusgram & its developer are incapable of providing any bounties. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 9353b22..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - v2/** - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index 9fc28a3..0000000 --- a/android/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks -upload-keystore.jks - diff --git a/android/NOTICE b/android/NOTICE deleted file mode 100644 index 06a9081..0000000 --- a/android/NOTICE +++ /dev/null @@ -1,190 +0,0 @@ - - Copyright (c) 2005-2014, The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts deleted file mode 100644 index a447891..0000000 --- a/android/app/build.gradle.kts +++ /dev/null @@ -1,80 +0,0 @@ -import java.util.Properties - -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.ujwal.focusgram" - compileSdk = flutter.compileSdkVersion - buildToolsVersion = "35.0.0" - ndkVersion = flutter.ndkVersion - - val keystorePropertiesFile = rootProject.file("key.properties") - val keystoreProperties = Properties() - if (keystorePropertiesFile.exists()) { - keystoreProperties.load(keystorePropertiesFile.inputStream()) - } - - signingConfigs { - create("release") { - keyAlias = keystoreProperties["keyAlias"] as String? - keyPassword = keystoreProperties["keyPassword"] as String? - storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String? - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - isCoreLibraryDesugaringEnabled = true - } - - kotlinOptions { - jvmTarget = "17" - } - - defaultConfig { - applicationId = "com.ujwal.focusgram" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 24 - targetSdk = 35 - versionCode = 4 - versionName = "2.1.0" - } - - buildTypes { - release { - if (keystorePropertiesFile.exists()) { - signingConfig = signingConfigs.getByName("release") - } else { - signingConfig = signingConfigs.getByName("debug") - } - - // Fix for release crash: Apply proguard rules - isMinifyEnabled = true - isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } - - // Narrow exclusions to only the specific modules that cause conflicts, - // not entire Google/Firebase groups (which would block AdMob & Firebase). - configurations.all { - exclude(group = "com.google.android.play", module = "core") - exclude(group = "com.google.android.play", module = "core-common") - } -} - -dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") -} - -flutter { - source = "../.." -} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index ea309ba..0000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,37 +0,0 @@ --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.** --dontwarn com.google.android.play.core.tasks.** --assumenosideeffects class com.google.android.play.core.** { *; } --assumenosideeffects class com.google.android.play.core.splitinstall.** { *; } --assumenosideeffects class com.google.android.play.core.tasks.** { *; } - -# Flutter Wrapper --keep class io.flutter.app.** { *; } --keep class io.flutter.plugin.** { *; } --keep class io.flutter.util.** { *; } --keep class io.flutter.view.** { *; } --keep class io.flutter.** { *; } --keep class io.flutter.plugins.** { *; } - -# flutter_local_notifications --keep class com.dexterous.flutterlocalnotifications.** { *; } --keep class com.google.firebase.messaging.** { *; } --dontwarn com.google.firebase.messaging.** - -# webview_flutter --keep class io.flutter.plugins.webviewflutter.** { *; } - -# Keystore and common --keep class com.ujwal.focusgram.** { *; } - -# Avoid stripping JS bridge names --keepattributes JavascriptInterface --keepclassmembers class * { - @android.webkit.JavascriptInterface ; -} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index fcde977..0000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt b/android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt deleted file mode 100644 index 445ab01..0000000 --- a/android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.ujwal.focusgram - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 9accd00..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 625081a..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 9c5d635..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 341e142..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index b2f4937..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 5f349f7..0000000 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index f9dcc18..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 7352438..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 2182cb5..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 46afce4..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 839c0d1..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be..0000000 --- a/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml deleted file mode 100644 index beab31f..0000000 --- a/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #000000 - \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88..0000000 --- a/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 91fb43d..0000000 --- a/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - instagram.com - cdninstagram.com - fbcdn.net - - diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 4d800c3..0000000 --- a/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts deleted file mode 100644 index dbee657..0000000 --- a/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html deleted file mode 100644 index 701b94c..0000000 --- a/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index b7532de..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -# This builtInKotlin flag was added automatically by Flutter migrator -android.builtInKotlin=false -# This newDsl flag was added automatically by Flutter migrator -android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e4ef43f..0000000 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/popup.json b/android/popup.json index 8320a7e..a2d78d7 100644 --- a/android/popup.json +++ b/android/popup.json @@ -1,10 +1,10 @@ { - "show": false, - "id": "popup_005", - "header": "FOCUSGRAM UPDATE DISCONTINUITION!!", - "body": "Due to NO Support from community, it is being difficult for me to maintain this project, due to which, Next coming update might be the LAST update for FocusGram. THANKS FOR UNDERSTANDING! ", - "max_shows": 6, - "button_text": "OKAY..." + "show": true, + "id": "popup_006", + "header": "App Update Notice!", + "body": "New App Update is available, Please update to the latest version to enjoy new features and improvements.", + "max_shows": 4, + "button_text": "OK BRO!" -} +} \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts deleted file mode 100644 index ca7fe06..0000000 --- a/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") diff --git a/Ported/app/build.gradle.kts b/app/build.gradle.kts similarity index 76% rename from Ported/app/build.gradle.kts rename to app/build.gradle.kts index 5eea104..eb8dfbc 100644 --- a/Ported/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -5,16 +7,42 @@ plugins { alias(libs.plugins.ksp) } +val useInstaPackage: Boolean = run { + val cliFlag = project.findProperty("useInstaPackage") as? String + if (cliFlag != null) return@run cliFlag.toBoolean() + + val targetPkg = project.findProperty("targetPackage") as? String + if (targetPkg != null) return@run (targetPkg == "insta") + + val envFlag = System.getenv("FOCUSGRAM_USE_INSTA_PACKAGE") + if (envFlag != null) return@run envFlag.toBoolean() + + val localPropsFile = rootProject.file("local.properties") + if (localPropsFile.exists()) { + val p = Properties() + localPropsFile.inputStream().use { p.load(it) } + p.getProperty("focusgram.use_insta_package")?.toBoolean() ?: false + } else { + false + } +} + +val targetAppId = if (useInstaPackage) "com.instagram.android" else "com.ujwal.focusgram" +val targetAppName = if (useInstaPackage) "Instagram" else "FocusGram" +val apkPrefix = if (useInstaPackage) "FocusGram-uncloned-insta" else "FocusGram" + android { namespace = "com.ujwal.focusgram" compileSdk = 35 defaultConfig { - applicationId = "com.ujwal.focusgram" + applicationId = targetAppId minSdk = 26 targetSdk = 35 - versionCode = 200 - versionName = "2.0.0" + versionCode = 300 + versionName = "3.0.0" + + manifestPlaceholders["appLabel"] = targetAppName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -93,6 +121,21 @@ android { ) } } + + testOptions { + unitTests.isReturnDefaultValues = true + } + + applicationVariants.all { + val variant = this + outputs.all { + val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl + if (output != null) { + val abi = output.getFilter(com.android.build.OutputFile.ABI) ?: "universal" + output.outputFileName = "$apkPrefix-v${variant.versionName}-$abi-${variant.buildType.name}.apk" + } + } + } } dependencies { @@ -134,6 +177,7 @@ dependencies { // Testing & Debug testImplementation(libs.junit) + testImplementation("org.json:json:20240303") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/Ported/app/proguard-rules.pro b/app/proguard-rules.pro similarity index 100% rename from Ported/app/proguard-rules.pro rename to app/proguard-rules.pro diff --git a/Ported/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml similarity index 95% rename from Ported/app/src/main/AndroidManifest.xml rename to app/src/main/AndroidManifest.xml index 12f0510..0689dd6 100644 --- a/Ported/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,7 +53,7 @@ android:allowBackup="false" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" + android:label="${appLabel}" android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" @@ -99,7 +99,7 @@ android:enabled="true" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -113,7 +113,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_redesign" android:roundIcon="@mipmap/ic_launcher_redesign" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -127,7 +127,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_pitch_black" android:roundIcon="@mipmap/ic_launcher_pitch_black" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -141,7 +141,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_all_white" android:roundIcon="@mipmap/ic_launcher_all_white" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -156,7 +156,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_abstract" android:roundIcon="@mipmap/ic_launcher_abstract" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -170,7 +170,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_forest" android:roundIcon="@mipmap/ic_launcher_forest" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -184,7 +184,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_golden" android:roundIcon="@mipmap/ic_launcher_golden" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -198,7 +198,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_metal" android:roundIcon="@mipmap/ic_launcher_metal" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -212,7 +212,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_scribble" android:roundIcon="@mipmap/ic_launcher_scribble" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -227,7 +227,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_polaroid" android:roundIcon="@mipmap/ic_launcher_polaroid" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -241,7 +241,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_viewfinder" android:roundIcon="@mipmap/ic_launcher_viewfinder" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -255,7 +255,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_minimalist" android:roundIcon="@mipmap/ic_launcher_minimalist" - android:label="@string/app_name"> + android:label="${appLabel}"> @@ -269,7 +269,7 @@ android:enabled="false" android:icon="@mipmap/ic_launcher_vibrant" android:roundIcon="@mipmap/ic_launcher_vibrant" - android:label="@string/app_name"> + android:label="${appLabel}"> diff --git a/app/src/main/assets/adi-registration.properties b/app/src/main/assets/adi-registration.properties new file mode 100644 index 0000000..f4bc2ba --- /dev/null +++ b/app/src/main/assets/adi-registration.properties @@ -0,0 +1 @@ +COQOQ33MWZHJAAAAAAAAAAAAAA \ No newline at end of file diff --git a/Ported/app/src/main/assets/extensions/ublock/LICENSE.txt b/app/src/main/assets/extensions/ublock/LICENSE.txt similarity index 100% rename from Ported/app/src/main/assets/extensions/ublock/LICENSE.txt rename to app/src/main/assets/extensions/ublock/LICENSE.txt diff --git a/Ported/app/src/main/assets/extensions/ublock/README.md b/app/src/main/assets/extensions/ublock/README.md similarity index 100% rename from Ported/app/src/main/assets/extensions/ublock/README.md rename to app/src/main/assets/extensions/ublock/README.md diff --git a/Ported/app/src/main/assets/extensions/ublock/background.js b/app/src/main/assets/extensions/ublock/background.js similarity index 88% rename from Ported/app/src/main/assets/extensions/ublock/background.js rename to app/src/main/assets/extensions/ublock/background.js index 531d023..70d181e 100644 --- a/Ported/app/src/main/assets/extensions/ublock/background.js +++ b/app/src/main/assets/extensions/ublock/background.js @@ -1,10 +1,6 @@ /** - * FocusGram Privacy & Content Blocker Engine โ€” WebExtension Background Script - * - * Provides: - * 1. Network-level interception of Instagram ads & telemetry (toggled dynamically via settings). - * 2. Native messaging bridge connecting GeckoView Android host with in-page content scripts. - * 3. Dispatcher for SPA navigation, stealth updates, Reels metadata, and UI events. + * Background script routing ad/telemetry blocking, native host messaging, + * and content script coordination for web requests and settings updates. */ 'use strict'; @@ -27,6 +23,8 @@ let cachedSettings = { ghostHideTypingIndicator: true, unblurMode: 'tap', videoDownloadEnabled: false, + storySearchEnabled: true, + customBioFontsEnabled: true, chatLockMode: 'individual', chatLockAll: false, lockedChatIds: [], @@ -63,21 +61,25 @@ const BLOCKED_URL_PATTERNS = [ const GHOST_BLOCKED_URL_PATTERNS = [ "*://*.instagram.com/api/v1/stories/reel/seen*", + "*://*.instagram.com/stories/reel/seen*", + "*://*.instagram.com/api/v1/stories/update_seen*", "*://*.instagram.com/api/v1/direct_v2/threads/*/items/*/seen*", "*://*.instagram.com/api/v1/direct_v2/threads/broadcast/typing*", - "*://*.instagram.com/api/v1/direct_v2/threads/typing*" + "*://*.instagram.com/api/v1/direct_v2/threads/typing*", + "*://*.instagram.com/api/v1/direct_v2/threads/seen*", + "*://*.instagram.com/api/v1/direct_v2/threads/*/seen*", + "*://*.instagram.com/api/v1/direct_v2/threads/*/mark_seen*", + "*://*.instagram.com/api/v1/direct_v2/threads/*/mark_read*" ]; -// Network-level interception: ONLY active when noAds is enabled in settings if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.onBeforeRequest) { browser.webRequest.onBeforeRequest.addListener( function (details) { if (cachedSettings.noAds === false || !userIsAuthenticated) { - // User turned ad blocker off or user is not logged in: do not cancel requests return { cancel: false }; } const url = (details.url || '').toLowerCase(); - // NEVER block anything on or related to authentication, login, signup, or security checks + // Preserve auth flows and challenge endpoints without interception. if (url.includes('/accounts/') || url.includes('/login') || url.includes('/signup') || url.includes('facebook.net') || url.includes('facebook.com') || url.includes('/ajax/bz') || url.includes('/api/v1/web/accounts/') || url.includes('/api/v1/accounts/')) { @@ -91,16 +93,18 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o ); console.log('[Content Blocker] Network interception active across', BLOCKED_URL_PATTERNS.length, 'patterns.'); - // Ghost Mode WebRequest Level Interception browser.webRequest.onBeforeRequest.addListener( function (details) { if (!cachedSettings.ghostModeEnabled) return { cancel: false }; const url = (details.url || '').toLowerCase(); - if (cachedSettings.ghostHideStorySeen && url.includes('stories/reel/seen')) { + if (url.includes('/broadcast/text') || url.includes('/broadcast/media')) { + return { cancel: false }; + } + if (cachedSettings.ghostHideStorySeen && (url.includes('stories/reel/seen') || url.includes('story/seen') || url.includes('update_seen'))) { console.log('[Ghost Mode] Blocked story seen network call:', details.url); return { cancel: true }; } - if (cachedSettings.ghostHideDirectSeen && url.includes('/seen')) { + if (cachedSettings.ghostHideDirectSeen && (url.includes('/seen') || url.includes('mark_seen') || url.includes('mark_read'))) { console.log('[Ghost Mode] Blocked direct seen network call:', details.url); return { cancel: true }; } @@ -114,10 +118,8 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o ["blocking"] ); - // --- Story Viewer List Interception (Client-Side Search) --- - // Passively captures the response of Instagram's own web client when opening viewers list. - // Zero automated/extra requests โ€” uses web client's authenticated response. - const storyViewersCache = new Map(); // mediaId -> Array<{pk, username, fullName, avatar, hasLiked}> + // Passively capture viewers list responses without issuing external requests. + const storyViewersCache = new Map(); browser.webRequest.onBeforeRequest.addListener( function (details) { @@ -127,7 +129,7 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o const mediaId = match[1]; - // Immediately broadcast detection when request is SENT so search bar appears right away! + // Broadcast early phase so UI surfaces the search bar before payload arrives. broadcastToTabs({ action: 'storyViewersDetected', phase: 'loading', @@ -142,7 +144,7 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o filter.ondata = function (event) { chunks.push(event.data); - filter.write(event.data); // Transparently stream data to Instagram web client + filter.write(event.data); }; filter.onstop = async function () { @@ -215,7 +217,6 @@ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.o ); } -// Establish native messaging port with FocusGram Android host let nativePort = null; const pendingNativeMessages = []; @@ -301,7 +302,6 @@ function broadcastToTabs(message) { } } -// Listen for messages from in-page content scripts if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) { browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message) return; @@ -311,8 +311,10 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa } if (message.action === 'getSettings') { - sendResponse({ settings: cachedSettings }); - return true; + if (typeof sendResponse === 'function') { + sendResponse({ settings: cachedSettings }); + } + return Promise.resolve({ settings: cachedSettings }); } if (message.action === 'getStoryViewers') { diff --git a/Ported/app/src/main/assets/extensions/ublock/content.js b/app/src/main/assets/extensions/ublock/content.js similarity index 82% rename from Ported/app/src/main/assets/extensions/ublock/content.js rename to app/src/main/assets/extensions/ublock/content.js index 134d1f7..1f91306 100644 --- a/Ported/app/src/main/assets/extensions/ublock/content.js +++ b/app/src/main/assets/extensions/ublock/content.js @@ -1,8 +1,6 @@ /** - * FocusGram โ€” Cosmetic Filtering & DOM Scriptlet - * - * Injects CSS cosmetic filtering rules and monitors dynamic SPA DOM - * insertions to eliminate sponsored posts and ad banners when enabled. + * Cosmetic filtering and scriptlet injections. + * Collapses sponsored cards, hides ad banners, and skips sponsored reel units. */ 'use strict'; @@ -17,15 +15,10 @@ if (path.includes('/accounts/edit')) { return false; } - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) { + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) { return true; } - const cookies = document.cookie || ''; - const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id='); - if ((path === '/' || path === '') && !hasAuthCookie) { - return true; - } - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) { + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) { return true; } } catch (_) {} @@ -35,11 +28,11 @@ if (isAuthPage()) { const existing = document.getElementById(COSMETIC_STYLE_ID); if (existing) existing.remove(); - return; // NEVER run on auth, login, or signup pages + return; } const CSS_RULES = ` - /* Hide sponsored feed articles without breaking Infinite Scroll IntersectionObserver or feed containers */ + /* Maintain 1px height so IntersectionObserver viewport triggers continue firing for infinite scroll */ div[data-ad-preview], div[data-testid="post_ad"], article:has(a[href*="/ads/"]), @@ -92,12 +85,16 @@ const STRICT_AD_CTAS = [ 'shop now', 'install now', 'apply now', - 'book now', 'order now', 'get offer' + 'book now', 'order now', 'get offer', 'learn more' ]; + function findReelCardForVideo(video) { + if (!video) return null; + return video.closest('[data-fg-reel-card="1"]') || video.closest('article'); + } + function filterDynamicSponsoredPosts() { if (!adBlockingEnabled) return; - // Strictly target individual article posts to avoid hiding high-level feed containers const articles = document.querySelectorAll('article'); for (let i = 0; i < articles.length; i++) { const article = articles[i]; @@ -105,7 +102,6 @@ let isAd = false; - // Check post header only (Sponsored, Paid partnership) const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]'); for (let j = 0; j < headerElements.length; j++) { if (headerElements[j].children.length > 2) continue; @@ -118,7 +114,6 @@ } } - // Check CTA button text specifically on buttons (excluding FocusGram download buttons) if (!isAd) { const buttons = article.querySelectorAll('div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), a[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), button'); for (let k = 0; k < buttons.length; k++) { @@ -130,7 +125,6 @@ } } - // Check ad-specific links (strictly /ads/, /ad_, ads.instagram.com) if (!isAd) { if (article.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"]')) { isAd = true; @@ -150,24 +144,23 @@ } } - // Filter Reels video ads on reels surface without ever breaking layout const videos = document.querySelectorAll('video'); for (let i = 0; i < videos.length; i++) { const video = videos[i]; - const card = video.closest('[data-fg-reel-card="1"]') || video.closest('article'); + const card = findReelCardForVideo(video); if (!card || card.dataset.adFiltered || card.dataset.fgReelAd) continue; - let isSponsored = !!card.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"], a[href*="/about/ads"]') || - !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [data-ad-preview]'); + let isSponsored = !!card.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"], a[href*="/about/ads"], a[href*="facebook.com/ads"]') || + !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [aria-label*="advertisement" i], [data-ad-preview], [data-ad-id]'); if (!isSponsored) { const badges = card.querySelectorAll('span, div[role="button"], button'); for (let s = 0; s < badges.length; s++) { const el = badges[s]; if (el.children.length > 2) continue; - const raw = (el.textContent || '').trim(); - if (!raw || raw.length > 15) continue; - if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) { + const raw = (el.textContent || '').trim().toLowerCase(); + if (!raw || raw.length > 25) continue; + if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || raw === 'sponsored' || raw === 'paid partnership' || raw === 'advertisement') { if (!el.closest('span[dir="auto"]')) { isSponsored = true; break; @@ -184,7 +177,6 @@ card.style.setProperty('visibility', 'hidden', 'important'); card.style.setProperty('pointer-events', 'none', 'important'); - // Skip ad reel automatically if currently visible const r = card.getBoundingClientRect(); if (r.top >= -100 && r.top <= window.innerHeight * 0.8) { const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]'); @@ -198,7 +190,6 @@ } } - // Dynamic MutationObserver for SPA scrolling const observer = new MutationObserver(() => { if (adBlockingEnabled) { filterDynamicSponsoredPosts(); @@ -225,7 +216,6 @@ init(); } - // Listen for settings changes from background if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) { browser.runtime.onMessage.addListener((msg) => { if (msg && msg.action === 'updateSettings' && msg.settings) { diff --git a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js b/app/src/main/assets/extensions/ublock/focusgram_content.js similarity index 85% rename from Ported/app/src/main/assets/extensions/ublock/focusgram_content.js rename to app/src/main/assets/extensions/ublock/focusgram_content.js index e6d0f1c..f8e0cf9 100644 --- a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js +++ b/app/src/main/assets/extensions/ublock/focusgram_content.js @@ -1,21 +1,12 @@ /** - * FocusGram In-Page Controller & Stealth Privacy Engine - * - * Injected as a WebExtension content script across instagram.com at document_start. - * Features: - * 1. Stealth Mode: Main-world fetch and XHR interceptors for Story views, DM Read Receipts, and Typing indicators. - * 2. Bottom Bar Trimming: Hides Instagram's web bottom navigation bar and adds margin for the native app bar. - * 3. Dynamic Margins: Removes margin when keyboard/inputs are active so comments and DM input boxes are never blocked. - * 4. Top Logo Trigger: Intercepts clicks and long-presses on the Instagram logo to show FocusGram Settings. - * 5. Focus/Typing Bridge: Notifies native Android host to hide/show the floating bottom navigation bar. - * 6. SPA Navigation: Provides instant client-side route transitions without page reloads. - * 7. Digital Wellness Guardrails: Dynamic CSS filtering for Stories, Reels, Explore blur, Likes, and Suggested posts. + * In-page WebExtension controller injected across web sessions at document_start. + * Handles stealth request filtering, navigation bar trimming, viewport margin offsets, + * client-side chat locking, and dynamic wellness guardrails. */ 'use strict'; (function () { - // Senior Blueprint: Direct content script communication with WebExtension background script function postToNative(message) { if (!message) return; try { @@ -32,19 +23,19 @@ function isAuthPage() { try { + const host = (window.location.hostname || '').toLowerCase(); + if (host.includes('facebook.com') || host.includes('accountscenter.')) { + return true; + } const path = (window.location.pathname || '').toLowerCase(); if (path.includes('/accounts/edit')) { return false; } - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) { + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || + path.startsWith('/two_factor') || path.startsWith('/challenge') || path.startsWith('/onetap')) { return true; } - const cookies = document.cookie || ''; - const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id='); - if ((path === '/' || path === '') && !hasAuthCookie) { - return true; - } - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) { + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) { return true; } } catch (_) {} @@ -53,21 +44,20 @@ if (isAuthPage()) { postToNative({ action: 'authStateChanged', isAuthenticated: false, isAuthPage: true }); - // Watch for navigation/login completion without running any scripts or CSS let authCheckInterval = setInterval(() => { if (!isAuthPage()) { clearInterval(authCheckInterval); window.location.reload(); } }, 1000); - return; // Leave login/signup completely untouched! + return; } else { postToNative({ action: 'authStateChanged', isAuthenticated: true, isAuthPage: false }); } console.log('[FocusGram] In-page controller initialized.'); - // --- Immediate anti-flash style injected at document_start --- + // Anti-flash style injected at document_start. try { const immediateStyle = document.createElement('style'); immediateStyle.id = 'focusgram-immediate-nav-hide'; @@ -104,14 +94,17 @@ hideFollowerCounts: false, hideSuggestedPosts: false, hideShopTab: false, + noAutoplay: false, + grayscale: false, videoDownloadEnabled: false, + storySearchEnabled: true, + customBioFontsEnabled: true, ghostModeEnabled: false, ghostHideStorySeen: true, ghostHideDirectSeen: true, ghostHideTypingIndicator: true }; - // --- 1. GHOST MODE INTERCEPTION ENGINE --- function setupGhostModeInterceptors() { const GHOST_SCRIPT_ID = 'focusgram-ghost-interceptor'; if (document.getElementById(GHOST_SCRIPT_ID)) return; @@ -131,13 +124,78 @@ blockAds: true, blockSponsored: true, blockSuggested: false, - blockVideos: false + blockVideos: false, + blockAutoplay: false, + storySearchEnabled: true, + customBioFontsEnabled: true }; + // HTML5 video autoplay interception + const allowedVideos = new WeakSet(); + function isReelVideo() { + try { + const path = (window.location.pathname || '').toLowerCase(); + return path.startsWith('/reel') || path.includes('/reel'); + } catch (_) { + return false; + } + } + + function shouldBlockAutoplay() { + if (isReelVideo()) return false; + const cfg = window.__fgFilterConfig; + return cfg && cfg.blockAutoplay !== false; + } + + if (window.HTMLVideoElement && HTMLVideoElement.prototype.play) { + const origPlay = HTMLVideoElement.prototype.play; + HTMLVideoElement.prototype.play = function() { + try { + if (window.__fgMediaPaused) { + this.removeAttribute('autoplay'); + this.autoplay = false; + this.pause(); + return Promise.resolve(); + } + if (!shouldBlockAutoplay() || allowedVideos.has(this)) { + return origPlay.apply(this, arguments); + } + this.removeAttribute('autoplay'); + this.autoplay = false; + this.pause(); + return Promise.resolve(); + } catch (_) { + return origPlay.apply(this, arguments); + } + }; + + ['click', 'pointerup'].forEach(function(eventType) { + document.addEventListener(eventType, function(e) { + if (!e.isTrusted) return; + if (e.target.closest('button[aria-label="Like"], button[aria-label="Comment"], button[aria-label="Share"], a[role="link"]:not(:has(video))')) return; + const v = e.target.tagName === 'VIDEO' ? e.target : (e.target.closest('video') || (e.target.querySelector ? e.target.querySelector('video') : null)); + if (v) { + allowedVideos.add(v); + try { origPlay.apply(v); } catch (_) {} + } + }, true); + }); + } + window.addEventListener('message', function(e) { if (!e || !e.data) return; if (e.data.type === 'GHOSTIFY_SETTINGS_UPDATE' && e.data.settings) { - Object.assign(window.__focusgramGhostSettings, e.data.settings); + const s = e.data.settings; + if (s.enabled !== undefined) window.__focusgramGhostSettings.enabled = !!s.enabled; + const story = s.hideStorySeen !== undefined ? !!s.hideStorySeen : (s.igStory !== undefined ? !!s.igStory : window.__focusgramGhostSettings.hideStorySeen); + const seen = s.hideDirectSeen !== undefined ? !!s.hideDirectSeen : (s.igSeen !== undefined ? !!s.igSeen : window.__focusgramGhostSettings.hideDirectSeen); + const typing = s.hideTyping !== undefined ? !!s.hideTyping : (s.igTyping !== undefined ? !!s.igTyping : window.__focusgramGhostSettings.hideTyping); + window.__focusgramGhostSettings.hideStorySeen = story; + window.__focusgramGhostSettings.hideDirectSeen = seen; + window.__focusgramGhostSettings.hideTyping = typing; + window.__focusgramGhostSettings.igStory = story; + window.__focusgramGhostSettings.igSeen = seen; + window.__focusgramGhostSettings.igTyping = typing; } if (e.data.type === 'FOCUSGRAM_FILTER_UPDATE' && e.data.config) { Object.assign(window.__fgFilterConfig, e.data.config); @@ -376,7 +434,8 @@ 'update_seen_for_reel', 'reel_seen', 'stories_update_seen', - 'mark_story_read' + 'mark_story_read', + 'viewseenat' ]; const SEEN_PATTERNS = [ @@ -389,7 +448,12 @@ 'polarisdirectmarkasseenmutation', 'directseenmutation', 'usepolarismarkthreadseenmutation', - 'useigdmarkthreadasreadmutation' + 'useigdmarkthreadasreadmutation', + 'markdirectthreadseen', + 'mark_direct_thread_seen', + 'direct_v2/threads/seen', + 'direct_v2/threads/mark_seen', + 'direct_v2/threads/mark_read' ]; const TYPING_PATTERNS = [ @@ -404,7 +468,11 @@ 'typing_on', 'is_composing', 'update_activity_status', - 'activity_status' + 'activity_status', + 'typingindicatorstoredprocedure', + 'directsendtyping', + 'direct_send_typing', + 'igdsendtyping' ]; function isDirectSendEndpoint(url) { @@ -412,7 +480,9 @@ return u.includes('broadcast/text') || u.includes('broadcast/media') || u.includes('threads/broadcast') || - u.includes('action=send_message'); + u.includes('action=send_message') || + u.includes('send_item') || + u.includes('publish_message'); } async function decodeBody(body) { @@ -450,16 +520,20 @@ } function checkBlock(url, bodyStr) { - if (isDirectSendEndpoint(url)) return null; + if (isDirectSendEndpoint(url) || isDirectSendEndpoint(bodyStr)) return null; const path = String(window.location?.pathname || '').toLowerCase(); if (path.startsWith('/accounts') || path.startsWith('/login')) return null; const s = window.__focusgramGhostSettings; if (!s || !s.enabled) return null; + const storyActive = s.hideStorySeen !== undefined ? s.hideStorySeen : !!s.igStory; + const seenActive = s.hideDirectSeen !== undefined ? s.hideDirectSeen : !!s.igSeen; + const typingActive = s.hideTyping !== undefined ? s.hideTyping : !!s.igTyping; + const combined = (String(bodyStr || '') + ' ' + String(url || '')).toLowerCase(); - if (s.hideStorySeen && matchesAny(combined, STORY_PATTERNS)) return 'IG_STORY'; - if (s.hideDirectSeen && matchesAny(combined, SEEN_PATTERNS)) return 'IG_SEEN'; - if (s.hideTyping && matchesAny(combined, TYPING_PATTERNS)) return 'IG_TYPING'; + if (storyActive && matchesAny(combined, STORY_PATTERNS)) return 'IG_STORY'; + if (seenActive && matchesAny(combined, SEEN_PATTERNS)) return 'IG_SEEN'; + if (typingActive && matchesAny(combined, TYPING_PATTERNS)) return 'IG_TYPING'; return null; } @@ -497,13 +571,14 @@ return { data: {} }; } - // --- Focus & Visibility Spoofing --- + // Visibility and focus spoofing. function shouldSpoofVisibility() { const s = window.__focusgramGhostSettings; if (!s || !s.enabled) return null; + const storyActive = s.hideStorySeen !== undefined ? s.hideStorySeen : !!s.igStory; const path = String(window.location?.pathname || '').toLowerCase(); - if (s.hideStorySeen && path.startsWith('/stories/')) { + if (storyActive && path.startsWith('/stories/')) { return 'unfocused'; } return null; @@ -524,17 +599,72 @@ Object.defineProperty(document, 'visibilityState', { get: function() { const spoof = shouldSpoofVisibility(); - if (spoof === 'unfocused') return 'visible'; + if (spoof === 'unfocused') return 'hidden'; return originalVisibilityState && originalVisibilityState.get ? originalVisibilityState.get.call(document) : 'visible'; }, configurable: true }); + + const originalHidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden'); + Object.defineProperty(document, 'hidden', { + get: function() { + const spoof = shouldSpoofVisibility(); + if (spoof === 'unfocused') return true; + return originalHidden && originalHidden.get ? + originalHidden.get.call(document) : false; + }, + configurable: true + }); } catch (err) { console.warn('[FocusGram Ghost] Focus hook notice:', err); } - // --- Hook window.fetch --- + // Intercept WebSocket frames (MQTT typing indicators and read receipts). + if (typeof WebSocket !== 'undefined' && WebSocket.prototype && WebSocket.prototype.send) { + if (!window.__FG_GHOST_WS_HOOKED__) { + window.__FG_GHOST_WS_HOOKED__ = true; + const originalWsSend = WebSocket.prototype.send; + const wsUrls = new WeakMap(); + + const OriginalWebSocket = window.WebSocket; + window.WebSocket = new Proxy(OriginalWebSocket, { + construct(target, args, newTarget) { + const ws = Reflect.construct(target, args, newTarget); + try { + wsUrls.set(ws, String(args[0] || '')); + } catch (_) {} + return ws; + } + }); + + function decodeWsSync(data) { + if (!data) return ''; + try { + if (typeof data === 'string') return data; + if (data instanceof ArrayBuffer) return new TextDecoder().decode(data); + if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data); + } catch (_) {} + return ''; + } + + WebSocket.prototype.send = function(data) { + try { + const socketUrl = wsUrls.get(this) || this.url || ''; + const dataText = decodeWsSync(data); + const blockType = checkBlock(socketUrl, dataText); + if (blockType) { + console.log('[FocusGram Ghost] Hooked ' + blockType + ' WebSocket frame: ' + socketUrl); + return; // Drop frame + } + } catch (e) { + console.warn('[FocusGram Ghost] WebSocket check error:', e); + } + return originalWsSend.apply(this, arguments); + }; + } + } + const originalFetch = window.fetch; window.fetch = async function(input, init) { const url = typeof input === 'string' ? input : (input && input.url ? input.url : ''); @@ -608,7 +738,6 @@ return response; }; - // --- Hook navigator.sendBeacon --- const originalBeacon = navigator.sendBeacon; if (typeof originalBeacon === 'function') { navigator.sendBeacon = function(url, data) { @@ -631,7 +760,6 @@ }; } - // --- Hook XMLHttpRequest --- const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; @@ -755,6 +883,35 @@ } }, true); + // Intercept clicks on in-page Back chevron in DM threads/inbox so it never pops history + document.addEventListener('click', function(e) { + const path = (window.location.pathname || '').toLowerCase(); + const backBtn = e.target.closest('svg[aria-label="Back"], svg[aria-label="back"], svg[aria-label="Chevron left"], [aria-label="Back"], [aria-label="back"], a[href*="/direct/inbox"], a[href*="/direct/"]') + ?.closest('a, button, div[role="button"]'); + if (backBtn) { + if (path.startsWith('/direct/t/') || path.startsWith('/direct/threads/') || (path.startsWith('/direct/') && !path.startsWith('/direct/inbox'))) { + e.preventDefault(); + e.stopPropagation(); + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/direct/inbox/'); + } else { + window.location.href = '/direct/inbox/'; + } + return; + } + if (path === '/direct' || path === '/direct/' || path.startsWith('/direct/inbox')) { + e.preventDefault(); + e.stopPropagation(); + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/'); + } else { + window.location.href = '/'; + } + return; + } + } + }, true); + function checkSuggestedTrap() { if (window.location.pathname.includes('/explore/people')) { window.__focusgramNavigate('/'); @@ -782,6 +939,55 @@ return res; }; + // Override history.back and history.go in DMs so navigation follows: + // user's dm -> /direct/inbox -> home screen + // without popping previous browser history entries (which exposes locked chats). + const _origHistoryBack = window.history.back; + window.history.back = function() { + const path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/direct/t/') || path.startsWith('/direct/threads/') || (path.startsWith('/direct/') && !path.startsWith('/direct/inbox'))) { + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/direct/inbox/'); + } else { + window.location.href = '/direct/inbox/'; + } + return; + } + if (path === '/direct' || path === '/direct/' || path.startsWith('/direct/inbox')) { + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/'); + } else { + window.location.href = '/'; + } + return; + } + return _origHistoryBack.apply(this, arguments); + }; + + const _origHistoryGo = window.history.go; + window.history.go = function(delta) { + if (typeof delta === 'number' && delta < 0) { + const path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/direct/t/') || path.startsWith('/direct/threads/') || (path.startsWith('/direct/') && !path.startsWith('/direct/inbox'))) { + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/direct/inbox/'); + } else { + window.location.href = '/direct/inbox/'; + } + return; + } + if (path === '/direct' || path === '/direct/' || path.startsWith('/direct/inbox')) { + if (typeof window.__focusgramNavigate === 'function') { + window.__focusgramNavigate('/'); + } else { + window.location.href = '/'; + } + return; + } + } + return _origHistoryGo.apply(this, arguments); + }; + window.addEventListener('popstate', function() { try { window.dispatchEvent(new CustomEvent('focusgram:urlchange', { detail: window.location.href })); @@ -806,6 +1012,10 @@ type: "GHOSTIFY_SETTINGS_UPDATE", source: "GHOSTIFY_EXTENSION", settings: { + enabled: isGhostActive, + hideTyping: isGhostActive && !!currentSettings.ghostHideTypingIndicator, + hideDirectSeen: isGhostActive && !!currentSettings.ghostHideDirectSeen, + hideStorySeen: isGhostActive && !!currentSettings.ghostHideStorySeen, igTyping: isGhostActive && !!currentSettings.ghostHideTypingIndicator, igSeen: isGhostActive && !!currentSettings.ghostHideDirectSeen, igStory: isGhostActive && !!currentSettings.ghostHideStorySeen @@ -817,7 +1027,10 @@ config: { blockAds: currentSettings.noAds !== false, blockSponsored: currentSettings.noAds !== false, - blockSuggested: !!currentSettings.hideSuggestedPosts + blockSuggested: !!currentSettings.hideSuggestedPosts, + blockAutoplay: currentSettings.noAutoplay !== false, + storySearchEnabled: currentSettings.storySearchEnabled !== false, + customBioFontsEnabled: currentSettings.customBioFontsEnabled !== false } }, "*"); } @@ -828,7 +1041,7 @@ } }); - // --- 2. BOTTOM NAVBAR TRIMMING & DYNAMIC MARGINS --- + // Bottom navbar trimming and dynamic margins. const STYLE_ID = 'focusgram-ui-guardrails-style'; function updateGuardrailsCSS() { @@ -876,19 +1089,13 @@ padding-bottom: 0px !important; } - /* ========================================================================== - 3. INSTAGRAM REELS (100% INSPIRATION MATCH) - - Full viewport edge-to-edge container (100dvh). - - Video element covers 100% without letterboxing or black bars. - - Floating navigation bar sits below. - - FULL CAPTION, AUDIO BANNER, USER PROFILE, and ACTION BUTTONS sit ABOVE it. - ========================================================================== */ + /* Reels viewport and overlay offsets */ :root { --fg-reel-bottom-offset: 16px; --fg-reel-scrubber-offset: 2px; } - /* Reels fixed scroller: Ultra-smooth momentum scrolling & snapping */ + /* Reels fixed scroller */ [data-fg-reel-scroller], div[style*="position: fixed"][style*="overflow-y: scroll"], div[style*="position: fixed"][style*="overflow-y: auto"], @@ -940,7 +1147,7 @@ transform: translateZ(0) !important; } - /* Video scaling: seamless edge-to-edge presentation */ + /* Video scaling: edge-to-edge presentation */ [data-fg-reel-card] video, [data-fg-reel] video, html[data-fg-island="FLOATING"] video { @@ -1018,8 +1225,6 @@ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85) !important; } - } - /* Ensure comments sheet / input composer sits flush above keyboard without artificial gap */ [role="dialog"] form, [role="dialog"] [style*="position: fixed"], @@ -1087,9 +1292,9 @@ if (currentSettings.blurExplore) { css += ` - main a[href*="/p/"]:not([data-fg-reveal]):not(.fg-unblurred) img, - main a[href*="/reel/"]:not([data-fg-reveal]):not(.fg-unblurred) img, - div[style*="grid"]:not([data-fg-reveal]):not(.fg-unblurred) img { + html[data-fg-explore] main a[href*="/p/"]:not([data-fg-reveal]):not(.fg-unblurred) img, + html[data-fg-explore] main a[href*="/reel/"]:not([data-fg-reveal]):not(.fg-unblurred) img, + main a[href*="/explore/"]:not([data-fg-reveal]):not(.fg-unblurred) img { filter: blur(24px) !important; transition: filter 0.2s ease-in-out !important; } @@ -1120,6 +1325,20 @@ .fg-unblurred div { filter: none !important; } + + /* Comment GIFs, sticker picker, and comment media must NEVER be blurred */ + img[src*="giphy"], + img[src*="tenor"], + img[alt*="GIF" i], + img[alt*="gif" i], + div[role="dialog"] img[src*="giphy"], + div[aria-modal="true"] img[src*="giphy"], + article ul img[src*="giphy"], + ul[class*="comment" i] img, + form img[src*="giphy"], + form img[src*="tenor"] { + filter: none !important; + } `; if (currentSettings.hideLikeCounts) { @@ -1128,20 +1347,9 @@ section a[href*="/liked_by/"], span:has(> a[href*="/liked_by/"]), div:has(> a[href*="/liked_by/"]), - section:has(svg[aria-label="Like"]) + div:not(:has(form)):not(:has(input)), - section:has(svg[aria-label="Unlike"]) + div:not(:has(form)):not(:has(input)), - section:has(svg[aria-label="Activity Feed"]) + div:not(:has(form)):not(:has(input)), - div[role="button"]:has(svg[aria-label="Like"]) + div span, - div[role="button"]:has(svg[aria-label="Unlike"]) + div span, - span[role="button"]:has(svg[aria-label="Like"]) + span, - span[role="button"]:has(svg[aria-label="Unlike"]) + span, - div[role="button"]:has(svg[aria-label="Like"]) span, - div[role="button"]:has(svg[aria-label="Unlike"]) span, - div:has(> span > svg[aria-label="Like"]) + div span, - div:has(> span > svg[aria-label="Unlike"]) + div span, - /* In Reels overlay: the count text right below the like heart icon */ - div:has(> svg[aria-label="Like"]) + div, - div:has(> svg[aria-label="Unlike"]) + div { + section:has(svg[aria-label="Like"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)), + section:has(svg[aria-label="Unlike"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)), + section:has(svg[aria-label="Activity Feed"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)) { display: none !important; } `; @@ -1219,7 +1427,7 @@ styleEl.textContent = css; } - // --- 2. BOTTOM NAVBAR TRIMMING --- + // Bottom navbar trimming. function hideNav() { try { if (window.location.pathname.startsWith('/direct')) return; @@ -1291,10 +1499,11 @@ - // --- 3. REELS INTERACTION-BASED COUNTER & HISTORY TRACKER --- + // Reels interaction tracking and history recorder. let lastTrackedReelCode = null; let lastTrackedCaption = ''; let lastTrackedAuthor = ''; + let isInitialReelOnTabEntry = true; const harvestedReels = new Map(); function handleReelMetaEvent(raw) { @@ -1363,6 +1572,8 @@ const path = window.location.pathname || ''; // Only run when user is on Reels or viewing a Reel if (!path.startsWith('/reels') && !path.startsWith('/reel')) { + isInitialReelOnTabEntry = true; + lastTrackedReelCode = null; return; } @@ -1595,7 +1806,29 @@ return; } - // CODE CHANGED! Update last tracked state + // Handle initial reel vs subsequent scrolled reels + if (isInitialReelOnTabEntry) { + // First reel encountered on tab mount: capture code and send only metadata + // so session counter starts at 0 without counting the initial landing reel. + isInitialReelOnTabEntry = false; + lastTrackedReelCode = code; + lastTrackedCaption = caption; + lastTrackedAuthor = author; + + postToNative({ + action: 'reelWatched', + type: 'meta', + code: code, + url: reelUrl, + title: caption || (author ? ('Reel by @' + author) : ''), + caption: caption, + author: author || '', + thumbnail: '' + }); + return; + } + + // Subsequent reel scrolled to: fire IMMEDIATELY with zero artificial delay! lastTrackedReelCode = code; lastTrackedCaption = caption; lastTrackedAuthor = author; @@ -1611,28 +1844,22 @@ }); } - // Attach interaction listeners for Reels tracking without thrashing scroll frames - let checkReelDebounceTimer = null; - function debouncedCheckAndTrackActiveReel() { - if (checkReelDebounceTimer) clearTimeout(checkReelDebounceTimer); - checkReelDebounceTimer = setTimeout(checkAndTrackActiveReel, 120); - } - - window.addEventListener('scroll', debouncedCheckAndTrackActiveReel, { passive: true }); + // Attach interaction listeners for Reels tracking to update instantly + window.addEventListener('scroll', checkAndTrackActiveReel, { passive: true }); window.addEventListener('scrollend', checkAndTrackActiveReel, { passive: true }); - window.addEventListener('touchend', () => setTimeout(checkAndTrackActiveReel, 100), { passive: true }); - window.addEventListener('wheel', debouncedCheckAndTrackActiveReel, { passive: true }); + window.addEventListener('touchend', () => checkAndTrackActiveReel(), { passive: true }); + window.addEventListener('wheel', checkAndTrackActiveReel, { passive: true }); window.addEventListener('keydown', (e) => { if (['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Space'].includes(e.key)) { - setTimeout(checkAndTrackActiveReel, 120); + checkAndTrackActiveReel(); } }); window.addEventListener('popstate', checkAndTrackActiveReel); window.addEventListener('hashchange', checkAndTrackActiveReel); window.addEventListener('focusgram:urlchange', checkAndTrackActiveReel); - // Periodic ticker (every 400ms) to ensure reel transitions are never missed - setInterval(checkAndTrackActiveReel, 400); + // Periodic ticker (every 250ms) to ensure reel transitions are never missed + setInterval(checkAndTrackActiveReel, 250); // Ensure clicking 'Post' or 'Story' in the Create dropdown opens file/media picker document.addEventListener('click', (e) => { @@ -1654,7 +1881,7 @@ } }, true); - // --- 3b. REELS LAYOUT SCROLLER & OVERLAY POSITIONING (100% INSPIRATION MATCH) --- + // Reels layout scroller and overlay positioning. function fixReels() { const path = window.location.pathname.toLowerCase(); if (!path.startsWith('/reels') && !path.startsWith('/reel')) return; @@ -1703,21 +1930,26 @@ // Check if this reel is an ad / sponsored if (currentSettings.noAds !== false) { - let isSponsored = !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [aria-label*="advertisement" i], [data-ad-preview], a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]'); + let isSponsored = !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [aria-label*="advertisement" i], [data-ad-preview], [data-ad-id], a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"], a[href*="facebook.com/ads"]'); if (!isSponsored) { - const badges = card.querySelectorAll('span, div[role="button"], button'); + const strictAdCtas = ['shop now', 'install now', 'get offer']; + const badges = card.querySelectorAll('button:not([data-fg-x-act]):not([data-fg-download-btn]), div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn])'); for (let s = 0; s < badges.length; s++) { const el = badges[s]; if (el.children.length > 2) continue; - const raw = (el.textContent || '').trim(); - if (!raw || raw.length > 15) continue; - if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) { + const raw = (el.textContent || '').trim().toLowerCase(); + if (!raw || raw.length > 25) continue; + if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || raw === 'sponsored' || raw === 'paid partnership' || raw === 'advertisement') { if (!el.closest('span[dir="auto"]')) { isSponsored = true; break; } } + if (strictAdCtas.includes(raw) && !el.closest('span[dir="auto"]')) { + isSponsored = true; + break; + } } } @@ -1882,8 +2114,7 @@ observer.observe(root, { childList: true, subtree: true }); } - // --- DEDICATED MODAL DETECTION OBSERVER (Senior Blueprint) --- - // Watch for modal-related attribute changes and structure modifications + // Observe modal attributes and tree changes. let lastModalCheckTime = 0; const modalDebounceMs = 50; @@ -1906,7 +2137,7 @@ } } - // --- 4. SPA URL & MODAL WATCHER --- + // SPA route and modal state observer. let lastReportedUrl = window.location.href; let lastModalState = false; let lastInputFocused = false; @@ -1924,8 +2155,7 @@ path.startsWith('/signup'); } - // --- SHARE SHEET DETECTION --- - // Track when share/modal API was called (scroll lock is primary indicator for comments) + // Share sheet detection. let shareSheetActive = false; let shareSheetCheckInterval = null; let lastShareSheetCheckTime = 0; @@ -1935,7 +2165,6 @@ if (now - lastShareSheetCheckTime < 150) return; lastShareSheetCheckTime = now; - // Efficient modal lookup without DOM thrashing const shareModals = Array.from(document.querySelectorAll('[role="dialog"], [aria-modal="true"], div[style*="fixed"]')).filter(el => { if (el.getAttribute('data-fg-hide')) return false; const cs = window.getComputedStyle(el); @@ -1970,7 +2199,6 @@ } } - // Intercept fetch to detect share/DM API calls and start monitoring const originalFetch = window.fetch.bind(window); window.fetch = function(...args) { const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); @@ -1985,13 +2213,14 @@ return originalFetch.apply(this, args); }; - // --- 6. UNBLUR REVEAL ENGINE (Tap & 350ms Hold) --- + // Unblur reveal engine (tap and 350ms hold). const HOLD_MS = 350, MOVE_TOL = 10; const root = document.documentElement; let timer = 0, sx = 0, sy = 0, swallowUntil = 0; function updateFeedFlag() { root.toggleAttribute("data-fg-feed", location.pathname === "/" || location.pathname === ""); + root.toggleAttribute("data-fg-explore", location.pathname.startsWith("/explore")); } updateFeedFlag(); new MutationObserver(updateFeedFlag).observe(document, { childList: true, subtree: true }); @@ -2000,6 +2229,14 @@ function getBlurredTarget(el) { if (!el) return null; + // Comment GIFs, sticker picker, and comment controls must NEVER be treated as unblur targets + if (el.closest('img[src*="giphy"], img[src*="tenor"], img[alt*="GIF" i], img[alt*="gif" i], form, ul[class*="comment" i]')) { + return null; + } + if (el.tagName === 'IMG' && (el.src?.includes('giphy') || el.src?.includes('tenor') || el.alt?.toLowerCase().includes('gif'))) { + return null; + } + if (currentSettings.blurFeed) { // Never treat carousel next/prev arrows or navigation buttons as unblur triggers if (el.closest('button[aria-label="Next"], button[aria-label="Go back"], button[aria-label="Next slide"], button[aria-label="Previous slide"], button[aria-label="Chevron"]')) { @@ -2094,59 +2331,101 @@ if (getBlurredTarget(e.target)) e.preventDefault(); }, true); - // --- In-Page Reels Metadata Extractor --- - let lastExtractedReelUrl = ''; + // In-page reels metadata extractor. + let lastExtractedReelKey = ''; function checkAndExtractReelMetadata() { const url = window.location.href; if (!url.includes('/reel/') && !url.includes('/reels/')) return; - if (lastExtractedReelUrl === url) return; + + const videos = Array.from(document.querySelectorAll('video')); + const activeVideo = videos.find(v => !v.paused && v.getBoundingClientRect().height > 200) || + videos.find(v => { + const r = v.getBoundingClientRect(); + return r.top >= -50 && r.top < window.innerHeight * 0.6; + }) || videos[0]; let title = ''; let thumbnailUrl = ''; let author = ''; + let reelShortcode = ''; - const ogTitle = document.querySelector('meta[property="og:title"]'); - const ogImage = document.querySelector('meta[property="og:image"]'); - if (ogTitle && ogTitle.content) title = ogTitle.content.trim(); - if (ogImage && ogImage.content) thumbnailUrl = ogImage.content.trim(); + if (activeVideo) { + thumbnailUrl = activeVideo.getAttribute('poster') || ''; + let card = activeVideo.closest('article, section, div[role="presentation"]') || activeVideo.parentElement; + while (card && card !== document.body && card !== document.documentElement) { + if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break; + if (card.getBoundingClientRect().height > window.innerHeight * 0.5) break; + card = card.parentElement; + } - if (!title && document.title) { - title = document.title.replace(' on Instagram', '').replace('โ€ข Instagram photos and videos', '').trim(); - } - if (title) { - const authorMatch = title.match(/^([^(@:]+)(?:\s*\((@[^)]+)\))?/); - if (authorMatch) { - author = (authorMatch[2] || authorMatch[1]).trim(); + if (card) { + const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); + if (link && link.href) { + const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{5,})/.exec(link.href); + if (lm) reelShortcode = lm[1]; + } + + const authorEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), header h2, header h3, header span[dir="auto"]'); + if (authorEl && authorEl.textContent) { + author = authorEl.textContent.trim().replace(/^@/, ''); + } else if (authorEl && authorEl.getAttribute('href')) { + const m = authorEl.getAttribute('href').match(/^\/([A-Za-z0-9._]+)\/?/); + if (m) author = m[1]; + } + + const textCandidates = Array.from(card.querySelectorAll('h1, h2, span[dir="auto"], div[dir="auto"]')); + for (const el of textCandidates) { + if (el.closest('a[href*="/audio/"]') || el.closest('header') || el.closest('button') || el.closest('a[role="link"]')) continue; + const txt = (el.textContent || '').trim(); + const lower = txt.toLowerCase(); + if (txt.length >= 2 && txt !== author && !lower.includes('follow') && !lower.startsWith('liked by') && !lower.includes('original audio') && !['ad', 'sponsored'].includes(lower)) { + title = txt.slice(0, 300); + break; + } + } } } - if (!thumbnailUrl) { - const videoEl = document.querySelector('video[poster]') || document.querySelector('video'); - if (videoEl && videoEl.getAttribute('poster')) { - thumbnailUrl = videoEl.getAttribute('poster'); - } - } - if (!author) { - const authorEl = document.querySelector('a[role="link"][tabindex="0"] span') || - document.querySelector('header a') || - document.querySelector('article header a'); - if (authorEl && authorEl.textContent) { - author = authorEl.textContent.trim(); + if (!reelShortcode) { + const urlMatch = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{5,})/.exec(window.location.pathname); + if (urlMatch && videos.length <= 1) { + reelShortcode = urlMatch[1]; } } - if (title || thumbnailUrl) { - lastExtractedReelUrl = url; - postToNative({ - action: 'reelViewed', - type: 'reelViewed', - url: url, - title: title || '', - author: author || '', - thumbnailUrl: thumbnailUrl || '' - }); - console.log('[FocusGram] In-page extracted reel metadata:', url); + if (!title && !author && videos.length <= 1) { + const ogTitle = document.querySelector('meta[property="og:title"]'); + if (ogTitle && ogTitle.content) title = ogTitle.content.trim(); + if (title) { + const authorMatch = title.match(/^([^(@:]+)(?:\s*\((@[^)]+)\))?/); + if (authorMatch) { + author = (authorMatch[2] || authorMatch[1]).trim().replace(/^@/, ''); + } + } + const ogImage = document.querySelector('meta[property="og:image"]'); + if (!thumbnailUrl && ogImage && ogImage.content) thumbnailUrl = ogImage.content.trim(); } + + const effectiveUrl = reelShortcode ? `https://www.instagram.com/reel/${reelShortcode}/` : url; + const dedupeKey = `${reelShortcode || author || ''}___${title || ''}`; + + if (!title && !author && !reelShortcode) { + return; + } + + if (lastExtractedReelKey === dedupeKey) return; + lastExtractedReelKey = dedupeKey; + + postToNative({ + action: 'reelViewed', + type: 'reelViewed', + url: effectiveUrl, + code: reelShortcode || (author ? `reel_${author.toLowerCase()}` : ''), + title: title || '', + author: author || '', + thumbnailUrl: thumbnailUrl || '' + }); + console.log('[FocusGram] In-page extracted reel metadata:', effectiveUrl); } function isBottomSheetOpen() { @@ -2304,10 +2583,7 @@ return mode; } - /* ========================================================================== - CHAT PRIVACY VEIL & INBOX PREVIEW MASKING - Zero-latency pure black cover over locked chat threads + snippet redaction. - ========================================================================== */ + // Chat veil and inbox preview masking. let chatLockMode = 'individual'; let chatLockAll = false; let lockedChatIds = new Set(); @@ -2846,7 +3122,7 @@ const badge = document.createElement('span'); badge.className = 'fg-chat-lock-badge'; badge.style.cssText = 'margin-left: 6px; font-size: 11px; opacity: 0.85; vertical-align: middle; display: inline-block;'; - badge.textContent = '๐Ÿ”’'; + badge.innerHTML = ''; titleTarget.appendChild(badge); } } else { @@ -2961,39 +3237,54 @@ try { // Hide Like Counts dynamic text if (currentSettings.hideLikeCounts) { + // 1. Liked by links and their parent containers document.querySelectorAll('a[href*="/liked_by/"]').forEach((el) => { el.style.setProperty('display', 'none', 'important'); const parent = el.closest('div'); - if (parent && parent.textContent && /liked/i.test(parent.textContent)) { + if (parent && !parent.querySelector('svg') && parent.textContent && /liked/i.test(parent.textContent)) { parent.style.setProperty('display', 'none', 'important'); } }); - document.querySelectorAll('article section ~ div span, article section ~ div div, div[role="presentation"] span, article span, article div').forEach((el) => { + + // 2. Feed post like counts text (e.g. "Liked by ...", "97 likes", "12 others") + document.querySelectorAll('article section ~ div span, article section ~ div div, div[role="presentation"] span').forEach((el) => { + if (el.querySelector('svg') || el.closest('svg') || el.querySelector('img') || el.closest('form')) return; if (el.children.length > 2) return; const text = (el.textContent || '').trim(); if (/^(liked\s+by|[\d,.]+[KkMm]?\s+likes?|[\d,.]+\s+others|be\s+the\s+first\s+to\s+like)/i.test(text)) { el.style.setProperty('display', 'none', 'important'); } }); + + // 3. Standalone count buttons in feed post: 97 + document.querySelectorAll('article span[role="button"], section span[role="button"]').forEach((el) => { + if (el.querySelector('svg') || el.closest('svg')) return; + const text = (el.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text)) { + el.style.setProperty('display', 'none', 'important'); + } + }); + + // 4. Reels like count in right interaction rail: + // Sibling or child span with numbers/K/M (e.g. 192K) document.querySelectorAll('svg[aria-label="Like"], svg[aria-label="Unlike"]').forEach((svg) => { const btn = svg.closest('[role="button"]') || svg.parentElement; - if (btn) { - btn.querySelectorAll('span, div').forEach(child => { - if (!child.contains(svg) && /^\d+[\d,.]*[KkMm]?$/.test((child.textContent || '').trim())) { + if (!btn) return; + const container = btn.parentElement; + if (container) { + container.querySelectorAll('span, div').forEach((child) => { + if (child.contains(svg) || child.querySelector('svg') || child.closest('svg')) return; + const text = (child.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text) || /^[\d,.]+[KkMm]?\s+likes?$/i.test(text)) { child.style.setProperty('display', 'none', 'important'); } }); - let sibling = btn.nextElementSibling; - if (sibling && /^\d+[\d,.]*[KkMm]?$/.test((sibling.textContent || '').trim())) { - sibling.style.setProperty('display', 'none', 'important'); - } - const parent = btn.parentElement; - if (parent && parent.children.length <= 4) { - parent.querySelectorAll('span').forEach(sp => { - if (/^\d+[\d,.]*[KkMm]?$/.test((sp.textContent || '').trim())) { - sp.style.setProperty('display', 'none', 'important'); - } - }); + let sib = btn.nextElementSibling; + if (sib && !sib.contains(svg) && !sib.querySelector('svg')) { + const text = (sib.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text) || /^[\d,.]+[KkMm]?\s+likes?$/i.test(text)) { + sib.style.setProperty('display', 'none', 'important'); + } } } }); @@ -3356,7 +3647,7 @@ cleanUpDynamicContent(); }, 250); - // --- 4. INPUT FOCUS / TYPING BRIDGE --- + // Input focus and typing bridge. function setupInputFocusListeners() { document.addEventListener('focusin', (e) => { const target = e.target; @@ -3380,7 +3671,7 @@ }, true); } - // --- 5. SENIOR BLUEPRINT NATIVE MESSAGING & SPA NAVIGATION --- + // Native messaging and SPA route navigation. const ROUTES = { home: "/", search: "/explore/", @@ -3457,6 +3748,28 @@ if (msg.settings.unblurMode) { mode = msg.settings.unblurMode; } + if (currentSettings.storySearchEnabled === false) { + const existing = document.getElementById('fg-story-search-card'); + if (existing) { + existing.remove(); + storySearchCardEl = null; + } + const noRes = document.getElementById('fg-no-viewer-results'); + if (noRes) noRes.remove(); + document.querySelectorAll('div[role="dialog"] [data-fg-viewer-hidden]').forEach(el => { + el.style.display = ''; + delete el.dataset.fgViewerHidden; + }); + } + if (currentSettings.customBioFontsEnabled === false) { + if (bioToolbarEl) { + bioToolbarEl.remove(); + bioToolbarEl = null; + } + const inlineBar = document.getElementById('fg-bio-inline-bar'); + if (inlineBar) inlineBar.remove(); + postToNative({ action: 'bioInputFocused', focused: false }); + } updateGuardrailsCSS(); syncGhostSettings(); syncChatLockSettings(msg.settings); @@ -3495,7 +3808,7 @@ Object.assign(currentSettings, { noAds: config.noAds !== undefined ? config.noAds : currentSettings.noAds, noStories: config.hideStories !== undefined ? config.hideStories : currentSettings.noStories, - noReels: config.hideReels !== undefined ? config.hideReels : currentSettings.noReels, + noReels: config.hideReels !== undefined ? config.hideReels : currentSettings.hideReels, blurExplore: config.blurExplore !== undefined ? config.blurExplore : currentSettings.blurExplore, blurReels: config.blurReels !== undefined ? config.blurReels : currentSettings.blurReels, blurFeed: config.blurFeed !== undefined ? config.blurFeed : currentSettings.blurFeed, @@ -3508,14 +3821,76 @@ videoDownloadEnabled: config.videoDownloadEnabled !== undefined ? config.videoDownloadEnabled : currentSettings.videoDownloadEnabled, grayscale: config.grayscale !== undefined ? config.grayscale : currentSettings.grayscale, minimalMode: config.minimalMode !== undefined ? config.minimalMode : currentSettings.minimalMode, - ghostModeEnabled: ghost.enabled !== undefined ? ghost.enabled : currentSettings.ghostModeEnabled, - ghostHideStorySeen: ghost.igStory !== undefined ? ghost.igStory : currentSettings.ghostHideStorySeen, - ghostHideDirectSeen: ghost.igSeen !== undefined ? ghost.igSeen : currentSettings.ghostHideDirectSeen, - ghostHideTypingIndicator: ghost.igTyping !== undefined ? ghost.igTyping : currentSettings.ghostHideTypingIndicator + storySearchEnabled: config.storySearchEnabled !== undefined ? config.storySearchEnabled : currentSettings.storySearchEnabled, + customBioFontsEnabled: config.customBioFontsEnabled !== undefined ? config.customBioFontsEnabled : currentSettings.customBioFontsEnabled, + ghostModeEnabled: ghost.enabled !== undefined ? !!ghost.enabled : currentSettings.ghostModeEnabled, + ghostHideStorySeen: ghost.hideStorySeen !== undefined ? !!ghost.hideStorySeen : (ghost.igStory !== undefined ? !!ghost.igStory : currentSettings.ghostHideStorySeen), + ghostHideDirectSeen: ghost.hideDirectSeen !== undefined ? !!ghost.hideDirectSeen : (ghost.igSeen !== undefined ? !!ghost.igSeen : currentSettings.ghostHideDirectSeen), + ghostHideTypingIndicator: ghost.hideTyping !== undefined ? !!ghost.hideTyping : (ghost.igTyping !== undefined ? !!ghost.igTyping : currentSettings.ghostHideTypingIndicator) }); + + if (currentSettings.storySearchEnabled === false) { + const existing = document.getElementById('fg-story-search-card'); + if (existing) { + existing.remove(); + storySearchCardEl = null; + } + const noRes = document.getElementById('fg-no-viewer-results'); + if (noRes) noRes.remove(); + document.querySelectorAll('div[role="dialog"] [data-fg-viewer-hidden]').forEach(el => { + el.style.display = ''; + delete el.dataset.fgViewerHidden; + }); + } + + if (currentSettings.customBioFontsEnabled === false) { + if (bioToolbarEl) { + bioToolbarEl.remove(); + bioToolbarEl = null; + } + const inlineBar = document.getElementById('fg-bio-inline-bar'); + if (inlineBar) inlineBar.remove(); + const fontToolbar = document.getElementById('fg-bio-font-toolbar'); + if (fontToolbar) fontToolbar.remove(); + postToNative({ action: 'bioInputFocused', focused: false }); + } updateGuardrailsCSS(); syncGhostSettings(); } + + if (event.data && event.data.type === 'FOCUSGRAM_FILTER_UPDATE' && event.data.config) { + const cfg = event.data.config; + if (cfg.storySearchEnabled !== undefined) { + currentSettings.storySearchEnabled = cfg.storySearchEnabled; + } + if (cfg.customBioFontsEnabled !== undefined) { + currentSettings.customBioFontsEnabled = cfg.customBioFontsEnabled; + } + if (currentSettings.storySearchEnabled === false) { + const existing = document.getElementById('fg-story-search-card'); + if (existing) { + existing.remove(); + storySearchCardEl = null; + } + const noRes = document.getElementById('fg-no-viewer-results'); + if (noRes) noRes.remove(); + document.querySelectorAll('div[role="dialog"] [data-fg-viewer-hidden]').forEach(el => { + el.style.display = ''; + delete el.dataset.fgViewerHidden; + }); + } + if (currentSettings.customBioFontsEnabled === false) { + if (bioToolbarEl) { + bioToolbarEl.remove(); + bioToolbarEl = null; + } + const inlineBar = document.getElementById('fg-bio-inline-bar'); + if (inlineBar) inlineBar.remove(); + const fontToolbar = document.getElementById('fg-bio-font-toolbar'); + if (fontToolbar) fontToolbar.remove(); + postToNative({ action: 'bioInputFocused', focused: false }); + } + } }); // Forward Reels metadata events to native host @@ -3532,7 +3907,6 @@ } }); - // Senior Blueprint: Direct content script communication with WebExtension background script function postToNative(message) { if (!message) return; try { @@ -3570,8 +3944,7 @@ } }); - // --- INSTAGRAM THEME DETECTION --- - // Detect if Instagram is in light or dark mode and notify native + // Detect theme state and notify native host. let lastSentTheme = null; function detectAndSendTheme() { @@ -3647,7 +4020,7 @@ // Query initial settings from background if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { - browser.runtime.sendMessage({ action: 'getSettings' }, (response) => { + const applyLoadedSettings = (response) => { if (response && response.settings) { Object.assign(currentSettings, response.settings); if (response.settings.unblurMode) { @@ -3657,10 +4030,16 @@ syncGhostSettings(); syncChatLockSettings(response.settings); } - }); + }; + + try { + const res = browser.runtime.sendMessage({ action: 'getSettings' }, applyLoadedSettings); + if (res && typeof res.then === 'function') { + res.then(applyLoadedSettings).catch(() => {}); + } + } catch (_) {} } - // --- 6. INITIALIZE --- updateIslandModeFromUrl(); setupGhostModeInterceptors(); updateGuardrailsCSS(); @@ -3712,10 +4091,7 @@ subtree: false }); - // --- 7. INFINITE SCROLL PRESERVATION --- - // Instagram web uses IntersectionObserver and scroll sentinels to trigger next batch of feed posts. - // When ads are hidden or sentinel reaches bottom, synthetic scroll events and clicking "View older posts" - // ensures smooth, uninterrupted infinite feed scrolling past post 9. + // Trigger scroll events and click sentinels so infinite scroll continues after ad removal. let lastInfiniteCheck = 0; function checkAndTriggerInfiniteScroll() { const now = Date.now(); @@ -3757,9 +4133,7 @@ } } catch (_) {} - /* ========================================================================== - 8. LIVE BIO FONT STYLIST (KEYBOARD ACCESSORY BAR & REAL-TIME INPUT) - ========================================================================== */ + // Bio font stylist keyboard accessory and real-time input conversion. const BIO_FONT_MAPS = { normal: null, bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, // Mathematical Bold (Zero gaps) @@ -3855,6 +4229,7 @@ } function createBioFontToolbar() { + if (currentSettings.customBioFontsEnabled === false) return null; if (document.getElementById('fg-bio-font-toolbar')) { return document.getElementById('fg-bio-font-toolbar'); } @@ -3998,6 +4373,10 @@ window.__fgSetBioStyle = setBioActiveStyle; function showBioFontToolbar(target) { + if (currentSettings.customBioFontsEnabled === false) { + hideBioFontToolbar(); + return; + } activeBioTarget = target; createBioFontToolbar(); if (bioToolbarEl) { @@ -4011,10 +4390,18 @@ if (bioToolbarEl) { bioToolbarEl.style.display = 'none'; } + if (currentSettings.customBioFontsEnabled === false) { + const tb = document.getElementById('fg-bio-font-toolbar'); + if (tb) tb.remove(); + bioToolbarEl = null; + const ib = document.getElementById('fg-bio-inline-bar'); + if (ib) ib.remove(); + } } // Intercept keystrokes on document capture level for instant Unicode glyph conversion document.addEventListener('beforeinput', (e) => { + if (currentSettings.customBioFontsEnabled === false) return; if (!activeBioTarget || activeBioStyle === 'normal') return; if (e.data && e.inputType === 'insertText') { e.preventDefault(); @@ -4032,6 +4419,7 @@ // Dynamic focusin detection across document document.addEventListener('focusin', (e) => { + if (currentSettings.customBioFontsEnabled === false) return; if (isBioTarget(e.target)) { showBioFontToolbar(e.target); postToNative({ action: 'bioInputFocused', focused: true }); @@ -4062,11 +4450,7 @@ }); } - /* ========================================================================== - 9. NETWORK-TRIGGERED STORY VIEWER SEARCH - - Directly displays the search bar when the /list_reel_media_viewer/ - network request is sent or received. - ========================================================================== */ + // Story viewer search triggered by list_reel_media_viewer network activity. let storySearchCardEl = null; let storyViewersData = []; @@ -4226,6 +4610,15 @@ } function checkAndMountStorySearch() { + if (currentSettings.storySearchEnabled === false) { + const existing = document.getElementById('fg-story-search-card'); + if (existing) { + existing.remove(); + storySearchCardEl = null; + } + return; + } + const info = findViewersDialog(); if (!info || !info.header) { const existing = document.getElementById('fg-story-search-card'); @@ -4245,6 +4638,7 @@ function handleStoryViewersDetected(detail) { if (!detail) return; + if (currentSettings.storySearchEnabled === false) return; checkAndMountStorySearch(); if (detail.phase === 'ready' && detail.data) { @@ -4307,3 +4701,4 @@ })(); + diff --git a/Ported/app/src/main/assets/extensions/ublock/ghost.js b/app/src/main/assets/extensions/ublock/ghost.js similarity index 100% rename from Ported/app/src/main/assets/extensions/ublock/ghost.js rename to app/src/main/assets/extensions/ublock/ghost.js diff --git a/Ported/app/src/main/assets/extensions/ublock/manifest.json b/app/src/main/assets/extensions/ublock/manifest.json similarity index 91% rename from Ported/app/src/main/assets/extensions/ublock/manifest.json rename to app/src/main/assets/extensions/ublock/manifest.json index 1324918..1ca5a83 100644 --- a/Ported/app/src/main/assets/extensions/ublock/manifest.json +++ b/app/src/main/assets/extensions/ublock/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "FocusGram Content Engine", - "version": "2.1.0", + "version": "3.0.0", "description": "Privacy-preserving network-level content blocking, stealth protection, and cosmetic filtering for FocusGram.", "author": "FocusGram Project", "homepage_url": "https://github.com/Ujwal223/FocusGram", @@ -28,7 +28,8 @@ ], "js": [ "content.js", - "focusgram_content.js" + "focusgram_content.js", + "media_downloader.js" ], "run_at": "document_start", "all_frames": true diff --git a/Ported/app/src/main/assets/scripts/media_downloader.js b/app/src/main/assets/extensions/ublock/media_downloader.js similarity index 92% rename from Ported/app/src/main/assets/scripts/media_downloader.js rename to app/src/main/assets/extensions/ublock/media_downloader.js index a6f6942..ac34536 100644 --- a/Ported/app/src/main/assets/scripts/media_downloader.js +++ b/app/src/main/assets/extensions/ublock/media_downloader.js @@ -1,13 +1,8 @@ /** - * FocusGram โ€” Standalone In-Page Media Downloader - * Injected directly into the page context by UserScriptManager. - * - * Provides: - * 1. Home Feed download buttons (inline next to Save/Share, or overlay fallback). - * 2. Reels download buttons (in-page vertical action column + ReelsTopBar native trigger). - * 3. Profile avatar HD downloader badge. - * 4. Multi-tier resolution: GraphQL PolarisPostRootQuery -> React Fiber -> DOM -> Performance Resource Timings -> In-Memory Harvest Cache. - * 5. Direct communication with native Android host via 'focusgram-download://' URI interception. + * In-page media downloader injected into web contexts. + * Attaches download buttons to feed posts, reels, and profiles, resolving media URLs + * via GraphQL, React Fiber, DOM inspection, and performance resource timings before + * delegating to the native host over custom URI schemes or extension messaging. */ 'use strict'; @@ -16,15 +11,14 @@ function isAuthPage() { try { var path = (window.location.pathname || '').toLowerCase(); - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; - var c = document.cookie || ''; - if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + if (path.includes('/accounts/edit')) return false; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; } catch (_) {} return false; } - if (isAuthPage()) return; // Leave login/signup completely untouched + if (isAuthPage()) return; if (window.__fgMediaDownloaderInstalled) return; window.__fgMediaDownloaderInstalled = true; @@ -32,13 +26,46 @@ const FG_DL_ATTR = 'data-fg-download-btn'; const IG_POST_ROOT_QUERY_DOC_ID = '27852811784380813'; + let downloadEnabled = false; + function isDownloadEnabled() { + if (typeof downloadEnabled === 'boolean' && downloadEnabled) return true; if (window.__FOCUSGRAM_CONFIG__ && window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled !== undefined) { return !!window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled; } return false; } + if (typeof browser !== 'undefined' && browser.runtime) { + if (browser.runtime.onMessage) { + browser.runtime.onMessage.addListener((msg) => { + if (msg && msg.action === 'updateSettings' && msg.settings) { + if (msg.settings.videoDownloadEnabled !== undefined) { + downloadEnabled = !!msg.settings.videoDownloadEnabled; + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + } + }); + } + if (browser.runtime.sendMessage) { + try { + const res = browser.runtime.sendMessage({ action: 'getSettings' }); + if (res && typeof res.then === 'function') { + res.then((r) => { + if (r && r.settings && r.settings.videoDownloadEnabled !== undefined) { + downloadEnabled = !!r.settings.videoDownloadEnabled; + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + }).catch(() => {}); + } + } catch (_) {} + } + } + let isGlobalScrolling = false; let globalScrollTimeout = null; let lastScrollTime = 0; @@ -58,7 +85,6 @@ } catch (_) {} } - // 1. Try WebExtension native messaging bridge first try { if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { browser.runtime.sendMessage({ @@ -71,7 +97,6 @@ } } catch (_) {} - // 2. Dispatch event for content script bridge try { window.dispatchEvent(new CustomEvent('focusgram:download-request', { detail: { url: url || '', filename: filename || '', error: error || '' } @@ -109,7 +134,6 @@ function parseMediaItem(item, slideIndex = 0) { if (!item) return null; - // 1. Carousel check: carousel_media or edge_sidecar_to_children if (item.carousel_media && Array.isArray(item.carousel_media) && item.carousel_media.length > 0) { const target = item.carousel_media[slideIndex] || item.carousel_media[0]; const parsed = parseMediaItem(target, 0); @@ -162,7 +186,6 @@ async function fetchMediaByShortcode(shortcode, slideIndex = 0) { if (!shortcode) return null; - // 1. Try GraphQL PolarisPostRootQuery try { const apiURL = new URL('/graphql/query', window.location.origin); const headers = { @@ -206,7 +229,6 @@ console.warn('[FocusGram Downloader] GraphQL shortcode query error:', e); } - // 2. Fallback to /p/{shortcode}/?__a=1&__d=dis try { const res = await fetch(`/p/${shortcode}/?__a=1&__d=dis`, { headers: { @@ -250,7 +272,7 @@ const entry = entries[i]; const name = entry.name || ''; if (!name.startsWith('http://') && !name.startsWith('https://')) continue; - if (isLikelyImageUrl(name)) continue; // STRICTLY IGNORE ALL IMAGES + if (isLikelyImageUrl(name)) continue; if (!name.includes('cdninstagram.com') && !name.includes('fbcdn.net')) continue; if (name.includes('.mp4') || name.includes('/v/t50.') || name.includes('/v/t16.') || @@ -341,14 +363,13 @@ return { url: ogVideo.content, isVideo: true, author: fiberData?.author, code: fiberData?.code }; } - // CRITICAL: When video is expected, NEVER return an image! + // Do not fall back to photo stills when expecting video. if (fiberData && (fiberData.code || fiberData.author)) { return { url: '', isVideo: true, code: fiberData.code, author: fiberData.author }; } return null; } - // PHOTO ONLY const fiberContainer = extractFromFiber(container); if (fiberContainer && fiberContainer.url && !isLikelyVideoUrl(fiberContainer.url)) { return fiberContainer; @@ -383,7 +404,6 @@ async function handleMediaDownload(postContainer, shortcode, activeVideo) { try { - // Determine active slide index for carousels let slideIndex = 0; if (postContainer) { const activeStep = postContainer.querySelector('button[aria-current="step"]'); @@ -400,7 +420,6 @@ let isVideo = hasVideoElement; let author = ''; - // 1. Instant check in harvested in-memory media cache (GraphQL/Feed/Clips responses) try { const harvest = (window.wrappedJSObject && window.wrappedJSObject.__fg_media_harvest) || window.__fg_media_harvest; if (harvest && shortcode && harvest[shortcode]) { @@ -441,7 +460,6 @@ } } catch (_) {} - // 2. Active stream from performance timing entries โ€” STRICTLY FOR VIDEOS ONLY! if (!resolvedUrl && hasVideoElement) { const streamMedia = getActiveStreamFromPerformance(); if (streamMedia && streamMedia.url && isLikelyVideoUrl(streamMedia.url)) { @@ -450,7 +468,6 @@ } } - // 3. Check recent media harvest queue (especially for Reels viewer where URL lacks shortcode) if (!resolvedUrl) { try { const recents = (window.wrappedJSObject && window.wrappedJSObject.__fg_recent_media) || window.__fg_recent_media; @@ -495,7 +512,6 @@ } catch (_) {} } - // 4. Check DOM & React Fiber on activeVideo or container if (!resolvedUrl) { const domMedia = extractBestDomUrl(activeVideo || postContainer, hasVideoElement); if (domMedia && domMedia.url && domMedia.url.startsWith('http')) { @@ -511,7 +527,6 @@ if (!author && domMedia?.author) author = domMedia.author; } - // 5. Query Instagram API via shortcode if ((!resolvedUrl || (hasVideoElement && isLikelyImageUrl(resolvedUrl))) && shortcode) { const apiMedia = await fetchMediaByShortcode(shortcode, slideIndex); if (apiMedia && apiMedia.url && apiMedia.url.startsWith('http')) { @@ -532,7 +547,6 @@ } } - // 6. Last-resort fallback for video streams if still empty if (hasVideoElement && (!resolvedUrl || isLikelyImageUrl(resolvedUrl))) { const streamFallback = getActiveStreamFromPerformance(); if (streamFallback && streamFallback.url && isLikelyVideoUrl(streamFallback.url)) { @@ -541,7 +555,6 @@ } } - // Extract author from DOM if still missing if (!author && postContainer) { const authorEl = postContainer.querySelector('header a, a[role="link"]:not([href*="audio"]):not([href*="explore"]):not([href*="/p/"]):not([href*="/reel/"])'); if (authorEl && authorEl.textContent) { @@ -549,11 +562,9 @@ } } - // Guard: If video was requested, REJECT if resolvedUrl is an image! if (hasVideoElement && isLikelyImageUrl(resolvedUrl)) { resolvedUrl = ''; } - // Guard: If photo was requested, REJECT if resolvedUrl is a video! if (!hasVideoElement && isLikelyVideoUrl(resolvedUrl)) { resolvedUrl = ''; } @@ -568,7 +579,6 @@ return; } - // Failed to resolve const errorMsg = hasVideoElement ? 'Could not extract MP4 video URL for this post' : 'Could not extract photo URL for this post'; triggerNativeDownload(null, null, errorMsg); } catch (e) { @@ -579,7 +589,6 @@ async function handleProfilePictureDownload(username, avatarImg) { try { - // 1. Check harvested profile pictures from GraphQL / API responses const harvested = (window.wrappedJSObject && window.wrappedJSObject.__fg_profile_pics) || window.__fg_profile_pics; if (harvested && username && harvested[username.toLowerCase()]) { const hdUrl = harvested[username.toLowerCase()]; @@ -589,7 +598,6 @@ } } - // 2. Attempt official Instagram web_profile_info API for original HD avatar if (username) { try { const res = await fetch('/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), { @@ -612,7 +620,7 @@ } } - // 3. Fallback to DOM avatar image โ€” NEVER strip /s150x150/ or regex-mutate signed Meta CDN URLs! + // Meta CDN URL parameters and path tokens are HMAC-signed; mutating them breaks signatures. if (avatarImg) { let url = ''; if (avatarImg.srcset) { @@ -649,7 +657,6 @@ if (RESERVED.includes(usernameCandidate.toLowerCase())) return; const username = usernameCandidate; - // Find profile avatar in header or main const avatarImgs = Array.from(document.querySelectorAll('header img, section header img, main header img, [role="main"] header img, img[alt*="profile picture" i], img[alt*="\'s profile" i]')) .filter(img => { const r = img.getBoundingClientRect(); @@ -658,11 +665,9 @@ const avatarImg = avatarImgs[0]; if (!avatarImg) return; - // The button or container enclosing the avatar const avatarBtn = avatarImg.closest('button, [role="button"]') || avatarImg.parentElement; if (!avatarBtn) return; - // Wrapper is either the parent of the avatar button, or the button itself const wrapper = avatarBtn.parentElement || avatarBtn; if (wrapper.querySelector('[data-fg-profile-dl="1"]')) return; if (document.querySelector('[data-fg-profile-dl="1"]')) return; @@ -721,7 +726,6 @@ function findActionRow(post) { if (!post) return null; - // 1. Look for Save/Bookmark button const saveBtn = post.querySelector('button[aria-label*="Save" i], [role="button"][aria-label*="Save" i], button[aria-label*="Bookmark" i], [role="button"][aria-label*="Bookmark" i], svg[aria-label*="Save" i], svg[aria-label*="Bookmark" i], svg polygon, svg path[d^="M20 22"], svg path[d^="M20 21"]'); if (saveBtn) { const btn = saveBtn.closest('button, [role="button"]') || saveBtn; @@ -730,7 +734,6 @@ } } - // 2. Look for Share button const shareBtn = post.querySelector('button[aria-label*="Share" i], [role="button"][aria-label*="Share" i], svg[aria-label*="Share" i], svg path[d^="M13.973 20.046"], svg path[d*="M22 3L2 10"]'); if (shareBtn) { const btn = shareBtn.closest('button, [role="button"]') || shareBtn; @@ -739,7 +742,6 @@ } } - // 3. Look for Comment button const commentBtn = post.querySelector('button[aria-label*="Comment" i], [role="button"][aria-label*="Comment" i], svg[aria-label*="Comment" i]'); if (commentBtn) { const btn = commentBtn.closest('button, [role="button"]') || commentBtn; @@ -748,7 +750,6 @@ } } - // 4. Look for Like button const likeBtn = post.querySelector('button[aria-label*="Like" i], [role="button"][aria-label*="Like" i], svg[aria-label*="Like" i], svg path[d*="M16.792 3.904"]'); if (likeBtn) { const btn = likeBtn.closest('button, [role="button"]') || likeBtn; @@ -757,7 +758,6 @@ } } - // 5. Look for section containing action buttons const section = post.querySelector('section'); if (section) { const buttons = section.querySelectorAll('button, [role="button"]'); @@ -779,7 +779,6 @@ const posts = []; const seen = new Set(); - // Target individual
elements document.querySelectorAll('article').forEach(el => { const r = el.getBoundingClientRect(); if (r.height > 120 && r.width > 120 && !seen.has(el)) { @@ -791,7 +790,6 @@ posts.forEach(post => { if (post.querySelector('[' + FG_DL_ATTR + '="1"]')) return; - // Extract shortcode from post const link = post.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); const href = link ? (link.getAttribute('href') || '') : ''; const codeMatch = href.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); @@ -799,7 +797,6 @@ const actionInfo = findActionRow(post); - // Create download button const dlBtn = document.createElement('div'); dlBtn.className = 'fg-feed-download-btn'; dlBtn.setAttribute(FG_DL_ATTR, '1'); @@ -838,7 +835,7 @@ handleMediaDownload(post, code); }; - // Touch events: strictly require stationary 30ms hold and abort immediately on any movement/scroll + // Require stationary 30ms hold to prevent accidental triggers during fling scrolls. dlBtn.addEventListener('touchstart', (e) => { if (isGlobalScrolling || (Date.now() - lastScrollTime < 250)) { isHolding = false; @@ -893,14 +890,12 @@ holdThresholdPassed = false; }, true); - // Raw clicks are always cancelled so scroll-release never clicks button dlBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }, true); if (actionInfo && actionInfo.row && actionInfo.anchor) { - // Inline action bar button matching Instagram styling dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;transition:transform 0.15s ease;'; dlBtn.innerHTML = ``; if (actionInfo.position === 'before') { @@ -909,7 +904,6 @@ actionInfo.row.insertBefore(dlBtn, actionInfo.anchor.nextSibling || actionInfo.anchor); } } else { - // Fallback: append to section or overlay on post media container (NOT avatar!) const section = post.querySelector('section'); if (section) { dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;'; @@ -931,7 +925,7 @@ } function syncReelsDownloadButton() { - // User requested removing in-page Reels download button (native ReelsTopBar handles reel downloads) + // ReelsTopBar handles reel downloads natively; strip injected buttons. document.querySelectorAll('[data-fg-reel-dl="1"]').forEach(el => el.remove()); } @@ -972,7 +966,6 @@ if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; } - // Also check fiber for code if (!code && (activeVideo || card)) { const fiberData = extractFromFiber(activeVideo) || extractFromFiber(card); if (fiberData && fiberData.code) { @@ -987,18 +980,23 @@ } } - // Attach to global window scope so FocusGramApp ReelsTopBar can invoke it directly window.__fgDownloadActiveReel = downloadActiveReel; window.addEventListener('focusgram:download-reel', downloadActiveReel); - // Periodic heartbeat to maintain buttons across SPA renders + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'FOCUSGRAM_UPDATE' && e.data.config) { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + }); + setInterval(() => { syncDownloadButtons(); syncProfileDownloadButton(); syncReelsDownloadButton(); }, 300); - // Initial pass setTimeout(() => { syncDownloadButtons(); syncProfileDownloadButton(); diff --git a/Ported/app/src/main/assets/omni.ja b/app/src/main/assets/omni.ja similarity index 100% rename from Ported/app/src/main/assets/omni.ja rename to app/src/main/assets/omni.ja diff --git a/Ported/app/src/main/assets/reelhistory/bg.js b/app/src/main/assets/reelhistory/bg.js similarity index 100% rename from Ported/app/src/main/assets/reelhistory/bg.js rename to app/src/main/assets/reelhistory/bg.js diff --git a/Ported/app/src/main/assets/reelhistory/manifest.json b/app/src/main/assets/reelhistory/manifest.json similarity index 100% rename from Ported/app/src/main/assets/reelhistory/manifest.json rename to app/src/main/assets/reelhistory/manifest.json diff --git a/Ported/app/src/main/assets/reelhistory/reels.js b/app/src/main/assets/reelhistory/reels.js similarity index 81% rename from Ported/app/src/main/assets/reelhistory/reels.js rename to app/src/main/assets/reelhistory/reels.js index 4512142..e3a43da 100644 --- a/Ported/app/src/main/assets/reelhistory/reels.js +++ b/app/src/main/assets/reelhistory/reels.js @@ -1,10 +1,8 @@ -const DWELL_MS = 1000; // a reel counts after 1 second on screen +const DWELL_MS = 200; const REEL_RE = /^\/reels?\/([A-Za-z0-9_-]{8,})\/?/; const CODE_RE = /^[A-Za-z0-9_.-]{3,}$/; -// ---- In-Page API Interception via Injected Script ---- -// Content scripts run in an isolated world where exportFunction may not exist. -// Injecting a DOM script into the page context guarantees direct access to fetch and XMLHttpRequest. +// Injected into page context to intercept fetch and XMLHttpRequest responses across isolated worlds. (function injectApiHook() { const SCRIPT_ID = 'fg-reel-api-hook'; if (document.getElementById(SCRIPT_ID)) return; @@ -166,9 +164,8 @@ const CODE_RE = /^[A-Za-z0-9_.-]{3,}$/; (document.head || document.documentElement).appendChild(s); })(); -// ---- Source 2: Store harvested API metadata ---- -const apiMeta = new Map(); // code -> { author, caption } -const authorToCode = new Map(); // lowercase username -> code +const apiMeta = new Map(); +const authorToCode = new Map(); window.addEventListener('fg-api-reel', (e) => { if (e && e.detail) { @@ -179,7 +176,6 @@ window.addEventListener('fg-api-reel', (e) => { } }); -// ---- Source 1 (primary): the tags in the page head ---- function fromHead(code) { const get = (sel, attr) => { const e = document.querySelector(sel); @@ -202,17 +198,16 @@ function fromHead(code) { return author || caption ? { author, caption } : null; } -// Head first, DOM second, API third, field by field. +// Resolve metadata from active DOM card, cached API responses, and head tags. function lookup(code) { const a = apiMeta.get(code) || {}; - const h = fromHead(code) || {}; - let author = a.author || h.author || ''; - let caption = a.caption || h.caption || ''; + let author = a.author || ''; + let caption = a.caption || ''; if (!author || !caption) { const videos = Array.from(document.querySelectorAll('video')); for (const v of videos) { - let card = v.closest('article') || v.closest('section') || v.parentElement; + let card = v.closest('article, section, div[role="presentation"]') || v.parentElement; while (card && card !== document.body && card !== document.documentElement) { if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break; const r = card.getBoundingClientRect(); @@ -221,9 +216,10 @@ function lookup(code) { } if (!card) continue; const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); - if (link && link.href && link.href.includes(code)) { + const isMatch = (link && link.href && link.href.includes(code)) || (!v.paused && v.getBoundingClientRect().height > 200); + if (isMatch) { if (!author) { - const aEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"])'); + const aEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), header h2, header h3'); if (aEl) author = aEl.textContent.trim().replace(/^@/, ''); } if (!caption) { @@ -250,20 +246,26 @@ function lookup(code) { } } } - break; + if (author || caption) break; } } } + if (!author || !caption) { + const h = fromHead(code) || {}; + if (!author) author = h.author || ''; + if (!caption) caption = h.caption || ''; + } + if (!author && code.startsWith('reel_')) { author = code.replace(/^reel_/, ''); } return { author: author || '', caption: caption || '' }; } -// ---- Detect which reel is on screen and report it ---- let cur = null; let timer = null; +let isInitialReelMount = true; const unfilled = new Set(); function report(type, code) { @@ -275,60 +277,65 @@ function report(type, code) { } function getActiveReelCode() { - const m = REEL_RE.exec(location.pathname); - if (m) return m[1]; - const videos = Array.from(document.querySelectorAll('video')); const activeVideo = videos.find(v => !v.paused && v.getBoundingClientRect().height > 200) || videos.find(v => { const r = v.getBoundingClientRect(); return r.top >= -50 && r.top < window.innerHeight * 0.6; }) || videos[0]; - if (!activeVideo) return null; - let card = activeVideo.closest('article') || activeVideo.closest('section') || activeVideo.parentElement; - while (card && card !== document.body && card !== document.documentElement) { - if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break; - const r = card.getBoundingClientRect(); - if (r.height > window.innerHeight * 0.5) break; - card = card.parentElement; - } - if (!card) return null; - - // 1. Direct link to reel - const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); - if (link && link.href) { - const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{8,})/.exec(link.href); - if (lm) return lm[1]; - } - - // 2. Identify author - const authorEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), a[href^="/"]:not([href*="/audio/"]):not([href*="/reel"])'); - if (authorEl) { - let username = authorEl.textContent.trim().replace(/^@/, '').toLowerCase(); - if (!username && authorEl.getAttribute('href')) { - const match = authorEl.getAttribute('href').match(/^\/([A-Za-z0-9._]+)\/?/); - if (match) username = match[1].toLowerCase(); + if (activeVideo) { + if (window.__fg_media_cache && (activeVideo.currentSrc || activeVideo.src)) { + const src = activeVideo.currentSrc || activeVideo.src; + const cleanSrc = src.split('?')[0]; + for (const [code, item] of window.__fg_media_cache) { + if (item.videoUrl && (cleanSrc.includes(item.videoUrl.split('?')[0]) || item.videoUrl.split('?')[0].includes(cleanSrc))) { + return code; + } + } } - if (username) { - return 'reel_' + username; + + let card = activeVideo.closest('article, section, div[role="presentation"]') || activeVideo.parentElement; + while (card && card !== document.body && card !== document.documentElement) { + if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break; + const r = card.getBoundingClientRect(); + if (r.height > window.innerHeight * 0.5) break; + card = card.parentElement; + } + if (card) { + const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); + if (link && link.href) { + const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{5,})/.exec(link.href); + if (lm) return lm[1]; + } } } + const m = REEL_RE.exec(location.pathname); + if (m) return m[1]; + return null; } function tick() { + if (!location.pathname.startsWith('/reel')) { + isInitialReelMount = true; + cur = null; + clearTimeout(timer); + return; + } const code = getActiveReelCode(); if (code === cur) return; cur = code; clearTimeout(timer); if (!code) return; - timer = setTimeout(() => { - if (cur !== code || document.visibilityState !== 'visible') return; - const found = report('watched', code); - if (!found.author || !found.caption) unfilled.add(code); - }, DWELL_MS); + if (isInitialReelMount) { + isInitialReelMount = false; + report('meta', code); + return; + } + const found = report('watched', code); + if (!found.author || !found.caption) unfilled.add(code); } function fill() { diff --git a/Ported/app/src/main/assets/reellock/bg.js b/app/src/main/assets/reellock/bg.js similarity index 80% rename from Ported/app/src/main/assets/reellock/bg.js rename to app/src/main/assets/reellock/bg.js index c392395..071f595 100644 --- a/Ported/app/src/main/assets/reellock/bg.js +++ b/app/src/main/assets/reellock/bg.js @@ -1,10 +1,10 @@ -let lastBeat = 0; // last time the page said it is on /direct/t/ -let armedAt = 0; // last time a reel feed request was cancelled +let lastBeat = 0; +let armedAt = 0; browser.runtime.onMessage.addListener((m) => { if (!m || m.t !== 'fg-reel') return; lastBeat = (m.active && m.enabled !== false) ? Date.now() : 0; - return Promise.resolve({ armedAt }); // tell the page when a reel was last detected + return Promise.resolve({ armedAt }); }); // Match request names first, doc_ids as a backup (doc_ids change on Instagram updates). diff --git a/Ported/app/src/main/assets/reellock/manifest.json b/app/src/main/assets/reellock/manifest.json similarity index 100% rename from Ported/app/src/main/assets/reellock/manifest.json rename to app/src/main/assets/reellock/manifest.json diff --git a/Ported/app/src/main/assets/reellock/reellock.js b/app/src/main/assets/reellock/reellock.js similarity index 81% rename from Ported/app/src/main/assets/reellock/reellock.js rename to app/src/main/assets/reellock/reellock.js index 60b1591..0e172a7 100644 --- a/Ported/app/src/main/assets/reellock/reellock.js +++ b/app/src/main/assets/reellock/reellock.js @@ -1,10 +1,10 @@ const inDM = () => location.pathname.startsWith('/direct/t/'); const isToggleActive = () => document.documentElement.getAttribute('data-fg-block-dm-reels') !== 'false'; -const MIN = 0.45; // "big" = at least 45% of viewport width and height -let on = false; // currently on /direct/t/ -let reel = false; // a DM reel viewer is open right now -let armedAt = 0; // last reel detection time reported by bg.js -let consumed = 0; // detections at or before this time are already handled +const MIN = 0.45; +let on = false; +let reel = false; +let armedAt = 0; +let consumed = 0; const locked = new Set(); const css = document.createElement('style'); @@ -62,20 +62,19 @@ function scan() { const vids = [...document.querySelectorAll('video')].filter(big); - if (reel && !vids.length) { endReel(); return; } // viewer closed + if (reel && !vids.length) { endReel(); return; } if (!reel) { - // Start only if a reel feed request was cancelled in the last 5 seconds. + // Start only if a reel feed request was cancelled in the last 5 seconds (avoids trapping direct video messages). const fresh = armedAt > consumed && Date.now() - armedAt < 5000; - if (!(fresh && vids.length)) return; // e.g. a video message: leave it alone + if (!(fresh && vids.length)) return; reel = true; } vids.forEach(lockRoot); } -// Block swipe/wheel gestures while a DM reel is open, except over things -// that scroll on their own (the comments list, for example). +// Block swipe and wheel gestures while a DM reel is open, allowing independent sub-scrollers like comments. function insideScroller(el) { for (let p = el; p && p !== document.body && p !== document.documentElement; p = p.parentElement) { if (p.nodeType !== 1) continue; diff --git a/Ported/app/src/main/assets/scripts/autoplay_blocker.js b/app/src/main/assets/scripts/autoplay_blocker.js similarity index 73% rename from Ported/app/src/main/assets/scripts/autoplay_blocker.js rename to app/src/main/assets/scripts/autoplay_blocker.js index ae475ca..21a2ee8 100644 --- a/Ported/app/src/main/assets/scripts/autoplay_blocker.js +++ b/app/src/main/assets/scripts/autoplay_blocker.js @@ -1,8 +1,8 @@ /** - * FocusGram โ€” Feed & Video Autoplay Blocker UserScript + * Feed video autoplay blocker. * - * Terminates automated HTML5 video autoplay and loops in feed and explore, - * while seamlessly preserving intentional user clicks and reels playback. + * Intercepts HTML5 play() calls to prevent automatic playback in feed and explore + * while permitting intentional user tap-to-play gestures and reels playback. */ 'use strict'; @@ -11,15 +11,14 @@ function isAuthPage() { try { var path = (window.location.pathname || '').toLowerCase(); - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; - var c = document.cookie || ''; - if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + if (path.includes('/accounts/edit')) return false; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; } catch (_) {} return false; } - if (isAuthPage()) return; // Leave login/signup completely untouched + if (isAuthPage()) return; if (window.__fgAutoplayPatched) return; window.__fgAutoplayPatched = true; @@ -47,14 +46,12 @@ const closestVid = target.closest('video'); if (closestVid) return closestVid; - // Check if inside post container or media wrapper const container = target.closest('article, [role="presentation"], div[style*="aspect-ratio"], div:has(video)'); if (container) { const v = container.querySelector('video'); if (v) return v; } - // Search upward through ancestor elements let p = target; for (let i = 0; i < 6 && p; i++) { const v = p.querySelector('video'); @@ -69,23 +66,27 @@ if (HTMLVideoElement.prototype.play) { HTMLVideoElement.prototype.play = function () { try { - // If on Reels or blockAutoplay is off, allow playback + if (window.__fgMediaPaused) { + this.removeAttribute('autoplay'); + this.autoplay = false; + this.pause(); + return Promise.resolve(); + } + if (!shouldBlock()) { return originalPlay.apply(this, arguments); } - // Strictly allow ONLY videos that the user explicitly tapped if (allowedVideos.has(this)) { return originalPlay.apply(this, arguments); } - // Strip autoplay attributes and freeze playback this.removeAttribute('autoplay'); this.autoplay = false; this.loop = false; this.pause(); - // Return resolved promise so React/Instagram engine does not crash + // Resolving promise avoids uncaught rejection in React video wrappers. return Promise.resolve(); } catch (e) { return originalPlay.apply(this, arguments); @@ -93,11 +94,10 @@ }; } - // Capture explicit user click/tap gestures directly on post videos ['click', 'pointerup'].forEach((eventType) => { document.addEventListener(eventType, function (e) { if (!e.isTrusted) return; - // Ignore if tapped on buttons/comments/links outside the video area + // Ignore interaction when tapping surrounding action buttons or comments. if (e.target.closest('button[aria-label="Like"], button[aria-label="Comment"], button[aria-label="Share"], a[role="link"]:not(:has(video))')) { return; } diff --git a/Ported/app/src/main/assets/scripts/content_hider.js b/app/src/main/assets/scripts/content_hider.js similarity index 87% rename from Ported/app/src/main/assets/scripts/content_hider.js rename to app/src/main/assets/scripts/content_hider.js index b44fe7c..16ae783 100644 --- a/Ported/app/src/main/assets/scripts/content_hider.js +++ b/app/src/main/assets/scripts/content_hider.js @@ -1,11 +1,5 @@ /** - * FocusGram โ€” Content Hider, UI Guardrails & SPA Navigation UserScript - * - * Provides: - * 1. Hiding Instagram's native web bottom navigation bar so only FocusGram's native bar appears. - * 2. Instant SPA navigation helper without page reloads. - * 3. Profile navigation resolving the current user profile rather than edit profile. - * 4. Dynamic CSS injection reacting to FocusSettings (Stories, Reels, Ads, Suggested Posts, Vanity Metrics). + * Content hider, layout adjustments, and client-side SPA routing. */ 'use strict'; @@ -16,10 +10,9 @@ function isAuthPage() { try { var path = (window.location.pathname || '').toLowerCase(); - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; - var c = document.cookie || ''; - if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + if (path.includes('/accounts/edit')) return false; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; } catch (_) {} return false; } @@ -27,14 +20,13 @@ if (isAuthPage()) { var existing = document.getElementById(STYLE_ID); if (existing) existing.remove(); - return; // Leave login/signup completely untouched + return; // Leave login and signup untouched. } - // 1. Client-Side SPA Navigation Helper window.__focusgramNavigate = function (path) { if (!path) return; try { - // Priority 1: Match and click navigation link in the DOM + // First try matching and clicking navigation anchors already in the DOM. let target = null; if (path === '/') { target = document.querySelector('a[href="/"][role="link"]') || @@ -61,7 +53,6 @@ return true; } - // Priority 2: HTML5 History API pushState if (window.history && typeof window.history.pushState === 'function') { window.history.pushState(null, '', path); window.dispatchEvent(new PopStateEvent('popstate', { state: null })); @@ -71,14 +62,11 @@ console.warn('[FocusGram] SPA navigation error:', e); } - // Fallback: standard assignment window.location.href = path; }; - // 2. Client-Side Profile Navigation Helper window.__focusgramOpenProfile = function () { try { - // Find user profile avatar/link in navigation bar const profileLink = document.querySelector('a[href*="/profile/"]') || document.querySelector('a[role="link"]:has(img[alt*="profile" i])') || document.querySelector('a[role="link"]:has(img[alt*="\'s profile picture" i])') || @@ -93,7 +81,6 @@ } } - // Check viewer username from window configuration objects const username = window._sharedData?.config?.viewer?.username || window.__initialData?.data?.viewer?.username || window.__viewerUsername; @@ -109,11 +96,10 @@ window.__focusgramNavigate('/'); }; - // 3. Dynamic Styles Generation reacting to FocusSettings function updateStyles() { const config = window.__FOCUSGRAM_CONFIG__ || {}; let css = ` - /* ALWAYS hide Instagram web's own bottom navigation bar โ€” strictly require Explore link so DMs are never hidden */ + /* Hide Instagram web navigation bar when it contains Explore, leaving DM headers visible. */ nav[role="navigation"]:has(a[href*="/explore/"]), div[role="navigation"]:has(a[href*="/explore/"]), footer[role="contentinfo"]:has(a[href*="/explore/"]) { @@ -169,7 +155,7 @@ if (config.blurFeed) { css += ` - /* Feed Carousel Slides & Single Post Media - ONLY blur media elements, NEVER parent wrappers! */ + /* Blur feed media elements without blurring parent containers. */ html[data-fg-feed] article img:not(header img):not([alt*="profile" i]):not([data-fg-reveal]):not(.fg-unblurred), html[data-fg-feed] article video:not([data-fg-reveal]):not(.fg-unblurred), html[data-fg-feed] article canvas:not([data-fg-reveal]):not(.fg-unblurred) { @@ -177,7 +163,7 @@ transition: filter 0.25s ease-in-out !important; } - /* Preserve feed header avatars unblurred */ + /* Keep header avatars unblurred. */ html[data-fg-feed] article header img, html[data-fg-feed] article img[alt*="profile" i] { filter: none !important; @@ -187,9 +173,9 @@ if (config.blurExplore) { css += ` - main a[href*="/p/"]:not([data-fg-reveal]):not(.fg-unblurred) img, - main a[href*="/reel/"]:not([data-fg-reveal]):not(.fg-unblurred) img, - div[style*="grid"]:not([data-fg-reveal]):not(.fg-unblurred) img { + html[data-fg-explore] main a[href*="/p/"]:not([data-fg-reveal]):not(.fg-unblurred) img, + html[data-fg-explore] main a[href*="/reel/"]:not([data-fg-reveal]):not(.fg-unblurred) img, + main a[href*="/explore/"]:not([data-fg-reveal]):not(.fg-unblurred) img { filter: blur(24px) !important; transition: filter 0.2s ease-in-out !important; } @@ -220,6 +206,20 @@ .fg-unblurred div { filter: none !important; } + + /* Comment GIFs, sticker picker, and comment media must NEVER be blurred */ + img[src*="giphy"], + img[src*="tenor"], + img[alt*="GIF" i], + img[alt*="gif" i], + div[role="dialog"] img[src*="giphy"], + div[aria-modal="true"] img[src*="giphy"], + article ul img[src*="giphy"], + ul[class*="comment" i] img, + form img[src*="giphy"], + form img[src*="tenor"] { + filter: none !important; + } `; if (config.hideLikeCounts) { @@ -228,19 +228,9 @@ section a[href*="/liked_by/"], span:has(> a[href*="/liked_by/"]), div:has(> a[href*="/liked_by/"]), - section:has(svg[aria-label="Like"]) + div:not(:has(form)):not(:has(input)), - section:has(svg[aria-label="Unlike"]) + div:not(:has(form)):not(:has(input)), - section:has(svg[aria-label="Activity Feed"]) + div:not(:has(form)):not(:has(input)), - div[role="button"]:has(svg[aria-label="Like"]) + div span, - div[role="button"]:has(svg[aria-label="Unlike"]) + div span, - span[role="button"]:has(svg[aria-label="Like"]) + span, - span[role="button"]:has(svg[aria-label="Unlike"]) + span, - div:has(> svg[aria-label="Like"]) + span, - div:has(> svg[aria-label="Unlike"]) + span, - button:has(svg[aria-label="Like"]) + span, - button:has(svg[aria-label="Unlike"]) + span, - [role="button"]:has(svg[aria-label="Like"]) span, - [role="button"]:has(svg[aria-label="Unlike"]) span { + section:has(svg[aria-label="Like"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)), + section:has(svg[aria-label="Unlike"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)), + section:has(svg[aria-label="Activity Feed"]) + div:not(:has(form)):not(:has(input)):not(:has(svg)) { display: none !important; visibility: hidden !important; } @@ -266,7 +256,6 @@ `; } - /* Instagram Reels Layout & Overlay Lift (Always active, regardless of ad blocker) */ css += ` :root { --fg-reel-bottom-offset: 16px; @@ -340,15 +329,13 @@ styleEl.textContent = css; } - // Dynamic scanning for elements not coverable by pure CSS function scanDynamicDOM() { const config = window.__FOCUSGRAM_CONFIG__ || {}; try { - // Filter Sponsored Ads if (config.noAds !== false) { const SPONSORED_KW = ['sponsored', 'gesponsert', 'sponsorisรฉ', 'sponsorise', 'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung', 'anuncio', 'werbeanzeige']; const AD_CTA = ['shop now', 'install now', 'sign up', 'apply now', 'book now', 'order now', 'get offer']; - // Strictly target individual article elements to avoid hiding feed wrappers + // Collapse individual articles to 1px to avoid blanking feed container wrappers. document.querySelectorAll('article').forEach((article) => { if (article.dataset.fgAdFiltered) return; let isAd = false; @@ -434,9 +421,7 @@ }); } - // Filter Suggested Posts if (config.hideSuggestedPosts) { - // Strictly target individual article elements or specific suggested-users widgets document.querySelectorAll('article').forEach((article) => { if (article.dataset.fgSuggestedHidden) return; const headers = article.querySelectorAll('header span, header h2, header h3, h2, h3, h4'); @@ -462,47 +447,60 @@ }); } - // Hide Like Counts if (config.hideLikeCounts) { + // 1. Liked by links and their parent containers document.querySelectorAll('a[href*="/liked_by/"]').forEach((el) => { el.style.setProperty('display', 'none', 'important'); const parent = el.closest('div'); - if (parent && parent.textContent && /liked/i.test(parent.textContent)) { + if (parent && !parent.querySelector('svg') && parent.textContent && /liked/i.test(parent.textContent)) { parent.style.setProperty('display', 'none', 'important'); } }); - document.querySelectorAll('article section ~ div span, article section ~ div div, div[role="presentation"] span, article span, article div').forEach((el) => { + + // 2. Feed post like counts text (e.g. "Liked by ...", "97 likes", "12 others") + document.querySelectorAll('article section ~ div span, article section ~ div div, div[role="presentation"] span').forEach((el) => { + if (el.querySelector('svg') || el.closest('svg') || el.querySelector('img') || el.closest('form')) return; if (el.children.length > 2) return; const text = (el.textContent || '').trim(); if (/^(liked\s+by|[\d,.]+[KkMm]?\s+likes?|[\d,.]+\s+others|be\s+the\s+first\s+to\s+like)/i.test(text)) { el.style.setProperty('display', 'none', 'important'); } }); + + // 3. Standalone count buttons in feed post: 97 + document.querySelectorAll('article span[role="button"], section span[role="button"]').forEach((el) => { + if (el.querySelector('svg') || el.closest('svg')) return; + const text = (el.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text)) { + el.style.setProperty('display', 'none', 'important'); + } + }); + + // 4. Reels like count in right interaction rail: + // Sibling or child span with numbers/K/M (e.g. 192K) document.querySelectorAll('svg[aria-label="Like"], svg[aria-label="Unlike"]').forEach((svg) => { const btn = svg.closest('[role="button"]') || svg.parentElement; - if (btn) { - btn.querySelectorAll('span, div').forEach(child => { - if (!child.contains(svg) && /^\d+[\d,.]*[KkMm]?$/.test((child.textContent || '').trim())) { + if (!btn) return; + const container = btn.parentElement; + if (container) { + container.querySelectorAll('span, div').forEach((child) => { + if (child.contains(svg) || child.querySelector('svg') || child.closest('svg')) return; + const text = (child.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text) || /^[\d,.]+[KkMm]?\s+likes?$/i.test(text)) { child.style.setProperty('display', 'none', 'important'); } }); - let sibling = btn.nextElementSibling; - if (sibling && /^\d+[\d,.]*[KkMm]?$/.test((sibling.textContent || '').trim())) { - sibling.style.setProperty('display', 'none', 'important'); - } - const parent = btn.parentElement; - if (parent && parent.children.length <= 4) { - parent.querySelectorAll('span').forEach(sp => { - if (/^\d+[\d,.]*[KkMm]?$/.test((sp.textContent || '').trim())) { - sp.style.setProperty('display', 'none', 'important'); - } - }); + let sib = btn.nextElementSibling; + if (sib && !sib.contains(svg) && !sib.querySelector('svg')) { + const text = (sib.textContent || '').trim(); + if (/^[\d,.]+[KkMm]?$/.test(text) || /^[\d,.]+[KkMm]?\s+likes?$/i.test(text)) { + sib.style.setProperty('display', 'none', 'important'); + } } } }); } - // Hide Follower Counts if (config.hideFollowerCounts) { document.querySelectorAll('a[href*="/followers/"], a[href*="/following/"]').forEach((el) => { el.querySelectorAll('span').forEach(s => { @@ -511,10 +509,9 @@ }); } - // Always tag and lift Reels bottom metadata & action buttons above the floating nav bar tagReelsOverlay(); - // Minimal Mode in-page SPA redirect guard + // Minimal mode redirects root/explore browsing into direct messages. if (config.minimalMode) { const curPath = (window.location.pathname || '').toLowerCase(); if (curPath === '/' || curPath === '' || curPath.startsWith('/explore') || curPath.startsWith('/reels') || curPath.startsWith('/reel') || curPath.startsWith('/p/')) { @@ -633,6 +630,7 @@ const path = window.location.pathname || ''; const isFeed = path === '/' || path === ''; document.documentElement.toggleAttribute('data-fg-feed', isFeed); + document.documentElement.toggleAttribute('data-fg-explore', path.startsWith('/explore')); } updateFeedAttr(); window.addEventListener('popstate', updateFeedAttr); @@ -645,17 +643,24 @@ function getBlurredTarget(el) { if (!el) return null; + + // Comment GIFs, sticker picker, and comment controls must NEVER be treated as unblur targets + if (el.closest('img[src*="giphy"], img[src*="tenor"], img[alt*="GIF" i], img[alt*="gif" i], form, ul[class*="comment" i]')) { + return null; + } + if (el.tagName === 'IMG' && (el.src?.includes('giphy') || el.src?.includes('tenor') || el.alt?.toLowerCase().includes('gif'))) { + return null; + } const config = window.__FOCUSGRAM_CONFIG__ || {}; if (config.blurFeed) { - // Never treat carousel next/prev arrows or navigation buttons as unblur triggers + // Ignore slide advance controls, headers, and reactions when resolving unblur targets. if (el.closest('button[aria-label="Next"], button[aria-label="Go back"], button[aria-label="Next slide"], button[aria-label="Previous slide"], button[aria-label="Chevron"], header, section:has(svg[aria-label="Like"])')) { return null; } const article = el.closest('article'); if (article) { - // Find specific clicked media element or its immediate slide const media = el.matches('img, video, canvas') ? el : (el.closest('img, video, canvas') || el.querySelector('img, video, canvas')); const slide = el.closest('li, [role="group"], [style*="scroll-snap-align"], [data-testid*="slide"]') || (media ? media.closest('div[style*="width: 100%"], div[style*="min-width"]') : null); @@ -667,7 +672,7 @@ return media; } - // Fallback for clicking media container of single post + // Single post media container fallback. const mediaContainer = article.querySelector('div[style*="aspect-ratio"], div:has(> img), div:has(> video)'); if (mediaContainer && mediaContainer.contains(el)) { const postImg = mediaContainer.querySelector('img:not(header img), video'); @@ -758,7 +763,6 @@ } }, true); - // Expose hook to reload styles on config update window.__focusgramUpdateStyles = function () { updateStyles(); scanDynamicDOM(); @@ -774,7 +778,7 @@ }); } - // Debounced observer to hide dynamic elements smoothly + // Debounce DOM scanning to avoid thrashing on feed mutations. let timer = null; const observer = new MutationObserver(() => { if (timer) clearTimeout(timer); diff --git a/Ported/app/src/main/assets/scripts/fetch_interceptor.js b/app/src/main/assets/scripts/fetch_interceptor.js similarity index 91% rename from Ported/app/src/main/assets/scripts/fetch_interceptor.js rename to app/src/main/assets/scripts/fetch_interceptor.js index 91d1ec1..41c64a5 100644 --- a/Ported/app/src/main/assets/scripts/fetch_interceptor.js +++ b/app/src/main/assets/scripts/fetch_interceptor.js @@ -1,13 +1,8 @@ /** - * FocusGram Unified Network Ad & Content Filter via Main-World Fetch Interception - * Injected at document_start in the page execution context. + * Network ad and sponsored content filter via main-world fetch and XHR interception. * - * Intercepts Instagram GraphQL and REST API responses (Feed, Reels, Stories) - * and purges ad/sponsored objects before React receives them. - * This guarantees: - * 1. Ads are 100% blocked before rendering. - * 2. React never builds DOM elements for ads. - * 3. Scroll-snap and virtual scroller remain continuous and never collapse or go blank. + * Purges sponsored and recommended nodes from GraphQL and REST payloads + * before React hydration to preserve virtual scroll dimensions. */ 'use strict'; @@ -16,15 +11,13 @@ try { var path = (window.location.pathname || '').toLowerCase(); if (path.includes('/accounts/edit')) return false; - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; - var c = document.cookie || ''; - if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; } catch (_) {} return false; } - if (isAuthPage()) return; // Leave login/signup completely untouched + if (isAuthPage()) return; if (window.__fgFetchInterceptorInstalled) return; window.__fgFetchInterceptorInstalled = true; @@ -35,7 +28,9 @@ blockSuggested: false, blockVideos: false, blockAutoplay: false, - blockGraphQLQueryWhenFeedPosts: false + blockGraphQLQueryWhenFeedPosts: false, + storySearchEnabled: true, + customBioFontsEnabled: true }; const AD_KEYWORDS = [ @@ -108,17 +103,14 @@ const config = window.__fgFilterConfig; if (!config || !node || typeof node !== 'object') return false; - // Filter ads if (config.blockAds !== false && isAdNode(node)) { return true; } - // Filter sponsored posts if (config.blockSponsored !== false && isSponsoredNode(node) && !isAdNode(node)) { return true; } - // Filter suggested posts if enabled if (config.blockSuggested && isSuggestedNode(node)) { return true; } @@ -497,7 +489,6 @@ window.__fgProcessDirectMessagesPayload = processDirectMessagesPayload; - // Override fetch in the main world const _fetch = window.fetch.bind(window); window.fetch = async function (input, init) { @@ -507,13 +498,13 @@ ? input.href : input?.url ?? ''; - // Story viewer request detection: immediately notify UI when request is sent and received + // Notify UI when story viewers endpoint is requested or resolved if (url.includes('/list_reel_media_viewer/')) { const mediaIdMatch = url.match(/\/api\/v1\/media\/(\d+)\/list_reel_media_viewer\//); const mediaId = mediaIdMatch ? mediaIdMatch[1] : ''; const loadingDetail = { phase: 'loading', mediaId: mediaId, url: url }; - // Dispatch to both window & document for maximum compatibility across script compartments + // Dispatch across window and document compartments window.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail })); document.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail })); window.postMessage({ type: 'FOCUSGRAM_STORY_VIEWERS', detail: loadingDetail }, '*'); @@ -533,10 +524,8 @@ return response; } - // Always execute fetch const response = await _fetch(input, init); - // Only inspect GraphQL, feed, clips, and threads endpoints if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/') && !url.includes('/api/v1/direct_v2/threads/')) { return response; } @@ -579,8 +568,7 @@ const originalContentType = response.headers.get('content-type') || 'application/json; charset=utf-8'; const newHeaders = new Headers(response.headers); - // CRITICAL: Must delete content-length and content-encoding so the uncompressed - // JSON body is not truncated or corrupted by the browser HTTP layer + // Delete content-length and content-encoding so the modified JSON body is not truncated. newHeaders.delete('content-length'); newHeaders.delete('content-encoding'); newHeaders.set('content-type', originalContentType); @@ -601,7 +589,7 @@ configurable: true }); - // Hook XMLHttpRequest for complete coverage of background API calls + // Hook XMLHttpRequest for background requests const origXHR = window.XMLHttpRequest; if (origXHR && origXHR.prototype) { const origOpen = origXHR.prototype.open; @@ -641,12 +629,7 @@ }; } - /* ========================================================================== - STORY VIEWER CLIENT-SIDE SEARCH ENGINE - Autonomous DOM detector + Network Interception. - Injects a native-style Instagram search bar inside the "Viewers" modal - whether opened from cached story data or network requests. - ========================================================================== */ + // Story viewer search bar injection for the viewers modal. let storyViewersData = []; let storySearchCardEl = null; @@ -656,7 +639,6 @@ const headings = Array.from(d.querySelectorAll('h2, [role="heading"], div')); const h2 = headings.find(el => el.textContent.trim().toLowerCase() === 'viewers' && el.children.length === 0); if (h2) { - // h2.parentElement is the flex row containing Close button, Viewers title, and spacer const headerRow = h2.parentElement; const scrollContainer = d.querySelector('div[style*="overflow"]') || d.querySelector('div[style*="height"]') || @@ -778,8 +760,14 @@ } } - row.style.display = matched ? '' : 'none'; - if (matched) matchedCount++; + if (matched) { + row.style.display = ''; + delete row.dataset.fgViewerHidden; + matchedCount++; + } else { + row.style.display = 'none'; + row.dataset.fgViewerHidden = 'true'; + } }); // Handle "No results found" placeholder @@ -798,7 +786,37 @@ } } + function cleanupStorySearch() { + const existing = document.getElementById('fg-story-search-card'); + if (existing) { + existing.remove(); + storySearchCardEl = null; + } + const noResults = document.getElementById('fg-no-viewer-results'); + if (noResults) noResults.remove(); + + document.querySelectorAll('[data-fg-viewer-hidden]').forEach(el => { + el.style.display = ''; + delete el.dataset.fgViewerHidden; + }); + } + + function isStorySearchEnabled() { + if (window.__fgFilterConfig && typeof window.__fgFilterConfig.storySearchEnabled === 'boolean') { + return window.__fgFilterConfig.storySearchEnabled; + } + if (window.__FOCUSGRAM_CONFIG__ && typeof window.__FOCUSGRAM_CONFIG__.storySearchEnabled === 'boolean') { + return window.__FOCUSGRAM_CONFIG__.storySearchEnabled; + } + return false; + } + function checkAndMountStorySearch() { + if (!isStorySearchEnabled()) { + cleanupStorySearch(); + return; + } + const info = findViewersDialog(); if (!info || !info.header) { const existing = document.getElementById('fg-story-search-card'); @@ -820,6 +838,10 @@ function handleStoryViewersDetected(detail) { if (!detail) return; + if (!isStorySearchEnabled()) { + cleanupStorySearch(); + return; + } checkAndMountStorySearch(); if (detail.phase === 'ready' && detail.data) { @@ -854,9 +876,33 @@ }); viewersObserver.observe(document.documentElement, { childList: true, subtree: true }); - window.__fgSetFilterConfig = function (cfg) { + function handleFilterConfigUpdate(cfg) { if (typeof cfg === 'object' && cfg) { + if (!window.__fgFilterConfig) window.__fgFilterConfig = {}; Object.assign(window.__fgFilterConfig, cfg); + if (window.__fgFilterConfig.storySearchEnabled === false) { + cleanupStorySearch(); + } else { + checkAndMountStorySearch(); + } } + } + + const prevFetchSetFilter = window.__fgSetFilterConfig; + window.__fgSetFilterConfig = function (cfg) { + if (typeof prevFetchSetFilter === 'function') prevFetchSetFilter(cfg); + handleFilterConfigUpdate(cfg); }; + + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'FOCUSGRAM_FILTER_UPDATE' && e.data.config) { + handleFilterConfigUpdate(e.data.config); + } + }); + window.addEventListener('focusgram:filter-config-update', (e) => { + handleFilterConfigUpdate(e.detail); + }); + document.addEventListener('focusgram:filter-config-update', (e) => { + handleFilterConfigUpdate(e.detail); + }); })(); diff --git a/Ported/app/src/main/assets/scripts/ghost.js b/app/src/main/assets/scripts/ghost.js similarity index 100% rename from Ported/app/src/main/assets/scripts/ghost.js rename to app/src/main/assets/scripts/ghost.js diff --git a/Ported/app/src/main/assets/scripts/grayscale.js b/app/src/main/assets/scripts/grayscale.js similarity index 81% rename from Ported/app/src/main/assets/scripts/grayscale.js rename to app/src/main/assets/scripts/grayscale.js index b1eee87..259e5bb 100644 --- a/Ported/app/src/main/assets/scripts/grayscale.js +++ b/app/src/main/assets/scripts/grayscale.js @@ -1,8 +1,7 @@ /** - * FocusGram โ€” Hardware-Accelerated CSS Grayscale Filter UserScript + * Dynamic CSS grayscale filter. * - * Applies full grayscale filter to strip colorful dopamine triggers from Instagram, - * while allowing instant toggle and schedule support without reload. + * Applies a full grayscale filter to media and document root when enabled in settings. */ 'use strict'; @@ -44,6 +43,14 @@ window.__fgSetGrayscale = applyGrayscale; + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'FOCUSGRAM_UPDATE' && e.data.config) { + if (e.data.config.grayscale !== undefined) { + applyGrayscale(!!e.data.config.grayscale); + } + } + }); + const initialGrayscale = (window.__FOCUSGRAM_CONFIG__ && window.__FOCUSGRAM_CONFIG__.grayscale) || false; applyGrayscale(initialGrayscale); diff --git a/app/src/main/assets/scripts/media_downloader.js b/app/src/main/assets/scripts/media_downloader.js new file mode 100644 index 0000000..ac34536 --- /dev/null +++ b/app/src/main/assets/scripts/media_downloader.js @@ -0,0 +1,1006 @@ +/** + * In-page media downloader injected into web contexts. + * Attaches download buttons to feed posts, reels, and profiles, resolving media URLs + * via GraphQL, React Fiber, DOM inspection, and performance resource timings before + * delegating to the native host over custom URI schemes or extension messaging. + */ + +'use strict'; + +(function () { + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.includes('/accounts/edit')) return false; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) return; + + if (window.__fgMediaDownloaderInstalled) return; + window.__fgMediaDownloaderInstalled = true; + + const FG_DL_ATTR = 'data-fg-download-btn'; + const IG_POST_ROOT_QUERY_DOC_ID = '27852811784380813'; + + let downloadEnabled = false; + + function isDownloadEnabled() { + if (typeof downloadEnabled === 'boolean' && downloadEnabled) return true; + if (window.__FOCUSGRAM_CONFIG__ && window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled !== undefined) { + return !!window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled; + } + return false; + } + + if (typeof browser !== 'undefined' && browser.runtime) { + if (browser.runtime.onMessage) { + browser.runtime.onMessage.addListener((msg) => { + if (msg && msg.action === 'updateSettings' && msg.settings) { + if (msg.settings.videoDownloadEnabled !== undefined) { + downloadEnabled = !!msg.settings.videoDownloadEnabled; + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + } + }); + } + if (browser.runtime.sendMessage) { + try { + const res = browser.runtime.sendMessage({ action: 'getSettings' }); + if (res && typeof res.then === 'function') { + res.then((r) => { + if (r && r.settings && r.settings.videoDownloadEnabled !== undefined) { + downloadEnabled = !!r.settings.videoDownloadEnabled; + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + }).catch(() => {}); + } + } catch (_) {} + } + } + + let isGlobalScrolling = false; + let globalScrollTimeout = null; + let lastScrollTime = 0; + window.addEventListener('scroll', () => { + isGlobalScrolling = true; + lastScrollTime = Date.now(); + clearTimeout(globalScrollTimeout); + globalScrollTimeout = setTimeout(() => { + isGlobalScrolling = false; + }, 250); + }, { passive: true, capture: true }); + + function triggerNativeDownload(url, filename, error) { + if (url) { + try { + window.location.href = 'focusgram-download://download?url=' + encodeURIComponent(url) + '&filename=' + encodeURIComponent(filename || ''); + } catch (_) {} + } + + try { + if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { + browser.runtime.sendMessage({ + action: 'downloadMedia', + url: url || '', + filename: filename || '', + error: error || '' + }); + return; + } + } catch (_) {} + + try { + window.dispatchEvent(new CustomEvent('focusgram:download-request', { + detail: { url: url || '', filename: filename || '', error: error || '' } + })); + window.postMessage({ + type: 'FOCUSGRAM_DOWNLOAD_REQUEST', + url: url || '', + filename: filename || '', + error: error || '' + }, '*'); + } catch (e) { + console.error('[FocusGram Downloader] triggerNativeDownload error:', e); + } + } + + function getCookie(name) { + try { + const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\\/+^])/g, '\\$1') + '=([^;]*)')); + return match ? decodeURIComponent(match[1]) : ''; + } catch (_) {} + return ''; + } + + function getFbDtsg() { + try { + for (const script of document.scripts) { + const text = script.textContent || ''; + const match = text.match(/"DTSGInitialData",\[\],\{"token":"([^"]+)"/) || text.match(/"dtsg":\s*\{\s*"token":"([^"]+)"/); + if (match) return match[1]; + } + } catch (_) {} + return ''; + } + + function parseMediaItem(item, slideIndex = 0) { + if (!item) return null; + + if (item.carousel_media && Array.isArray(item.carousel_media) && item.carousel_media.length > 0) { + const target = item.carousel_media[slideIndex] || item.carousel_media[0]; + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.pk || item.id || parsed.id; + parsed.code = item.code || parsed.code; + parsed.author = item.user?.username || parsed.author; + return parsed; + } + } + if (item.edge_sidecar_to_children?.edges && Array.isArray(item.edge_sidecar_to_children.edges) && item.edge_sidecar_to_children.edges.length > 0) { + const edge = item.edge_sidecar_to_children.edges[slideIndex] || item.edge_sidecar_to_children.edges[0]; + const target = edge?.node; + if (target) { + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.id || parsed.id; + parsed.code = item.shortcode || parsed.code; + parsed.author = item.owner?.username || parsed.author; + return parsed; + } + } + } + + const isVideo = !!(item.is_video || item.media_type === 2 || (item.video_versions && item.video_versions.length > 0) || item.video_url); + let videoUrl = item.video_url || ''; + let imageUrl = item.display_url || ''; + + if (item.video_versions && Array.isArray(item.video_versions) && item.video_versions.length > 0) { + videoUrl = item.video_versions[0]?.url || videoUrl; + } + if (item.image_versions2?.candidates && Array.isArray(item.image_versions2.candidates) && item.image_versions2.candidates.length > 0) { + imageUrl = item.image_versions2.candidates[0]?.url || imageUrl; + } else if (item.display_resources && Array.isArray(item.display_resources) && item.display_resources.length > 0) { + imageUrl = item.display_resources[item.display_resources.length - 1]?.src || imageUrl; + } + + return { + isVideo: isVideo && !!videoUrl, + url: (isVideo && videoUrl) ? videoUrl : (imageUrl || videoUrl), + videoUrl: videoUrl, + imageUrl: imageUrl, + code: item.code || item.shortcode || '', + id: item.pk || item.id || '', + author: item.user?.username || item.owner?.username || '', + caption: item.caption?.text || item.edge_media_to_caption?.edges?.[0]?.node?.text || '' + }; + } + + async function fetchMediaByShortcode(shortcode, slideIndex = 0) { + if (!shortcode) return null; + + try { + const apiURL = new URL('/graphql/query', window.location.origin); + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'x-fb-friendly-name': 'PolarisPostRootQuery', + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest', + }; + const csrfToken = getCookie('csrftoken'); + if (csrfToken) headers['x-csrftoken'] = csrfToken; + + const fbDtsg = getFbDtsg(); + const bodyParams = { + fb_api_caller_class: 'RelayModern', + fb_api_req_friendly_name: 'PolarisPostRootQuery', + doc_id: IG_POST_ROOT_QUERY_DOC_ID, + variables: JSON.stringify({ + shortcode: shortcode, + __relay_internal__pv__PolarisShortDramaEnabledrelayprovider: false, + __relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: false, + }), + server_timestamps: 'true', + }; + if (fbDtsg) bodyParams.fb_dtsg = fbDtsg; + + const res = await fetch(apiURL.href, { + method: 'POST', + headers: headers, + body: new URLSearchParams(bodyParams).toString(), + credentials: 'include' + }); + + if (res.ok) { + const json = await res.json(); + const item = json?.data?.['xdt_api__v1__media__shortcode__web_info']?.items?.[0]; + if (item) { + return parseMediaItem(item, slideIndex); + } + } + } catch (e) { + console.warn('[FocusGram Downloader] GraphQL shortcode query error:', e); + } + + try { + const res = await fetch(`/p/${shortcode}/?__a=1&__d=dis`, { + headers: { + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest' + }, + credentials: 'include' + }); + if (res.ok) { + const json = await res.json(); + const item = json?.items?.[0] || json?.graphql?.shortcode_media; + if (item) return parseMediaItem(item, slideIndex); + } + } catch (_) {} + + return null; + } + + function isLikelyImageUrl(url) { + if (!url || typeof url !== 'string') return false; + const lower = url.toLowerCase(); + return lower.includes('.jpg') || lower.includes('.jpeg') || lower.includes('.png') || + lower.includes('.webp') || lower.includes('dst-jpg') || lower.includes('dst-png') || + lower.includes('dst-webp') || lower.startsWith('data:image'); + } + + function isLikelyVideoUrl(url) { + if (!url || typeof url !== 'string') return false; + if (isLikelyImageUrl(url)) return false; + const lower = url.toLowerCase(); + return lower.includes('.mp4') || lower.includes('.webm') || lower.includes('/v/t50.') || + lower.includes('/v/t16.') || lower.includes('video_dashinit') || lower.startsWith('data:video'); + } + + function getActiveStreamFromPerformance() { + try { + const perf = (window.wrappedJSObject && window.wrappedJSObject.performance) || window.performance; + if (!perf || typeof perf.getEntriesByType !== 'function') return null; + const entries = perf.getEntriesByType('resource'); + for (let i = entries.length - 1; i >= 0 && i >= entries.length - 100; i--) { + const entry = entries[i]; + const name = entry.name || ''; + if (!name.startsWith('http://') && !name.startsWith('https://')) continue; + if (isLikelyImageUrl(name)) continue; + if (!name.includes('cdninstagram.com') && !name.includes('fbcdn.net')) continue; + + if (name.includes('.mp4') || name.includes('/v/t50.') || name.includes('/v/t16.') || + name.includes('video_dashinit') || (name.includes('bytestart') && !isLikelyImageUrl(name))) { + try { + const u = new URL(name); + u.searchParams.delete('bytestart'); + u.searchParams.delete('byteend'); + return { url: u.toString(), isVideo: true }; + } catch (_) { + const cleanUrl = name.replace(/[?&]bytestart=\d+/, '').replace(/[?&]byteend=\d+/, ''); + return { url: cleanUrl, isVideo: true }; + } + } + } + } catch (_) {} + return null; + } + + function extractFromFiber(node) { + if (!node) return null; + try { + const key = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactProps')); + if (!key) return null; + let fiber = node[key]; + for (let i = 0; i < 40 && fiber; i++) { + const props = fiber.memoizedProps || fiber.pendingProps; + if (props) { + const author = props.user?.username || props.owner?.username || props.media?.user?.username || props.media?.owner?.username || props.item?.user?.username || props.clip?.user?.username || ''; + const code = props.code || props.shortcode || props.media?.code || props.media?.shortcode || props.item?.code || props.clip?.code || props.post?.code || ''; + + if (props.video_versions && Array.isArray(props.video_versions) && props.video_versions[0]?.url) { + return { url: props.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.media?.video_versions?.[0]?.url) { + return { url: props.media.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.item?.video_versions?.[0]?.url) { + return { url: props.item.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.clip?.video_versions?.[0]?.url) { + return { url: props.clip.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.post?.video_versions?.[0]?.url) { + return { url: props.post.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.videoUrl && typeof props.videoUrl === 'string' && props.videoUrl.startsWith('http') && isLikelyVideoUrl(props.videoUrl)) { + return { url: props.videoUrl, isVideo: true, code: code, author: author }; + } + if (props.display_url && typeof props.display_url === 'string' && props.display_url.startsWith('http')) { + return { url: props.display_url, isVideo: false, code: code, author: author }; + } + if (code || author) { + return { code: code, author: author }; + } + } + fiber = fiber.return; + } + } catch (_) {} + return null; + } + + function extractBestDomUrl(container, isVideoExpected = false) { + if (!container) return null; + + const video = container.querySelector('video') || (container.tagName === 'VIDEO' ? container : null); + const hasVideo = !!video || isVideoExpected; + + if (hasVideo) { + const fiberData = (video && (extractFromFiber(video) || extractFromFiber(video.parentElement))) || extractFromFiber(container); + if (fiberData && fiberData.url && isLikelyVideoUrl(fiberData.url)) { + return fiberData; + } + + if (video) { + const vSrc = video.src || video.currentSrc || ''; + if (vSrc && (vSrc.startsWith('http://') || vSrc.startsWith('https://')) && !vSrc.startsWith('blob:') && isLikelyVideoUrl(vSrc)) { + return { url: vSrc, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + const source = video.querySelector('source'); + if (source && source.src && (source.src.startsWith('http://') || source.src.startsWith('https://')) && !source.src.startsWith('blob:') && isLikelyVideoUrl(source.src)) { + return { url: source.src, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + } + + const ogVideo = document.querySelector('meta[property="og:video"], meta[property="og:video:secure_url"]'); + if (ogVideo && ogVideo.content && (ogVideo.content.startsWith('http://') || ogVideo.content.startsWith('https://')) && isLikelyVideoUrl(ogVideo.content)) { + return { url: ogVideo.content, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + + // Do not fall back to photo stills when expecting video. + if (fiberData && (fiberData.code || fiberData.author)) { + return { url: '', isVideo: true, code: fiberData.code, author: fiberData.author }; + } + return null; + } + + const fiberContainer = extractFromFiber(container); + if (fiberContainer && fiberContainer.url && !isLikelyVideoUrl(fiberContainer.url)) { + return fiberContainer; + } + + const imgs = Array.from(container.querySelectorAll('img')).filter(img => { + const r = img.getBoundingClientRect(); + return r.width > 120 && r.height > 120; + }); + const mainImg = imgs[0]; + if (mainImg) { + if (mainImg.srcset) { + const parts = mainImg.srcset.split(',').map(s => s.trim().split(/\s+/)); + const best = parts[parts.length - 1]?.[0]; + if (best && (best.startsWith('http://') || best.startsWith('https://'))) { + return { url: best.replace(/&/g, '&'), isVideo: false, code: fiberContainer?.code, author: fiberContainer?.author }; + } + } + const iSrc = mainImg.currentSrc || mainImg.src; + if (iSrc && (iSrc.startsWith('http://') || iSrc.startsWith('https://'))) { + return { url: iSrc.replace(/&/g, '&'), isVideo: false, code: fiberContainer?.code, author: fiberContainer?.author }; + } + } + + const ogImage = document.querySelector('meta[property="og:image"]'); + if (ogImage && ogImage.content && (ogImage.content.startsWith('http://') || ogImage.content.startsWith('https://'))) { + return { url: ogImage.content, isVideo: false }; + } + + return null; + } + + async function handleMediaDownload(postContainer, shortcode, activeVideo) { + try { + let slideIndex = 0; + if (postContainer) { + const activeStep = postContainer.querySelector('button[aria-current="step"]'); + if (activeStep && activeStep.parentElement) { + const steps = Array.from(activeStep.parentElement.querySelectorAll('button')); + const idx = steps.indexOf(activeStep); + if (idx >= 0) slideIndex = idx; + } + } + + const hasVideoElement = !!(activeVideo || (postContainer && (postContainer.querySelector('video') || postContainer.tagName === 'VIDEO')) || window.location.pathname.includes('/reel')); + + let resolvedUrl = ''; + let isVideo = hasVideoElement; + let author = ''; + + try { + const harvest = (window.wrappedJSObject && window.wrappedJSObject.__fg_media_harvest) || window.__fg_media_harvest; + if (harvest && shortcode && harvest[shortcode]) { + const cached = harvest[shortcode]; + if (cached.author) author = cached.author; + if (cached.carousel && cached.carousel.length > 0 && cached.carousel[slideIndex]) { + const slide = cached.carousel[slideIndex]; + if (hasVideoElement) { + if (slide.isVideo && slide.videoUrl && isLikelyVideoUrl(slide.videoUrl)) { + resolvedUrl = slide.videoUrl; + isVideo = true; + } + } else { + if (!slide.isVideo && slide.imageUrl && isLikelyImageUrl(slide.imageUrl)) { + resolvedUrl = slide.imageUrl; + isVideo = false; + } else if (slide.url && !isLikelyVideoUrl(slide.url)) { + resolvedUrl = slide.url; + isVideo = false; + } + } + } else { + if (hasVideoElement) { + if (cached.videoUrl && isLikelyVideoUrl(cached.videoUrl)) { + resolvedUrl = cached.videoUrl; + isVideo = true; + } + } else { + if (cached.imageUrl && isLikelyImageUrl(cached.imageUrl)) { + resolvedUrl = cached.imageUrl; + isVideo = false; + } else if (cached.url && !isLikelyVideoUrl(cached.url)) { + resolvedUrl = cached.url; + isVideo = false; + } + } + } + } + } catch (_) {} + + if (!resolvedUrl && hasVideoElement) { + const streamMedia = getActiveStreamFromPerformance(); + if (streamMedia && streamMedia.url && isLikelyVideoUrl(streamMedia.url)) { + resolvedUrl = streamMedia.url; + isVideo = true; + } + } + + if (!resolvedUrl) { + try { + const recents = (window.wrappedJSObject && window.wrappedJSObject.__fg_recent_media) || window.__fg_recent_media; + if (recents && recents.length > 0) { + const authorEl = postContainer ? (postContainer.querySelector('header a, a[role="link"]:not([href*="audio"]):not([href*="explore"]):not([href*="/p/"]):not([href*="/reel/"])')) : null; + const cardAuthor = (authorEl?.textContent || '').trim().toLowerCase().replace('@', ''); + if (cardAuthor) { + const match = recents.find(r => (r.author || '').toLowerCase() === cardAuthor); + if (match) { + if (!author && match.author) author = match.author; + if (hasVideoElement && match.videoUrl && isLikelyVideoUrl(match.videoUrl)) { + resolvedUrl = match.videoUrl; + isVideo = true; + } else if (!hasVideoElement && (match.imageUrl || match.url)) { + const candidate = match.imageUrl || match.url; + if (!isLikelyVideoUrl(candidate)) { + resolvedUrl = candidate; + isVideo = false; + } + } + if (!shortcode && match.code) shortcode = match.code; + } + } + if (!resolvedUrl && (window.location.pathname.includes('/reels') || window.location.pathname.includes('/reel/'))) { + const top = recents[0]; + if (top) { + if (!author && top.author) author = top.author; + if (hasVideoElement && top.videoUrl && isLikelyVideoUrl(top.videoUrl)) { + resolvedUrl = top.videoUrl; + isVideo = true; + } else if (!hasVideoElement && (top.imageUrl || top.url)) { + const candidate = top.imageUrl || top.url; + if (!isLikelyVideoUrl(candidate)) { + resolvedUrl = candidate; + isVideo = false; + } + } + if (!shortcode && top.code) shortcode = top.code; + } + } + } + } catch (_) {} + } + + if (!resolvedUrl) { + const domMedia = extractBestDomUrl(activeVideo || postContainer, hasVideoElement); + if (domMedia && domMedia.url && domMedia.url.startsWith('http')) { + if (hasVideoElement && isLikelyVideoUrl(domMedia.url)) { + resolvedUrl = domMedia.url; + isVideo = true; + } else if (!hasVideoElement && !isLikelyVideoUrl(domMedia.url)) { + resolvedUrl = domMedia.url; + isVideo = false; + } + } + if (!shortcode && domMedia?.code) shortcode = domMedia.code; + if (!author && domMedia?.author) author = domMedia.author; + } + + if ((!resolvedUrl || (hasVideoElement && isLikelyImageUrl(resolvedUrl))) && shortcode) { + const apiMedia = await fetchMediaByShortcode(shortcode, slideIndex); + if (apiMedia && apiMedia.url && apiMedia.url.startsWith('http')) { + if (hasVideoElement) { + const targetVid = (apiMedia.videoUrl && isLikelyVideoUrl(apiMedia.videoUrl)) ? apiMedia.videoUrl : (isLikelyVideoUrl(apiMedia.url) ? apiMedia.url : ''); + if (targetVid) { + resolvedUrl = targetVid; + isVideo = true; + } + } else { + const targetImg = (apiMedia.imageUrl && isLikelyImageUrl(apiMedia.imageUrl)) ? apiMedia.imageUrl : (!isLikelyVideoUrl(apiMedia.url) ? apiMedia.url : ''); + if (targetImg) { + resolvedUrl = targetImg; + isVideo = false; + } + } + if (!author && apiMedia.author) author = apiMedia.author; + } + } + + if (hasVideoElement && (!resolvedUrl || isLikelyImageUrl(resolvedUrl))) { + const streamFallback = getActiveStreamFromPerformance(); + if (streamFallback && streamFallback.url && isLikelyVideoUrl(streamFallback.url)) { + resolvedUrl = streamFallback.url; + isVideo = true; + } + } + + if (!author && postContainer) { + const authorEl = postContainer.querySelector('header a, a[role="link"]:not([href*="audio"]):not([href*="explore"]):not([href*="/p/"]):not([href*="/reel/"])'); + if (authorEl && authorEl.textContent) { + author = authorEl.textContent.trim().replace(/^@/, ''); + } + } + + if (hasVideoElement && isLikelyImageUrl(resolvedUrl)) { + resolvedUrl = ''; + } + if (!hasVideoElement && isLikelyVideoUrl(resolvedUrl)) { + resolvedUrl = ''; + } + + if (resolvedUrl && (resolvedUrl.startsWith('http://') || resolvedUrl.startsWith('https://') || resolvedUrl.startsWith('data:'))) { + const cleanAuthor = author ? author.replace(/^@/, '').trim().slice(0, 13) : ''; + const authorPrefix = cleanAuthor ? ('@' + cleanAuthor) : '@Focusgram'; + const isFinalVideo = hasVideoElement && isVideo && !isLikelyImageUrl(resolvedUrl); + const ext = isFinalVideo ? '.mp4' : '.jpg'; + const filename = `${authorPrefix} - Focusgram${slideIndex > 0 ? ('_' + (slideIndex + 1)) : ''}${ext}`; + triggerNativeDownload(resolvedUrl, filename, null); + return; + } + + const errorMsg = hasVideoElement ? 'Could not extract MP4 video URL for this post' : 'Could not extract photo URL for this post'; + triggerNativeDownload(null, null, errorMsg); + } catch (e) { + console.error('[FocusGram Downloader] handleMediaDownload error:', e); + triggerNativeDownload(null, null, 'Download error: ' + (e.message || e)); + } + } + + async function handleProfilePictureDownload(username, avatarImg) { + try { + const harvested = (window.wrappedJSObject && window.wrappedJSObject.__fg_profile_pics) || window.__fg_profile_pics; + if (harvested && username && harvested[username.toLowerCase()]) { + const hdUrl = harvested[username.toLowerCase()]; + if (hdUrl && hdUrl.startsWith('http')) { + triggerNativeDownload(hdUrl, username + '_profile_pic_hd.jpg', null); + return; + } + } + + if (username) { + try { + const res = await fetch('/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), { + headers: { + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest' + }, + credentials: 'include' + }); + if (res.ok) { + const json = await res.json(); + const hdUrl = json?.data?.user?.profile_pic_url_hd || json?.data?.user?.profile_pic_url; + if (hdUrl && hdUrl.startsWith('http')) { + triggerNativeDownload(hdUrl, username + '_profile_pic_hd.jpg', null); + return; + } + } + } catch (e) { + console.warn('[FocusGram Downloader] HD profile pic API failed:', e); + } + } + + // Meta CDN URL parameters and path tokens are HMAC-signed; mutating them breaks signatures. + if (avatarImg) { + let url = ''; + if (avatarImg.srcset) { + const parts = avatarImg.srcset.split(',').map(s => s.trim().split(/\s+/)); + url = parts[parts.length - 1]?.[0] || ''; + } + if (!url) url = avatarImg.currentSrc || avatarImg.src || ''; + if (url && url.startsWith('http')) { + const cleanUrl = url.replace(/&/g, '&'); + triggerNativeDownload(cleanUrl, (username || 'instagram') + '_profile_pic.jpg', null); + return; + } + } + + triggerNativeDownload(null, null, 'Could not extract profile picture URL'); + } catch (e) { + triggerNativeDownload(null, null, 'Profile download error: ' + (e.message || e)); + } + } + + function syncProfileDownloadButton() { + if (!isDownloadEnabled()) { + document.querySelectorAll('[data-fg-profile-dl="1"]').forEach(el => el.remove()); + return; + } + + const path = (window.location.pathname || '').replace(/^\/|\/$/g, ''); + if (!path) return; + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) return; + + const RESERVED = ['explore', 'reels', 'reel', 'p', 'direct', 'stories', 'accounts', 'developer', 'about', 'legal', 'settings']; + const usernameCandidate = segments[0]; + if (RESERVED.includes(usernameCandidate.toLowerCase())) return; + const username = usernameCandidate; + + const avatarImgs = Array.from(document.querySelectorAll('header img, section header img, main header img, [role="main"] header img, img[alt*="profile picture" i], img[alt*="\'s profile" i]')) + .filter(img => { + const r = img.getBoundingClientRect(); + return r.width >= 40 && r.height >= 40; + }); + const avatarImg = avatarImgs[0]; + if (!avatarImg) return; + + const avatarBtn = avatarImg.closest('button, [role="button"]') || avatarImg.parentElement; + if (!avatarBtn) return; + + const wrapper = avatarBtn.parentElement || avatarBtn; + if (wrapper.querySelector('[data-fg-profile-dl="1"]')) return; + if (document.querySelector('[data-fg-profile-dl="1"]')) return; + + const pos = window.getComputedStyle(wrapper).position; + if (!pos || pos === 'static') wrapper.style.position = 'relative'; + + const dlBadge = document.createElement('div'); + dlBadge.setAttribute('data-fg-profile-dl', '1'); + dlBadge.setAttribute('role', 'button'); + dlBadge.setAttribute('tabindex', '0'); + dlBadge.setAttribute('aria-label', 'Download Profile Picture'); + dlBadge.title = 'Download Profile Picture'; + dlBadge.style.cssText = [ + 'position:absolute', + 'bottom:-4px', + 'right:-4px', + 'width:36px', + 'height:36px', + 'border-radius:50%', + 'background:linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)', + 'color:#ffffff', + 'border:2.5px solid #18181c', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'cursor:pointer', + 'z-index:9999', + 'box-shadow:0 3px 10px rgba(0,0,0,0.5)', + 'pointer-events:auto', + 'transition:transform 0.15s ease' + ].join(';'); + + dlBadge.innerHTML = ``; + + let lastTrigger = 0; + const triggerDownload = (e) => { + e.preventDefault(); + e.stopPropagation(); + const now = Date.now(); + if (now - lastTrigger < 1000) return; + lastTrigger = now; + dlBadge.style.transform = 'scale(0.85)'; + setTimeout(() => { dlBadge.style.transform = 'scale(1)'; }, 150); + handleProfilePictureDownload(username, avatarImg); + }; + + dlBadge.addEventListener('click', triggerDownload, true); + dlBadge.addEventListener('touchend', triggerDownload, true); + dlBadge.addEventListener('touchstart', (e) => e.stopPropagation(), true); + dlBadge.addEventListener('pointerdown', (e) => e.stopPropagation(), true); + + wrapper.appendChild(dlBadge); + } + + function findActionRow(post) { + if (!post) return null; + + const saveBtn = post.querySelector('button[aria-label*="Save" i], [role="button"][aria-label*="Save" i], button[aria-label*="Bookmark" i], [role="button"][aria-label*="Bookmark" i], svg[aria-label*="Save" i], svg[aria-label*="Bookmark" i], svg polygon, svg path[d^="M20 22"], svg path[d^="M20 21"]'); + if (saveBtn) { + const btn = saveBtn.closest('button, [role="button"]') || saveBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'before' }; + } + } + + const shareBtn = post.querySelector('button[aria-label*="Share" i], [role="button"][aria-label*="Share" i], svg[aria-label*="Share" i], svg path[d^="M13.973 20.046"], svg path[d*="M22 3L2 10"]'); + if (shareBtn) { + const btn = shareBtn.closest('button, [role="button"]') || shareBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + const commentBtn = post.querySelector('button[aria-label*="Comment" i], [role="button"][aria-label*="Comment" i], svg[aria-label*="Comment" i]'); + if (commentBtn) { + const btn = commentBtn.closest('button, [role="button"]') || commentBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + const likeBtn = post.querySelector('button[aria-label*="Like" i], [role="button"][aria-label*="Like" i], svg[aria-label*="Like" i], svg path[d*="M16.792 3.904"]'); + if (likeBtn) { + const btn = likeBtn.closest('button, [role="button"]') || likeBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + const section = post.querySelector('section'); + if (section) { + const buttons = section.querySelectorAll('button, [role="button"]'); + if (buttons.length >= 1) { + const lastBtn = buttons[buttons.length - 1]; + return { row: lastBtn.parentElement || section, anchor: lastBtn, position: 'after' }; + } + } + + return null; + } + + function syncDownloadButtons() { + if (!isDownloadEnabled()) { + document.querySelectorAll('[' + FG_DL_ATTR + '="1"]').forEach(btn => btn.remove()); + return; + } + + const posts = []; + const seen = new Set(); + + document.querySelectorAll('article').forEach(el => { + const r = el.getBoundingClientRect(); + if (r.height > 120 && r.width > 120 && !seen.has(el)) { + seen.add(el); + posts.push(el); + } + }); + + posts.forEach(post => { + if (post.querySelector('[' + FG_DL_ATTR + '="1"]')) return; + + const link = post.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); + const href = link ? (link.getAttribute('href') || '') : ''; + const codeMatch = href.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); + const shortcode = codeMatch ? codeMatch[1] : ''; + + const actionInfo = findActionRow(post); + + const dlBtn = document.createElement('div'); + dlBtn.className = 'fg-feed-download-btn'; + dlBtn.setAttribute(FG_DL_ATTR, '1'); + dlBtn.setAttribute('tabindex', '0'); + dlBtn.setAttribute('role', 'button'); + dlBtn.setAttribute('aria-label', 'Download Media'); + dlBtn.title = 'Download Media'; + + let lastTrigger = 0; + let touchStartX = 0; + let touchStartY = 0; + let touchStartTime = 0; + let isHolding = false; + let hasMoved = false; + let holdThresholdPassed = false; + let holdTimer = null; + + const executeDownload = () => { + const now = Date.now(); + if (now - lastTrigger < 1000) return; + lastTrigger = now; + + dlBtn.style.transform = 'scale(0.82)'; + setTimeout(() => { dlBtn.style.transform = 'scale(1)'; }, 150); + + let code = shortcode; + if (!code) { + const l = post.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); + const h = l ? (l.getAttribute('href') || '') : ''; + const m = h.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); + if (m && m[1] !== 'p' && m[1] !== 'reel' && m[1] !== 'reels') { + code = m[1]; + } + } + + handleMediaDownload(post, code); + }; + + // Require stationary 30ms hold to prevent accidental triggers during fling scrolls. + dlBtn.addEventListener('touchstart', (e) => { + if (isGlobalScrolling || (Date.now() - lastScrollTime < 250)) { + isHolding = false; + hasMoved = true; + return; + } + if (!e.touches || e.touches.length === 0) return; + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + touchStartTime = Date.now(); + isHolding = true; + hasMoved = false; + holdThresholdPassed = false; + + clearTimeout(holdTimer); + holdTimer = setTimeout(() => { + if (isHolding && !hasMoved && !isGlobalScrolling && (Date.now() - lastScrollTime >= 250)) { + holdThresholdPassed = true; + } + }, 30); + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchmove', (e) => { + if (!isHolding || !e.touches || e.touches.length === 0) return; + const dx = Math.abs(e.touches[0].clientX - touchStartX); + const dy = Math.abs(e.touches[0].clientY - touchStartY); + if (dx > 3 || dy > 3 || isGlobalScrolling) { + hasMoved = true; + isHolding = false; + clearTimeout(holdTimer); + } + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchcancel', () => { + isHolding = false; + hasMoved = true; + clearTimeout(holdTimer); + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchend', (e) => { + e.preventDefault(); + e.stopPropagation(); + clearTimeout(holdTimer); + const holdDuration = Date.now() - touchStartTime; + + if (isHolding && !hasMoved && (holdThresholdPassed || holdDuration >= 30) && !isGlobalScrolling && (Date.now() - lastScrollTime >= 250)) { + executeDownload(); + } + + isHolding = false; + hasMoved = false; + holdThresholdPassed = false; + }, true); + + dlBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }, true); + + if (actionInfo && actionInfo.row && actionInfo.anchor) { + dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;transition:transform 0.15s ease;'; + dlBtn.innerHTML = ``; + if (actionInfo.position === 'before') { + actionInfo.row.insertBefore(dlBtn, actionInfo.anchor); + } else { + actionInfo.row.insertBefore(dlBtn, actionInfo.anchor.nextSibling || actionInfo.anchor); + } + } else { + const section = post.querySelector('section'); + if (section) { + dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;'; + dlBtn.innerHTML = ``; + section.appendChild(dlBtn); + } else { + const postMedia = post.querySelector('video')?.parentElement || + Array.from(post.querySelectorAll('img')).find(img => !img.closest('header') && !img.alt.includes('profile') && img.getBoundingClientRect().width > 120)?.parentElement || + post; + const pos = window.getComputedStyle(postMedia).position; + if (!pos || pos === 'static') postMedia.style.position = 'relative'; + + dlBtn.style.cssText = 'position:absolute;bottom:12px;right:12px;z-index:90;width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,0.65);border:1px solid rgba(255,255,255,0.25);color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;backdrop-filter:blur(8px);box-shadow:0 2px 8px rgba(0,0,0,0.4);pointer-events:auto;transition:transform 0.15s ease;'; + dlBtn.innerHTML = ``; + postMedia.appendChild(dlBtn); + } + } + }); + } + + function syncReelsDownloadButton() { + // ReelsTopBar handles reel downloads natively; strip injected buttons. + document.querySelectorAll('[data-fg-reel-dl="1"]').forEach(el => el.remove()); + } + + async function downloadActiveReel() { + try { + const vh = window.innerHeight; + const videos = Array.from(document.querySelectorAll('video')); + let activeVideo = null; + let minCenterDist = Infinity; + for (const v of videos) { + const r = v.getBoundingClientRect(); + if (r.height > 100 && r.bottom > 0 && r.top < vh) { + const dist = Math.abs((r.top + r.height / 2) - vh / 2); + if (!v.paused) { + activeVideo = v; + break; + } + if (dist < minCenterDist) { + minCenterDist = dist; + activeVideo = v; + } + } + } + if (!activeVideo && videos.length > 0) activeVideo = videos[0]; + + const card = activeVideo ? (activeVideo.closest('[data-fg-reel-card]') || activeVideo.closest('article') || activeVideo.parentElement) : document; + + let code = null; + if (card) { + const reelLink = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); + if (reelLink && reelLink.href) { + const m = reelLink.href.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); + if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; + } + } + if (!code) { + const m = window.location.pathname.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); + if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; + } + + if (!code && (activeVideo || card)) { + const fiberData = extractFromFiber(activeVideo) || extractFromFiber(card); + if (fiberData && fiberData.code) { + code = fiberData.code; + } + } + + await handleMediaDownload(card, code, activeVideo); + } catch (e) { + console.error('[FocusGram Downloader] downloadActiveReel error:', e); + triggerNativeDownload(null, null, 'Download error: ' + (e.message || e)); + } + } + + window.__fgDownloadActiveReel = downloadActiveReel; + window.addEventListener('focusgram:download-reel', downloadActiveReel); + + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'FOCUSGRAM_UPDATE' && e.data.config) { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + } + }); + + setInterval(() => { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + }, 300); + + setTimeout(() => { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + }, 200); + +})(); diff --git a/Ported/app/src/main/assets/scripts/native_feel.js b/app/src/main/assets/scripts/native_feel.js similarity index 87% rename from Ported/app/src/main/assets/scripts/native_feel.js rename to app/src/main/assets/scripts/native_feel.js index bd25114..4859f82 100644 --- a/Ported/app/src/main/assets/scripts/native_feel.js +++ b/app/src/main/assets/scripts/native_feel.js @@ -1,12 +1,6 @@ /** - * FocusGram โ€” Native Feel, Touch Smoothing & Keyboard Fix UserScript - * - * Implements native Android application styling and touch physics: - * 1. Hides web scrollbars and eliminates blue tap highlights. - * 2. Restricts text selection to input and editable areas to prevent accidental selection during gestures. - * 3. Enforces hardware-accelerated momentum scrolling (120Hz display refresh support). - * 4. Dispatches synthetic viewport and orientation change events upon soft keyboard show/hide, - * preventing Instagram Direct Message (DM) thread freeze and layout stalls. + * Native touch physics, keyboard resize adaptation, file input enhancement, + * and live bio font styling. */ 'use strict'; @@ -16,15 +10,13 @@ try { var path = (window.location.pathname || '').toLowerCase(); if (path.includes('/accounts/edit')) return false; - if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; - var c = document.cookie || ''; - if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; - if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup') || path.startsWith('/two_factor') || path.startsWith('/challenge')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], form input[type="password"]')) return true; } catch (_) {} return false; } - if (isAuthPage()) return; // Leave login/signup completely untouched + if (isAuthPage()) return; if (window.__fgNativeFeelInitialized) return; window.__fgNativeFeelInitialized = true; @@ -32,7 +24,6 @@ const STYLE_ID = 'focusgram-native-feel-styles'; const CSS_RULES = ` - /* 1. Hide all scrollbars across containers */ * { -ms-overflow-style: none !important; scrollbar-width: none !important; @@ -43,13 +34,12 @@ height: 0 !important; } - /* 2. Eliminate web touch callouts and tap highlights */ * { -webkit-tap-highlight-color: transparent !important; -webkit-touch-callout: none !important; } - /* 3. Disable browser text selection except on editable fields */ + /* Restrict selection to editable surfaces so gestures don't trigger drag-select. */ * { -webkit-user-select: none !important; user-select: none !important; @@ -59,17 +49,14 @@ user-select: text !important; } - /* 4. Enforce hardware-accelerated momentum touch scrolling on scrollable containers */ html, body, [data-fg-reel-scroller], div[style*="overflow"], main { -webkit-overflow-scrolling: touch !important; } - /* 5. Remove web focus rings and outlines */ *:focus, *:focus-visible { outline: none !important; } - /* 6. Subtle smooth fade-in for image loading */ img { animation: fgFadeIn 0.12s ease-in-out; } @@ -207,20 +194,15 @@ } catch (_) {} } - /* ========================================================================== - 7. LIVE BIO FONT STYLIST & REAL-TIME INPUT ENGINE - Directly mounts a dedicated styling accessory bar above textarea#pepBio, - handles bidirectional Unicode mathematical font conversion, - and transforms typing in real time. - ========================================================================== */ + // Unicode mathematical alphanumeric font mapping offsets for the bio styler. const BIO_FONT_MAPS = { normal: null, - bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, // Mathematical Bold (Zero gaps) - sans: { u: 0x1D5D4, l: 0x1D5EE, d: 0x1D7EC }, // Sans-Serif Bold (Zero gaps) - sans_italic: { u: 0x1D608, l: 0x1D622, d: null }, // Sans-Serif Italic (Zero gaps) - mono: { u: 0x1D670, l: 0x1D68A, d: 0x1D7F6 }, // Monospace (Zero gaps) - gothic: { u: 0x1D56C, l: 0x1D586, d: null }, // Bold Fraktur/Gothic (Zero gaps) - script: { u: 0x1D4D0, l: 0x1D4EA, d: null } // Bold Script/Cursive (Zero gaps) + bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, + sans: { u: 0x1D5D4, l: 0x1D5EE, d: 0x1D7EC }, + sans_italic: { u: 0x1D608, l: 0x1D622, d: null }, + mono: { u: 0x1D670, l: 0x1D68A, d: 0x1D7F6 }, + gothic: { u: 0x1D56C, l: 0x1D586, d: null }, + script: { u: 0x1D4D0, l: 0x1D4EA, d: null } }; let activeBioStyle = 'bold'; @@ -329,7 +311,7 @@ if (!target) return; - // Convert ONLY selected text if user made an explicit text selection + // Apply style to active text selection if present; otherwise subsequent typing uses this style. const val = target.value || ''; if (val && target.selectionStart !== undefined && target.selectionEnd !== undefined && target.selectionStart !== target.selectionEnd) { const start = target.selectionStart; @@ -341,8 +323,6 @@ setReactInputValue(target, before + converted + after); target.setSelectionRange(start, start + converted.length); } - // If NO text is selected, do NOT alter existing text! - // activeBioStyle is set, so only characters typed afterwards will be converted. updateBioCharCounter(target); } @@ -473,7 +453,29 @@ return bar; } + function isBioFontsEnabled() { + if (window.__fgFilterConfig && typeof window.__fgFilterConfig.customBioFontsEnabled === 'boolean') { + return window.__fgFilterConfig.customBioFontsEnabled; + } + if (window.__FOCUSGRAM_CONFIG__ && typeof window.__FOCUSGRAM_CONFIG__.customBioFontsEnabled === 'boolean') { + return window.__FOCUSGRAM_CONFIG__.customBioFontsEnabled; + } + return false; + } + + function cleanupBioStylist() { + const existing = document.getElementById('fg-bio-inline-bar'); + if (existing) existing.remove(); + bioInlineBarEl = null; + notifyBioFocus(false); + } + function checkAndMountBioStylist() { + if (!isBioFontsEnabled()) { + cleanupBioStylist(); + return; + } + const bioEl = document.getElementById('pepBio') || document.querySelector('textarea[aria-label="Bio"]') || document.querySelector('textarea[placeholder="Bio"]') || @@ -487,6 +489,7 @@ // Intercept keystrokes for real-time live typing transformation document.addEventListener('beforeinput', (e) => { + if (!isBioFontsEnabled()) return; if (!isBioTarget(e.target) || activeBioStyle === 'normal') return; if (e.data && e.inputType === 'insertText') { e.preventDefault(); @@ -502,6 +505,7 @@ }, true); document.addEventListener('focusin', (e) => { + if (!isBioFontsEnabled()) return; if (isBioTarget(e.target)) { activeBioTarget = e.target; mountBioInlineBar(e.target); @@ -521,14 +525,42 @@ } }, true); - // Watch for dynamic rendering of Bio textarea on /accounts/edit/ + // Detect bio textarea mount during SPA transitions on /accounts/edit/. const bioObserver = new MutationObserver(() => { checkAndMountBioStylist(); }); bioObserver.observe(document.documentElement, { childList: true, subtree: true }); setInterval(checkAndMountBioStylist, 500); - // Initialize immediately or upon DOM ready + function handleBioConfigUpdate(cfg) { + if (!cfg) return; + if (!window.__fgFilterConfig) window.__fgFilterConfig = {}; + Object.assign(window.__fgFilterConfig, cfg); + if (cfg.customBioFontsEnabled === false) { + cleanupBioStylist(); + } else if (cfg.customBioFontsEnabled === true) { + checkAndMountBioStylist(); + } + } + + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'FOCUSGRAM_FILTER_UPDATE' && e.data.config) { + handleBioConfigUpdate(e.data.config); + } + }); + window.addEventListener('focusgram:filter-config-update', (e) => { + handleBioConfigUpdate(e.detail); + }); + document.addEventListener('focusgram:filter-config-update', (e) => { + handleBioConfigUpdate(e.detail); + }); + + const prevNativeSetFilter = window.__fgSetFilterConfig; + window.__fgSetFilterConfig = function (cfg) { + if (typeof prevNativeSetFilter === 'function') prevNativeSetFilter(cfg); + handleBioConfigUpdate(cfg); + }; + injectNativeStyles(); applyScrollSmoothing(); setupKeyboardViewportFix(); diff --git a/app/src/main/assets/scripts/reel_metadata_extractor.js b/app/src/main/assets/scripts/reel_metadata_extractor.js new file mode 100644 index 0000000..6227bfb --- /dev/null +++ b/app/src/main/assets/scripts/reel_metadata_extractor.js @@ -0,0 +1,162 @@ +/** + * Reel metadata extractor. + * + * Extracts author, caption, and thumbnail URLs from client-side DOM + * (Open Graph tags, JSON-LD data, and document headers) for local watch history. + */ + +'use strict'; + +(function () { + if (window.__fgReelExtractorInitialized) return; + window.__fgReelExtractorInitialized = true; + + let lastExtractedReelKey = ''; + + function extractMetadata() { + const currentUrl = window.location.href; + + if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) { + return null; + } + + const videos = Array.from(document.querySelectorAll('video')); + const activeVideo = videos.find(v => !v.paused && v.getBoundingClientRect().height > 200) || + videos.find(v => { + const r = v.getBoundingClientRect(); + return r.top >= -50 && r.top < window.innerHeight * 0.6; + }) || videos[0]; + + let title = ''; + let thumbnailUrl = ''; + let author = ''; + let reelShortcode = ''; + + if (activeVideo) { + thumbnailUrl = activeVideo.getAttribute('poster') || ''; + let card = activeVideo.closest('article, section, div[role="presentation"]') || activeVideo.parentElement; + while (card && card !== document.body && card !== document.documentElement) { + if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break; + if (card.getBoundingClientRect().height > window.innerHeight * 0.5) break; + card = card.parentElement; + } + + if (card) { + const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); + if (link && link.href) { + const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{5,})/.exec(link.href); + if (lm) reelShortcode = lm[1]; + } + + const authorEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), header h2, header h3, header span[dir="auto"]'); + if (authorEl && authorEl.textContent) { + author = authorEl.textContent.trim().replace(/^@/, ''); + } else if (authorEl && authorEl.getAttribute('href')) { + const m = authorEl.getAttribute('href').match(/^\/([A-Za-z0-9._]+)\/?/); + if (m) author = m[1]; + } + + const textCandidates = Array.from(card.querySelectorAll('h1, h2, span[dir="auto"], div[dir="auto"]')); + for (const el of textCandidates) { + if (el.closest('a[href*="/audio/"]') || el.closest('header') || el.closest('button') || el.closest('a[role="link"]')) continue; + const txt = (el.textContent || '').trim(); + const lower = txt.toLowerCase(); + if (txt.length >= 2 && txt !== author && !lower.includes('follow') && !lower.startsWith('liked by') && !lower.includes('original audio') && !['ad', 'sponsored'].includes(lower)) { + title = txt.slice(0, 300); + break; + } + } + } + } + + if (!reelShortcode) { + const urlMatch = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{5,})/.exec(window.location.pathname); + if (urlMatch && videos.length <= 1) { + reelShortcode = urlMatch[1]; + } + } + + // Only fall back to static head meta when viewing a standalone single-reel URL, never during SPA feed swiping. + if (!title && !author && videos.length <= 1) { + const ogTitle = document.querySelector('meta[property="og:title"]'); + const ogImage = document.querySelector('meta[property="og:image"]'); + if (ogTitle && ogTitle.content) title = ogTitle.content.trim(); + if (ogImage && ogImage.content) thumbnailUrl = ogImage.content.trim(); + + if (!title && document.title) { + title = document.title.replace(' on Instagram', '').replace('โ€ข Instagram photos and videos', '').trim(); + } + + if (title) { + const authorMatch = title.match(/^([^(@:]+)(?:\s*\((@[^)]+)\))?/); + if (authorMatch) { + author = (authorMatch[2] || authorMatch[1]).trim().replace(/^@/, ''); + } + } + } + + if (!thumbnailUrl && !title && !author && !reelShortcode) { + return null; + } + + const effectiveUrl = reelShortcode ? `https://www.instagram.com/reel/${reelShortcode}/` : currentUrl; + const dedupeKey = `${reelShortcode || author || ''}___${title || ''}`; + + if (lastExtractedReelKey === dedupeKey) { + return null; + } + lastExtractedReelKey = dedupeKey; + + return { + url: effectiveUrl, + code: reelShortcode || author || '', + title: title || 'Instagram Reel', + author: author || 'Instagram Creator', + thumbnailUrl: thumbnailUrl || '', + timestamp: Date.now() + }; + } + + function extractAndBroadcast() { + const data = extractMetadata(); + if (!data) return; + + try { + window.dispatchEvent(new CustomEvent('focusgram:reelmetadata', { + detail: data + })); + } catch (_) {} + + try { + if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { + browser.runtime.sendMessage({ + type: 'ReelMetadata', + payload: data + }).catch(() => {}); + } + } catch (_) {} + + console.log('[FocusGram] Reel metadata extracted on-device:', data.url); + } + + extractAndBroadcast(); + + window.addEventListener('focusgram:routechange', () => { + setTimeout(extractAndBroadcast, 400); + }); + + let debounceTimer = null; + const observer = new MutationObserver(() => { + if (window.location.href.includes('/reel')) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(extractAndBroadcast, 500); + } + }); + + observer.observe(document.documentElement || document.body, { + childList: true, + subtree: true + }); + + console.log('[FocusGram] Reels Metadata Extractor active.'); +})(); diff --git a/Ported/app/src/main/assets/scripts/spa_navigation_monitor.js b/app/src/main/assets/scripts/spa_navigation_monitor.js similarity index 76% rename from Ported/app/src/main/assets/scripts/spa_navigation_monitor.js rename to app/src/main/assets/scripts/spa_navigation_monitor.js index df4fc18..3a0091d 100644 --- a/Ported/app/src/main/assets/scripts/spa_navigation_monitor.js +++ b/app/src/main/assets/scripts/spa_navigation_monitor.js @@ -1,10 +1,8 @@ /** - * FocusGram โ€” SPA Navigation Monitor & Dynamic Mutation Bridge + * Client-side SPA navigation monitor. * - * Hooks into HTML5 History API (pushState, replaceState) and popstate events - * to detect client-side route transitions in Instagram's Single Page Application (SPA). - * Instantly re-enforces content disabling guards and informs native delegates - * within 5ms without relying on full page reload cycles. + * Patches HTML5 History API and listens to navigation events to notify + * content filters and native delegates of in-page route changes. */ 'use strict'; @@ -22,7 +20,6 @@ if (targetUrl === lastUrl) return; lastUrl = targetUrl; - // 1. Immediately re-apply content hiding guards (< 5ms) if (typeof window.__fgContentHiderSweep === 'function') { try { window.__fgContentHiderSweep(); @@ -31,7 +28,7 @@ } } - // 2. Dispatch custom DOM event for other modular UserScripts (e.g. Autoplay blocker, Reels metadata) + // Notify other user scripts (autoplay blocker, reel extractor). try { window.dispatchEvent(new CustomEvent('focusgram:routechange', { detail: { url: targetUrl, timestamp: Date.now() } @@ -40,7 +37,7 @@ console.warn('[FocusGram SPA Monitor] Event dispatch error:', e); } - // 3. Inform WebExtension runtime if bridge is present + // Forward route change to WebExtension background worker when available. try { if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { browser.runtime.sendMessage({ diff --git a/app/src/main/assets/scripts/stealth_instagram.js b/app/src/main/assets/scripts/stealth_instagram.js new file mode 100644 index 0000000..7cb9d69 --- /dev/null +++ b/app/src/main/assets/scripts/stealth_instagram.js @@ -0,0 +1,358 @@ +/** + * Client-side privacy filter. + * + * Suppresses story viewed telemetry, direct message read receipts, and typing indicators + * via fetch, XMLHttpRequest, sendBeacon, and WebSocket interception. + */ + +'use strict'; + +(function () { + if (window.__FG_STEALTH_INITIALIZED__) return; + window.__FG_STEALTH_INITIALIZED__ = true; + + const DEFAULT_CONFIG = { + enabled: false, + igStory: true, + igSeen: true, + igTyping: true, + hideStorySeen: true, + hideDirectSeen: true, + hideTyping: true + }; + + window.__FOCUSGRAM_GHOST_CONFIG__ = Object.assign({}, DEFAULT_CONFIG, window.__FOCUSGRAM_GHOST_CONFIG__ || {}); + + const PATTERNS = { + igTyping: [ + "indicate_activity", + "typing_indicator", + "activity_indicator", + "is_typing", + "direct_v2/threads/broadcast/typing", + "direct_v2/threads/typing", + "sendtypingindicator", + "send_typing_indicator", + "typing_on", + "is_composing", + "update_activity_status", + "activity_status", + "typingindicatorstoredprocedure", + "directsendtyping", + "direct_send_typing", + "igdsendtyping" + ], + igSeen: [ + "mark_read", + "mark_seen", + "thread_seen", + "directmarkasseen", + "markasseen", + "directthreadmarkitemsseen", + "polarisdirectmarkasseenmutation", + "directseenmutation", + "usepolarismarkthreadseenmutation", + "useigdmarkthreadasreadmutation", + "markdirectthreadseen", + "mark_direct_thread_seen", + "direct_v2/threads/seen", + "direct_v2/threads/mark_seen", + "direct_v2/threads/mark_read" + ], + igStory: [ + "storiesupdateseenmutation", + "polarisstoriesseenmutation", + "usepolarisstoriesv3seenmutation", + "reelmediaseen", + "storiesupdateseen", + "seenstoriesupdatemutation", + "polarisapireelseenmutation", + "xdt_mark_story_reel_seen", + "26997980659837802", + "polarisapiforcestoryseenmutation", + "xdt_api__v1__stories__reel__seen", + "9647304595318258", + "api/v1/stories/reel/seen", + "stories/reel/seen", + "mark_story_seen", + "update_seen_for_reel", + "reel_seen", + "stories_update_seen", + "mark_story_read", + "viewseenat" + ] + }; + + function shouldBlock(data, url, method) { + const config = window.__FOCUSGRAM_GHOST_CONFIG__; + if (!config || !config.enabled) return null; + + const path = String(window.location?.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login')) return null; + + const urlString = String(url || "").toLowerCase(); + const bodyText = (typeof data === "string" ? data : "").toLowerCase(); + const text = `${bodyText} ${urlString}`; + + // Do not suppress actual user message dispatch or media uploads. + if (urlString.includes("threads/broadcast/text") || + urlString.includes("direct_v2/threads/broadcast") || + urlString.includes("broadcast/media") || + urlString.includes("action=send_message") || + urlString.includes("send_item") || + urlString.includes("publish_message") || + urlString.includes("configure")) { + return null; + } + + const typingActive = config.hideTyping !== undefined ? config.hideTyping : !!config.igTyping; + const storyActive = config.hideStorySeen !== undefined ? config.hideStorySeen : !!config.igStory; + const seenActive = config.hideDirectSeen !== undefined ? config.hideDirectSeen : !!config.igSeen; + + if (typingActive && PATTERNS.igTyping.some(p => text.includes(p))) { + return "IG_TYPING"; + } + + if (storyActive && PATTERNS.igStory.some(p => text.includes(p))) { + return "IG_STORY"; + } + + if (seenActive && PATTERNS.igSeen.some(p => text.includes(p))) { + return "IG_SEEN"; + } + + return null; + } + + function createMockPayload(blockType, url, bodyText) { + const combined = `${bodyText} ${url}`.toLowerCase(); + const isGraphQL = combined.includes("/graphql") || combined.includes("fb_api_req_friendly_name") || combined.includes("doc_id"); + + if (!isGraphQL) { + return { status: "ok", blocked: blockType }; + } + + if (blockType === "IG_STORY") { + if (combined.includes("xdt_api__v1__stories__reel__seen") || + combined.includes("polarisapiforcestoryseenmutation") || + combined.includes("9647304595318258")) { + return { + data: { + xdt_api__v1__stories__reel__seen: { + __typename: "XDTEmptyRecord" + } + } + }; + } + return { + data: { + xdt_mark_story_reel_seen: { + __typename: "XDTMarkSeenResponse" + } + } + }; + } + + return { data: {} }; + } + + const originalFetch = window.fetch; + window.fetch = async function (input, init) { + const url = typeof input === "string" ? input : (input && input.url ? input.url : ""); + const method = (init && init.method) || (input && input.method) || "GET"; + const body = (init && init.body) || ""; + + let bodyText = ""; + try { + if (typeof body === "string") { + bodyText = body; + } else if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { + bodyText = body.toString(); + } else if (typeof FormData !== "undefined" && body instanceof FormData) { + const parts = []; + for (const [k, v] of body.entries()) { + parts.push(k + '=' + (typeof v === 'string' ? v : '')); + } + bodyText = parts.join('&'); + } else if (typeof Blob !== "undefined" && body instanceof Blob) { + bodyText = await body.text(); + } else if (body instanceof ArrayBuffer) { + bodyText = new TextDecoder().decode(body); + } else if (ArrayBuffer.isView(body)) { + bodyText = new TextDecoder().decode(body); + } else if (!body && typeof Request !== "undefined" && input instanceof Request) { + const cloned = input.clone(); + bodyText = await cloned.text(); + } + } catch (_) {} + + const blockType = shouldBlock(bodyText, url, method); + if (blockType) { + console.log(`[FocusGram Stealth] Filtered fetch: ${blockType} -> ${url}`); + const payload = createMockPayload(blockType, url, bodyText); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + return originalFetch.apply(this, arguments); + }; + + const originalXhrOpen = XMLHttpRequest.prototype.open; + const originalXhrSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._fgStealthUrl = url; + this._fgStealthMethod = method; + return originalXhrOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + let bodyText = ""; + try { + if (typeof body === "string") { + bodyText = body; + } else if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { + bodyText = body.toString(); + } else if (typeof FormData !== "undefined" && body instanceof FormData) { + const parts = []; + for (const [k, v] of body.entries()) { + parts.push(k + '=' + (typeof v === 'string' ? v : '')); + } + bodyText = parts.join('&'); + } + } catch (_) {} + + const blockType = shouldBlock(bodyText, this._fgStealthUrl, this._fgStealthMethod); + if (blockType) { + console.log(`[FocusGram Stealth] Filtered XHR: ${blockType} -> ${this._fgStealthUrl}`); + const payload = createMockPayload(blockType, this._fgStealthUrl, bodyText); + Object.defineProperty(this, 'readyState', { value: 4, writable: true }); + Object.defineProperty(this, 'status', { value: 200, writable: true }); + Object.defineProperty(this, 'responseText', { value: JSON.stringify(payload), writable: true }); + Object.defineProperty(this, 'response', { value: JSON.stringify(payload), writable: true }); + setTimeout(() => { + this.dispatchEvent(new Event('readystatechange')); + this.dispatchEvent(new Event('load')); + }, 10); + return; + } + + return originalXhrSend.apply(this, arguments); + }; + + // Intercept WebSocket frames (MQTT typing indicators and read receipts). + if (typeof WebSocket !== 'undefined' && WebSocket.prototype && WebSocket.prototype.send) { + if (!window.__FG_STEALTH_WS_HOOKED__) { + window.__FG_STEALTH_WS_HOOKED__ = true; + const originalWsSend = WebSocket.prototype.send; + const wsUrlMap = new WeakMap(); + + const OriginalWs = window.WebSocket; + window.WebSocket = new Proxy(OriginalWs, { + construct(target, args, newTarget) { + const ws = Reflect.construct(target, args, newTarget); + try { + wsUrlMap.set(ws, String(args[0] || '')); + } catch (_) {} + return ws; + } + }); + + WebSocket.prototype.send = function (data) { + try { + const socketUrl = wsUrlMap.get(this) || this.url || ''; + let text = ''; + if (typeof data === 'string') { + text = data; + } else if (data instanceof ArrayBuffer) { + text = new TextDecoder().decode(data); + } else if (ArrayBuffer.isView(data)) { + text = new TextDecoder().decode(data); + } + const blockType = shouldBlock(text, socketUrl, 'WS'); + if (blockType) { + console.log(`[FocusGram Stealth] Filtered WebSocket frame: ${blockType} -> ${socketUrl}`); + return; // Drop silently + } + } catch (e) { + console.warn('[FocusGram Stealth] WebSocket send check error:', e); + } + return originalWsSend.apply(this, arguments); + }; + } + } + + // Report false for document focus and hidden for visibility on stories to suppress auto-seen telemetry. + const originalHasFocus = document.hasFocus.bind(document); + document.hasFocus = function () { + const config = window.__FOCUSGRAM_GHOST_CONFIG__; + const storyActive = config && config.enabled && (config.hideStorySeen !== undefined ? config.hideStorySeen : config.igStory); + if (storyActive && window.location.pathname.startsWith('/stories/')) { + return false; + } + return originalHasFocus(); + }; + + try { + const originalVisibilityState = Object.getOwnPropertyDescriptor(Document.prototype, 'visibilityState'); + Object.defineProperty(document, 'visibilityState', { + get: function () { + const config = window.__FOCUSGRAM_GHOST_CONFIG__; + const storyActive = config && config.enabled && (config.hideStorySeen !== undefined ? config.hideStorySeen : config.igStory); + if (storyActive && window.location.pathname.startsWith('/stories/')) { + return 'hidden'; + } + return originalVisibilityState && originalVisibilityState.get ? + originalVisibilityState.get.call(document) : 'visible'; + }, + configurable: true + }); + + const originalHidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden'); + Object.defineProperty(document, 'hidden', { + get: function () { + const config = window.__FOCUSGRAM_GHOST_CONFIG__; + const storyActive = config && config.enabled && (config.hideStorySeen !== undefined ? config.hideStorySeen : config.igStory); + if (storyActive && window.location.pathname.startsWith('/stories/')) { + return true; + } + return originalHidden && originalHidden.get ? + originalHidden.get.call(document) : false; + }, + configurable: true + }); + } catch (_) {} + + // Sync updated privacy toggles from host and extension messages. + function applyGhostSettings(source) { + if (!source) return; + const enabled = source.enabled !== undefined ? !!source.enabled : window.__FOCUSGRAM_GHOST_CONFIG__.enabled; + const story = source.hideStorySeen !== undefined ? !!source.hideStorySeen : (source.igStory !== undefined ? !!source.igStory : window.__FOCUSGRAM_GHOST_CONFIG__.igStory); + const seen = source.hideDirectSeen !== undefined ? !!source.hideDirectSeen : (source.igSeen !== undefined ? !!source.igSeen : window.__FOCUSGRAM_GHOST_CONFIG__.igSeen); + const typing = source.hideTyping !== undefined ? !!source.hideTyping : (source.igTyping !== undefined ? !!source.igTyping : window.__FOCUSGRAM_GHOST_CONFIG__.igTyping); + Object.assign(window.__FOCUSGRAM_GHOST_CONFIG__, { + enabled: enabled, + hideStorySeen: story, + hideDirectSeen: seen, + hideTyping: typing, + igStory: story, + igSeen: seen, + igTyping: typing + }); + } + + window.addEventListener("message", (event) => { + if (!event.data) return; + if (event.data.type === "GHOSTIFY_SETTINGS_UPDATE" && event.data.settings) { + applyGhostSettings(event.data.settings); + } + if (event.data.source === "FOCUSGRAM_HOST" && (event.data.type === "FOCUSGRAM_UPDATE" || event.data.type === "STEALTH_UPDATE")) { + const ghost = event.data.ghost || event.data.config || {}; + applyGhostSettings(ghost); + } + }); + + console.log("[FocusGram] Stealth Privacy Engine active."); +})(); diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt b/app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt similarity index 85% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt rename to app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt index f25e432..dded6b8 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/FocusGramApp.kt @@ -12,9 +12,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** - * FocusGramApp - * Application entry point for FocusGram Native. - * Manages global singleton lifecycles such as GeckoRuntime, Room Database, and Settings DataStore. + * Application entry point managing process isolation, storage singletons, and engine pre-warming. + * + * GeckoView spawns sandboxed child processes for tab and compositor isolation; service initialization + * is constrained exclusively to the primary application process. */ class FocusGramApp : Application() { @@ -40,21 +41,19 @@ class FocusGramApp : Application() { Log.i(TAG, "FocusGram Native Application starting up on main process...") - // Initialize local persistence & security components database = FocusGramDatabase.getInstance(this) settingsRepository = SettingsRepository(this) encryptedStorage = EncryptedStorage(this) - // Pre-warm GeckoRuntime, mount Content Blocker WebExtension, and run legacy migration + // Pre-warming GeckoRuntime and mounting uBlock on Main avoids cold-start frame drops + // when the primary WebView composable binds to the session manager. applicationScope.launch(Dispatchers.Main) { try { - // 1. Migrate legacy Flutter data if present kotlinx.coroutines.withContext(Dispatchers.IO) { val migrator = LegacyFlutterDataMigrator(this@FocusGramApp, settingsRepository, database) migrator.checkAndMigrate() } - // 2. Pre-warm GeckoRuntime singleton & mount Content Blocker on UI thread val manager = GeckoRuntimeManager.getInstance(this@FocusGramApp) manager.extensionManager.initializeUBlockOrigin() Log.i(TAG, "GeckoRuntime & Content Blocker initialized successfully.") diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt b/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt similarity index 78% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt rename to app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt index 2d53f0f..11eec2f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt @@ -10,9 +10,10 @@ import com.ujwal.focusgram.core.ui.theme.FocusGramTheme import com.ujwal.focusgram.ui.FocusGramApp /** - * MainActivity - * Single-Activity host for Jetpack Compose UI and GeckoView container. - * Requests native 120Hz/high display refresh rate for fluid scrolling. + * Single-Activity host mounting Compose UI and coordinating window attributes. + * + * Requests the highest available display refresh mode (e.g., 90Hz/120Hz) on supported hardware + * and keeps window-level task descriptions aligned with the active launcher icon alias. */ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -35,6 +36,17 @@ class MainActivity : FragmentActivity() { } catch (_: Exception) {} } + override fun onStop() { + super.onStop() + // Ensure Live Session Time Counter notification auto-dismisses immediately when minimized + com.ujwal.focusgram.feature.screentime.ScreenTimeService.stopSession(this) + } + + override fun onDestroy() { + super.onDestroy() + com.ujwal.focusgram.feature.screentime.ScreenTimeService.stopSession(this) + } + private fun applyHighRefreshRate() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt similarity index 100% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt similarity index 74% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt index bc6fe8d..cb8860f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt @@ -4,13 +4,12 @@ import androidx.room.Entity import androidx.room.PrimaryKey /** - * ScreenTimeRecord - * Tracks total on-device Instagram usage seconds per calendar date. + * Daily on-device screen time total and Reel consumption count. */ @Entity(tableName = "screen_time_records") data class ScreenTimeRecord( @PrimaryKey - val date: String, // Format: YYYY-MM-DD + val date: String, // ISO date string: YYYY-MM-DD val seconds: Int, val reelsSeconds: Int = 0, val reelsWatched: Int = 0, @@ -18,9 +17,8 @@ data class ScreenTimeRecord( ) /** - * ReelsHistoryItem - * Local client-side history of reels viewed by the user. - * Zero video media downloaded or cached to disk. + * Metadata record for a viewed Reel. Stores only title and author URL; + * raw video streams are never persisted. */ @Entity(tableName = "reels_history") data class ReelsHistoryItem( @@ -35,8 +33,7 @@ data class ReelsHistoryItem( ) /** - * SessionRecord - * Detailed session records for mindful digital wellness analytics. + * Timestamped session boundary recording elapsed usage intervals. */ @Entity(tableName = "session_records") data class SessionRecord( @@ -50,8 +47,7 @@ data class SessionRecord( ) /** - * SnapshotItem - * Saved offline DOM bookmarks and page state for offline viewing. + * Serialized HTML content and metadata for offline post or thread review. */ @Entity(tableName = "saved_snapshots") data class SnapshotItem( diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt similarity index 86% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt index 559bb78..3774b22 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt @@ -6,9 +6,7 @@ import androidx.room.Room import androidx.room.RoomDatabase /** - * FocusGramDatabase - * 100% on-device SQLite database managed by Room. - * Stores screen time metrics, reels history, focus sessions, and saved offline snapshots. + * Room SQLite database for screen time intervals, reels history, and snapshots. */ @Database( entities = [ @@ -40,6 +38,7 @@ abstract class FocusGramDatabase : RoomDatabase() { FocusGramDatabase::class.java, DB_NAME ) + // Drops and recreates tables on version bump; historical tables are ephemeral local caches .fallbackToDestructiveMigration() .build() .also { instance = it } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt similarity index 87% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt index d367eca..21879fc 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/LegacyFlutterDataMigrator.kt @@ -11,10 +11,8 @@ import org.json.JSONArray import org.json.JSONObject /** - * LegacyFlutterDataMigrator - * Reads FlutterSharedPreferences.xml on first launch of FocusGram Native Kotlin - * and migrates all existing toggles, screen time data, reels history, and credit balances into - * Jetpack DataStore and Room Database. + * One-time migrator that reads legacy Flutter SharedPreferences from disk + * and transfers settings, screen time metrics, and reel history into DataStore and Room. */ class LegacyFlutterDataMigrator( private val context: Context, @@ -40,7 +38,6 @@ class LegacyFlutterDataMigrator( Log.i(TAG, "Found ${allEntries.size} entries in FlutterSharedPreferences. Beginning migration...") try { - // 1. Migrate Focus & Content Toggles allEntries[key("no_ads")]?.let { if (it is Boolean) settingsRepository.setNoAds(it) } allEntries[key("no_stories")]?.let { if (it is Boolean) settingsRepository.setNoStories(it) } allEntries[key("no_reels")]?.let { if (it is Boolean) settingsRepository.setNoReels(it) } @@ -54,8 +51,8 @@ class LegacyFlutterDataMigrator( allEntries[key("hide_follower_counts")]?.let { if (it is Boolean) settingsRepository.setHideFollowerCounts(it) } allEntries[key("hide_shop_tab")]?.let { if (it is Boolean) settingsRepository.setHideShopTab(it) } allEntries[key("minimal_mode_enabled")]?.let { if (it is Boolean) settingsRepository.setMinimalMode(it) } + allEntries[key("set_sanitize_links")]?.let { if (it is Boolean) settingsRepository.setSanitizeLinks(it) } - // 2. Migrate Mindful Gates & Wellness val breathGateEnabled = (allEntries[key("set_show_breath_gate")] as? Boolean) ?: true val breathGateSeconds = (allEntries[key("breath_gate_seconds")] as? Number)?.toInt() ?: 10 settingsRepository.setBreathGate(breathGateEnabled, breathGateSeconds) @@ -66,26 +63,17 @@ class LegacyFlutterDataMigrator( allEntries[key("grayscale_enabled")]?.let { if (it is Boolean) settingsRepository.setGrayscale(it) } - // 3. Migrate Credits & Ad Balances - val instaMin = (allEntries[key("insta_min")] as? Number)?.toInt() ?: 0 - if (instaMin > 0) settingsRepository.addInstaMinutes(instaMin) - - val reelsMin = (allEntries[key("reels_min")] as? Number)?.toInt() ?: 0 - if (reelsMin > 0) settingsRepository.addReelsMinutes(reelsMin) - - // 4. Migrate Screen Time Records from JSON string val rawScreenTime = allEntries[key("screen_time_data")] as? String if (!rawScreenTime.isNullOrBlank()) { migrateScreenTime(rawScreenTime) } - // 5. Migrate Reels History from JSON string val rawReelsHistory = allEntries[key("reels_history")] as? String if (!rawReelsHistory.isNullOrBlank()) { migrateReelsHistory(rawReelsHistory) } - // Mark migration complete in legacy prefs + // Flag migration so subsequent boots bypass legacy preferences inspection sharedPrefs.edit().putBoolean(KEY_MIGRATED_MARKER, true).apply() Log.i(TAG, "โœ“ FlutterSharedPreferences data migration completed successfully!") @@ -136,6 +124,7 @@ class LegacyFlutterDataMigrator( } } + // Flutter's shared_preferences package prepends "flutter." to all stored keys private fun key(flutterKey: String) = "flutter.$flutterKey" companion object { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt similarity index 81% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt index 38b3f9f..c1eb9f9 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt @@ -20,9 +20,7 @@ import org.json.JSONObject private val Context.dataStore: DataStore by preferencesDataStore(name = "focusgram_settings") /** - * SettingsRepository - * Reactive Jetpack DataStore repository managing all focus toggles, - * digital wellness guardrails, mindful gate parameters, and rewarded credit balances on-device. + * Persists toggles, cooldown thresholds, and focus metrics via Preferences DataStore. */ class SettingsRepository(private val context: Context) { @@ -54,35 +52,35 @@ class SettingsRepository(private val context: Context) { requireWordChallenge = prefs[KEY_REQUIRE_WORD_CHALLENGE] ?: true, wordChallengeCount = prefs[KEY_WORD_CHALLENGE_COUNT] ?: 30, effortFrictionEnabled = prefs[KEY_EFFORT_FRICTION_ENABLED] ?: true, - cooldownMinutes = prefs[KEY_COOLDOWN_MINUTES] ?: 15, + cooldownMinutes = prefs[KEY_COOLDOWN_MINUTES] ?: 5, dailyLimitMinutes = prefs[KEY_DAILY_LIMIT_MINUTES] ?: 60, dailyLimitAdClaimedDate = prefs[KEY_DAILY_LIMIT_AD_DATE] ?: "", + dailyLimitExtensionsCount = prefs[KEY_DAILY_LIMIT_EXTENSIONS_COUNT] ?: 0, grayscaleEnabled = prefs[KEY_GRAYSCALE_ENABLED] ?: false, smoothScroll120Hz = prefs[KEY_SMOOTH_SCROLL_120HZ] ?: true, reelsHistoryEnabled = prefs[KEY_REELS_HISTORY_ENABLED] ?: true, - reelsMinutes = prefs[KEY_REELS_MINUTES] ?: 0, - instaMinutes = prefs[KEY_INSTA_MINUTES] ?: 0, - adsWatchedToday = prefs[KEY_ADS_WATCHED_TODAY] ?: 0, adsWatchedAllTime = prefs[KEY_ADS_WATCHED_ALL_TIME] ?: 0, lastAdDate = prefs[KEY_LAST_AD_DATE] ?: "", - userLevel = prefs[KEY_USER_LEVEL] ?: 1, - userXP = prefs[KEY_USER_XP] ?: 0, appLockEnabled = prefs[KEY_APP_LOCK_ENABLED] ?: false, biometricEnabled = prefs[KEY_BIOMETRIC_ENABLED] ?: false, autoLockOnBackground = prefs[KEY_AUTO_LOCK_BACKGROUND] ?: true, - isFirstRun = prefs[KEY_IS_FIRST_RUN] ?: false, + isFirstRun = prefs[KEY_IS_FIRST_RUN] ?: true, startupPage = prefs[KEY_STARTUP_PAGE] ?: "home", videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: false, sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true, notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false, - notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false, + notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: true, + notifyEvery5Minutes = prefs[KEY_NOTIFY_EVERY_5_MINUTES] ?: true, portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true, + showSessionTimeInBottomBar = prefs[KEY_SHOW_SESSION_TIME_IN_BOTTOM_BAR] ?: true, appIcon = prefs[KEY_APP_ICON] ?: "default", customIconsEnabled = prefs[KEY_CUSTOM_ICONS_ENABLED] ?: false, + storySearchEnabled = prefs[KEY_STORY_SEARCH_ENABLED] ?: true, + customBioFontsEnabled = prefs[KEY_CUSTOM_BIO_FONTS_ENABLED] ?: true, chatLockMode = prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual", chatLockAll = (prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual") == "whole_dm", @@ -94,7 +92,14 @@ class SettingsRepository(private val context: Context) { ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false, ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true, ghostHideDirectSeen = prefs[KEY_GHOST_HIDE_DIRECT_SEEN] ?: true, - ghostHideTypingIndicator = prefs[KEY_GHOST_HIDE_TYPING] ?: true + ghostHideTypingIndicator = prefs[KEY_GHOST_HIDE_TYPING] ?: true, + + activeSessionDurationMinutes = prefs[KEY_ACTIVE_SESSION_DURATION_MINUTES] ?: 0, + activeSessionRemainingSeconds = prefs[KEY_ACTIVE_SESSION_REMAINING_SECONDS] ?: 0, + activeSessionElapsedSeconds = prefs[KEY_ACTIVE_SESSION_ELAPSED_SECONDS] ?: 0, + activeSessionReelsWatched = prefs[KEY_ACTIVE_SESSION_REELS_WATCHED] ?: 0, + activeSessionReelCodes = prefs[KEY_ACTIVE_SESSION_REEL_CODES] ?: emptySet(), + activeSessionIsActive = prefs[KEY_ACTIVE_SESSION_IS_ACTIVE] ?: false ) } @@ -155,6 +160,7 @@ class SettingsRepository(private val context: Context) { prefs[KEY_BREATH_GATE_SECONDS] = seconds } } + suspend fun setShowBreathGate(enabled: Boolean) = setBreathGate(enabled) suspend fun setEffortFriction(enabled: Boolean, wordCount: Int = 30) { context.dataStore.edit { prefs -> @@ -171,9 +177,10 @@ class SettingsRepository(private val context: Context) { suspend fun extendDailyLimit5Minutes(today: String) { context.dataStore.edit { prefs -> - val current = prefs[KEY_DAILY_LIMIT_MINUTES] ?: 60 - prefs[KEY_DAILY_LIMIT_MINUTES] = current + 5 + val currentDate = prefs[KEY_DAILY_LIMIT_AD_DATE] ?: "" + val currentCount = if (currentDate == today) prefs[KEY_DAILY_LIMIT_EXTENSIONS_COUNT] ?: 0 else 0 prefs[KEY_DAILY_LIMIT_AD_DATE] = today + prefs[KEY_DAILY_LIMIT_EXTENSIONS_COUNT] = (currentCount + 1).coerceAtMost(2) } } @@ -182,59 +189,35 @@ class SettingsRepository(private val context: Context) { suspend fun setSanitizeLinks(enabled: Boolean) = setBoolean(KEY_SANITIZE_LINKS, enabled) suspend fun setNotifySessionEnd(enabled: Boolean) = setBoolean(KEY_NOTIFY_SESSION_END, enabled) suspend fun setNotifyPersistent(enabled: Boolean) = setBoolean(KEY_NOTIFY_PERSISTENT, enabled) + suspend fun setNotifyEvery5Minutes(enabled: Boolean) = setBoolean(KEY_NOTIFY_EVERY_5_MINUTES, enabled) suspend fun setPortraitOnly(enabled: Boolean) = setBoolean(KEY_PORTRAIT_ONLY, enabled) + suspend fun setShowSessionTimeInBottomBar(enabled: Boolean) = setBoolean(KEY_SHOW_SESSION_TIME_IN_BOTTOM_BAR, enabled) - suspend fun addInstaMinutes(minutes: Int) { + suspend fun saveActiveSession( + durationMinutes: Int, + remainingSeconds: Int, + elapsedSeconds: Int, + reelsWatched: Int = 0, + reelCodes: Set = emptySet(), + isActive: Boolean + ) { context.dataStore.edit { prefs -> - val current = prefs[KEY_INSTA_MINUTES] ?: 0 - prefs[KEY_INSTA_MINUTES] = (current + minutes).coerceAtLeast(0) + prefs[KEY_ACTIVE_SESSION_DURATION_MINUTES] = durationMinutes + prefs[KEY_ACTIVE_SESSION_REMAINING_SECONDS] = remainingSeconds + prefs[KEY_ACTIVE_SESSION_ELAPSED_SECONDS] = elapsedSeconds + prefs[KEY_ACTIVE_SESSION_REELS_WATCHED] = reelsWatched + prefs[KEY_ACTIVE_SESSION_REEL_CODES] = reelCodes + prefs[KEY_ACTIVE_SESSION_IS_ACTIVE] = isActive } } - suspend fun deductInstaMinute() { + suspend fun clearActiveSession() { context.dataStore.edit { prefs -> - val current = prefs[KEY_INSTA_MINUTES] ?: 0 - if (current > 0) prefs[KEY_INSTA_MINUTES] = current - 1 - } - } - - suspend fun addReelsMinutes(minutes: Int) { - context.dataStore.edit { prefs -> - val current = prefs[KEY_REELS_MINUTES] ?: 0 - prefs[KEY_REELS_MINUTES] = (current + minutes).coerceAtLeast(0) - } - } - - suspend fun deductReelsMinute() { - context.dataStore.edit { prefs -> - val current = prefs[KEY_REELS_MINUTES] ?: 0 - if (current > 0) prefs[KEY_REELS_MINUTES] = current - 1 - } - } - - suspend fun recordRewardedAd(todayKey: String, minutesEarned: Int = 2) { - context.dataStore.edit { prefs -> - val lastDate = prefs[KEY_LAST_AD_DATE] ?: "" - val todayCount = if (lastDate == todayKey) (prefs[KEY_ADS_WATCHED_TODAY] ?: 0) else 0 - val allTime = (prefs[KEY_ADS_WATCHED_ALL_TIME] ?: 0) + 1 - - prefs[KEY_LAST_AD_DATE] = todayKey - prefs[KEY_ADS_WATCHED_TODAY] = todayCount + 1 - prefs[KEY_ADS_WATCHED_ALL_TIME] = allTime - - // Credit the minutes - val currentInsta = prefs[KEY_INSTA_MINUTES] ?: 0 - prefs[KEY_INSTA_MINUTES] = currentInsta + minutesEarned - } - } - - suspend fun addXP(amount: Int) { - context.dataStore.edit { prefs -> - val currentXP = prefs[KEY_USER_XP] ?: 0 - val newXP = currentXP + amount - prefs[KEY_USER_XP] = newXP - // 100 XP per level - prefs[KEY_USER_LEVEL] = (newXP / 100) + 1 + prefs[KEY_ACTIVE_SESSION_IS_ACTIVE] = false + prefs[KEY_ACTIVE_SESSION_REMAINING_SECONDS] = 0 + prefs[KEY_ACTIVE_SESSION_ELAPSED_SECONDS] = 0 + prefs[KEY_ACTIVE_SESSION_REELS_WATCHED] = 0 + prefs[KEY_ACTIVE_SESSION_REEL_CODES] = emptySet() } } @@ -363,6 +346,32 @@ class SettingsRepository(private val context: Context) { suspend fun setUseSeparateChatPin(enabled: Boolean) = setBoolean(KEY_USE_SEPARATE_CHAT_PIN, enabled) suspend fun setChatBiometricEnabled(enabled: Boolean) = setBoolean(KEY_CHAT_BIOMETRIC_ENABLED, enabled) + suspend fun setFirstRunCompleted() { + context.dataStore.edit { prefs -> + prefs[KEY_IS_FIRST_RUN] = false + } + } + + suspend fun resetFirstRun() { + context.dataStore.edit { prefs -> + prefs[KEY_IS_FIRST_RUN] = true + } + } + + suspend fun setDismissedUpdateVersion(version: String) { + context.dataStore.edit { prefs -> + prefs[KEY_LAST_DISMISSED_UPDATE] = version + } + } + + suspend fun setStorySearchEnabled(enabled: Boolean) = setBoolean(KEY_STORY_SEARCH_ENABLED, enabled) + suspend fun setCustomBioFontsEnabled(enabled: Boolean) = setBoolean(KEY_CUSTOM_BIO_FONTS_ENABLED, enabled) + + suspend fun getDismissedUpdateVersion(): String? { + val prefs = context.dataStore.data.first() + return prefs[KEY_LAST_DISMISSED_UPDATE] + } + companion object { val KEY_NO_ADS = booleanPreferencesKey("no_ads") val KEY_NO_STORIES = booleanPreferencesKey("no_stories") @@ -391,30 +400,31 @@ class SettingsRepository(private val context: Context) { val KEY_COOLDOWN_MINUTES = intPreferencesKey("cooldown_minutes") val KEY_DAILY_LIMIT_MINUTES = intPreferencesKey("daily_limit_minutes") val KEY_DAILY_LIMIT_AD_DATE = stringPreferencesKey("daily_limit_ad_date") + val KEY_DAILY_LIMIT_EXTENSIONS_COUNT = intPreferencesKey("daily_limit_extensions_count") val KEY_GRAYSCALE_ENABLED = booleanPreferencesKey("grayscale_enabled") val KEY_SMOOTH_SCROLL_120HZ = booleanPreferencesKey("smooth_scroll_120hz") val KEY_REELS_HISTORY_ENABLED = booleanPreferencesKey("reels_history_enabled") - val KEY_REELS_MINUTES = intPreferencesKey("reels_min") - val KEY_INSTA_MINUTES = intPreferencesKey("insta_min") - val KEY_ADS_WATCHED_TODAY = intPreferencesKey("ads_today_count") val KEY_ADS_WATCHED_ALL_TIME = intPreferencesKey("ads_all_time") val KEY_LAST_AD_DATE = stringPreferencesKey("ads_today_date") - val KEY_USER_LEVEL = intPreferencesKey("user_level") - val KEY_USER_XP = intPreferencesKey("user_xp") val KEY_APP_LOCK_ENABLED = booleanPreferencesKey("app_lock_enabled") val KEY_BIOMETRIC_ENABLED = booleanPreferencesKey("biometric_enabled") val KEY_AUTO_LOCK_BACKGROUND = booleanPreferencesKey("auto_lock_background") val KEY_IS_FIRST_RUN = booleanPreferencesKey("set_is_first_run") + val KEY_LAST_DISMISSED_UPDATE = stringPreferencesKey("last_dismissed_update_version") val KEY_STARTUP_PAGE = stringPreferencesKey("startup_page") val KEY_VIDEO_DOWNLOAD_ENABLED = booleanPreferencesKey("video_download_enabled") val KEY_SANITIZE_LINKS = booleanPreferencesKey("set_sanitize_links") val KEY_NOTIFY_SESSION_END = booleanPreferencesKey("set_notify_session_end") val KEY_NOTIFY_PERSISTENT = booleanPreferencesKey("set_notify_persistent") + val KEY_NOTIFY_EVERY_5_MINUTES = booleanPreferencesKey("set_notify_every_5_minutes") val KEY_PORTRAIT_ONLY = booleanPreferencesKey("portrait_only") + val KEY_SHOW_SESSION_TIME_IN_BOTTOM_BAR = booleanPreferencesKey("show_session_time_in_bottom_bar") + val KEY_STORY_SEARCH_ENABLED = booleanPreferencesKey("story_search_enabled") + val KEY_CUSTOM_BIO_FONTS_ENABLED = booleanPreferencesKey("custom_bio_fonts_enabled") val KEY_GHOST_MODE_ENABLED = booleanPreferencesKey("ghost_mode_enabled") val KEY_GHOST_HIDE_STORY_SEEN = booleanPreferencesKey("ghost_hide_story_seen") @@ -422,6 +432,13 @@ class SettingsRepository(private val context: Context) { val KEY_GHOST_HIDE_TYPING = booleanPreferencesKey("ghost_hide_typing") val KEY_LAST_SESSION_END_TIMESTAMP = longPreferencesKey("last_session_end_timestamp") + val KEY_ACTIVE_SESSION_DURATION_MINUTES = intPreferencesKey("active_session_duration_minutes") + val KEY_ACTIVE_SESSION_REMAINING_SECONDS = intPreferencesKey("active_session_remaining_seconds") + val KEY_ACTIVE_SESSION_ELAPSED_SECONDS = intPreferencesKey("active_session_elapsed_seconds") + val KEY_ACTIVE_SESSION_REELS_WATCHED = intPreferencesKey("active_session_reels_watched") + val KEY_ACTIVE_SESSION_REEL_CODES = stringSetPreferencesKey("active_session_reel_codes") + val KEY_ACTIVE_SESSION_IS_ACTIVE = booleanPreferencesKey("active_session_is_active") + val KEY_APP_ICON = stringPreferencesKey("app_icon") val KEY_CUSTOM_ICONS_ENABLED = booleanPreferencesKey("custom_icons_enabled") val KEY_CHAT_LOCK_MODE = stringPreferencesKey("chat_lock_mode") diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt similarity index 94% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt index d73ae30..9ae0c62 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt @@ -13,10 +13,8 @@ import org.mozilla.geckoview.GeckoRuntime import org.mozilla.geckoview.WebExtension /** - * ExtensionManager - * Manages the lifecycle, installation, and two-way messaging of built-in WebExtensions in GeckoView. - * Provides real-time synchronization between Android Settings and the in-page Content Script, - * handling SPA navigation, logo actions, typing focus, ad-blocking toggling, and Ghost Mode. + * Bridges between native Android settings and the bundled uBlock Origin / FocusGram + * content script over GeckoView WebExtension messaging ports. */ class ExtensionManager(private val runtime: GeckoRuntime) { @@ -306,6 +304,8 @@ class ExtensionManager(private val runtime: GeckoRuntime) { put("minimalMode", settings.minimalModeEnabled) put("videoDownloadEnabled", settings.videoDownloadEnabled) put("portraitOnly", settings.portraitOnly) + put("storySearchEnabled", settings.storySearchEnabled) + put("customBioFontsEnabled", settings.customBioFontsEnabled) put("ghostModeEnabled", settings.ghostModeEnabled) put("ghostHideStorySeen", settings.ghostHideStorySeen) put("ghostHideDirectSeen", settings.ghostHideDirectSeen) @@ -363,7 +363,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } /** - * Senior Blueprint: Sets feed post reveal mode ("tap" or "hold") dynamically. + * Updates feed post reveal mode ("tap" or "hold") in the running content script. */ fun setRevealMode(mode: String) { try { @@ -379,7 +379,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } /** - * Executes instant client-side SPA navigation inside Instagram web without full reload. + * Directs the in-page content script to navigate via client-side History pushState. */ fun navigateSpa(path: String) { try { @@ -394,16 +394,16 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } /** - * Opens the authenticated user's profile inside Instagram web. + * Navigates to the current user's profile without reloading the page. */ fun openProfileSpa() { go("profile") } /** - * Senior Blueprint: Triggers client-side SPA navigation in Instagram - * by clicking the real hidden navigation anchor. - * @param target "home", "search", "reels", "dm", "profile" + * Simulates a user click on Instagram's internal navigation anchor. + * + * @param target One of "home", "search", "reels", "dm", "profile". */ fun go(target: String) { try { @@ -434,8 +434,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } /** - * Senior Blueprint: Synchronizes IslandMode (NORMAL, FLOATING, HIDDEN) - * with the WebExtension content script to dynamically update body padding. + * Signals navigation layout changes so the content script adjusts document body padding. */ fun setIslandMode(mode: IslandMode) { try { @@ -460,7 +459,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { suspend fun setUBlockEnabled(enabled: Boolean) = setContentBlockerEnabled(enabled) /** - * Sends request to in-page content script to extract and report active Reel video URL for downloading. + * Requests the content script to extract and report the active Reel video source URL. */ fun triggerReelDownload() { try { @@ -475,8 +474,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } /** - * Sends the active Unicode font style selection to the WebExtension content script - * for real-time live bio transformation. + * Sends the selected Unicode character style to the content script for in-place text styling. */ fun setBioStyle(style: String) { try { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt similarity index 86% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt index 958ec11..4bc4a35 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt @@ -7,6 +7,7 @@ import android.util.Log import org.mozilla.geckoview.AllowOrDeny import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.WebRequestError /** * FocusGramNavigationDelegate @@ -27,18 +28,27 @@ class FocusGramNavigationDelegate( val host = uri.host?.lowercase() ?: "" val scheme = uri.scheme?.lowercase() ?: "" - // 1. Allow internal asset protocols, data URIs, and javascript evaluation + // Internal scheme evaluation if (scheme == "resource" || scheme == "about" || scheme == "data" || scheme == "blob" || scheme == "javascript") { return GeckoResult.fromValue(AllowOrDeny.ALLOW) } - // 2. Prevent deep-linking into the official native Instagram app + // Allow internal local app-private files + if (scheme == "file") { + val path = uri.path ?: "" + val appFilesPath = context.filesDir.absolutePath + if (path.startsWith(appFilesPath)) { + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + } + + // Block attempts to hand off navigation to the official native Instagram app if (scheme == "instagram") { Log.d(TAG, "Prevented native Instagram app deep link: ${request.uri}") return GeckoResult.fromValue(AllowOrDeny.DENY) } - // 2b. Intercept native media download requests from in-page client scripts + // Custom scheme used by in-page downloader scripts to pass media URLs to native storage if (scheme == "focusgram-download") { val mediaUrl = uri.getQueryParameter("url") ?: "" val filename = uri.getQueryParameter("filename") ?: "" @@ -55,7 +65,7 @@ class FocusGramNavigationDelegate( return GeckoResult.fromValue(AllowOrDeny.DENY) } - // 3. Intercept navigation trap to suggested accounts (/explore/people) from Favorites + // Instagram traps users into /explore/people when Favorites feed is empty; force back home val path = uri.path?.lowercase() ?: "" if (path.contains("/explore/people")) { Log.i(TAG, "Intercepted suggested accounts trap (/explore/people). Redirecting to home feed.") @@ -63,7 +73,7 @@ class FocusGramNavigationDelegate( return GeckoResult.fromValue(AllowOrDeny.DENY) } - // 3b. Minimal Mode enforcement: redirect home feed, explore, reels, and individual posts to DMs inbox + // Minimal mode restrains usage exclusively to Direct Messages if (sessionManager.currentSettings.minimalModeEnabled && (host == "instagram.com" || host == "www.instagram.com")) { if (path == "/" || path == "" || path.startsWith("/explore") || path.startsWith("/reels") || path.startsWith("/reel") || path.startsWith("/p/")) { Log.i(TAG, "Minimal Mode active. Redirecting restricted path $path to /direct/inbox/") @@ -72,15 +82,12 @@ class FocusGramNavigationDelegate( } } - // 4. Allow Instagram, Meta CDN, and Authentication login domains + // Whitelisted domain tree (Instagram, CDN, Meta Account Center) if (isAllowedHost(host)) { return GeckoResult.fromValue(AllowOrDeny.ALLOW) } - // 5. For external links: - // ONLY launch external system browser if the navigation was triggered by an explicit user gesture - // (tapping an external link in bio/story) or opening a new tab (target="_blank"). - // NEVER launch external browser for background pings, telemetry, iframes, or non-user requests! + // Launch external browser only for intentional user taps, suppressing background redirects/telemetry val isExplicitUserNavigation = request.hasUserGesture || request.target == GeckoSession.NavigationDelegate.TARGET_WINDOW_NEW if (isExplicitUserNavigation && (scheme == "http" || scheme == "https")) { Log.i(TAG, "User clicked external link: ${request.uri}. Launching external browser intent.") @@ -147,8 +154,15 @@ class FocusGramNavigationDelegate( sessionManager.updateNavigationState(canBack = canGoBack, canForward = false) } - override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { - // Will be called when forward navigation changes + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {} + + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError + ): GeckoResult? { + Log.w(TAG, "Navigation error: uri=$uri, category=${error.category}, code=${error.code}") + return GeckoResult.fromValue(null) } private fun isAllowedHost(host: String): Boolean { @@ -202,7 +216,7 @@ class FocusGramNavigationDelegate( "instagr.am", "cdninstagram.com", "fbcdn.net", - // Required for Facebook Authentication & Account Center logins + // Meta Account Center & OAuth domains necessary for Instagram login "facebook.com", "m.facebook.com", "accountscenter.instagram.com", diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt similarity index 84% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt index f9eed62..dae68d6 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt @@ -10,9 +10,7 @@ import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoSession /** - * FocusGramPermissionDelegate - * Handles web content and Android runtime permission requests (Camera, Microphone, Media) - * from GeckoView sessions while maintaining user privacy and strict origin isolation. + * Filters and delegates Android runtime and content origin permission requests from GeckoView. */ class FocusGramPermissionDelegate( private val context: Context, @@ -30,8 +28,8 @@ class FocusGramPermissionDelegate( ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED } - // On Android 13+ (API 33+), READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are deprecated - // and automatically denied by the OS. System file pickers do not require storage permissions. + // Android 13+ (API 33+) deprecates broad storage permissions and auto-denies them; + // modern media selection goes through SAF / Photo Picker instead. val actionableUngranted = if (android.os.Build.VERSION.SDK_INT >= 33) { ungranted.filter { it != android.Manifest.permission.READ_EXTERNAL_STORAGE && @@ -50,8 +48,7 @@ class FocusGramPermissionDelegate( Handler(Looper.getMainLooper()).post { try { onAndroidPermissionRequest.invoke(actionableUngranted.toTypedArray()) { granted -> - // Always grant to GeckoView so it proceeds to onFilePrompt() where - // native ActivityResultContracts (Photo Picker / SAF) handle file access securely. + // Grant to GeckoView so it advances to onFilePrompt where SAF handles the URI callback.grant() } } catch (e: Exception) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt similarity index 93% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt index 35a263d..d4fc4a7 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt @@ -11,9 +11,7 @@ import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoSession /** - * FocusGramPromptDelegate - * Handles browser prompts (file picker, camera uploads, JavaScript alerts, password saves) - * through native Android Jetpack contracts while preventing data leakage and compositor freezing. + * Bridges GeckoView prompts (file uploads, dialogs, form submissions) to native Android handlers. */ class FocusGramPromptDelegate( private val context: Context, @@ -47,8 +45,7 @@ class FocusGramPromptDelegate( val result = GeckoResult() - // CRITICAL: GeckoView calls onFilePrompt on a background thread. - // ActivityResultLauncher.launch() MUST be called on the main Looper thread. + // GeckoView invokes onFilePrompt on a worker thread; launcher contracts require main looper Handler(Looper.getMainLooper()).post { try { handler.onPickFiles(mimeTypes, isMultiple, capture) { uris -> diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt similarity index 73% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt index cf6f8f0..28cb673 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoResultExtensions.kt @@ -6,8 +6,10 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** - * Await extension for GeckoResult. - * Bridges Mozilla's reactive GeckoResult into Kotlin Coroutines seamlessly. + * Adapts GeckoView's callback-based [GeckoResult] to a cancellable coroutine. + * + * If the engine fails with a null exception, wraps it in [RuntimeException] + * because [suspendCancellableCoroutine.resumeWithException] requires a non-null Throwable. */ suspend fun GeckoResult.await(): T? = suspendCancellableCoroutine { continuation -> accept( diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt similarity index 87% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt index 2a53aff..1d961d4 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt @@ -7,14 +7,10 @@ import org.mozilla.geckoview.GeckoRuntime import org.mozilla.geckoview.GeckoRuntimeSettings /** - * GeckoRuntimeManager - * High-performance, privacy-hardened singleton managing the Mozilla GeckoView engine lifecycle. + * Initializes and owns the singleton [GeckoRuntime] lifecycle. * - * Configures: - * - ContentBlocking: ETP Strict, Social Tracking Protection, First-Party Cookie Isolation. - * - Extensions Process: Enabled for Content Blocker WebExtension execution. - * - Privacy: Query parameter stripping, DNT, Sec-GPC. - * - Hardening: about:config disabled in production, console output managed. + * Configures strict tracking protection (ETP strict, social tracking isolation, + * query parameter stripping) and enables WebExtension execution for client-side content scripts. */ class GeckoRuntimeManager private constructor(context: Context) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt similarity index 87% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt index d2fef14..edc2748 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt @@ -12,14 +12,14 @@ import org.mozilla.geckoview.GeckoSession import org.mozilla.geckoview.GeckoSessionSettings /** - * GeckoSessionManager - * Manages the primary GeckoSession lifecycle, background suspension, memory reclamation, - * and page progress tracking for FocusGram. + * Manages the primary [GeckoSession] lifecycle, foreground/background media states, + * and DOM navigation bridge for Instagram web. */ class GeckoSessionManager(private val runtime: GeckoRuntime) { val session: GeckoSession var userScriptManager: UserScriptManager? = null + var extensionManager: ExtensionManager? = null var currentSettings: FocusSettings = FocusSettings() private val _isLoading = MutableStateFlow(false) @@ -55,6 +55,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup") if (!isAuth) { injectBottomBarTrimmer() + extensionManager?.broadcastSettings(currentSettings) userScriptManager?.injectAll(session, currentSettings) } } @@ -67,7 +68,8 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { .allowJavascript(true) .userAgentMode(GeckoSessionSettings.USER_AGENT_MODE_MOBILE) .userAgentOverride(IOS_USER_AGENT) - .usePrivateMode(false) // Retain Instagram authentication cookies locally + // False keeps session cookies on disk; private mode clears them on process death + .usePrivateMode(false) .build() session = GeckoSession(settings) @@ -94,6 +96,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup") if (isAuthenticated && !isAuth) { injectBottomBarTrimmer() + extensionManager?.broadcastSettings(currentSettings) userScriptManager?.injectAll(session, currentSettings) } } @@ -134,7 +137,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun onPause() { if (isOpen) { Log.d(TAG, "Suspending GeckoSession for background...") - session.setActive(false) + pauseMedia() } } @@ -167,7 +170,9 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun updateSettings(settings: FocusSettings) { currentSettings = settings - if (isAuthenticated) { + val url = _currentUrl.value.lowercase() + val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup") + if (!isAuth) { userScriptManager?.injectConfig(session, settings) } } @@ -297,14 +302,15 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { } /** - * Senior Blueprint: Ensures bottom navigation bar is marked and hidden - * using position: fixed and bottom bounds inspection. + * Identifies Instagram's fixed bottom tab bar in the DOM and suppresses it + * so it does not collide with the native Jetpack Compose Island navigation bar. */ fun injectBottomBarTrimmer() { val js = """javascript:(function() { try { var p = (window.location.pathname || '').toLowerCase(); - if (p.startsWith('/direct') || (p.startsWith('/accounts') && !p.startsWith('/accounts/edit')) || p.startsWith('/login') || p.startsWith('/signup')) return; + var h = (window.location.hostname || '').toLowerCase(); + if (p.startsWith('/direct') || (p.startsWith('/accounts') && !p.startsWith('/accounts/edit')) || p.startsWith('/login') || p.startsWith('/signup') || p.startsWith('/two_factor') || p.startsWith('/challenge') || p.startsWith('/onetap') || h.includes('facebook.com') || h.includes('accountscenter.')) return; var c = document.cookie || ''; if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return; if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return; @@ -376,6 +382,25 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { } fun goBack(): Boolean { + val currentUrlStr = _currentUrl.value.lowercase() + val path = IslandMode.extractPath(currentUrlStr).substringBefore('?').substringBefore('#').trimEnd('/').ifEmpty { "/" } + + val isDmThread = path.startsWith("/direct/t/") || path.startsWith("/direct/threads/") || (path.startsWith("/direct/") && path != "/direct/inbox" && path != "/direct") + val isDmInbox = path == "/direct" || path == "/direct/inbox" + val isRootHome = path == "/" || path.isEmpty() + + if (isDmThread) { + navigateTo("https://www.instagram.com/direct/inbox/") + return true + } + if (isDmInbox) { + navigateTo("https://www.instagram.com/") + return true + } + if (isRootHome) { + return false + } + if (_canGoBack.value) { session.goBack() return true @@ -393,7 +418,10 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { return false } - fun attachNavigationDelegate(context: Context, onRouteChanged: ((String) -> Unit)? = null) { + fun attachNavigationDelegate( + context: Context, + onRouteChanged: ((String) -> Unit)? = null + ) { session.navigationDelegate = FocusGramNavigationDelegate(context, this, onRouteChanged) } @@ -408,9 +436,9 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun pauseMedia() { try { - session.setActive(false) - val pauseJs = "javascript:(function(){ try { document.querySelectorAll('video, audio').forEach(function(m){ m.pause(); }); } catch(_) {} })();" + val pauseJs = "javascript:(function(){ try { window.__fgMediaPaused = true; document.querySelectorAll('video, audio').forEach(function(m){ try { m.pause(); } catch(_) {} }); } catch(_) {} })();" session.loadUri(pauseJs) + session.setActive(false) } catch (e: Exception) { Log.w(TAG, "Error pausing media: ${e.message}") } @@ -419,6 +447,8 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun resumeMedia() { try { session.setActive(true) + val resumeJs = "javascript:(function(){ try { window.__fgMediaPaused = false; } catch(_) {} })();" + session.loadUri(resumeJs) } catch (e: Exception) { Log.w(TAG, "Error resuming media: ${e.message}") } @@ -435,6 +465,8 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { companion object { private const val TAG = "GeckoSessionManager" const val DEFAULT_URL = "https://www.instagram.com/" + + // Mobile Safari UA causes Instagram to serve touch-friendly web layouts without Google Play banners const val IOS_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt similarity index 65% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt index 0847e46..f9a5d1b 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt @@ -6,9 +6,7 @@ import com.ujwal.focusgram.core.model.FocusSettings import org.mozilla.geckoview.GeckoSession /** - * UserScriptManager - * Loads, configures, and evaluates FocusGram client-side scripts inside GeckoSession, - * including stealth privacy hooks, ad cosmetic filters, and UI guardrails. + * Reads asset scripts and evaluates them in the running GeckoSession document context. */ class UserScriptManager(private val context: Context) { @@ -26,8 +24,8 @@ class UserScriptManager(private val context: Context) { } /** - * Injects the dynamic FocusSettings JSON config into the web execution environment - * and broadcasts updates to active scripts. + * Serializes [FocusSettings] and chat lock state to `window.__FOCUSGRAM_CONFIG__` + * and dispatches a DOM custom event for active script listeners. */ fun injectConfig(session: GeckoSession, settings: FocusSettings) { val lockedIdsJson = org.json.JSONArray(settings.lockedChatIds.map { it.lowercase().trim().removePrefix("@") }).toString() @@ -53,10 +51,15 @@ class UserScriptManager(private val context: Context) { grayscale: ${settings.grayscaleEnabled}, minimalMode: ${settings.minimalModeEnabled}, videoDownloadEnabled: ${settings.videoDownloadEnabled}, - portraitOnly: ${settings.portraitOnly} + portraitOnly: ${settings.portraitOnly}, + storySearchEnabled: ${settings.storySearchEnabled}, + customBioFontsEnabled: ${settings.customBioFontsEnabled} }; window.__FOCUSGRAM_GHOST_CONFIG__ = { enabled: ${settings.ghostModeEnabled}, + hideStorySeen: ${settings.ghostHideStorySeen}, + hideDirectSeen: ${settings.ghostHideDirectSeen}, + hideTyping: ${settings.ghostHideTypingIndicator}, igStory: ${settings.ghostHideStorySeen}, igSeen: ${settings.ghostHideDirectSeen}, igTyping: ${settings.ghostHideTypingIndicator} @@ -87,27 +90,46 @@ class UserScriptManager(private val context: Context) { window.__focusgramGhostSettings.hideStorySeen = ${settings.ghostHideStorySeen}; window.__focusgramGhostSettings.hideDirectSeen = ${settings.ghostHideDirectSeen}; window.__focusgramGhostSettings.hideTyping = ${settings.ghostHideTypingIndicator}; + window.__focusgramGhostSettings.igStory = ${settings.ghostHideStorySeen}; + window.__focusgramGhostSettings.igSeen = ${settings.ghostHideDirectSeen}; + window.__focusgramGhostSettings.igTyping = ${settings.ghostHideTypingIndicator}; } if (window.__fgSetGrayscale) { window.__fgSetGrayscale(${settings.grayscaleEnabled}); } + const filterUpdatePayload = { + blockAds: ${settings.noAds}, + blockSponsored: ${settings.noAds}, + blockSuggested: ${settings.hideSuggestedPosts}, + storySearchEnabled: ${settings.storySearchEnabled}, + customBioFontsEnabled: ${settings.customBioFontsEnabled} + }; if (window.__fgSetFilterConfig) { - window.__fgSetFilterConfig({ - blockAds: ${settings.noAds}, - blockSponsored: ${settings.noAds}, - blockSuggested: ${settings.hideSuggestedPosts} - }); + window.__fgSetFilterConfig(filterUpdatePayload); } + try { + window.dispatchEvent(new CustomEvent('focusgram:filter-config-update', { detail: filterUpdatePayload })); + document.dispatchEvent(new CustomEvent('focusgram:filter-config-update', { detail: filterUpdatePayload })); + } catch(_) {} document.documentElement.setAttribute('data-fg-block-dm-reels', '${settings.blockDmReelsSwiping}'); window.postMessage({ source: "GHOSTIFY_EXTENSION", type: "GHOSTIFY_SETTINGS_UPDATE", settings: { - igTyping: ${settings.ghostModeEnabled && settings.ghostHideTypingIndicator}, + enabled: ${settings.ghostModeEnabled}, + hideStorySeen: ${settings.ghostModeEnabled && settings.ghostHideStorySeen}, + hideDirectSeen: ${settings.ghostModeEnabled && settings.ghostHideDirectSeen}, + hideTyping: ${settings.ghostModeEnabled && settings.ghostHideTypingIndicator}, + igStory: ${settings.ghostModeEnabled && settings.ghostHideStorySeen}, igSeen: ${settings.ghostModeEnabled && settings.ghostHideDirectSeen}, - igStory: ${settings.ghostModeEnabled && settings.ghostHideStorySeen} + igTyping: ${settings.ghostModeEnabled && settings.ghostHideTypingIndicator} } }, "*"); + window.postMessage({ + source: "FOCUSGRAM_HOST", + type: "FOCUSGRAM_FILTER_UPDATE", + config: filterUpdatePayload + }, "*"); window.postMessage({ source: "FOCUSGRAM_HOST", type: "FOCUSGRAM_UPDATE", @@ -120,9 +142,10 @@ class UserScriptManager(private val context: Context) { } /** - * Evaluates a UserScript inside the active GeckoSession document. - * Safely URL-encodes the script body to prevent Gecko's C++ parser from - * rejecting scripts containing newlines or control characters. + * Executes JavaScript inside the active session. + * + * Wraps the script in an IIFE and percent-encodes it because Gecko's parser + * rejects raw newlines or unencoded control characters in `javascript:` URIs. */ fun evaluate(session: GeckoSession, script: String) { if (script.isBlank()) return @@ -136,13 +159,24 @@ class UserScriptManager(private val context: Context) { } fun injectAll(session: GeckoSession, settings: FocusSettings) { + val scripts = listOf( + "fetch_interceptor.js", + "content_hider.js", + "autoplay_blocker.js", + "grayscale.js", + "native_feel.js", + "media_downloader.js", + "stealth_instagram.js" + ) val bundle = StringBuilder() - bundle.append(loadScript("fetch_interceptor.js")).append("\n") - bundle.append(loadScript("content_hider.js")).append("\n") - bundle.append(loadScript("autoplay_blocker.js")).append("\n") - bundle.append(loadScript("grayscale.js")).append("\n") - bundle.append(loadScript("native_feel.js")).append("\n") - bundle.append(loadScript("media_downloader.js")).append("\n") + for (name in scripts) { + val code = loadScript(name) + if (code.isNotBlank()) { + bundle.append("try {\n") + .append(code) + .append("\n} catch (e) { console.error('[FocusGram] Script error in ").append(name).append(":', e); }\n") + } + } evaluate(session, bundle.toString()) injectConfig(session, settings) } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt similarity index 67% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt index 10df1cc..489e0a9 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt @@ -1,12 +1,12 @@ package com.ujwal.focusgram.core.model /** - * FocusSettings - * Immutable data model representing all user-configurable focus toggles, - * digital wellness guardrails, mindful gate parameters, and reward credits. + * User-configurable toggles and operational thresholds stored on-device. + * + * All state is persisted via DataStore Preferences. Defaults prioritize distraction + * elimination (ad blocking, autoplay suppression, explore blurring). */ data class FocusSettings( - // Focus Mode & Content Guardrails val noAds: Boolean = true, val noStories: Boolean = false, val noReels: Boolean = false, @@ -15,7 +15,7 @@ data class FocusSettings( val blurExplore: Boolean = true, val blurReels: Boolean = false, val blurFeed: Boolean = false, - val unblurMode: String = "tap", // "tap" or "hold" + val unblurMode: String = "tap", // "tap" reveals on click; "hold" requires continuous touch val tapToUnblur: Boolean = true, val requireLongPress: Boolean = false, val hideSuggestedPosts: Boolean = false, @@ -26,47 +26,41 @@ data class FocusSettings( val hideFeedPosts: Boolean = false, val blockDmReelsSwiping: Boolean = true, - // Mindful Gates & Wellness val showBreathGate: Boolean = true, val breathGateSeconds: Int = 10, val requireWordChallenge: Boolean = true, val wordChallengeCount: Int = 30, val effortFrictionEnabled: Boolean = true, - val cooldownMinutes: Int = 15, + val cooldownMinutes: Int = 5, val dailyLimitMinutes: Int = 60, val dailyLimitAdClaimedDate: String = "", + val dailyLimitExtensionsCount: Int = 0, - // Appearance & System val grayscaleEnabled: Boolean = false, val smoothScroll120Hz: Boolean = true, val reelsHistoryEnabled: Boolean = true, - // Rewarded Ads & Discipline Economy - val reelsMinutes: Int = 0, - val instaMinutes: Int = 0, - val adsWatchedToday: Int = 0, val adsWatchedAllTime: Int = 0, val lastAdDate: String = "", - val userLevel: Int = 1, - val userXP: Int = 0, - // App Lock & Privacy val appLockEnabled: Boolean = false, val biometricEnabled: Boolean = false, val autoLockOnBackground: Boolean = true, - val isFirstRun: Boolean = false, + val isFirstRun: Boolean = true, - // Extras & Notifications val startupPage: String = "home", val videoDownloadEnabled: Boolean = false, val sanitizeLinks: Boolean = true, val notifySessionEnd: Boolean = false, - val notifyPersistent: Boolean = false, + val notifyPersistent: Boolean = true, + val notifyEvery5Minutes: Boolean = true, val portraitOnly: Boolean = true, + val showSessionTimeInBottomBar: Boolean = true, val appIcon: String = "default", val customIconsEnabled: Boolean = false, + val storySearchEnabled: Boolean = true, + val customBioFontsEnabled: Boolean = true, - // Chat Lock & Direct Message Privacy val chatLockMode: String = "individual", // "off", "individual", "whole_dm" val chatLockAll: Boolean = false, val lockedChatIds: Set = emptySet(), @@ -74,9 +68,15 @@ data class FocusSettings( val useSeparateChatPin: Boolean = false, val chatBiometricEnabled: Boolean = true, - // Ghost Mode (Privacy & Stealth) val ghostModeEnabled: Boolean = false, val ghostHideStorySeen: Boolean = true, val ghostHideDirectSeen: Boolean = true, - val ghostHideTypingIndicator: Boolean = true + val ghostHideTypingIndicator: Boolean = true, + + val activeSessionDurationMinutes: Int = 0, + val activeSessionRemainingSeconds: Int = 0, + val activeSessionElapsedSeconds: Int = 0, + val activeSessionReelsWatched: Int = 0, + val activeSessionReelCodes: Set = emptySet(), + val activeSessionIsActive: Boolean = false ) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt similarity index 52% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt index af03788..9629c40 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt @@ -1,43 +1,39 @@ package com.ujwal.focusgram.core.model /** - * IslandMode - * Defines the presentation state of FocusGram's native navigation island - * and the corresponding content-script body padding for Instagram web. + * Presentation states for the native navigation island overlay and corresponding + * page bottom padding in Instagram web. * - * Implements Senior Architecture Blueprint: - * - Edge-to-edge full-screen GeckoView. - * - Floating native OLED island pill overlay. - * - Route-driven visibility and padding states: - * - NORMAL: Feed, Explore, User Profile, Direct Inbox (island shown, 96px body padding) - * - FLOATING: Reels (full bleed, 0px body padding, translucent island) - * - HIDDEN: Direct conversation threads, Comments, Stories, Create post (0px body padding) + * Instagram web layouts rely on CSS viewport heights and sticky footers. Injecting + * 96px bottom padding prevents the floating island from obscuring buttons in feed + * and explore, while full-bleed views (Reels) need 0px padding to avoid letterboxing. */ enum class IslandMode { /** * Standard browsing view (Feed, Search/Explore, User Profile, Direct Inbox). - * Island is shown with frosted glass elevation. - * Content script applies bottom padding (96px) so content scrolls behind the island. + * The native island is displayed and web content receives bottom padding (96px). */ NORMAL, /** * Reels view (/reels, /reel/...). - * Video container is full-bleed 100vh with 0px body padding to avoid distortion or gaps. - * Island floats translucently and automatically accommodates full-screen gestures. + * Full-screen 100vh layout with 0px padding to prevent video letterboxing or scroll glitches. + * The island is rendered with reduced opacity. */ FLOATING, /** - * Hidden state (DM thread /direct/t/, Comments /comments or modal, Stories /stories/, Create /create, Soft keyboard IME). - * Island is completely animated offscreen so bottom input composers sit completely flush. + * Active interaction view (DM conversations, comment sheets, story viewer, create composer). + * The island is translated offscreen so input fields align directly with the software keyboard. */ HIDDEN; companion object { /** - * Resolves the IslandMode from a given URL path. - * Uses pure string parsing for cross-platform reliability on both Android runtime and JVM JUnit tests. + * Resolves the navigation mode for a given URL path. + * + * Uses string splitting rather than android.net.Uri to allow direct execution + * in host JVM unit tests without Android mock stubs. */ fun fromUrl(url: String?): IslandMode { if (url.isNullOrBlank()) return NORMAL @@ -55,7 +51,7 @@ enum class IslandMode { } /** - * Extracts the URL path segment cleanly from absolute or relative URLs. + * Returns the path portion of a URL, defaulting to "/" if no path exists. */ fun extractPath(url: String): String { val clean = url.trim().lowercase() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt similarity index 90% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt index 2efafd8..4b51d7e 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt @@ -8,14 +8,11 @@ import androidx.security.crypto.MasterKey import java.security.MessageDigest /** - * EncryptedStorage - * Hardware-backed cryptographic vault utilizing Android KeyStore MasterKey (AES-256 GCM) - * and EncryptedSharedPreferences (AES-256 SIV + AES-256 GCM). + * Hardware-backed encrypted storage for authentication PINs and lockout timestamps. * - * Secures: - * - App Lock 4-digit PIN (hashed with SHA-256 + salt and encrypted at rest). - * - Biometric authentication state. - * - Brute-force lockout and failed attempt tracking. + * Keys and values are encrypted using Android KeyStore master keys (AES-256 GCM) + * via [EncryptedSharedPreferences]. PINs are salted with a fixed prefix before hashing + * so the raw 4-digit code is never written into preferences in plain form. */ class EncryptedStorage(context: Context) { @@ -36,7 +33,7 @@ class EncryptedStorage(context: Context) { } /** - * Hashes and securely stores user's App Lock PIN. + * Stores the salted SHA-256 digest of the app lock PIN and resets any active lockout. */ fun savePin(pin: String) { val hashedPin = hashPin(pin) @@ -49,7 +46,7 @@ class EncryptedStorage(context: Context) { } /** - * Verifies entered PIN against hardware-encrypted hash. + * Compares entered PIN digest against stored digest, enforcing lockout on repeated failures. */ fun verifyPin(enteredPin: String): Boolean { if (isLockedOut()) { @@ -82,8 +79,6 @@ class EncryptedStorage(context: Context) { Log.i(TAG, "App Lock PIN removed from vault.") } - // --- Chat Lock PIN Management --- - fun saveChatPin(pin: String) { val hashedPin = hashPin("chat_$pin") securePrefs.edit() @@ -122,8 +117,6 @@ class EncryptedStorage(context: Context) { Log.i(TAG, "Chat Lock PIN removed from vault.") } - // --- Brute Force Protection --- - fun isLockedOut(): Boolean { val lockoutUntil = securePrefs.getLong(KEY_LOCKOUT_UNTIL, 0) return System.currentTimeMillis() < lockoutUntil @@ -139,6 +132,7 @@ class EncryptedStorage(context: Context) { val currentAttempts = securePrefs.getInt(KEY_FAILED_ATTEMPTS, 0) + 1 val editor = securePrefs.edit().putInt(KEY_FAILED_ATTEMPTS, currentAttempts) + // Lock out after 5 consecutive failed attempts to slow down brute-force PIN guessing if (currentAttempts >= MAX_FAILED_ATTEMPTS) { val lockoutDurationMs = LOCKOUT_DURATION_SECONDS * 1000L val lockoutUntil = System.currentTimeMillis() + lockoutDurationMs @@ -155,8 +149,6 @@ class EncryptedStorage(context: Context) { .apply() } - // --- Generic Key/Value Secure Storage --- - fun putSecureString(key: String, value: String) { securePrefs.edit().putString(key, value).apply() } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt similarity index 80% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt index 8e14377..75e158f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt @@ -47,14 +47,10 @@ import com.ujwal.focusgram.core.ui.theme.PureBlack import kotlin.math.abs /** - * CupertinoWheelPicker - * Authentic Apple Clock styled rotating drum wheel picker. - * Features: - * - 3D cylindrical drum perspective: center item is perfectly flat (0ยฐ), 1 above/below are tilted, - * 2 above/below tilt progressively and vanish into top/bottom shadow gradients. - * - Center row frosted lens highlight with subtle borders. - * - Hardware CLOCK_TICK haptics with Vibrator fallback. - * - Smooth inertial snap fling behavior. + * Rotating drum wheel picker with 3D cylindrical projection and inertial snap flinging. + * + * Items above and below the center index rotate along the X-axis and scale down + * to simulate a physical tumbler drum. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -66,7 +62,8 @@ fun CupertinoWheelPicker( visibleItemsCount: Int = 5, itemHeight: Dp = 46.dp, label: String = "min", - showGradientShadows: Boolean = true + showGradientShadows: Boolean = true, + labelProvider: ((Int) -> String)? = null ) { val view = LocalView.current val density = LocalDensity.current @@ -79,17 +76,22 @@ fun CupertinoWheelPicker( val listState = rememberLazyListState(initialFirstVisibleItemIndex = initialIndex) val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = listState) - // Calculate current centered index based on scroll offset val currentCenteredIndex by remember { derivedStateOf { - val firstVisible = listState.firstVisibleItemIndex - val offset = listState.firstVisibleItemScrollOffset - val extra = if (offset > itemHeightPx / 2f) 1 else 0 - (firstVisible + extra).coerceIn(0, (items.size - 1).coerceAtLeast(0)) + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + initialIndex + } else { + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 + val closest = visibleItems.minByOrNull { item -> + abs((item.offset + item.size / 2) - viewportCenter) + } + closest?.index ?: initialIndex + } } } - // Trigger iOS CLOCK_TICK haptic feedback and notify on index change LaunchedEffect(listState) { snapshotFlow { currentCenteredIndex } .collect { index -> @@ -106,7 +108,6 @@ fun CupertinoWheelPicker( .fillMaxWidth(), contentAlignment = Alignment.Center ) { - // iOS Clock Center Lens Highlight Bar with subtle outline Box( modifier = Modifier .fillMaxWidth() @@ -138,17 +139,17 @@ fun CupertinoWheelPicker( .fillMaxWidth() .height(itemHeight) .graphicsLayer { - // Calculate continuous distance from center for ultra-smooth 3D projection - val scrollFraction = if (itemHeightPx > 0f) listState.firstVisibleItemScrollOffset / itemHeightPx else 0f - val continuousCenter = listState.firstVisibleItemIndex + scrollFraction - val distanceFromCenter = index - continuousCenter + val layoutInfo = listState.layoutInfo + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2f + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + val distanceFromCenter = if (itemInfo != null && itemHeightPx > 0f) { + ((itemInfo.offset + itemInfo.size / 2f) - viewportCenter) / itemHeightPx + } else { + (index - currentCenteredIndex).toFloat() + } val absDistance = abs(distanceFromCenter) - // 3D Drum Perspective: - // Center item (0) is perfectly flat (rotationX = 0f) - // Item 1 above (-1) tilts top away (+28f) - // Item 1 below (+1) tilts bottom away (-28f) - // Outer items progressively tilt and vanish into shadow + // Cylindrical drum perspective tilt and foreshortening rotationX = (-distanceFromCenter * 28f).coerceIn(-65f, 65f) scaleX = (1f - absDistance * 0.09f).coerceIn(0.70f, 1f) scaleY = (1f - absDistance * 0.09f).coerceIn(0.70f, 1f) @@ -162,14 +163,15 @@ fun CupertinoWheelPicker( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { + val displayText = labelProvider?.invoke(itemValue) ?: itemValue.toString() Text( - text = itemValue.toString(), + text = displayText, color = if (isSelected) Color.White else Color(0xFF8E8E93), fontSize = if (isSelected) 30.sp else 24.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, textAlign = TextAlign.Center ) - if (label.isNotBlank()) { + if (label.isNotBlank() && labelProvider == null) { Text( text = " $label", color = if (isSelected) Color.White.copy(alpha = 0.85f) else Color(0xFF636366), @@ -183,7 +185,6 @@ fun CupertinoWheelPicker( } if (showGradientShadows) { - // Top cylinder gradient shadow (fades upper tilted items into pure black) Box( modifier = Modifier .align(Alignment.TopCenter) @@ -200,7 +201,6 @@ fun CupertinoWheelPicker( ) ) - // Bottom cylinder gradient shadow (fades lower tilted items into pure black) Box( modifier = Modifier .align(Alignment.BottomCenter) @@ -221,9 +221,7 @@ fun CupertinoWheelPicker( } /** - * CupertinoOptionWheelPicker - * Reusable iOS drum wheel picker for discrete options such as "Off", "5s", "10 min", etc. - * Features 3D cylinder perspective with center item flat, progressive tilt, and gradient fades. + * Cylindrical drum wheel picker for discrete option lists with center magnification. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -250,10 +248,17 @@ fun CupertinoOptionWheelPicker( val currentCenteredIndex by remember { derivedStateOf { - val firstVisible = listState.firstVisibleItemIndex - val offset = listState.firstVisibleItemScrollOffset - val extra = if (offset > itemHeightPx / 2f) 1 else 0 - (firstVisible + extra).coerceIn(0, (options.size - 1).coerceAtLeast(0)) + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + initialIndex + } else { + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 + val closest = visibleItems.minByOrNull { item -> + abs((item.offset + item.size / 2) - viewportCenter) + } + closest?.index ?: initialIndex + } } } @@ -273,7 +278,6 @@ fun CupertinoOptionWheelPicker( .fillMaxWidth(), contentAlignment = Alignment.Center ) { - // Center frosted highlight lens Box( modifier = Modifier .fillMaxWidth() @@ -306,9 +310,14 @@ fun CupertinoOptionWheelPicker( .fillMaxWidth() .height(itemHeight) .graphicsLayer { - val scrollFraction = if (itemHeightPx > 0f) listState.firstVisibleItemScrollOffset / itemHeightPx else 0f - val continuousCenter = listState.firstVisibleItemIndex + scrollFraction - val distanceFromCenter = index - continuousCenter + val layoutInfo = listState.layoutInfo + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2f + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + val distanceFromCenter = if (itemInfo != null && itemHeightPx > 0f) { + ((itemInfo.offset + itemInfo.size / 2f) - viewportCenter) / itemHeightPx + } else { + (index - currentCenteredIndex).toFloat() + } val absDistance = abs(distanceFromCenter) rotationX = (-distanceFromCenter * 26f).coerceIn(-60f, 60f) @@ -336,7 +345,6 @@ fun CupertinoOptionWheelPicker( } if (showGradientShadows) { - // Top cylinder shadow Box( modifier = Modifier .align(Alignment.TopCenter) @@ -353,7 +361,6 @@ fun CupertinoOptionWheelPicker( ) ) - // Bottom cylinder shadow Box( modifier = Modifier .align(Alignment.BottomCenter) @@ -383,9 +390,7 @@ private fun getVibrator(context: Context): Vibrator? { } /** - * Perform crisp haptic tick even when user has system "touch feedback" disabled. - * Uses direct physical vibration (35ms pulse) to ensure tactile feedback on all devices - * (both ERM and LRA motors, like MX Player), without relying on unsupported predefined effects. + * Wheel tick haptic fallback when system-level touch feedback is disabled. */ private fun performWheelHaptic(context: Context, view: android.view.View) { HapticHelper.performWheelTick(context, view) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt similarity index 88% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt index cb19691..ce9616e 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ujwal.focusgram.core.ui.theme.DarkBorder import com.ujwal.focusgram.core.ui.theme.DarkCard +import androidx.compose.ui.graphics.Brush import com.ujwal.focusgram.core.ui.theme.DarkCardElevated import com.ujwal.focusgram.core.ui.theme.InstagramBlue import com.ujwal.focusgram.core.ui.theme.PureBlack @@ -46,8 +47,7 @@ import com.ujwal.focusgram.core.ui.theme.TextSecondary import com.ujwal.focusgram.core.ui.theme.tactilePress /** - * FocusGramCard - * High-contrast OLED dark surface card container with subtle borders. + * Dark surface container card with liquid glass styling, rounded corners, and optional tactile click feedback. */ @Composable fun FocusGramCard( @@ -58,17 +58,30 @@ fun FocusGramCard( content: @Composable () -> Unit ) { val shape = RoundedCornerShape(16.dp) + val glassBorderBrush = Brush.verticalGradient( + colors = listOf( + Color(0x2DFFFFFF), + Color(0x0DFFFFFF) + ) + ) + val glassBgBrush = Brush.verticalGradient( + colors = listOf( + backgroundColor.copy(alpha = 0.90f), + backgroundColor.copy(alpha = 0.78f) + ) + ) + val cardModifier = if (onClick != null) { modifier .clip(shape) - .border(BorderStroke(1.dp, borderColor), shape) - .background(backgroundColor) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) .tactilePress(onClick = onClick) } else { modifier .clip(shape) - .border(BorderStroke(1.dp, borderColor), shape) - .background(backgroundColor) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) } Box(modifier = cardModifier) { @@ -77,8 +90,7 @@ fun FocusGramCard( } /** - * FocusGramSectionHeader - * Categorical section divider with uppercase title and secondary description. + * Categorical header displaying an uppercase section title and optional subtitle. */ @Composable fun FocusGramSectionHeader( @@ -111,8 +123,7 @@ fun FocusGramSectionHeader( } /** - * FocusGramTopBar - * Minimalist edge-to-edge top bar with back navigation. + * Top app bar with back navigation and custom trailing actions. */ @Composable fun FocusGramTopBar( @@ -159,8 +170,7 @@ fun FocusGramTopBar( } /** - * FocusGramToggleRow - * Standard settings row with title, description, and switch. + * Settings row with icon, title, description, and an on/off switch. */ @Composable fun FocusGramToggleRow( diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt similarity index 70% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt index 6414a40..0b42ede 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt @@ -3,6 +3,7 @@ package com.ujwal.focusgram.core.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -32,6 +33,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight @@ -46,16 +48,13 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -// iOS Palette val IOSBg = Color(0xFF000000) val IOSGroupBg = Color(0xFF1C1C1E) val IOSDivider = Color(0xFF2C2C2E) val IOSTextPrimary = Color(0xFFFFFFFF) /** - * DynamicNetworkIcon - * Asynchronously loads project logos dynamically over the network on page open. - * Does NOT bundle images in the APK to ensure zero APK bloat. + * Loads project and brand icons from remote URLs to keep base APK install size low. */ @Composable fun DynamicNetworkIcon( @@ -123,8 +122,7 @@ val IOSRed = Color(0xFFFF3B30) val IOSTeal = Color(0xFF5AC8FA) /** - * IOSSectionTitle - * Small uppercase section header standard in iOS Settings and Instagram iOS settings. + * Section label rendered in uppercase small caps above grouped settings containers. */ @Composable fun IOSSectionTitle(title: String) { @@ -139,28 +137,39 @@ fun IOSSectionTitle(title: String) { } /** - * IOSGroupedCard - * Rounded corner container grouping settings rows together. + * Grouped rounded-corner container for settings rows with subtle liquid glass styling. */ @Composable fun IOSGroupedCard( modifier: Modifier = Modifier, content: @Composable () -> Unit ) { - val shape = RoundedCornerShape(12.dp) + val shape = RoundedCornerShape(14.dp) + val glassBorderBrush = Brush.verticalGradient( + colors = listOf( + Color(0x2DFFFFFF), + Color(0x0DFFFFFF) + ) + ) + val glassBgBrush = Brush.verticalGradient( + colors = listOf( + Color(0xF01E1E22), + Color(0xE0161619) + ) + ) Column( modifier = modifier .fillMaxWidth() .clip(shape) - .background(IOSGroupBg) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) ) { content() } } /** - * IOSSwitch - * Apple iOS authentic toggle switch with fluid sliding animation and tactile styling. + * Sliding toggle switch styled with smooth thumb offset animation. */ @Composable fun IOSSwitch( @@ -202,8 +211,7 @@ fun IOSSwitch( } /** - * IOSSettingsRow - * Single row inside an iOS grouped card. + * Grouped item row with icon, title, optional subtitle, and switch or navigation chevron. */ @Composable fun IOSSettingsRow( @@ -217,79 +225,106 @@ fun IOSSettingsRow( onCheckedChange: ((Boolean) -> Unit)? = null, trailingText: String? = null, onClick: (() -> Unit)? = null, - showDivider: Boolean = true + showDivider: Boolean = true, + isDependent: Boolean = false ) { + val baseModifier = if (isDependent) Modifier.background(Color(0x0AFFFFFF)) else Modifier val rowModifier = if (onClick != null && checked == null) { Modifier .fillMaxWidth() + .then(baseModifier) .clickable { onClick() } } else { - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() + .then(baseModifier) } Column(modifier = rowModifier) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + start = if (isDependent) 15.dp else 16.dp, + end = 16.dp, + top = if (isDependent) 10.dp else 12.dp, + bottom = if (isDependent) 10.dp else 12.dp + ), verticalAlignment = Alignment.CenterVertically ) { + if (isDependent) { + Box( + modifier = Modifier + .size(width = 16.dp, height = 20.dp) + .padding(end = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "โ†ณ", + color = Color(0xFF636366), + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + } + if (iconRes != null) { Box( modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(7.dp)) - .background(iconBgColor), + .size(if (isDependent) 24.dp else 30.dp) + .clip(RoundedCornerShape(if (isDependent) 6.dp else 7.dp)) + .background(if (isDependent) iconBgColor.copy(alpha = 0.65f) else iconBgColor), contentAlignment = Alignment.Center ) { Icon( painter = androidx.compose.ui.res.painterResource(id = iconRes), contentDescription = null, tint = Color.Unspecified, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(if (isDependent) 16.dp else 20.dp) ) } - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(if (isDependent) 10.dp else 14.dp)) } else if (iconUrl != null) { DynamicNetworkIcon( imageUrl = iconUrl, fallbackIcon = icon ?: Icons.Default.Info, - iconBgColor = iconBgColor + iconBgColor = if (isDependent) iconBgColor.copy(alpha = 0.65f) else iconBgColor, + modifier = Modifier.size(if (isDependent) 24.dp else 30.dp) ) - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(if (isDependent) 10.dp else 14.dp)) } else if (icon != null) { Box( modifier = Modifier - .size(30.dp) - .clip(RoundedCornerShape(7.dp)) - .background(iconBgColor), + .size(if (isDependent) 24.dp else 30.dp) + .clip(RoundedCornerShape(if (isDependent) 6.dp else 7.dp)) + .background(if (isDependent) iconBgColor.copy(alpha = 0.65f) else iconBgColor), contentAlignment = Alignment.Center ) { Icon( imageVector = icon, contentDescription = null, - tint = Color.White, - modifier = Modifier.size(18.dp) + tint = if (isDependent) Color.White.copy(alpha = 0.85f) else Color.White, + modifier = Modifier.size(if (isDependent) 14.dp else 18.dp) ) } - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(if (isDependent) 10.dp else 14.dp)) } Column(modifier = Modifier.weight(1f)) { Text( text = title, - color = IOSTextPrimary, - fontSize = 16.sp, + color = if (isDependent) IOSTextPrimary.copy(alpha = 0.88f) else IOSTextPrimary, + fontSize = if (isDependent) 15.sp else 16.sp, fontWeight = FontWeight.Normal ) if (subtitle != null) { Spacer(modifier = Modifier.height(2.dp)) Text( text = subtitle, - color = IOSTextSecondary, - fontSize = 13.sp, + color = if (isDependent) IOSTextSecondary.copy(alpha = 0.85f) else IOSTextSecondary, + fontSize = if (isDependent) 12.sp else 13.sp, fontWeight = FontWeight.Normal, - lineHeight = 17.sp + lineHeight = if (isDependent) 16.sp else 17.sp ) } } @@ -307,7 +342,7 @@ fun IOSSettingsRow( Text( text = trailingText, color = IOSTextSecondary, - fontSize = 15.sp, + fontSize = if (isDependent) 14.sp else 15.sp, fontWeight = FontWeight.Normal ) Spacer(modifier = Modifier.width(6.dp)) @@ -328,7 +363,13 @@ fun IOSSettingsRow( HorizontalDivider( color = IOSDivider, thickness = 0.5.dp, - modifier = Modifier.padding(start = if (icon != null) 60.dp else 16.dp) + modifier = Modifier.padding( + start = if (isDependent) { + if (icon != null || iconRes != null || iconUrl != null) 58.dp else 28.dp + } else { + if (icon != null || iconRes != null || iconUrl != null) 60.dp else 16.dp + } + ) ) } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt similarity index 97% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt index b543c70..de8db20 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/SkeletonScreen.kt @@ -72,8 +72,7 @@ fun ShimmerBrush(): Brush { } /** - * SkeletonScreen - * Native OLED shimmer skeleton displayed during Instagram page cold-start. + * Placeholder shimmer layout rendered while the web session loads the initial document. */ @Composable fun SkeletonScreen( @@ -102,7 +101,6 @@ fun SkeletonScreen( @Composable private fun FeedSkeleton(brush: Brush) { Column(modifier = Modifier.fillMaxSize()) { - // Stories Tray Shimmer Row( modifier = Modifier .fillMaxWidth() @@ -133,7 +131,6 @@ private fun FeedSkeleton(brush: Brush) { Spacer(modifier = Modifier.height(12.dp)) - // Feed Post 1 PostSkeleton(brush) } } @@ -163,7 +160,6 @@ private fun PostSkeleton(brush: Brush) { ) } - // Post Media Area Box( modifier = Modifier .fillMaxWidth() @@ -171,7 +167,6 @@ private fun PostSkeleton(brush: Brush) { .background(brush) ) - // Post Actions Row( modifier = Modifier .fillMaxWidth() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt similarity index 77% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt index 1ce1b3c..e4fbc96 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Color.kt @@ -2,26 +2,29 @@ package com.ujwal.focusgram.core.ui.theme import androidx.compose.ui.graphics.Color -// Pure OLED Black Design System +/** + * OLED high-contrast color tokens. + * + * Uses true black (#000000) surfaces to conserve battery on OLED panels + * and match Instagram's dark mode palette. + */ val PureBlack = Color(0xFF000000) val DarkSurface = Color(0xFF0A0A0A) val DarkCard = Color(0xFF141414) val DarkCardElevated = Color(0xFF1E1E1E) val DarkBorder = Color(0xFF262626) -// Typography & Content val TextPrimary = Color(0xFFF5F5F5) val TextSecondary = Color(0xFFA3A3A3) val TextMuted = Color(0xFF737373) -// FocusGram Brand Accents val InstagramBlue = Color(0xFF3797EF) +val InstagramPink = Color(0xFFE1306C) val FocusGold = Color(0xFFFFD700) val FocusGoldDark = Color(0xFFB8860B) val FocusGreen = Color(0xFF2ECC71) val AlertRed = Color(0xFFED4956) -// Light Mode Tokens val PureWhite = Color(0xFFFFFFFF) val LightGrayBg = Color(0xFFF2F2F7) val SecondaryText = Color(0xFF8E8E93) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt similarity index 87% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt index 14b2985..22c6f59 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Motion.kt @@ -18,8 +18,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView /** - * FocusGram Motion & Spring Physics - * Tactile spring dynamics tailored for responsive OLED dark mode interactions. + * Spring animation specs for touch press and release states. */ object FocusGramMotion { val BouncySpring = spring( @@ -39,8 +38,7 @@ object FocusGramMotion { } /** - * Modifier to apply a tactile spring bounce and subtle scale on press - * accompanied by haptic feedback. + * Animates a scale-down press state and triggers a keyboard tap haptic on click. */ fun Modifier.tactilePress( targetScale: Float = 0.96f, @@ -74,9 +72,8 @@ fun Modifier.tactilePress( } /** - * Modifier to detect long press (600ms) and trigger onLongPress callback. - * Provides haptic feedback on trigger. - * NOTE: Use tactilePressWithLongPress for combined short tap + long press handling. + * Detects a sustained press and triggers haptic feedback on recognition. + * Prefer [tactilePressWithLongPress] if the element also handles short taps. */ fun Modifier.longPress( durationMillis: Long = 600L, @@ -97,8 +94,7 @@ fun Modifier.longPress( } /** - * Combined modifier for buttons that need both short-tap and long-press handling. - * Properly chains gesture detection to avoid event consumption conflicts. + * Handles concurrent tap and long-press gestures without pointer event collisions. */ fun Modifier.tactilePressWithLongPress( targetScale: Float = 0.96f, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt similarity index 89% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt index ca1557e..be49e21 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Shape.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp -// FocusGram Smooth Squircle Corner Radii val Shapes = Shapes( small = RoundedCornerShape(8.dp), medium = RoundedCornerShape(14.dp), diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Theme.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Theme.kt similarity index 100% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Theme.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Theme.kt diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Type.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Type.kt similarity index 100% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Type.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/ui/theme/Type.kt diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt similarity index 83% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt index 38d738b..702d88e 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt @@ -18,10 +18,10 @@ import com.ujwal.focusgram.MainActivity import com.ujwal.focusgram.R /** - * AppIconManager - * Controls dynamic launcher icon switching via Android PackageManager activity-alias toggles. - * LITERALLY replaces the app icon across Home Screen, App Drawer, Recents Page, and App Info page. - * Safe, purely client-side, with DONT_KILL_APP flag to avoid killing running tasks. + * Switches launcher icons dynamically via [PackageManager.setComponentEnabledSetting] + * on manifest `` entries. + * + * Uses [PackageManager.DONT_KILL_APP] to avoid killing the process during component state changes. */ object AppIconManager { private const val TAG = "AppIconManager" @@ -34,7 +34,6 @@ object AppIconManager { val category: String, @DrawableRes val iconRes: Int ) { - // --- Core Icons --- DEFAULT( id = "default", aliasSuffix = "MainActivityDefault", @@ -60,11 +59,10 @@ object AppIconManager { iconRes = R.mipmap.ic_launcher_all_white ), - // --- Custom Redesigns (alt-logos) --- REDESIGN( id = "redesign", aliasSuffix = "MainActivityRedesign", - displayName = "FocusGram Redesign", + displayName = "Modern Insta", subtitle = "Official modern brand redesign", category = "Redesigns", iconRes = R.mipmap.ic_launcher_redesign @@ -110,7 +108,6 @@ object AppIconManager { iconRes = R.mipmap.ic_launcher_scribble ), - // --- Originals (Authentic Brand History) --- POLAROID( id = "polaroid", aliasSuffix = "MainActivityPolaroid", @@ -152,9 +149,8 @@ object AppIconManager { } /** - * Atomically switches active launcher icon by enabling the chosen alias and disabling others. - * Replaces the app icon on Home Screen, App Drawer (App Screen), and App Info (System Settings). - * Also updates the running Activity's Recents overview TaskDescription immediately. + * Activates the selected launcher alias and deactivates all others. + * Also updates the running Activity's [ActivityManager.TaskDescription] for the Recents screen. */ fun setAppIcon(context: Context, iconId: String) { val targetOption = IconOption.fromId(iconId) @@ -163,34 +159,26 @@ object AppIconManager { Log.i(TAG, "Switching app icon to: ${targetOption.displayName} (${targetOption.aliasSuffix})") - // 1. Enable target alias so there's always at least one enabled launcher entry point - try { - val targetComponent = ComponentName(packageName, "$packageName.${targetOption.aliasSuffix}") - packageManager.setComponentEnabledSetting( - targetComponent, - PackageManager.COMPONENT_ENABLED_STATE_ENABLED, - PackageManager.DONT_KILL_APP + fun enableComponent(suffix: String, enable: Boolean) { + val state = if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + val names = listOf( + ComponentName(packageName, "com.ujwal.focusgram.$suffix"), + ComponentName(packageName, "$packageName.$suffix") ) - } catch (e: Exception) { - Log.e(TAG, "Failed enabling target launcher alias ${targetOption.aliasSuffix}: ${e.message}", e) - } - - // 2. Disable all other aliases - for (option in IconOption.values()) { - if (option == targetOption) continue - try { - val comp = ComponentName(packageName, "$packageName.${option.aliasSuffix}") - packageManager.setComponentEnabledSetting( - comp, - PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP - ) - } catch (e: Exception) { - Log.w(TAG, "Failed disabling launcher alias ${option.aliasSuffix}: ${e.message}") + for (comp in names) { + try { + packageManager.setComponentEnabledSetting(comp, state, PackageManager.DONT_KILL_APP) + } catch (_: Exception) {} } } - // 3. Immediately update TaskDescription for the Recents screen + enableComponent(targetOption.aliasSuffix, true) + + for (option in IconOption.values()) { + if (option == targetOption) continue + enableComponent(option.aliasSuffix, false) + } + if (context is Activity) { applyTaskDescription(context, iconId) } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt similarity index 88% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt index 12632ba..381e386 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt @@ -11,14 +11,11 @@ import android.view.HapticFeedbackConstants import android.view.View /** - * HapticHelper - * Universal haptic feedback and vibration utility for FocusGram. + * Haptic and vibration feedback utility. * - * Guarantees physical vibration across all hardware motors (ERM & LRA), - * regardless of whether system "Touch feedback" is disabled in user Settings. - * Emulates the proven direct-hardware approach of media apps like MX Player - * by pulsing the physical vibrator directly with sufficient duration (35ms) - * to overcome motor inertia without relying on unsupported predefined effects. + * Uses direct physical motor pulses (25-60ms) to ensure consistent tactile response + * across both ERM (eccentric rotating mass) and LRA (linear resonant actuator) hardware, + * even when system-level touch feedback is toggled off in device settings. */ object HapticHelper { @@ -38,10 +35,9 @@ object HapticHelper { } /** - * Performs a crisp, responsive drum-wheel scroll tick. - * Direct hardware pulse of 35ms ensures ERM motors overcome inertia and - * rotate to produce an unmistakable tactile click, while LRA motors give a crisp bump. - * Debounced at 35ms to ensure smooth performance during rapid flings. + * Drum-wheel scroll tick pulse. + * 35ms pulse overcomes ERM motor inertia while delivering distinct tactile feedback on LRA hardware. + * Debounced at 35ms to prevent pulse queueing during rapid flings. */ fun performWheelTick(context: Context, view: View? = null) { val now = SystemClock.uptimeMillis() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt similarity index 92% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt index 185cc4f..68ccc33 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt @@ -15,8 +15,7 @@ import java.net.HttpURLConnection import java.net.URL /** - * OriginalIcon (Historical Instagram Brand Icon) - * Represents an authentic Instagram brand era logo downloaded dynamically on demand. + * Metadata descriptor for a historical brand icon available for on-demand download. */ data class OriginalIcon( val id: String, @@ -29,9 +28,7 @@ data class OriginalIcon( typealias HistoricalIcon = OriginalIcon /** - * HistoricalIconManager - * Dynamically downloads authentic original Instagram brand logos on demand, - * preventing any APK size bloat. + * Downloads and caches historical brand icons into app cache storage on demand. */ object HistoricalIconManager { private const val TAG = "HistoricalIconManager" @@ -75,7 +72,7 @@ object HistoricalIconManager { val downloadProgress: StateFlow = _downloadProgress.asStateFlow() /** - * Ensures all original icons are loaded from cache or downloaded on-the-fly. + * Loads cached icons from disk or downloads missing assets sequentially. */ suspend fun loadHistoricalIcons(context: Context) { if (_isLoading.value) return @@ -85,7 +82,6 @@ object HistoricalIconManager { val iconDir = File(context.cacheDir, "original_icons").apply { mkdirs() } val loadedMap = HashMap() - // 1. First check existing disk cache for (icon in ORIGINAL_ICONS) { val file = File(iconDir, "${icon.id}.png") if (file.exists() && file.length() > 0) { @@ -94,7 +90,6 @@ object HistoricalIconManager { } _downloadedIcons.value = loadedMap.toMap() - // 2. Identify missing icons to download val missing = ORIGINAL_ICONS.filter { !loadedMap.containsKey(it.id) } if (missing.isEmpty()) { _downloadProgress.value = 1f diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt similarity index 95% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt rename to app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt index 14c813b..ba950f0 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt @@ -30,9 +30,9 @@ data class DownloadProgressState( ) /** - * MediaDownloadHelper - * Native client-side media downloader using Android System DownloadManager & MediaStore. - * Compliant with on-device architecture: downloads directly to user's public Downloads directory. + * Handles media downloads using Android's system [DownloadManager] and [MediaStore]. + * + * Saves directly into the public Downloads directory without intermediate server relays. */ object MediaDownloadHelper { private const val TAG = "MediaDownloadHelper" @@ -47,8 +47,9 @@ object MediaDownloadHelper { private var lastDownloadTimestamp: Long = 0L /** - * Determines whether media is a video based on explicit URL and filename markers. - * Note: NEVER treat "/v/" in CDN URLs as video because all Meta CDN photos contain "/v/". + * Identifies video URLs. + * + * Note: Avoid matching "/v/" alone, as Meta's image CDNs also use "/v/t51" in photo paths. */ fun detectIsVideo(url: String, filename: String = ""): Boolean { val lowerUrl = url.lowercase() @@ -71,22 +72,18 @@ object MediaDownloadHelper { } /** - * Formats target filename according to FocusGram specification: - * Author name (first 10-13 characters) followed by ' - Focusgram.' - * E.g. '@ujwal - Focusgram.mp4' or '@ujwal - Focusgram.jpg'. + * Normalizes output filename format: `@ - Focusgram[slide].`. */ fun formatTargetFilename(rawFilename: String, isVideo: Boolean): String { val extension = if (isVideo) "mp4" else "jpg" var clean = rawFilename.trim() - // Strip known extension if present if (clean.endsWith(".mp4", ignoreCase = true) || clean.endsWith(".jpg", ignoreCase = true) || clean.endsWith(".jpeg", ignoreCase = true) || clean.endsWith(".png", ignoreCase = true) ) { clean = clean.substringBeforeLast('.') } - // Handle slide suffix (e.g. _2 for carousel slides) var slideSuffix = "" val slideMatch = Regex("_(\\d+)$").find(clean) if (slideMatch != null) { @@ -94,7 +91,6 @@ object MediaDownloadHelper { clean = clean.removeSuffix(slideSuffix) } - // Extract author prefix val authorName = when { clean.startsWith("@") -> clean.removePrefix("@").substringBefore(" - ").substringBefore('_').trim() clean.contains(" - Focusgram", ignoreCase = true) -> { @@ -413,10 +409,10 @@ object MediaDownloadHelper { } private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) { - // Inspect magic bytes to verify whether bytes are an image or a video + // Inspect header magic bytes (JPEG SOI 0xFFD8FF or PNG 0x89504E47) to guard against misidentified media val isActuallyImage = bytes.size >= 4 && ( - (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) || // JPEG - (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) // PNG + (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) || + (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) ) val resolvedIsVideo = if (isActuallyImage) false else isVideo val resolvedFilename = if (isActuallyImage && filename.endsWith(".mp4", ignoreCase = true)) { diff --git a/app/src/main/kotlin/com/ujwal/focusgram/core/utils/TimeFormatters.kt b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/TimeFormatters.kt new file mode 100644 index 0000000..68e1df8 --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/core/utils/TimeFormatters.kt @@ -0,0 +1,35 @@ +package com.ujwal.focusgram.core.utils + +import java.util.Locale + +object TimeFormatters { + /** + * Formats duration in minutes to a human-readable "Xh Ym", "Xh", or "Ym". + * Never outputs raw minutes over 59 (e.g. 270 min -> "4h 30m"). + */ + fun formatMinutes(minutes: Int): String { + if (minutes <= 0) return "0m" + val hours = minutes / 60 + val mins = minutes % 60 + return when { + hours > 0 && mins > 0 -> "${hours}h ${mins}m" + hours > 0 -> "${hours}h" + else -> "${mins}m" + } + } + + /** + * Formats seconds into MM:SS or HH:MM:SS. + */ + fun formatSeconds(seconds: Int): String { + if (seconds <= 0) return "00:00" + val hours = seconds / 3600 + val mins = (seconds % 3600) / 60 + val secs = seconds % 60 + return if (hours > 0) { + String.format(Locale.US, "%d:%02d:%02d", hours, mins, secs) + } else { + String.format(Locale.US, "%02d:%02d", mins, secs) + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt similarity index 98% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt index bfcb267..fcce026 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockScreen.kt @@ -59,8 +59,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt /** - * AppLockScreen - * Hardware-backed PIN and Biometric lock gate protecting FocusGram. + * PIN keypad and Biometric authentication gate. */ @Composable fun AppLockScreen( @@ -82,6 +81,7 @@ fun AppLockScreen( fun triggerBiometricPrompt() { if (!isBiometricAllowed) return + // BiometricPrompt requires a FragmentActivity. Unwraps ContextWrapper chains (e.g. TintContextWrapper). var ctx = context while (ctx is android.content.ContextWrapper) { if (ctx is FragmentActivity) break @@ -132,7 +132,6 @@ fun AppLockScreen( } } - // Trigger BiometricPrompt only if enabled and allowed LaunchedEffect(isBiometricAllowed) { if (isBiometricAllowed) { triggerBiometricPrompt() @@ -231,7 +230,6 @@ fun AppLockScreen( Spacer(modifier = Modifier.weight(1f)) - // Lock Icon Box( modifier = Modifier .size(68.dp) @@ -265,7 +263,6 @@ fun AppLockScreen( Spacer(modifier = Modifier.height(36.dp)) - // 4 PIN Dots with Shake Offset Row( modifier = Modifier .offset { IntOffset(shakeOffset.value.roundToInt(), 0) } @@ -285,7 +282,6 @@ fun AppLockScreen( } } - // Error Text if (errorMessage != null) { Text( text = errorMessage!!, @@ -299,7 +295,6 @@ fun AppLockScreen( Spacer(modifier = Modifier.weight(1.5f)) - // 3x4 Numeric Keypad Column( modifier = Modifier .fillMaxWidth() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt similarity index 98% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt index 8c42074..8568ace 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSettingsPage.kt @@ -36,6 +36,9 @@ private val IOSBlue = Color(0xFF007AFF) private val IOSGreen = Color(0xFF34C759) private val IOSOrange = Color(0xFFFF9500) +/** + * Settings page for toggling PIN and biometric authentication requirements. + */ @Composable fun AppLockSettingsPage( repository: SettingsRepository, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt similarity index 98% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt index 27b6434..4c171ff 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/AppLockSetupScreen.kt @@ -52,8 +52,7 @@ import com.ujwal.focusgram.core.ui.theme.TextSecondary import kotlinx.coroutines.launch /** - * AppLockSetupScreen - * 4-digit PIN creation and confirmation with AES-256 GCM encrypted storage. + * 4-digit PIN creation and confirmation step screen. */ @Composable fun AppLockSetupScreen( @@ -175,7 +174,6 @@ fun AppLockSetupScreen( Spacer(modifier = Modifier.height(30.dp)) - // PIN Dots Indicator Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { repeat(4) { idx -> val isFilled = idx < currentInput.length @@ -199,7 +197,6 @@ fun AppLockSetupScreen( Spacer(modifier = Modifier.weight(1f)) - // Numeric Keypad NumericKeypad( onDigit = { onDigitPress(it) }, onDelete = { onBackspace() } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt similarity index 96% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt index 60980c4..0e70c8d 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/applock/ChatLockScreen.kt @@ -69,9 +69,7 @@ private val ChatLockAccentGlow = Color(0xFFC77DFF) private val KeypadCardBg = Color(0xFF16161A) /** - * ChatLockScreen - * Bespoke, distinct hardware-backed PIN and Biometric security gate specifically - * tailored for locked Direct Messages and private conversations. + * PIN and biometric authentication gate for locked Direct Messages. */ @Composable fun ChatLockScreen( @@ -93,6 +91,7 @@ fun ChatLockScreen( fun triggerBiometricPrompt() { if (!isBiometricAllowed) return + // BiometricPrompt requires a FragmentActivity. Unwraps ContextWrapper chains. var ctx = context while (ctx is android.content.ContextWrapper) { if (ctx is FragmentActivity) break @@ -143,7 +142,6 @@ fun ChatLockScreen( } } - // Trigger Biometric only if explicitly allowed and configured LaunchedEffect(isBiometricAllowed) { if (isBiometricAllowed) { triggerBiometricPrompt() @@ -236,7 +234,6 @@ fun ChatLockScreen( .padding(horizontal = 28.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Top Navigation Bar (Exit / Cancel) Row( modifier = Modifier .fillMaxWidth() @@ -263,7 +260,6 @@ fun ChatLockScreen( Spacer(modifier = Modifier.size(36.dp)) } - // Subtle privacy status pill Row( modifier = Modifier .clip(RoundedCornerShape(12.dp)) @@ -293,7 +289,6 @@ fun ChatLockScreen( Spacer(modifier = Modifier.weight(0.7f)) - // Chat Lock Shield Badge Box( modifier = Modifier .size(72.dp) @@ -337,7 +332,6 @@ fun ChatLockScreen( Spacer(modifier = Modifier.height(28.dp)) - // 4 PIN Dots with Smooth Glow & Shake Animation Row( modifier = Modifier .offset { IntOffset(shakeOffset.value.roundToInt(), 0) } @@ -362,7 +356,6 @@ fun ChatLockScreen( } } - // Error Message if (errorMessage != null) { Text( text = errorMessage ?: "", @@ -378,14 +371,12 @@ fun ChatLockScreen( Spacer(modifier = Modifier.weight(1.2f)) - // Modern 3x4 Keypad Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { - // Only render "BIO" key if biometric is explicitly allowed & enrolled! val bioKey = if (isBiometricAllowed) "BIO" else "" val keypadRows = listOf( listOf("1", "2", "3"), diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt new file mode 100644 index 0000000..ae65656 --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt @@ -0,0 +1,334 @@ +package com.ujwal.focusgram.feature.diagnostics + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ujwal.focusgram.core.datastore.SettingsRepository +import com.ujwal.focusgram.core.model.FocusSettings +import com.ujwal.focusgram.core.ui.components.FocusGramCard +import com.ujwal.focusgram.core.ui.components.FocusGramSectionHeader +import com.ujwal.focusgram.core.ui.components.FocusGramTopBar +import com.ujwal.focusgram.core.ui.theme.AlertRed +import com.ujwal.focusgram.core.ui.theme.DarkCard +import com.ujwal.focusgram.core.ui.theme.DarkCardElevated +import com.ujwal.focusgram.core.ui.theme.FocusGold +import com.ujwal.focusgram.core.ui.theme.FocusGreen +import com.ujwal.focusgram.core.ui.theme.InstagramBlue +import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.ui.theme.TextMuted +import com.ujwal.focusgram.core.ui.theme.TextPrimary +import com.ujwal.focusgram.core.ui.theme.TextSecondary + +/** + * Developer diagnostics screen for inspecting state, testing simulation, and + * dynamically adjusting active session time and guardrail bypasses. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DebugMenuScreen( + repository: SettingsRepository, + onBack: () -> Unit, + onSimulateUpdate: () -> Unit = {}, + onSimulateNotice: () -> Unit = {}, + onResetLimits: () -> Unit = {}, + isLimitsBypassed: Boolean = false, + onToggleLimitsBypass: () -> Unit = onResetLimits, + currentSessionDurationMinutes: Int = 15, + currentSessionRemainingSeconds: Int = 15 * 60, + currentSessionElapsedSeconds: Int = 0, + onAdjustSessionTime: (newDurationMinutes: Int, newRemainingSeconds: Int, newElapsedSeconds: Int) -> Unit = { _, _, _ -> }, + modifier: Modifier = Modifier +) { + val settings by repository.settingsFlow.collectAsState(initial = FocusSettings()) + var customMinutesInput by remember { mutableStateOf(currentSessionDurationMinutes.toString()) } + + Column( + modifier = modifier + .fillMaxSize() + .background(PureBlack) + .padding(WindowInsets.statusBars.asPaddingValues()) + ) { + FocusGramTopBar( + title = "Developer Diagnostics", + onBack = onBack + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + item { + FocusGramCard( + modifier = Modifier.fillMaxWidth(), + backgroundColor = DarkCardElevated + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Current State", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = FocusGold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "AppLock: ${settings.appLockEnabled} ยท Daily Limit: ${settings.dailyLimitMinutes}m\n" + + "Cooldown: ${settings.cooldownMinutes}m ยท FirstRun: ${settings.isFirstRun}", + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary + ) + } + } + } + + item { + FocusGramSectionHeader(title = "Active Session Controller") + } + + item { + FocusGramCard( + modifier = Modifier.fillMaxWidth(), + backgroundColor = DarkCardElevated + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Adjust Session Time", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = FocusGold + ) + + Text( + text = "Duration: ${currentSessionDurationMinutes}m ยท Remaining: ${currentSessionRemainingSeconds / 60}m ${currentSessionRemainingSeconds % 60}s ยท Elapsed: ${currentSessionElapsedSeconds / 60}m ${currentSessionElapsedSeconds % 60}s", + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary + ) + + // Quick +/- adjustments + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + val newDuration = currentSessionDurationMinutes + 5 + val newRemaining = currentSessionRemainingSeconds + 300 + onAdjustSessionTime(newDuration, newRemaining, currentSessionElapsedSeconds) + }, + modifier = Modifier.weight(1f) + ) { + Text("+5m") + } + OutlinedButton( + onClick = { + val newDuration = currentSessionDurationMinutes + 15 + val newRemaining = currentSessionRemainingSeconds + 900 + onAdjustSessionTime(newDuration, newRemaining, currentSessionElapsedSeconds) + }, + modifier = Modifier.weight(1f) + ) { + Text("+15m") + } + OutlinedButton( + onClick = { + val newRemaining = (currentSessionRemainingSeconds - 300).coerceAtLeast(0) + val newDuration = maxOf(1, currentSessionDurationMinutes - 5) + onAdjustSessionTime(newDuration, newRemaining, currentSessionElapsedSeconds) + }, + modifier = Modifier.weight(1f) + ) { + Text("-5m") + } + OutlinedButton( + onClick = { + // Set remaining to 60s for immediate testing of sunset colors & session expiry + val newRemaining = 60 + val newDuration = maxOf(1, (currentSessionElapsedSeconds + 60 + 59) / 60) + onAdjustSessionTime(newDuration, newRemaining, currentSessionElapsedSeconds) + }, + modifier = Modifier.weight(1.3f) + ) { + Text("Set 1m") + } + } + + // Preset chips + Text( + text = "Presets:", + style = MaterialTheme.typography.labelMedium, + color = TextMuted + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + listOf(1, 5, 9, 15, 26, 27, 32, 45, 60).forEach { mins -> + FilterChip( + selected = currentSessionDurationMinutes == mins, + onClick = { + customMinutesInput = mins.toString() + val newRemaining = if (currentSessionElapsedSeconds > 0) { + (mins * 60 - currentSessionElapsedSeconds).coerceAtLeast(0) + } else { + mins * 60 + } + val newElapsed = if (currentSessionElapsedSeconds > 0) currentSessionElapsedSeconds else 0 + onAdjustSessionTime(mins, newRemaining, newElapsed) + }, + label = { Text("${mins}m") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = FocusGold, + selectedLabelColor = PureBlack + ) + ) + } + } + + // Custom minutes input field + OutlinedTextField( + value = customMinutesInput, + onValueChange = { customMinutesInput = it.filter { ch -> ch.isDigit() } }, + label = { Text("Set Custom Minutes") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FocusGold, + focusedLabelColor = FocusGold, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + val mins = customMinutesInput.toIntOrNull() ?: currentSessionDurationMinutes + if (mins > 0) { + val newRemaining = if (currentSessionElapsedSeconds > 0) { + (mins * 60 - currentSessionElapsedSeconds).coerceAtLeast(0) + } else { + mins * 60 + } + val newElapsed = if (currentSessionElapsedSeconds > 0) currentSessionElapsedSeconds else 0 + onAdjustSessionTime(mins, newRemaining, newElapsed) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = FocusGold, contentColor = PureBlack) + ) { + Text("Set Total Duration", fontWeight = FontWeight.Bold) + } + + Button( + onClick = { + val mins = customMinutesInput.toIntOrNull() ?: 15 + if (mins > 0) { + val newRemaining = mins * 60 + val newDuration = maxOf(mins, (currentSessionElapsedSeconds + newRemaining + 59) / 60) + onAdjustSessionTime(newDuration, newRemaining, currentSessionElapsedSeconds) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue) + ) { + Text("Set Remaining", fontWeight = FontWeight.Bold) + } + } + } + } + } + + item { + FocusGramSectionHeader(title = "Testing & Simulation") + } + + item { + FocusGramCard( + modifier = Modifier.fillMaxWidth(), + backgroundColor = DarkCardElevated + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + onClick = onSimulateUpdate, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue) + ) { + Text("Simulate Update Available (v3.1.0)") + } + Button( + onClick = onSimulateNotice, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = FocusGreen) + ) { + Text("Simulate Remote Notice (popup.json)") + } + Button( + onClick = onToggleLimitsBypass, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = if (isLimitsBypassed) AlertRed else FocusGold, + contentColor = if (isLimitsBypassed) Color.White else PureBlack + ) + ) { + Text( + text = if (isLimitsBypassed) "Don't Skip Cooldowns & Daily Limits" else "Skip Cooldowns & Daily Limits", + fontWeight = FontWeight.Bold + ) + } + } + } + } + + item { + Spacer(modifier = Modifier.height(30.dp)) + } + } + } +} diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt new file mode 100644 index 0000000..35dec26 --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt @@ -0,0 +1,360 @@ +package com.ujwal.focusgram.feature.history + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.Executors + +/** + * Persisted record for a watched Reel item. + * + * [author] and [caption] are populated asynchronously as DOM scrapers find them. + */ +data class ReelEntry( + val code: String, + val author: String, + val caption: String, + val lastSeen: Long, + val views: Int, +) { + val url: String get() = "https://www.instagram.com/reel/${code.removePrefix("reel_")}/" +} + +/** + * Manages locally-cached history of viewed Reels, persisted to a JSON file. + * + * Keeps up to 500 entries, newest first. Thread-safe via synchronized lock and single-thread executor. + */ +class ReelHistory(private val file: File) { + private val lock = Any() + private val io = Executors.newSingleThreadExecutor() + private val sessionCodes = HashSet() + private var entries: List = load() + + var onNewReelWatched: ((String) -> Unit)? = null + + private fun getTodayStartMillis(): Long { + val cal = java.util.Calendar.getInstance().apply { + set(java.util.Calendar.HOUR_OF_DAY, 0) + set(java.util.Calendar.MINUTE, 0) + set(java.util.Calendar.SECOND, 0) + set(java.util.Calendar.MILLISECOND, 0) + } + return cal.timeInMillis + } + + private fun calculateTodayCount(list: List): Int { + val start = getTodayStartMillis() + return list.count { it.lastSeen >= start } + } + + private val _items = MutableStateFlow(entries) + val items: StateFlow> = _items + + private val _sessionCount = MutableStateFlow(0) + val sessionCount: StateFlow = _sessionCount + + private val _todayCount = MutableStateFlow(calculateTodayCount(entries)) + val todayCount: StateFlow = _todayCount + + companion object { + private val IGNORED_CODES = setOf( + "home", "explore", "reels", "reel", "direct", "accounts", + "stories", "p", "about", "legal", "videos", "audio", "clips", + "trending", "foryou", "following", "live" + ) + private val GENERIC_AUTHORS = setOf( + "home", "explore", "reels", "reel", "direct", "instagram", + "instagram creator", "instagram user", "instagram video", "creator", "user", "" + ) + private val GENERIC_CAPTIONS = setOf( + "instagram reel", "reel", "reels", "instagram", "instagram video", "video", "" + ) + + fun normalizeCaption(caption: String): String { + return caption.lowercase() + .replace(Regex("\\s+"), " ") + .replace(Regex("[^a-z0-9 ]"), "") + .trim() + } + + fun isMatchingReel( + codeA: String, authorA: String, captionA: String, + codeB: String, authorB: String, captionB: String + ): Boolean { + val cleanCodeA = if (codeA.startsWith("reel_")) codeA.removePrefix("reel_") else codeA + val cleanCodeB = if (codeB.startsWith("reel_")) codeB.removePrefix("reel_") else codeB + val cleanAuthorA = authorA.trim().removePrefix("@").lowercase() + val cleanAuthorB = authorB.trim().removePrefix("@").lowercase() + + val validCodeA = cleanCodeA.length >= 4 && cleanCodeA !in IGNORED_CODES && cleanCodeA != cleanAuthorA + val validCodeB = cleanCodeB.length >= 4 && cleanCodeB !in IGNORED_CODES && cleanCodeB != cleanAuthorB + + // 1. Direct shortcode match + if (validCodeA && validCodeB && cleanCodeA.equals(cleanCodeB, ignoreCase = true)) { + return true + } + + // 2. Same author matching + if (cleanAuthorA.isNotBlank() && cleanAuthorA == cleanAuthorB) { + val normA = normalizeCaption(captionA) + val normB = normalizeCaption(captionB) + if (normA.isNotBlank() && normB.isNotBlank()) { + val prefixLen = minOf(normA.length, normB.length, 30) + if (prefixLen >= 8 && normA.take(prefixLen) == normB.take(prefixLen)) { + return true + } + } + // Author placeholder matching (only when captions match, never blindly merge different reels) + val isAuthorCodeA = cleanCodeA == cleanAuthorA || !validCodeA + val isAuthorCodeB = cleanCodeB == cleanAuthorB || !validCodeB + if (isAuthorCodeA && isAuthorCodeB) { + if (normA.isNotBlank() && normB.isNotBlank() && normA.take(15) == normB.take(15)) { + return true + } + } + } + + return false + } + } + + private fun sanitizeCaption(rawCaption: String, cleanAuthor: String): String { + val clean = rawCaption.trim() + val lower = clean.lowercase() + val isGeneric = lower.isBlank() || + lower in GENERIC_CAPTIONS || + lower.startsWith("instagram reel") || + lower.startsWith("reel by @") || + lower.startsWith("reel by ") || + (cleanAuthor.isNotBlank() && lower == cleanAuthor.lowercase()) || + lower.endsWith("ยท original audio") || + lower.endsWith("original audio") + return if (isGeneric) "" else clean + } + + private fun isCaptionInvalid(caption: String, author: String): Boolean { + return sanitizeCaption(caption, author).isBlank() + } + + fun onWatched(code: String, author: String, caption: String): Int = synchronized(lock) { + val cleanCode = code.trim().removePrefix("/").removeSuffix("/") + val lowerCode = cleanCode.lowercase() + if (cleanCode.length < 5 || lowerCode in IGNORED_CODES) { + return _sessionCount.value + } + val finalCode = if (cleanCode.startsWith("reel_")) cleanCode.removePrefix("reel_") else cleanCode + if (finalCode.length < 5 || finalCode.lowercase() in IGNORED_CODES) { + return _sessionCount.value + } + + val cleanAuthor = author.trim().removePrefix("@") + val lowerAuthor = cleanAuthor.lowercase() + val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram") + val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor + + val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor) + + val isNew = sessionCodes.add(finalCode) + val matchingEntries = entries.filter { existing -> + isMatchingReel( + existing.code, existing.author, existing.caption, + finalCode, sanitizedAuthor, sanitizedCaption + ) || existing.code.equals(finalCode, ignoreCase = true) + } + + val resolvedAuthor = sanitizedAuthor.ifEmpty { matchingEntries.firstOrNull { it.author.isNotBlank() }?.author ?: "" } + val resolvedCaption = sanitizedCaption.ifEmpty { + val oldCaption = matchingEntries.firstOrNull { it.caption.isNotBlank() }?.caption ?: "" + if (!isCaptionInvalid(oldCaption, resolvedAuthor)) oldCaption else "" + } + + val bestCode = if (finalCode.equals(sanitizedAuthor, ignoreCase = true) && matchingEntries.any { !it.code.equals(sanitizedAuthor, ignoreCase = true) && it.code.length >= 4 }) { + matchingEntries.first { !it.code.equals(sanitizedAuthor, ignoreCase = true) && it.code.length >= 4 }.code + } else { + finalCode + } + + val maxPriorViews = matchingEntries.maxOfOrNull { it.views } ?: 0 + val newViews = maxOf(maxPriorViews, matchingEntries.size) + 1 + + val updated = ReelEntry( + code = bestCode, + author = resolvedAuthor, + caption = resolvedCaption, + lastSeen = System.currentTimeMillis(), + views = newViews, + ) + + val remaining = entries.filter { existing -> + !isMatchingReel( + existing.code, existing.author, existing.caption, + finalCode, sanitizedAuthor, sanitizedCaption + ) && !existing.code.equals(finalCode, ignoreCase = true) && + !existing.code.equals(bestCode, ignoreCase = true) + } + + commit(listOf(updated) + remaining) + _sessionCount.value = sessionCodes.size + if (isNew) { + onNewReelWatched?.invoke(finalCode) + } + sessionCodes.size + } + + fun onMeta(code: String, author: String, caption: String) { + synchronized(lock) { + val cleanCode = if (code.startsWith("reel_")) code.removePrefix("reel_") else code + val cleanAuthor = author.trim().removePrefix("@") + val lowerAuthor = cleanAuthor.lowercase() + val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram") + val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor + val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor) + + if (entries.none { existing -> + isMatchingReel( + existing.code, existing.author, existing.caption, + cleanCode, sanitizedAuthor, sanitizedCaption + ) || existing.code.equals(cleanCode, ignoreCase = true) + }) return + + commit(entries.map { + if (isMatchingReel(it.code, it.author, it.caption, cleanCode, sanitizedAuthor, sanitizedCaption) || + it.code.equals(cleanCode, ignoreCase = true)) { + val resolvedAuthor = sanitizedAuthor.ifEmpty { it.author } + val currentCaptionInvalid = isCaptionInvalid(it.caption, resolvedAuthor) + val resolvedCaption = if (sanitizedCaption.isNotEmpty()) { + sanitizedCaption + } else if (!currentCaptionInvalid) { + it.caption + } else { + "" + } + it.copy( + code = if (cleanCode.length >= 5 && !cleanCode.equals(sanitizedAuthor, ignoreCase = true)) cleanCode else it.code, + author = resolvedAuthor, + caption = resolvedCaption + ) + } else it + }) + } + } + + fun delete(code: String) = synchronized(lock) { commit(entries.filter { it.code != code }) } + + fun deleteMatching(code: String, author: String, caption: String) = synchronized(lock) { + val cleanCode = if (code.startsWith("reel_")) code.removePrefix("reel_") else code + val cleanAuthor = author.trim().removePrefix("@") + val cleanCaption = caption.trim() + commit(entries.filter { existing -> + !isMatchingReel( + existing.code, existing.author, existing.caption, + cleanCode, cleanAuthor, cleanCaption + ) && !existing.code.equals(cleanCode, ignoreCase = true) + }) + } + + fun clear() = synchronized(lock) { commit(emptyList()) } + + fun clearToday() = synchronized(lock) { + val todayStart = getTodayStartMillis() + commit(entries.filter { it.lastSeen < todayStart }) + } + + fun clearPastDays(days: Int) = synchronized(lock) { + val cal = java.util.Calendar.getInstance().apply { + add(java.util.Calendar.DAY_OF_YEAR, -(days - 1)) + set(java.util.Calendar.HOUR_OF_DAY, 0) + set(java.util.Calendar.MINUTE, 0) + set(java.util.Calendar.SECOND, 0) + set(java.util.Calendar.MILLISECOND, 0) + } + val cutoff = cal.timeInMillis + commit(entries.filter { it.lastSeen < cutoff }) + } + + fun clearThisMonth() = synchronized(lock) { + val cal = java.util.Calendar.getInstance().apply { + set(java.util.Calendar.DAY_OF_MONTH, 1) + set(java.util.Calendar.HOUR_OF_DAY, 0) + set(java.util.Calendar.MINUTE, 0) + set(java.util.Calendar.SECOND, 0) + set(java.util.Calendar.MILLISECOND, 0) + } + val cutoff = cal.timeInMillis + commit(entries.filter { it.lastSeen < cutoff }) + } + + fun resetSession() = synchronized(lock) { + sessionCodes.clear() + _sessionCount.value = 0 + } + + /** + * Restores in-memory session reel state across app restarts for an ongoing session. + * Guarantees sessionCount will never regress below fallbackCount even if individual codes + * were partially persisted. + */ + fun restoreSession(codes: Set, fallbackCount: Int = 0) = synchronized(lock) { + sessionCodes.clear() + sessionCodes.addAll(codes) + if (sessionCodes.size < fallbackCount) { + for (i in sessionCodes.size until fallbackCount) { + sessionCodes.add("restored_reel_$i") + } + } + _sessionCount.value = sessionCodes.size + } + + fun getSessionCodes(): Set = synchronized(lock) { + sessionCodes.toSet() + } + + private fun commit(list: List) { + entries = list.take(500) + _items.value = entries + _todayCount.value = calculateTodayCount(entries) + val snapshot = entries + io.execute { save(snapshot) } + } + + private fun save(list: List) { + try { + val arr = JSONArray() + list.forEach { + arr.put( + JSONObject() + .put("code", it.code) + .put("author", it.author) + .put("caption", it.caption) + .put("lastSeen", it.lastSeen) + .put("views", it.views) + ) + } + // Writes to a temporary file before renaming to prevent corruption if killed mid-write + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(arr.toString()) + tmp.renameTo(file) + } catch (_: Exception) {} + } + + private fun load(): List = try { + if (!file.exists()) emptyList() else { + val arr = JSONArray(file.readText()) + (0 until arr.length()).map { + val o = arr.getJSONObject(it) + ReelEntry( + o.getString("code"), + o.optString("author"), + o.optString("caption"), + o.optLong("lastSeen"), + o.optInt("views", 1) + ) + } + } + } catch (_: Exception) { + emptyList() + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt similarity index 90% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt index e3dcf4c..4b8a52d 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt @@ -4,7 +4,11 @@ import org.json.JSONObject import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.WebExtension +/** + * Bridges WebExtension messages from content scripts into [ReelHistory]. + */ class ReelMessageDelegate(private val history: ReelHistory) : WebExtension.MessageDelegate { + // Instagram shortcode regex: alphanumeric with underscores, dashes, dots, at least 3 chars private val codeRe = Regex("^[A-Za-z0-9_.-]{3,}$") override fun onConnect(port: WebExtension.Port) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt similarity index 92% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt index bbb40ca..59b0e2c 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryRepository.kt @@ -10,13 +10,10 @@ import java.util.Locale import java.util.UUID /** - * ReelsHistoryRepository - * Manages locally stored metadata of reels viewed by the user. + * Repository for Room-backed Reels viewing history. * - * ARCHITECTURE RULES: - * - 100% on-device SQLite (Room). - * - Zero video media is downloaded or cached to disk. - * - Zero network calls or external logging. + * Keeps up to [MAX_ENTRIES] records. Only stores metadata (URLs, timestamps, titles); + * no media is cached or transmitted off-device. */ class ReelsHistoryRepository( private val reelsHistoryDao: ReelsHistoryDao diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt similarity index 56% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt index edaca7d..299dede 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt @@ -1,6 +1,8 @@ package com.ujwal.focusgram.feature.history import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,9 +66,16 @@ import java.util.Date import java.util.Locale /** - * ReelsHistoryScreen + * UI representation of a watched reel with aggregated rewatch counts. + */ +data class DisplayReelItem( + val entry: ReelEntry, + val rewatchCount: Int, + val latestTimestamp: Long +) + +/** * Searchable on-device viewer for watched Reels history. - * Zero video media downloaded or cached to disk. */ @Composable fun ReelsHistoryScreen( @@ -84,15 +93,69 @@ fun ReelsHistoryScreen( val allReels by reelHistory.items.collectAsState() val reels = remember(allReels, searchQuery) { - val deduplicated = allReels.distinctBy { it.code.ifBlank { it.url } } + val groups = LinkedHashMap>() + for (item in allReels) { + val cleanCode = item.code.trim() + .removePrefix("reel_") + .removePrefix("/") + .removeSuffix("/") + .substringAfterLast("/reel/") + .substringAfterLast("/reels/") + .substringBefore("?") + .substringBefore("&") + .substringBefore("/") + .trim() + .lowercase() + + val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories", "") + val cleanAuthor = item.author.trim().lowercase().removePrefix("@") + val cleanCaption = item.caption.trim().lowercase() + val isUsernameCode = cleanCode == cleanAuthor + + val validCode = cleanCode.length >= 4 && cleanCode !in ignoredCodes && !isUsernameCode + // Prioritize unique shortcode first so cards don't duplicate when metadata arrives + val key = if (validCode) { + "code_$cleanCode" + } else if (cleanAuthor.isNotBlank() && cleanCaption.isNotBlank()) { + "meta_${cleanAuthor}___${cleanCaption.take(80)}" + } else { + "url_${item.url.trim().substringBefore("?").trimEnd('/')}" + } + groups.getOrPut(key) { mutableListOf() }.add(item) + } + + val deduplicated = groups.values.map { list -> + val latestSeen = list.maxOf { it.lastSeen } + val maxViews = list.maxOf { it.views } + val totalRewatchCount = maxOf(maxViews, list.size) + val bestAuthor = list.firstOrNull { it.author.isNotBlank() }?.author ?: list.first().author + val bestCaption = list.firstOrNull { it.caption.isNotBlank() }?.caption ?: list.first().caption + val bestCode = list.firstOrNull { + it.code.isNotBlank() && it.code.length >= 4 && it.code.lowercase() != it.author.trim().lowercase().removePrefix("@") + }?.code ?: list.first().code + + val representative = list.first().copy( + code = bestCode, + author = bestAuthor, + caption = bestCaption, + lastSeen = latestSeen, + views = totalRewatchCount + ) + DisplayReelItem( + entry = representative, + rewatchCount = totalRewatchCount, + latestTimestamp = latestSeen + ) + }.sortedByDescending { it.latestTimestamp } + if (searchQuery.isBlank()) { deduplicated } else { val q = searchQuery.trim().lowercase() deduplicated.filter { - it.author.lowercase().contains(q) || - it.caption.lowercase().contains(q) || - it.code.lowercase().contains(q) + it.entry.author.lowercase().contains(q) || + it.entry.caption.lowercase().contains(q) || + it.entry.code.lowercase().contains(q) } } } @@ -119,8 +182,6 @@ fun ReelsHistoryScreen( } ) - - // Local search filter field OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, @@ -185,11 +246,13 @@ fun ReelsHistoryScreen( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(reels, key = { it.code }) { item -> + items(reels, key = { "${it.entry.code}_${it.entry.author}_${it.latestTimestamp}" }) { item -> ReelHistoryCard( item = item, - onClick = { onOpenReel(item.url) }, - onDelete = { reelHistory.delete(item.code) } + onClick = { onOpenReel(item.entry.url) }, + onDelete = { + reelHistory.deleteMatching(item.entry.code, item.entry.author, item.entry.caption) + } ) } } @@ -199,25 +262,76 @@ fun ReelsHistoryScreen( if (showClearDialog) { AlertDialog( onDismissRequest = { showClearDialog = false }, - title = { Text("Clear Reels History?", color = TextPrimary) }, - text = { + title = { Text( - "This removes all locally recorded reel visit history.", - color = TextSecondary + text = "Clear Reels History", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 18.sp ) }, - containerColor = DarkCard, - confirmButton = { - Button( - onClick = { - reelHistory.clear() - showClearDialog = false - }, - colors = ButtonDefaults.buttonColors(containerColor = AlertRed) + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Clear All") + Text( + text = "Choose the timeframe you would like to clear:", + color = TextSecondary, + fontSize = 13.sp, + lineHeight = 18.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + + ClearOptionRow( + label = "Today", + subtitle = "Clear reels watched today", + onClick = { + reelHistory.clearToday() + showClearDialog = false + } + ) + + ClearOptionRow( + label = "3 Days", + subtitle = "Clear reels from the past 3 days", + onClick = { + reelHistory.clearPastDays(3) + showClearDialog = false + } + ) + + ClearOptionRow( + label = "Last Week", + subtitle = "Clear reels from the past 7 days", + onClick = { + reelHistory.clearPastDays(7) + showClearDialog = false + } + ) + + ClearOptionRow( + label = "This Month", + subtitle = "Clear reels from this calendar month", + onClick = { + reelHistory.clearThisMonth() + showClearDialog = false + } + ) + + ClearOptionRow( + label = "All History", + subtitle = "Permanently remove all recorded reel history", + isDestructive = true, + onClick = { + reelHistory.clear() + showClearDialog = false + } + ) } }, + containerColor = DarkCardElevated, + confirmButton = {}, dismissButton = { TextButton(onClick = { showClearDialog = false }) { Text("Cancel", color = TextSecondary) @@ -227,9 +341,57 @@ fun ReelsHistoryScreen( } } +@Composable +private fun ClearOptionRow( + label: String, + subtitle: String, + isDestructive: Boolean = false, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (isDestructive) AlertRed.copy(alpha = 0.12f) else DarkCard) + .border( + width = 1.dp, + color = if (isDestructive) AlertRed.copy(alpha = 0.4f) else DarkBorder, + shape = RoundedCornerShape(10.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + color = if (isDestructive) AlertRed else TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + Text( + text = subtitle, + color = TextMuted, + fontSize = 11.sp + ) + } + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (isDestructive) AlertRed else TextMuted, + modifier = Modifier.size(16.dp) + ) + } + } +} + @Composable private fun ReelHistoryCard( - item: ReelEntry, + item: DisplayReelItem, onClick: () -> Unit, onDelete: () -> Unit ) { @@ -243,7 +405,6 @@ private fun ReelHistoryCard( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Thumbnail icon placeholder Box( modifier = Modifier .size(52.dp) @@ -262,9 +423,9 @@ private fun ReelHistoryCard( Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { - if (item.author.isNotBlank()) { + if (item.entry.author.isNotBlank()) { Text( - text = "@${item.author}", + text = "@${item.entry.author}", style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), color = InstagramBlue, maxLines = 1, @@ -273,7 +434,7 @@ private fun ReelHistoryCard( Spacer(modifier = Modifier.height(2.dp)) } Text( - text = if (item.caption.isNotBlank()) item.caption else "Reel /${item.code}", + text = if (item.entry.caption.isNotBlank()) item.entry.caption else "Reel /${item.entry.code}", style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Normal), color = TextPrimary, maxLines = 2, @@ -281,10 +442,11 @@ private fun ReelHistoryCard( ) Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { + val timeStr = formatRelativeTime(item.latestTimestamp) Text( - text = formatRelativeTime(item.lastSeen), + text = if (item.rewatchCount > 1) "$timeStr โ€ข x${item.rewatchCount}" else timeStr, style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), - color = TextMuted + color = if (item.rewatchCount > 1) InstagramBlue else TextMuted ) } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt similarity index 88% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt index ef50666..c290444 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/InstagramIcons.kt @@ -9,23 +9,7 @@ import androidx.compose.ui.graphics.vector.PathParser import androidx.compose.ui.unit.dp /** - * InstagramIcons - * 100% authentic vector assets extracted directly from the user's custom SVG files: - * - Home: - * Outline: /home/ujwal/Projects/FocusGram/bottom-icons/akar-icons--home-alt1.svg (viewBox 0 0 24 24) - * Filled: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/fluent--home-16-filled.svg (viewBox 0 0 16 16) - * - Search: - * Outline: /home/ujwal/Projects/FocusGram/bottom-icons/akar-icons--search.svg (viewBox 0 0 24 24) - * Filled: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/boxicons--search-filled.svg (viewBox 0 0 24 24) - * - Reels: - * Outline: /home/ujwal/Projects/FocusGram/bottom-icons/selfhst--instagram-reels-dark.svg (viewBox 0 0 512 512) - * Filled: /home/ujwal/Projects/FocusGram/bottom-icons/selfhst--instagram-reels-light.svg (viewBox 0 0 512 512) - * - Direct (Messages): - * Outline: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/DM).svg (viewBox 0 0 24 24) - * Filled: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/DM (Filled).svg (viewBox 0 0 640 640) - * - Profile: - * Outline: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/ant-design--user-outlined.svg (viewBox 0 0 1024 1024) - * Filled: /home/ujwal/Projects/FocusGram/bottom-icons/ci--paper-plane(for messages/boxicons--user-filled.svg (viewBox 0 0 24 24) + * Vector icon assets for navigation tabs and action controls. */ object InstagramIcons { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt similarity index 84% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt index f9dab43..96221cf 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathMeasure import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext @@ -90,14 +91,10 @@ enum class NavTab( } /** - * NativeBottomNavBar - * Authentic OLED navigation island with iOS-style fluid physics and Instagram iconography. - * Features: - * - Real-time light/dark theme adaptation from in-page GeckoView state. - * - Sliding capsule pill indicator and spring icon bounce for fluid tab transitions. - * - Double-click detection on Home & Reels to refresh in-place and scroll to top. - * - Profile long-press (1100ms) with clockwise perimeter loading bar starting above the Home icon. - * - Auto-hide in DMs, comments, search modals, and soft keyboard appearances. + * Navigation bar island rendered as a floating capsule with animated tab transitions. + * + * Supports double-tap on Home/Reels to refresh, and profile long-press to open Settings + * with a perimeter sweep progress animation starting above the Home icon. */ @Composable fun NativeBottomNavBar( @@ -111,6 +108,7 @@ fun NativeBottomNavBar( isDownloading: Boolean = false, isDownloadCompleted: Boolean = false, isDownloadFailed: Boolean = false, + sessionProgress: Float = 0f, isAuthenticated: Boolean = false, isAuthPage: Boolean = false, onNavClick: ((String) -> Unit)? = null, @@ -149,8 +147,15 @@ fun NativeBottomNavBar( } else { isInDirect } - val isExplicitAuthRoute = (lowerUrl.contains("/accounts") && !lowerUrl.contains("/accounts/edit")) || lowerUrl.contains("/login") || lowerUrl.contains("/signup") - val isAuth = isAuthPage || isExplicitAuthRoute + val isExplicitAuthRoute = (lowerUrl.contains("/accounts") && !lowerUrl.contains("/accounts/edit")) || + lowerUrl.contains("/login") || + lowerUrl.contains("/signup") || + lowerUrl.contains("/two_factor") || + lowerUrl.contains("/challenge") || + lowerUrl.contains("/onetap") || + lowerUrl.contains("accountscenter.") || + lowerUrl.contains("facebook.com") + val isAuth = isAuthPage || !isAuthenticated || isExplicitAuthRoute val isVisible = !isAuth && !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN // Real-time animated color palette matching Instagram's web theme @@ -229,6 +234,13 @@ fun NativeBottomNavBar( label = "animatedDownloadProgress" ) + // Animated active session perimeter progress + val animatedSessionProgress by animateFloatAsState( + targetValue = if (settings.showSessionTimeInBottomBar) sessionProgress.coerceIn(0f, 1f) else 0f, + animationSpec = tween(durationMillis = 1000, easing = LinearEasing), + label = "animatedSessionProgress" + ) + // Glow animation for download completion (glow out and glow in 2 times) val glowAnim = remember { Animatable(0f) } androidx.compose.runtime.LaunchedEffect(isDownloadCompleted) { @@ -320,7 +332,6 @@ fun NativeBottomNavBar( .border(BorderStroke(1.dp, glassBorderBrush), navShape), contentAlignment = Alignment.Center ) { - // 0. Frosted Glass Blur Background Layer (~13% more blur) Box( modifier = Modifier .fillMaxSize() @@ -329,7 +340,6 @@ fun NativeBottomNavBar( .background(islandBackground) ) - // 0.1 Specular Glass Sheen Gradient (+4% glass reflection) Box( modifier = Modifier .fillMaxSize() @@ -359,7 +369,6 @@ fun NativeBottomNavBar( val startXPx = (totalWidthPx / tabCount) / 2f val cornerRadiusPx = with(density) { min(32.dp.toPx(), totalHeightPx / 2f) } - // 1. Sliding Capsule Indicator (iOS-style physics) val animatedPillOffset by animateDpAsState( targetValue = tabWidthDp * selectedTabIndex, animationSpec = spring( @@ -380,7 +389,6 @@ fun NativeBottomNavBar( .align(Alignment.CenterStart) ) - // 2. Tab Items Row Row( modifier = Modifier .fillMaxSize() @@ -507,49 +515,40 @@ fun NativeBottomNavBar( } } - // 3. Profile Long-Press Animated Clockwise Perimeter Border Indicator + // Profile long-press clockwise perimeter border indicator starting above Home icon val currentProgress = longPressProgressAnim.value if (currentProgress > 0f) { Canvas(modifier = Modifier.fillMaxSize()) { val fullPath = Path().apply { moveTo(startXPx, 0f) - // Top edge to top-right lineTo(totalWidthPx - cornerRadiusPx, 0f) - // Top-right corner arc arcTo( rect = Rect(totalWidthPx - 2 * cornerRadiusPx, 0f, totalWidthPx, 2 * cornerRadiusPx), startAngleDegrees = 270f, sweepAngleDegrees = 90f, forceMoveTo = false ) - // Right edge going down lineTo(totalWidthPx, totalHeightPx - cornerRadiusPx) - // Bottom-right corner arc arcTo( rect = Rect(totalWidthPx - 2 * cornerRadiusPx, totalHeightPx - 2 * cornerRadiusPx, totalWidthPx, totalHeightPx), startAngleDegrees = 0f, sweepAngleDegrees = 90f, forceMoveTo = false ) - // Bottom edge to bottom-left lineTo(cornerRadiusPx, totalHeightPx) - // Bottom-left corner arc arcTo( rect = Rect(0f, totalHeightPx - 2 * cornerRadiusPx, 2 * cornerRadiusPx, totalHeightPx), startAngleDegrees = 90f, sweepAngleDegrees = 90f, forceMoveTo = false ) - // Left edge going up lineTo(0f, cornerRadiusPx) - // Top-left corner arc arcTo( rect = Rect(0f, 0f, 2 * cornerRadiusPx, 2 * cornerRadiusPx), startAngleDegrees = 180f, sweepAngleDegrees = 90f, forceMoveTo = false ) - // Top edge returning to Home start point lineTo(startXPx, 0f) close() } @@ -564,10 +563,10 @@ fun NativeBottomNavBar( path = segmentPath, brush = Brush.linearGradient( colors = listOf( - Color(0xFF833AB4), // Instagram Purple - Color(0xFFFD1D1D), // Instagram Red - Color(0xFFF77737), // Instagram Orange - Color(0xFFFFDC80) // Instagram Yellow + Color(0xFF833AB4), + Color(0xFFFD1D1D), + Color(0xFFF77737), + Color(0xFFFFDC80) ) ), style = Stroke( @@ -578,7 +577,6 @@ fun NativeBottomNavBar( } } - // 4. Media Download Clockwise Perimeter Progress Animation & 2x Completion Glow val currentDlProgress = animatedDownloadProgress val currentGlow = glowAnim.value if (currentDlProgress > 0f || currentGlow > 0f) { @@ -619,22 +617,21 @@ fun NativeBottomNavBar( val blueGradient = Brush.linearGradient( colors = listOf( - Color(0xFF00E5FF), // Bright Cyan - Color(0xFF0091FF), // Electric Sky Blue - Color(0xFF0055FF), // Deep Vivid Blue - Color(0xFF00D2FF) // Cyan-Blue + Color(0xFF00E5FF), + Color(0xFF0091FF), + Color(0xFF0055FF), + Color(0xFF00D2FF) ) ) val failureGradient = Brush.linearGradient( colors = listOf( - Color(0xFFFF3B30), // Bright Red - Color(0xFFCC0000), // Dark Red - Color(0xFF8B0000), // Very Dark Crimson - Color(0xFFFF3B30) // Bright Red + Color(0xFFFF3B30), + Color(0xFFCC0000), + Color(0xFF8B0000), + Color(0xFFFF3B30) ) ) - // 4a. If downloading or failed: Clockwise perimeter sweep starting above Home icon if (currentDlProgress > 0f && currentGlow == 0f) { val pathMeasure = PathMeasure() pathMeasure.setPath(fullPath, false) @@ -652,9 +649,7 @@ fun NativeBottomNavBar( ) } - // 4b. If download completed: 2x glowing outer halo + luminous neon perimeter rim if (currentGlow > 0f) { - // Outer diffused glow halo drawPath( path = fullPath, color = Color(0xFF00D2FF).copy(alpha = 0.6f * currentGlow), @@ -663,7 +658,6 @@ fun NativeBottomNavBar( cap = StrokeCap.Round ) ) - // Core luminous neon rim drawPath( path = fullPath, brush = Brush.linearGradient( @@ -682,6 +676,70 @@ fun NativeBottomNavBar( } } } + + // Active session perimeter progress indicator (golden sweep along the perimeter) + val currentSessionProgress = animatedSessionProgress + if (settings.showSessionTimeInBottomBar && currentSessionProgress > 0f && currentProgress == 0f && currentDlProgress == 0f && currentGlow == 0f) { + Canvas(modifier = Modifier.fillMaxSize()) { + val fullPath = Path().apply { + moveTo(startXPx, 0f) + lineTo(totalWidthPx - cornerRadiusPx, 0f) + arcTo( + rect = Rect(totalWidthPx - 2 * cornerRadiusPx, 0f, totalWidthPx, 2 * cornerRadiusPx), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(totalWidthPx, totalHeightPx - cornerRadiusPx) + arcTo( + rect = Rect(totalWidthPx - 2 * cornerRadiusPx, totalHeightPx - 2 * cornerRadiusPx, totalWidthPx, totalHeightPx), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(cornerRadiusPx, totalHeightPx) + arcTo( + rect = Rect(0f, totalHeightPx - 2 * cornerRadiusPx, 2 * cornerRadiusPx, totalHeightPx), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(0f, cornerRadiusPx) + arcTo( + rect = Rect(0f, 0f, 2 * cornerRadiusPx, 2 * cornerRadiusPx), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(startXPx, 0f) + close() + } + + val pathMeasure = PathMeasure() + pathMeasure.setPath(fullPath, false) + val totalLen = pathMeasure.length + val segmentPath = Path() + pathMeasure.getSegment(0f, totalLen * currentSessionProgress.coerceIn(0f, 1f), segmentPath, true) + + val p = currentSessionProgress.coerceIn(0f, 1f) + // Sunset effect: as session approaches completion, colors evolve from noon gold into warm sunset reddish-orange + val sunsetStart = lerp(Color(0xFFFFE57F), Color(0xFFFF8A65), p) + val sunsetMid1 = lerp(Color(0xFFFFD700), Color(0xFFFF7043), p) + val sunsetMid2 = lerp(Color(0xFFFFB300), Color(0xFFFF5722), p) + val sunsetEnd = lerp(Color(0xFFFFA000), Color(0xFFD84315), p) + + drawPath( + path = segmentPath, + brush = Brush.linearGradient( + colors = listOf(sunsetStart, sunsetMid1, sunsetMid2, sunsetEnd) + ), + style = Stroke( + width = 3.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + } } } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt similarity index 94% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt index 37c6c98..88a3a0f 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt @@ -34,12 +34,8 @@ import androidx.compose.ui.res.painterResource import com.ujwal.focusgram.R /** - * ReelsTopBar - * Minimal Instagram Reels top header: - * - "Reels" (Bold white header on left) - * - Session watched counter (pill in middle) - * - Download icon button (on right, opposite side of Reels) - * - Subtle vertical scrim gradient for legibility over video frames. + * Top header overlay for Reels sessions, displaying the current session view count + * and download trigger with a vertical scrim for legibility over video frames. */ @Composable fun ReelsTopBar( diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/notice/RemoteNoticeService.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/notice/RemoteNoticeService.kt new file mode 100644 index 0000000..d8af94b --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/notice/RemoteNoticeService.kt @@ -0,0 +1,113 @@ +package com.ujwal.focusgram.feature.notice + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.BufferedReader +import java.net.HttpURLConnection +import java.net.URL + +/** + * Data payload representing a broadcast announcement or critical notice fetched from GitHub. + */ +data class RemoteNotice( + val show: Boolean, + val id: String, + val header: String, + val body: String, + val maxShows: Int = 1, + val buttonText: String = "OK" +) + +/** + * Service that queries GitHub for remote notice/alert configurations (android/popup.json). + * + * Allows maintainers to broadcast critical notices or updates to all clients via a GitHub commit. + * All requests are read-only HTTP GET calls targeting raw GitHub contents. + */ +class RemoteNoticeService(private val context: Context) { + + companion object { + private const val TAG = "RemoteNoticeService" + const val POPUP_URL = + "https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json" + private const val PREFS_NAME = "focusgram_remote_notices" + private const val CONNECT_TIMEOUT_MS = 5000 + private const val READ_TIMEOUT_MS = 5000 + + fun getMockNotice(): RemoteNotice { + return RemoteNotice( + show = true, + id = "test_notice_preview", + header = "Test Broadcast Notice", + body = "This is a simulated broadcast notice to verify the dialog rendering, liquid glass styling, and dismiss actions.", + maxShows = 99, + buttonText = "Understood" + ) + } + } + + private val prefs: SharedPreferences by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + /** + * Silently fetches the latest notice from GitHub. + */ + suspend fun fetchNotice(): RemoteNotice? = withContext(Dispatchers.IO) { + try { + val urlString = "$POPUP_URL?t=${System.currentTimeMillis()}" + val url = URL(urlString) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + setRequestProperty("Cache-Control", "no-cache") + setRequestProperty("User-Agent", "FocusGram-Android-Client") + } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + return@withContext null + } + + val text = connection.inputStream.bufferedReader().use(BufferedReader::readText) + val json = JSONObject(text) + + val show = json.optBoolean("show", false) + val id = json.optString("id", "").trim() + val header = json.optString("header", json.optString("title", "Notice")).trim() + val body = json.optString("body", "").trim() + val maxShows = json.optInt("max_shows", 1) + val buttonText = json.optString("button_text", "OK").trim() + + RemoteNotice( + show = show, + id = id, + header = header, + body = body, + maxShows = maxShows, + buttonText = buttonText + ) + } catch (e: Exception) { + Log.d(TAG, "Notice fetch silent skip: ${e.message}") + null + } + } + + fun shouldShow(notice: RemoteNotice): Boolean { + if (!notice.show || notice.id.isBlank()) return false + val key = "notice_count_${notice.id}" + val count = prefs.getInt(key, 0) + return count < notice.maxShows + } + + fun markShown(noticeId: String) { + if (noticeId.isBlank()) return + val key = "notice_count_$noticeId" + val count = prefs.getInt(key, 0) + prefs.edit().putInt(key, count + 1).apply() + } +} diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..7b3a3ac --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt @@ -0,0 +1,1000 @@ +package com.ujwal.focusgram.feature.onboarding + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.view.HapticFeedbackConstants +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ujwal.focusgram.R +import com.ujwal.focusgram.core.datastore.SettingsRepository +import com.ujwal.focusgram.core.model.FocusSettings +import com.ujwal.focusgram.core.ui.components.CupertinoWheelPicker +import com.ujwal.focusgram.core.utils.AppIconManager +import com.ujwal.focusgram.core.ui.theme.DarkBorder +import com.ujwal.focusgram.core.ui.theme.DarkCard +import com.ujwal.focusgram.core.ui.theme.DarkCardElevated +import com.ujwal.focusgram.core.ui.theme.FocusGold +import com.ujwal.focusgram.core.ui.theme.FocusGreen +import com.ujwal.focusgram.core.ui.theme.InstagramBlue +import com.ujwal.focusgram.core.ui.theme.InstagramPink +import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.ui.theme.TextMuted +import com.ujwal.focusgram.core.ui.theme.TextPrimary +import com.ujwal.focusgram.core.ui.theme.TextSecondary +import com.ujwal.focusgram.core.utils.TimeFormatters +import kotlinx.coroutines.launch + +@Composable +fun OnboardingScreen( + settings: FocusSettings, + settingsRepository: SettingsRepository, + onComplete: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val view = LocalView.current + val scope = rememberCoroutineScope() + val totalPages = 6 + val pagerState = rememberPagerState(pageCount = { totalPages }) + + var notificationGranted by remember { mutableStateOf(false) } + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + notificationGranted = isGranted + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(PureBlack) + .padding(WindowInsets.statusBars.asPaddingValues()) + .padding(WindowInsets.navigationBars.asPaddingValues()) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${pagerState.currentPage + 1} of $totalPages", + style = MaterialTheme.typography.labelSmall.copy( + color = TextMuted, + fontWeight = FontWeight.Medium, + letterSpacing = 0.5.sp + ) + ) + + if (pagerState.currentPage < totalPages - 1) { + TextButton( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { + settingsRepository.setFirstRunCompleted() + onComplete() + } + } + ) { + Text( + text = "Skip", + style = MaterialTheme.typography.labelMedium.copy( + color = TextMuted, + fontWeight = FontWeight.SemiBold + ) + ) + } + } else { + Spacer(modifier = Modifier.width(48.dp)) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { page -> + when (page) { + 0 -> WelcomeSlide() + 1 -> TimeIntentSlide(settings = settings, repository = settingsRepository) + 2 -> CalmControlsSlide(settings = settings, repository = settingsRepository) + 3 -> LinkHandlingSlide() + 4 -> NotificationSlide( + isGranted = notificationGranted, + onRequestPermission = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + notificationGranted = true + } + } + ) + 5 -> LoginSecuritySlide() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 20.dp) + ) { + repeat(totalPages) { index -> + val isSelected = pagerState.currentPage == index + val width by animateDpAsState( + targetValue = if (isSelected) 22.dp else 7.dp, + animationSpec = tween(durationMillis = 250), + label = "dot_width" + ) + Box( + modifier = Modifier + .padding(horizontal = 3.dp) + .height(7.dp) + .width(width) + .clip(RoundedCornerShape(4.dp)) + .background( + if (isSelected) InstagramBlue else DarkCardElevated + ) + ) + } + } + + val isLastPage = pagerState.currentPage == totalPages - 1 + Button( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { + if (isLastPage) { + settingsRepository.setFirstRunCompleted() + onComplete() + } else { + pagerState.animateScrollToPage( + page = pagerState.currentPage + 1, + animationSpec = tween(durationMillis = 350) + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = if (isLastPage) "Get Started" else "Continue", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = PureBlack + ) + ) + } + } + } + } +} + +@Composable +private fun WelcomeSlide() { + val context = LocalContext.current + val scrollState = rememberScrollState() + val appIconBitmap = remember(context) { + AppIconManager.getIconBitmap(context, R.mipmap.ic_launcher) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(92.dp) + .clip(RoundedCornerShape(22.dp)) + .background(DarkCard) + .border(1.5.dp, DarkBorder, RoundedCornerShape(22.dp)), + contentAlignment = Alignment.Center + ) { + if (appIconBitmap != null) { + Image( + bitmap = appIconBitmap.asImageBitmap(), + contentDescription = "FocusGram App Logo", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(22.dp)) + ) + } else { + Icon( + imageVector = Icons.Default.HourglassBottom, + contentDescription = "FocusGram App Logo", + tint = FocusGold, + modifier = Modifier.size(44.dp) + ) + } + } + + Spacer(modifier = Modifier.height(28.dp)) + + Text( + text = "Welcome to FocusGram", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Use Instagram with healthy boundaries, zero tracking algorithms, and complete digital wellness.", + style = MaterialTheme.typography.bodyMedium.copy( + color = TextSecondary, + lineHeight = 22.sp + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ValuePropRow( + icon = Icons.Default.HourglassBottom, + tint = FocusGold, + title = "Timed Reel Sessions", + subtitle = "Watch Reels intentionally with timers instead of infinite feeds." + ) + ValuePropRow( + icon = Icons.Default.VisibilityOff, + tint = InstagramBlue, + title = "Calm Feed & Explore", + subtitle = "Blur or hide addictive feeds, recommendations, and explore grids." + ) + ValuePropRow( + icon = Icons.Default.Shield, + tint = FocusGreen, + title = "100% Client-Side Privacy", + subtitle = "Zero external servers, zero telemetry, zero credential interception." + ) + } + } +} + +@Composable +private fun LoginSecuritySlide() { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(FocusGreen.copy(alpha = 0.14f)) + .border(1.5.dp, FocusGreen.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = null, + tint = FocusGreen, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "Official Instagram Login", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Direct, secure, and 100% private", + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + fontSize = 13.sp + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SecurityCard( + icon = Icons.Default.Lock, + title = "Direct Official Login", + body = "You sign in straight on instagram.com inside isolated GeckoView. FocusGram never touches your credentials." + ) + + SecurityCard( + icon = Icons.Default.Shield, + title = "Complete Black Box", + body = "The login interface is an isolated web container. Passwords and 2FA credentials can never be intercepted or seen." + ) + + SecurityCard( + icon = Icons.Default.CheckCircle, + title = "Zero Off-Device Data", + body = "100% on-device and open source. Zero analytics, zero trackers, zero external servers." + ) + } + } +} + +@Composable +private fun TimeIntentSlide( + settings: FocusSettings, + repository: SettingsRepository +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + val view = LocalView.current + val dailyLimitOptions = remember { listOf(15, 30, 45, 60, 75, 90, 120, 150, 180, 240) } + val selectedIndex = remember(settings.dailyLimitMinutes) { + val exact = dailyLimitOptions.indexOf(settings.dailyLimitMinutes) + if (exact >= 0) exact + else { + val closest = dailyLimitOptions.indexOfFirst { it >= settings.dailyLimitMinutes } + if (closest >= 0) closest else dailyLimitOptions.lastIndex + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(FocusGold.copy(alpha = 0.15f)) + .border(1.5.dp, FocusGold.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.HourglassBottom, + contentDescription = null, + tint = FocusGold, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "Time With Intent", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Set daily limits and calm pause gates to prevent mindless scrolling.", + style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Daily Screen Time Limit: ${TimeFormatters.formatMinutes(settings.dailyLimitMinutes)}", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + color = TextPrimary + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + CupertinoWheelPicker( + items = dailyLimitOptions, + selectedIndex = selectedIndex, + onItemSelected = { idx -> + val chosen = dailyLimitOptions.getOrElse(idx) { 60 } + scope.launch { repository.setDailyLimitMinutes(chosen) } + }, + labelProvider = { TimeFormatters.formatMinutes(it) }, + itemHeight = 44.dp, + visibleItemsCount = 5, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ToggleRow( + title = "Mindfulness Breath Gate", + subtitle = "A brief 10-second pause before opening the app to reset intention.", + checked = settings.showBreathGate, + onCheckedChange = { checked -> + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { repository.setShowBreathGate(checked) } + } + ) + } +} + +@Composable +private fun CalmControlsSlide( + settings: FocusSettings, + repository: SettingsRepository +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + val view = LocalView.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(InstagramPink.copy(alpha = 0.15f)) + .border(1.5.dp, InstagramPink.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null, + tint = InstagramPink, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "Declutter Your Mind", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Choose which addictive parts of Instagram to quiet down by default:", + style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ToggleRow( + title = "Hide Feed Posts", + subtitle = "Removes main feed posts so you only open Instagram with purpose.", + checked = settings.hideFeedPosts, + onCheckedChange = { checked -> + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { repository.setHideFeedPosts(checked) } + } + ) + + ToggleRow( + title = "Block Video Autoplay", + subtitle = "Stops videos and reels from playing automatically.", + checked = settings.noAutoplay, + onCheckedChange = { checked -> + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { repository.setNoAutoplay(checked) } + } + ) + + ToggleRow( + title = "Hide Stories Tray", + subtitle = "Hides the top stories bar to prevent distraction.", + checked = settings.noStories, + onCheckedChange = { checked -> + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + scope.launch { repository.setNoStories(checked) } + } + ) + } + } +} + +@Composable +private fun LinkHandlingSlide() { + val context = LocalContext.current + val view = LocalView.current + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(InstagramBlue.copy(alpha = 0.15f)) + .border(1.5.dp, InstagramBlue.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + tint = InstagramBlue, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "Open Links in FocusGram", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Handle instagram.com links here instead of launching the tracking-heavy official app.", + style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .border( + BorderStroke( + 1.dp, + Brush.verticalGradient(listOf(Color(0x38FFFFFF), Color(0x10FFFFFF))) + ), + RoundedCornerShape(16.dp) + ) + .background( + Brush.verticalGradient(listOf(Color(0xEB1E1E22), Color(0xD9161619))) + ) + .padding(18.dp) + ) { + Column { + Text( + text = "Recommended Setup", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "1. Tap \"Configure App Links\" below\n2. Select \"Open by default\"\n3. Tap \"Add link\" and check all instagram.com domains", + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + lineHeight = 20.sp + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + openDefaultLinkSettings(context) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Configure App Links", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + color = InstagramBlue + ) + ) + } + } + } + } +} + +@Composable +private fun NotificationSlide( + isGranted: Boolean, + onRequestPermission: () -> Unit +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(FocusGreen.copy(alpha = 0.15f)) + .border(1.5.dp, FocusGreen.copy(alpha = 0.5f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.NotificationsActive, + contentDescription = null, + tint = FocusGreen, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "Useful Alerts Only", + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "FocusGram only notifies you when your session timer expires or when cooldown completes. Never any engagement hooks or spam.", + style = MaterialTheme.typography.bodyMedium.copy( + color = TextSecondary, + lineHeight = 22.sp + ), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(28.dp)) + + if (isGranted) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(FocusGreen.copy(alpha = 0.14f)) + .border(1.dp, FocusGreen.copy(alpha = 0.4f), RoundedCornerShape(14.dp)) + .padding(14.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = FocusGreen, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Notifications Enabled", + style = MaterialTheme.typography.labelLarge.copy( + color = FocusGreen, + fontWeight = FontWeight.Bold + ) + ) + } + } else { + OutlinedButton( + onClick = onRequestPermission, + shape = RoundedCornerShape(14.dp), + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text( + text = "Enable Session Notifications", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + color = FocusGreen + ) + ) + } + } + } +} + +@Composable +private fun ValuePropRow( + icon: ImageVector, + tint: Color, + title: String, + subtitle: String +) { + val shape = RoundedCornerShape(14.dp) + val glassBorderBrush = Brush.verticalGradient( + colors = listOf(Color(0x38FFFFFF), Color(0x10FFFFFF)) + ) + val glassBgBrush = Brush.verticalGradient( + colors = listOf(Color(0xEB1E1E22), Color(0xD9161619)) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(tint.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = TextPrimary + ) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + fontSize = 12.sp, + lineHeight = 16.sp + ) + ) + } + } +} + +@Composable +private fun SecurityCard( + icon: ImageVector, + title: String, + body: String +) { + val shape = RoundedCornerShape(14.dp) + val glassBorderBrush = Brush.verticalGradient( + colors = listOf(Color(0x38FFFFFF), Color(0x10FFFFFF)) + ) + val glassBgBrush = Brush.verticalGradient( + colors = listOf(Color(0xEB1E1E22), Color(0xD9161619)) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) + .padding(14.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(FocusGreen.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon(imageVector = icon, contentDescription = null, tint = FocusGreen, modifier = Modifier.size(18.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = TextPrimary + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + fontSize = 12.sp, + lineHeight = 17.sp + ) + ) + } + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + val shape = RoundedCornerShape(14.dp) + val glassBorderBrush = Brush.verticalGradient( + colors = listOf(Color(0x38FFFFFF), Color(0x10FFFFFF)) + ) + val glassBgBrush = Brush.verticalGradient( + colors = listOf(Color(0xEB1E1E22), Color(0xD9161619)) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .border(BorderStroke(1.dp, glassBorderBrush), shape) + .background(glassBgBrush) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = TextPrimary + ) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + fontSize = 12.sp + ) + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = PureBlack, + checkedTrackColor = InstagramBlue, + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = DarkCardElevated + ) + ) + } +} + +private fun openDefaultLinkSettings(context: Context) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val intent = Intent( + Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, + Uri.parse("package:${context.packageName}") + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } else { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}") + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } catch (_: Exception) { + val intent = Intent(Settings.ACTION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt similarity index 55% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt index 05767f1..a3f754c 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf import com.ujwal.focusgram.feature.history.ReelHistory +import com.ujwal.focusgram.core.utils.TimeFormatters import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -66,13 +67,17 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DateRangePicker import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.runtime.mutableStateOf +import com.ujwal.focusgram.core.ui.components.IOSDivider +import com.ujwal.focusgram.core.ui.components.IOSSectionTitle import kotlin.math.max enum class DailyBreakdownRange(val label: String) { @@ -82,6 +87,9 @@ enum class DailyBreakdownRange(val label: String) { CUSTOM("Custom") } +/** + * Screen time analytics screen displaying daily usage charts, Reels consumption stats, and log history. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScreenTimeScreen( @@ -107,7 +115,7 @@ fun ScreenTimeScreen( val todayMinutes = todaySeconds / 60 val todayReelsMinutes = todayReelsSeconds / 60 val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0) - val limitFraction = (todayMinutes.toFloat() / max(1, dailyLimitMinutes)).coerceIn(0f, 1f) + val limitFraction = if (dailyLimitMinutes > 0) (todayMinutes.toFloat() / dailyLimitMinutes).coerceIn(0f, 1f) else 0f var selectedRange by remember { mutableStateOf(DailyBreakdownRange.PAST_7_DAYS) } var customStartDateMillis by remember { mutableStateOf(null) } @@ -127,16 +135,14 @@ fun ScreenTimeScreen( else -> "${todayReelsMinutes}m ${todayReelsSeconds % 60}s" } - // Compute past 7 days breakdown for bar chart val last7Days = remember(records) { - val cal = Calendar.getInstance() - val list = mutableListOf>() // Pair(DayLabel, Minutes) + val list = mutableListOf>() // DayLabel, Minutes, isToday for (i in 6 downTo 0) { val dCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -i) } val dStr = dateFormat.format(dCal.time) val dLabel = dayLabelFormat.format(dCal.time) val mins = ((records.firstOrNull { it.date == dStr }?.seconds) ?: 0) / 60 - list.add(Pair(dLabel, mins)) + list.add(Triple(dLabel, mins, i == 0)) } list } @@ -145,6 +151,10 @@ fun ScreenTimeScreen( max(60, last7Days.maxOfOrNull { it.second } ?: 60) } + val maxScale = remember(maxDayMinutes, dailyLimitMinutes) { + max(maxDayMinutes, if (dailyLimitMinutes in 1..180) dailyLimitMinutes else maxDayMinutes) + } + val weeklyTotalMinutes = last7Days.sumOf { it.second } val weeklyDailyAverage = weeklyTotalMinutes / 7 @@ -258,7 +268,6 @@ fun ScreenTimeScreen( .background(IOSBg) .padding(WindowInsets.statusBars.asPaddingValues()) ) { - // iOS Navigation Bar Row( modifier = Modifier .fillMaxWidth() @@ -287,151 +296,268 @@ fun ScreenTimeScreen( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Summary Card item { IOSGroupedCard(modifier = Modifier.padding(top = 8.dp)) { Column(modifier = Modifier.padding(18.dp)) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "DAILY AVERAGE", + color = IOSTextSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${weeklyDailyAverage}m", + color = IOSTextPrimary, + fontSize = 32.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Today: $todayUsageText" + if (todayReelsSeconds > 0) " โ€ข $todayReelsText in Reels" else "", + color = IOSTextSecondary, + fontSize = 13.sp + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + // Apple-style bar chart area with fixed track heights and no clipping + Box( + modifier = Modifier + .fillMaxWidth() + .height(110.dp) + ) { + // Subtle limit guideline when applicable + if (dailyLimitMinutes in 1..maxScale) { + val limitFraction = (dailyLimitMinutes.toFloat() / maxScale).coerceIn(0f, 1f) + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .padding(bottom = (limitFraction * 80).dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(Color(0xFF3A3A3C)) + ) + Text( + text = "Limit", + color = IOSTextSecondary.copy(alpha = 0.7f), + fontSize = 9.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(bottom = 2.dp) + ) + } + } + + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + last7Days.forEach { (_, minutes, isToday) -> + val barFraction = if (minutes > 0) (minutes.toFloat() / maxScale).coerceIn(0.06f, 1f) else 0f + val animatedHeight by animateFloatAsState( + targetValue = barFraction, + animationSpec = tween(500), + label = "barHeight" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + if (minutes > 0) { + Text( + text = "${minutes}m", + color = if (isToday) IOSTextPrimary else IOSTextSecondary, + fontSize = 10.sp, + fontWeight = if (isToday) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1 + ) + } else { + Spacer(modifier = Modifier.height(14.dp)) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Bar track container with constant 80dp height + Box( + modifier = Modifier + .width(16.dp) + .height(80.dp), + contentAlignment = Alignment.BottomCenter + ) { + // Subtle background column track + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF2C2C2E)) + ) + + // Active filled bar + if (barFraction > 0f) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(animatedHeight) + .clip(RoundedCornerShape(4.dp)) + .background( + if (dailyLimitMinutes > 0 && minutes >= dailyLimitMinutes) Color(0xFFFF453A) + else IOSBlue + ) + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Dedicated X-Axis day labels row: completely decoupled from bar height to prevent clipping Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(IOSTeal.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center + last7Days.forEach { (dayLabel, _, isToday) -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) ) { - Icon( - imageVector = Icons.Default.HourglassTop, - contentDescription = null, - tint = IOSTeal, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(6.dp)) - Column { Text( - text = "Usage", - color = IOSTextSecondary, + text = dayLabel, + color = if (isToday) IOSTextPrimary else IOSTextSecondary, fontSize = 11.sp, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - Text( - text = todayUsageText, - color = IOSTextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - modifier = Modifier.weight(1.05f), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(IOSPurple.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - tint = IOSPurple, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(6.dp)) - Column { - Text( - text = "Reels Time", - color = IOSTextSecondary, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - Text( - text = todayReelsText, - color = IOSTextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Row( - modifier = Modifier.weight(0.95f), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(IOSOrange.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Insights, - contentDescription = null, - tint = IOSOrange, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(6.dp)) - Column { - Text( - text = "Watched", - color = IOSTextSecondary, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - maxLines = 1 - ) - Text( - text = "$todayReelsWatched", - color = IOSTextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Medium, + textAlign = TextAlign.Center ) + if (isToday) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .size(3.dp) + .clip(CircleShape) + .background(IOSBlue) + ) + } else { + Spacer(modifier = Modifier.height(5.dp)) + } } } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(18.dp)) + HorizontalDivider(color = IOSDivider, thickness = 0.5.dp) + Spacer(modifier = Modifier.height(14.dp)) - // Daily Limit Progress + // Glanceable metrics row following Apple HIG typography Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "USAGE TODAY", + color = IOSTextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = todayUsageText, + color = IOSTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "REELS TIME", + color = IOSTextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = todayReelsText, + color = IOSTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "WATCHED", + color = IOSTextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "$todayReelsWatched reels", + color = IOSTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + item { + IOSGroupedCard { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Daily Limit ($dailyLimitMinutes min)", - color = IOSTextSecondary, - fontSize = 12.sp + text = if (dailyLimitMinutes > 0) "Daily Limit (${TimeFormatters.formatMinutes(dailyLimitMinutes)})" else "Daily Limit (Off)", + color = IOSTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold ) Text( - text = "${(limitFraction * 100).toInt()}% used", - color = if (limitFraction >= 1f) Color(0xFFFF453A) else IOSTextPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold + text = if (dailyLimitMinutes > 0) "${(limitFraction * 100).toInt()}% used" else "Off", + color = if (dailyLimitMinutes > 0 && limitFraction >= 1f) Color(0xFFFF453A) else IOSTextSecondary, + fontSize = 13.sp, + fontWeight = FontWeight.Medium ) } - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = if (dailyLimitMinutes > 0) "${TimeFormatters.formatMinutes(dailyLimitMinutes)} daily allowance" else "No daily time limit active", + color = IOSTextSecondary, + fontSize = 12.sp + ) + + Spacer(modifier = Modifier.height(10.dp)) LinearProgressIndicator( progress = { limitFraction }, @@ -439,7 +565,7 @@ fun ScreenTimeScreen( .fillMaxWidth() .height(6.dp) .clip(RoundedCornerShape(3.dp)), - color = if (limitFraction >= 1f) Color(0xFFFF453A) else IOSTeal, + color = if (limitFraction >= 1f) Color(0xFFFF453A) else IOSBlue, trackColor = Color(0xFF2C2C2E), strokeCap = StrokeCap.Round ) @@ -447,171 +573,105 @@ fun ScreenTimeScreen( } } - // 7-Day Bar Chart item { IOSGroupedCard { - Column(modifier = Modifier.padding(18.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Last 7 Days", - color = IOSTextPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - Text( - text = "Avg $weeklyDailyAverage min/day", - color = IOSTextSecondary, - fontSize = 13.sp - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - Row( + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top + ) { + Box( modifier = Modifier - .fillMaxWidth() - .height(130.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(IOSOrange.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center ) { - last7Days.forEach { (dayLabel, minutes) -> - val barFraction = (minutes.toFloat() / maxDayMinutes).coerceIn(0.06f, 1f) - val animatedHeight by animateFloatAsState( - targetValue = barFraction, - animationSpec = tween(500), - label = "barHeight" - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f) - ) { - Text( - text = if (minutes > 0) "${minutes}m" else "-", - color = IOSTextSecondary, - fontSize = 10.sp, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Box( - modifier = Modifier - .fillMaxHeight(animatedHeight) - .width(18.dp) - .clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp)) - .background( - if (minutes >= dailyLimitMinutes) Color(0xFFFF453A) - else IOSBlue - ) - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - text = dayLabel, - color = IOSTextSecondary, - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) - } - } - } - } - } - } - - // Insights & Discipline Stats - item { - IOSGroupedCard { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Default.Insights, contentDescription = null, tint = IOSOrange, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Mindful Insights", - color = IOSTextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold + modifier = Modifier.size(18.dp) ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) - Text( - text = if (weeklyDailyAverage <= dailyLimitMinutes) { - "Great job! You are staying within your mindful intention limits this week." - } else { - "You are averaging slightly above your planned limit. Consider adjusting your daily timer." - }, - color = IOSTextSecondary, - fontSize = 13.sp, - lineHeight = 18.sp - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Mindful Insight", + color = IOSTextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = when { + dailyLimitMinutes <= 0 -> "You have no daily limit set. Monitor your daily usage breakdown below to maintain mindful habits." + weeklyDailyAverage <= dailyLimitMinutes -> "Great job! You are averaging ${TimeFormatters.formatMinutes(weeklyDailyAverage)} per day, well within your ${TimeFormatters.formatMinutes(dailyLimitMinutes)} daily limit." + else -> "You are averaging ${TimeFormatters.formatMinutes(weeklyDailyAverage)} per day, which exceeds your planned ${TimeFormatters.formatMinutes(dailyLimitMinutes)} limit. Take regular breaks to recharge." + }, + color = IOSTextSecondary, + fontSize = 13.sp, + lineHeight = 18.sp + ) + } } } } - // Recent Daily Log with customizable ranges + // iOS-style Segmented Control for time range item { - Row( + Box( modifier = Modifier .fillMaxWidth() - .padding(start = 4.dp, end = 4.dp, top = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .clip(RoundedCornerShape(9.dp)) + .background(Color(0xFF1C1C1E)) + .border(0.5.dp, Color(0xFF2C2C2E), RoundedCornerShape(9.dp)) + .padding(2.dp) ) { - Text( - text = "DAILY BREAKDOWN", - color = IOSTextSecondary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { DailyBreakdownRange.values().forEach { range -> val isSelected = selectedRange == range Box( modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(if (isSelected) IOSBlue else Color(0xFF2C2C2E)) + .weight(1f) + .height(30.dp) + .clip(RoundedCornerShape(7.dp)) + .background(if (isSelected) Color(0xFF3A3A3C) else Color.Transparent) .clickable { if (range == DailyBreakdownRange.CUSTOM) { showCustomPicker = true } else { selectedRange = range } - } - .padding(horizontal = 7.dp, vertical = 3.dp) + }, + contentAlignment = Alignment.Center ) { Text( text = range.label, color = if (isSelected) Color.White else IOSTextSecondary, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium ) } } } } + } - Spacer(modifier = Modifier.height(6.dp)) + item { + IOSSectionTitle("ACTIVITY BREAKDOWN") + } + item { IOSGroupedCard { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Column { if (displayedRecords.isEmpty()) { Text( text = "No screen time recorded for this period.", color = IOSTextSecondary, fontSize = 14.sp, - modifier = Modifier.padding(vertical = 12.dp) + modifier = Modifier.padding(16.dp) ) } else { displayedRecords.forEachIndexed { idx, record -> @@ -619,18 +679,21 @@ fun ScreenTimeScreen( val reelsM = record.reelsSeconds / 60 val reelsS = record.reelsSeconds % 60 val totalDisplay = if (record.seconds < 60) "${record.seconds}s" else "$mins min" + val dayRatio = if (maxDayMinutes > 0) (mins.toFloat() / maxDayMinutes).coerceIn(0.04f, 1f) else 0f + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = formatRecordDate(record.date), color = IOSTextPrimary, - fontSize = 15.sp + fontSize = 15.sp, + fontWeight = FontWeight.Medium ) val subtitleParts = mutableListOf() if (record.reelsSeconds > 0) { @@ -642,27 +705,54 @@ fun ScreenTimeScreen( subtitleParts.add("$watchedCount watched") } if (subtitleParts.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) Text( text = subtitleParts.joinToString(" โ€ข "), - color = IOSPurple, - fontSize = 12.sp, - fontWeight = FontWeight.Medium + color = IOSTextSecondary, + fontSize = 12.sp ) } } - Text( - text = totalDisplay, - color = if (mins >= dailyLimitMinutes) Color(0xFFFF453A) else IOSTextSecondary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Apple-style horizontal proportion bar + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color(0xFF2C2C2E)), + contentAlignment = Alignment.CenterStart + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(dayRatio) + .clip(RoundedCornerShape(2.dp)) + .background( + if (dailyLimitMinutes > 0 && mins >= dailyLimitMinutes) Color(0xFFFF453A) + else IOSBlue + ) + ) + } + + Text( + text = totalDisplay, + color = if (mins >= dailyLimitMinutes && dailyLimitMinutes > 0) Color(0xFFFF453A) else IOSTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + } } + if (idx < displayedRecords.lastIndex) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(Color(0xFF2C2C2E)) + HorizontalDivider( + color = IOSDivider, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 16.dp) ) } } diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt new file mode 100644 index 0000000..5b43b9e --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt @@ -0,0 +1,437 @@ +package com.ujwal.focusgram.feature.screentime + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import android.content.pm.ServiceInfo +import com.ujwal.focusgram.FocusGramApp +import com.ujwal.focusgram.MainActivity +import com.ujwal.focusgram.R +import com.ujwal.focusgram.core.database.ScreenTimeRecord +import com.ujwal.focusgram.core.database.SessionRecord +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Foreground service that tracks active session duration, updates the persistent notification, + * and broadcasts [ACTION_LIMIT_EXCEEDED] when daily limits are exhausted. + */ +class ScreenTimeService : Service() { + + private val serviceScope = CoroutineScope(Dispatchers.Default) + private var trackingJob: Job? = null + + private var sessionStartTime = 0L + private var sessionSeconds = 0 + private var sessionDurationMinutes = 15 + private var sessionType = "INSTAGRAM" // "INSTAGRAM" or "REELS" + private var isReelsSession = false + + private fun getSessionSubtitle(isPaused: Boolean = false): String { + val durationPart = if (sessionDurationMinutes > 0) "${sessionDurationMinutes}m" else "Stay intentional" + return if (isPaused) { + "Paused outside Instagram โ€ข $durationPart" + } else { + "Session in progress โ€ข $durationPart" + } + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + createIntervalNotificationChannel() + val minutes = sessionSeconds / 60 + val secs = sessionSeconds % 60 + val timeStr = String.format("%02d:%02d", minutes, secs) + val initialTitle = "Session Timer: $timeStr" + val initialNotification = buildNotification(initialTitle, getSessionSubtitle(false)) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + initialNotification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + } else { + startForeground(NOTIFICATION_ID, initialNotification) + } + } catch (e: Exception) { + Log.e(TAG, "Primary startForeground failed in onCreate: ${e.message}", e) + try { + startForeground(NOTIFICATION_ID, initialNotification) + } catch (fallbackEx: Exception) { + Log.e(TAG, "Fallback startForeground failed: ${fallbackEx.message}", fallbackEx) + stopSelf() + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + isReelsSession = intent.getBooleanExtra(EXTRA_IS_REELS, false) + val initSecs = intent.getIntExtra(EXTRA_INITIAL_SECONDS, 0) + if (initSecs > 0) { + sessionSeconds = initSecs + } + val durMins = intent.getIntExtra(EXTRA_SESSION_DURATION_MINUTES, 0) + if (durMins > 0) { + sessionDurationMinutes = durMins + } + sessionType = if (isReelsSession) "REELS" else "INSTAGRAM" + startTracking() + } + ACTION_UPDATE_ROUTE -> { + val wasReels = isReelsSession + isReelsSession = intent.getBooleanExtra(EXTRA_IS_REELS, false) + sessionType = if (isReelsSession) "REELS" else "INSTAGRAM" + if (wasReels != isReelsSession && trackingJob?.isActive == true) { + updateSessionNotification() + } + } + ACTION_PAUSE -> { + val initSecs = intent.getIntExtra(EXTRA_INITIAL_SECONDS, -1) + if (initSecs >= 0) { + sessionSeconds = initSecs + } + val durMins = intent.getIntExtra(EXTRA_SESSION_DURATION_MINUTES, 0) + if (durMins > 0) { + sessionDurationMinutes = durMins + } + pauseTracking() + } + ACTION_RESUME -> { + val initSecs = intent.getIntExtra(EXTRA_INITIAL_SECONDS, 0) + if (initSecs > 0) { + sessionSeconds = initSecs + } + val durMins = intent.getIntExtra(EXTRA_SESSION_DURATION_MINUTES, 0) + if (durMins > 0) { + sessionDurationMinutes = durMins + } + startTracking() + } + ACTION_STOP -> { + stopTracking() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } catch (_: Exception) {} + try { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.cancel(NOTIFICATION_ID) + } catch (_: Exception) {} + stopSelf() + } + } + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + stopTracking() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } catch (_: Exception) {} + try { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.cancel(NOTIFICATION_ID) + } catch (_: Exception) {} + } + + private fun startTracking() { + if (trackingJob?.isActive == true) return + + sessionStartTime = System.currentTimeMillis() - (sessionSeconds * 1000L) + updateSessionNotification() + + trackingJob = serviceScope.launch { + val app = FocusGramApp.instance + val settingsRepo = app.settingsRepository + + while (isActive) { + delay(1000L) + sessionSeconds++ + updateSessionNotification() + + // Check 5-minute interval alert + if (sessionSeconds > 0 && sessionSeconds % 300 == 0) { + try { + val settings = settingsRepo.settingsFlow.first() + if (settings.notifyEvery5Minutes) { + sendIntervalAlert(sessionSeconds / 60) + } + } catch (e: Exception) { + Log.w(TAG, "Error checking interval notification: ${e.message}") + } + } + } + } + } + + private fun updateSessionNotification() { + val minutes = sessionSeconds / 60 + val secs = sessionSeconds % 60 + val timeStr = String.format("%02d:%02d", minutes, secs) + val title = "Session Timer: $timeStr" + updateNotification(title, getSessionSubtitle(false)) + } + + private fun pauseTracking() { + trackingJob?.cancel() + trackingJob = null + val minutes = sessionSeconds / 60 + val secs = sessionSeconds % 60 + val timeStr = String.format("%02d:%02d", minutes, secs) + updateNotification("Session Timer: $timeStr (Paused)", getSessionSubtitle(true)) + } + + private fun stopTracking() { + trackingJob?.cancel() + trackingJob = null + + val endTime = System.currentTimeMillis() + val duration = ((endTime - sessionStartTime) / 1000).toInt() + + if (duration > 5) { + serviceScope.launch { + try { + val app = FocusGramApp.instance + app.database.sessionRecordDao().insert( + SessionRecord( + sessionType = sessionType, + startTime = sessionStartTime, + endTime = endTime, + durationSeconds = duration, + completed = true + ) + ) + } catch (e: Exception) { + Log.e(TAG, "Error saving session record on stop: ${e.message}", e) + } + } + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "FocusGram Session Timer", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows active Instagram usage session duration and limits." + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createIntervalNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_INTERVAL_ID, + "5-Minute Session Alerts", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Periodic heads-up alerts every 5 minutes during an active session." + enableLights(true) + enableVibration(true) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun sendIntervalAlert(minutesElapsed: Int) { + try { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + minutesElapsed, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val alert = NotificationCompat.Builder(this, CHANNEL_INTERVAL_ID) + .setContentTitle("Session Alert: $minutesElapsed min") + .setContentText("You've been using Instagram for $minutesElapsed minutes. Stay mindful.") + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setAutoCancel(true) + .build() + + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(INTERVAL_NOTIFICATION_BASE_ID + minutesElapsed, alert) + } catch (e: Exception) { + Log.w(TAG, "Failed to send 5-minute interval notification: ${e.message}") + } + } + + private fun buildNotification(title: String, content: String): Notification { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setAutoCancel(false) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + notification.flags = notification.flags or Notification.FLAG_ONGOING_EVENT or Notification.FLAG_NO_CLEAR + return notification + } + + private fun updateNotification(title: String, content: String) { + try { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(title, content)) + } catch (e: Exception) { + Log.w(TAG, "Failed to update notification: ${e.message}") + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val TAG = "ScreenTimeService" + const val CHANNEL_ID = "focusgram_session_timer" + const val CHANNEL_INTERVAL_ID = "focusgram_interval_alerts" + const val NOTIFICATION_ID = 1001 + const val INTERVAL_NOTIFICATION_BASE_ID = 2000 + + const val ACTION_START = "com.ujwal.focusgram.action.START_SESSION" + const val ACTION_PAUSE = "com.ujwal.focusgram.action.PAUSE_SESSION" + const val ACTION_RESUME = "com.ujwal.focusgram.action.RESUME_SESSION" + const val ACTION_STOP = "com.ujwal.focusgram.action.STOP_SESSION" + const val ACTION_UPDATE_ROUTE = "com.ujwal.focusgram.action.UPDATE_ROUTE" + const val ACTION_LIMIT_EXCEEDED = "com.ujwal.focusgram.action.LIMIT_EXCEEDED" + const val EXTRA_IS_REELS = "extra_is_reels" + const val EXTRA_INITIAL_SECONDS = "extra_initial_seconds" + const val EXTRA_SESSION_DURATION_MINUTES = "extra_session_duration_minutes" + + fun startSession(context: Context, isReels: Boolean = false, initialSeconds: Int = 0, sessionDurationMinutes: Int = 15) { + try { + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_IS_REELS, isReels) + putExtra(EXTRA_INITIAL_SECONDS, initialSeconds) + putExtra(EXTRA_SESSION_DURATION_MINUTES, sessionDurationMinutes) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to startSession: ${e.message}", e) + } + } + + fun pauseSession(context: Context, elapsedSeconds: Int = -1, sessionDurationMinutes: Int = 0) { + try { + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_PAUSE + if (elapsedSeconds >= 0) { + putExtra(EXTRA_INITIAL_SECONDS, elapsedSeconds) + } + if (sessionDurationMinutes > 0) { + putExtra(EXTRA_SESSION_DURATION_MINUTES, sessionDurationMinutes) + } + } + context.startService(intent) + } catch (e: Exception) { + Log.w(TAG, "Failed to pause ScreenTimeService: ${e.message}") + } + } + + fun resumeSession(context: Context, elapsedSeconds: Int = 0, sessionDurationMinutes: Int = 0) { + try { + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_RESUME + putExtra(EXTRA_INITIAL_SECONDS, elapsedSeconds) + if (sessionDurationMinutes > 0) { + putExtra(EXTRA_SESSION_DURATION_MINUTES, sessionDurationMinutes) + } + } + context.startService(intent) + } catch (e: Exception) { + Log.w(TAG, "Failed to resume ScreenTimeService: ${e.message}") + } + } + + fun updateRoute(context: Context, isReels: Boolean) { + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_UPDATE_ROUTE + putExtra(EXTRA_IS_REELS, isReels) + } + try { + context.startService(intent) + } catch (e: Exception) { + Log.w(TAG, "Failed to send route update to ScreenTimeService: ${e.message}") + } + } + + fun stopSession(context: Context) { + try { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + manager?.cancel(NOTIFICATION_ID) + } catch (_: Exception) {} + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_STOP + } + try { + context.startService(intent) + } catch (_: Exception) { + try { + context.stopService(Intent(context, ScreenTimeService::class.java)) + } catch (_: Exception) {} + } + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt similarity index 92% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt index 12f4c4a..faab375 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/AppSessionPicker.kt @@ -40,10 +40,7 @@ import com.ujwal.focusgram.core.ui.components.CupertinoWheelPicker import com.ujwal.focusgram.core.ui.theme.PureBlack /** - * AppSessionPicker - * Premium iOS monochrome intention timer displayed on launch or settings. - * Allows users to mindfully set their planned Instagram session duration (1 to 60 minutes) - * using an authentic Apple Clock rotating drum wheel picker or quick preset chips. + * Intention timer modal for configuring planned Instagram session duration (1 to 60 minutes). */ @Composable fun AppSessionPicker( @@ -66,7 +63,6 @@ fun AppSessionPicker( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() ) { - // Top Dismiss Button (if modal) if (onDismiss != null) { Row( modifier = Modifier.fillMaxWidth(), @@ -94,7 +90,6 @@ fun AppSessionPicker( Spacer(modifier = Modifier.height(12.dp)) - // Monochrome iOS Timer Badge Box( modifier = Modifier .size(64.dp) @@ -134,7 +129,6 @@ fun AppSessionPicker( Spacer(modifier = Modifier.weight(1f)) - // Apple Clock Single Minutes Drum Wheel (1..60) CupertinoWheelPicker( items = minutesList, selectedIndex = selectedMinuteIndex, @@ -149,7 +143,6 @@ fun AppSessionPicker( Spacer(modifier = Modifier.weight(1f)) - // Official Apple Style Pill Button (Pure White with Bold Black Text) Button( onClick = { onSessionSelected(selectedMinutes) }, modifier = Modifier diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/session/DailyLimitGateScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/DailyLimitGateScreen.kt new file mode 100644 index 0000000..d3f7fb3 --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/DailyLimitGateScreen.kt @@ -0,0 +1,301 @@ +package com.ujwal.focusgram.feature.session + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ujwal.focusgram.core.ui.theme.AlertRed +import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.utils.HapticHelper +import com.ujwal.focusgram.core.utils.TimeFormatters + +/** + * Fullscreen gate shown when the user's global daily screen time limit is reached. + * Allows up to 2 extensions (5 minutes each). Once exhausted, blocks app access for today. + */ +@Composable +fun DailyLimitGateScreen( + dailyLimitMinutes: Int, + todayMinutesUsed: Int, + extensionsRemaining: Int, + onExit: () -> Unit, + onWatchAdToExtend: () -> Unit, + onDebugBypass: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + HapticHelper.performSessionCompletionAlert(context) + } + + // Intercepts back navigation: closes the app so user cannot bypass + BackHandler(enabled = true) { + onExit() + } + + val isBlockedForToday = extensionsRemaining <= 0 + + Box( + modifier = modifier + .fillMaxSize() + .background(PureBlack) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) {}, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Box( + modifier = Modifier + .size(68.dp) + .clip(CircleShape) + .background(Color(0xFF1C1C1E)) + .border(1.dp, if (isBlockedForToday) AlertRed.copy(alpha = 0.5f) else Color(0xFF2C2C2E), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isBlockedForToday) Icons.Default.Lock else Icons.Default.HourglassBottom, + contentDescription = null, + tint = if (isBlockedForToday) AlertRed else Color.White, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = if (isBlockedForToday) "Daily Limit Reached" else "Daily Limit Reached", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (isBlockedForToday) { + "You have reached your daily Instagram screen time limit and used all extensions. FocusGram is locked for the rest of today." + } else { + "You have reached your planned daily Instagram limit of ${TimeFormatters.formatMinutes(dailyLimitMinutes)}. Take a break or extend." + }, + color = Color(0xFF8E8E93), + fontSize = 15.sp, + textAlign = TextAlign.Center, + lineHeight = 21.sp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + if (com.ujwal.focusgram.BuildConfig.DEBUG && onDebugBypass != null) { + Spacer(modifier = Modifier.height(16.dp)) + androidx.compose.material3.TextButton( + onClick = onDebugBypass + ) { + Text( + text = "Skip Daily Limit (Debug)", + color = Color(0xFFFF9800), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF1C1C1E)) + .border(0.5.dp, Color(0xFF2C2C2E), RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Today's Usage", + color = Color(0xFF8E8E93), + fontSize = 14.sp + ) + Text( + text = TimeFormatters.formatMinutes(todayMinutesUsed), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + + HorizontalDivider( + color = Color(0xFF2C2C2E), + thickness = 0.5.dp, + modifier = Modifier.padding(vertical = 12.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Daily Limit Set", + color = Color(0xFF8E8E93), + fontSize = 14.sp + ) + Text( + text = TimeFormatters.formatMinutes(dailyLimitMinutes), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + + HorizontalDivider( + color = Color(0xFF2C2C2E), + thickness = 0.5.dp, + modifier = Modifier.padding(vertical = 12.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Daily Extensions", + color = Color(0xFF8E8E93), + fontSize = 14.sp + ) + Text( + text = if (isBlockedForToday) "0 / 2 remaining (Locked)" else "$extensionsRemaining / 2 remaining", + color = if (extensionsRemaining > 0) Color.White else AlertRed, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + Button( + onClick = onExit, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(26.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ) + ) { + Text( + text = "Close Instagram", + color = Color.Black, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + if (!isBlockedForToday) { + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onWatchAdToExtend, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(26.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF2C2C2E)), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color(0xFF1C1C1E), + contentColor = Color.White + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Watch Ad to Extend (+5m)", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + if (com.ujwal.focusgram.BuildConfig.DEBUG && onDebugBypass != null) { + Spacer(modifier = Modifier.height(16.dp)) + androidx.compose.material3.TextButton( + onClick = onDebugBypass + ) { + Text( + text = "Skip Daily Limit (Debug)", + color = Color(0xFFFF9800), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt similarity index 84% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt index 35d03df..6b64195 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt @@ -41,13 +41,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ujwal.focusgram.core.ui.theme.PureBlack import com.ujwal.focusgram.core.utils.HapticHelper +import com.ujwal.focusgram.core.utils.TimeFormatters /** - * SessionExhaustedScreen - * Un-bypassable mindful lockdown gate displayed when the session timer expires. - * Premium iOS monochrome design (black, white, gray shades). - * Enforces session limits by consuming back gestures and clicks. - * Allows watching an ad to extend 3 minutes (up to 3 times per session). + * Lockdown gate presented when a timed session expires. + * + * Consumes back gestures and clicks to prevent bypassing the limit without exiting or extending. */ @Composable fun SessionExhaustedScreen( @@ -55,16 +54,16 @@ fun SessionExhaustedScreen( extensionsRemaining: Int, onExit: () -> Unit, onWatchAdToExtend: () -> Unit, + onDebugBypass: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val context = LocalContext.current - // Trigger physical session completion vibration alert (double buzz) LaunchedEffect(Unit) { HapticHelper.performSessionCompletionAlert(context) } - // Lock down back navigation: back button will exit the app, never return to webview + // Intercepts back navigation: exits the app rather than returning to the active session BackHandler(enabled = true) { onExit() } @@ -73,7 +72,7 @@ fun SessionExhaustedScreen( modifier = modifier .fillMaxSize() .background(PureBlack) - // Consume all touches so clicks never bleed through to GeckoView underneath + // Consumes all touches so clicks never bleed through to GeckoView underneath .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null @@ -88,7 +87,6 @@ fun SessionExhaustedScreen( ) { Spacer(modifier = Modifier.height(32.dp)) - // Monochrome iOS Hourglass Badge Box( modifier = Modifier .size(64.dp) @@ -126,9 +124,22 @@ fun SessionExhaustedScreen( modifier = Modifier.padding(horizontal = 16.dp) ) + if (com.ujwal.focusgram.BuildConfig.DEBUG && onDebugBypass != null) { + Spacer(modifier = Modifier.height(16.dp)) + androidx.compose.material3.TextButton( + onClick = onDebugBypass + ) { + Text( + text = "Skip Session Limit (Debug)", + color = Color(0xFFFF9800), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Spacer(modifier = Modifier.height(32.dp)) - // iOS Grouped Summary Card Box( modifier = Modifier .fillMaxWidth() @@ -149,7 +160,7 @@ fun SessionExhaustedScreen( fontSize = 14.sp ) Text( - text = "$elapsedMinutes min completed", + text = "${TimeFormatters.formatMinutes(elapsedMinutes)} completed", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold @@ -184,7 +195,6 @@ fun SessionExhaustedScreen( Spacer(modifier = Modifier.height(36.dp)) - // Primary Exit Action: Pure White Apple Style Pill Button Button( onClick = onExit, modifier = Modifier @@ -204,7 +214,6 @@ fun SessionExhaustedScreen( ) } - // Secondary Action: Extend with Ad (if extensions remaining) if (extensionsRemaining > 0) { Spacer(modifier = Modifier.height(12.dp)) @@ -241,6 +250,20 @@ fun SessionExhaustedScreen( } } + if (com.ujwal.focusgram.BuildConfig.DEBUG && onDebugBypass != null) { + Spacer(modifier = Modifier.height(16.dp)) + androidx.compose.material3.TextButton( + onClick = onDebugBypass + ) { + Text( + text = "Skip Session Limit (Debug)", + color = Color(0xFFFF9800), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt similarity index 98% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt index 15aa2ce..7a75e08 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionModal.kt @@ -45,8 +45,7 @@ import com.ujwal.focusgram.core.ui.theme.TextSecondary val REEL_SESSION_PRESETS = listOf(1, 3, 5, 10, 15, 20) /** - * SessionModal - * Bottom modal container to initiate a timed Reels session. + * Bottom sheet modal for selecting or customizing a timed Reels session. */ @Composable fun SessionModal( diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt similarity index 81% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt index 14f72fc..f2792e3 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt @@ -48,20 +48,25 @@ import android.widget.Toast import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.material3.CircularProgressIndicator import com.ujwal.focusgram.core.utils.AppIconManager +import com.ujwal.focusgram.core.utils.TimeFormatters import com.ujwal.focusgram.core.utils.HistoricalIconManager import com.ujwal.focusgram.core.security.EncryptedStorage import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ShoppingBag @@ -77,6 +82,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.foundation.Image +import androidx.compose.ui.graphics.Brush +import com.ujwal.focusgram.core.ui.components.FocusGramCard import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -92,6 +101,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.SystemUpdate +import com.ujwal.focusgram.feature.update.UpdateCheckResult +import com.ujwal.focusgram.feature.update.UpdateCheckerService +import com.ujwal.focusgram.feature.update.UpdateInfo import androidx.compose.material.icons.filled.Person import com.ujwal.focusgram.core.database.ScreenTimeDao import com.ujwal.focusgram.core.datastore.SettingsRepository @@ -124,7 +138,6 @@ enum class SettingsSubpage { FOCUS, GUARDRAILS, EXTRAS, - APPEARANCE, CHOOSE_APP_ICON, NOTIFICATIONS, GHOST, @@ -133,10 +146,10 @@ enum class SettingsSubpage { } /** - * SettingsScreen - * Redesigned to match the official iOS grouped Settings hierarchy with modular subpages. - * Provides authentic Apple monochrome aesthetics (black, white, gray variants), - * grouped category rows, and modular sub-screens ported from Flutter. + * Root settings coordinator displaying grouped preference sections and nested subpages. + * + * Dispatches mutations directly to [SettingsRepository] flows while delegating deep routes + * (history, screen time, biometric app lock) to top-level navigation callbacks. */ @Composable fun SettingsScreen( @@ -155,6 +168,8 @@ fun SettingsScreen( onSubpageChange: (SettingsSubpage) -> Unit = {}, encryptedStorage: EncryptedStorage? = null, onNavigateChatPinSetup: () -> Unit = {}, + onNavigateOnboarding: () -> Unit = {}, + onNavigateDebugMenu: () -> Unit = {}, modifier: Modifier = Modifier ) { val collectedSettings by repository.settingsFlow.collectAsState(initial = inputSettings ?: FocusSettings()) @@ -169,22 +184,24 @@ fun SettingsScreen( onSubpageChange(page) } var showLegalDialog by remember { mutableStateOf(false) } - var showDeveloperDialog by remember { mutableStateOf(false) } + var showAboutDialog by remember { mutableStateOf(false) } var pendingRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) } var adDialogReason by remember { mutableStateOf(null) } var showSupportAdDialog by remember { mutableStateOf(false) } + var isCheckingUpdate by remember { mutableStateOf(false) } + var availableUpdate by remember { mutableStateOf(null) } + val onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { action, reason -> pendingRewardAction = action adDialogReason = reason showSupportAdDialog = true } - // Intercept back presses: go back to MAIN subpage first, or pop screen BackHandler { if (activeSubpage == SettingsSubpage.CHOOSE_APP_ICON) { - setSubpage(SettingsSubpage.APPEARANCE) + setSubpage(SettingsSubpage.EXTRAS) } else if (activeSubpage != SettingsSubpage.MAIN) { setSubpage(SettingsSubpage.MAIN) } else { @@ -192,7 +209,6 @@ fun SettingsScreen( } } - // Legal Disclaimer Dialog if (showLegalDialog) { AlertDialog( onDismissRequest = { showLegalDialog = false }, @@ -232,89 +248,183 @@ fun SettingsScreen( ) } - // Developer Dialog - if (showDeveloperDialog) { + if (showAboutDialog) { AlertDialog( - onDismissRequest = { showDeveloperDialog = false }, - containerColor = Color(0xFF1C1C1E), - icon = { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color(0xFF2C2C2E)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(28.dp) - ) - } - }, - title = { - Text( - text = "Developer Profile", - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - }, + onDismissRequest = { showAboutDialog = false }, + containerColor = Color(0xEB1C1C1E), + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .border( + BorderStroke( + 1.dp, + Brush.verticalGradient(listOf(Color(0x38FFFFFF), Color(0x10FFFFFF))) + ), + RoundedCornerShape(24.dp) + ), text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val appIconBitmap = remember(context) { + AppIconManager.getIconBitmap(context, R.mipmap.ic_launcher) + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (appIconBitmap != null) { + Image( + bitmap = appIconBitmap.asImageBitmap(), + contentDescription = "FocusGram App Logo", + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(18.dp)) + ) + } else { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "FocusGram App Logo", + tint = Color.White, + modifier = Modifier.size(54.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Ujwal", + text = "FocusGram", color = Color.White, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp + fontWeight = FontWeight.Bold, + fontSize = 20.sp ) Text( - text = "Creator and maintainer of FocusGram. Independent open-source developer focused on privacy, digital wellbeing, and user-centric software.", + text = "v${com.ujwal.focusgram.BuildConfig.VERSION_NAME}", color = Color(0xFF8E8E93), - fontSize = 13.sp, - lineHeight = 18.sp - ) - Text( - text = "GitHub: @Ujwal223", - color = IOSBlue, - fontSize = 13.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "A digital wellness and intentionality client for Instagram built with native performance. FocusGram helps you stay in control of your time with custom friction gates, mindful limits, client-side content hiders, and ad blocking. 100% private and on-device.", + color = Color(0xFFC7C7CC), + fontSize = 13.sp, + lineHeight = 18.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(modifier = Modifier.height(18.dp)) + + FocusGramCard( + modifier = Modifier.fillMaxWidth(), + backgroundColor = Color(0xFF242428), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Ujwal223")) + context.startActivity(intent) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF3A3A3C)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Developer", + color = Color(0xFF8E8E93), + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + Text( + text = "Ujwal (@Ujwal223)", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = Color(0xFF8E8E93), + modifier = Modifier.size(14.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + FocusGramCard( + modifier = Modifier.fillMaxWidth(), + backgroundColor = Color(0xFF242428), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Ujwal223/FocusGram")) + context.startActivity(intent) + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF3A3A3C)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = IOSBlue, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "GitHub Repository", + color = Color(0xFF8E8E93), + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + Text( + text = "Ujwal223/FocusGram", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = Color(0xFF8E8E93), + modifier = Modifier.size(14.dp) + ) + } + } } }, confirmButton = { - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Ujwal223")) - context.startActivity(intent) - showDeveloperDialog = false - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black - ), - shape = RoundedCornerShape(20.dp) + TextButton( + onClick = { showAboutDialog = false } ) { - Text("GitHub Profile", fontWeight = FontWeight.Bold) - } - }, - dismissButton = { - OutlinedButton( - onClick = { showDeveloperDialog = false }, - border = BorderStroke(1.dp, Color(0xFF3A3A3C)), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = Color.White - ) - ) { - Text("Close") + Text("Close", color = Color.White, fontWeight = FontWeight.SemiBold) } } ) } - // Support Ad Dialog if (showSupportAdDialog && adDialogReason != null) { AlertDialog( onDismissRequest = { @@ -324,7 +434,7 @@ fun SettingsScreen( containerColor = Color(0xFF1C1C1E), title = { Text( - text = "Support FocusGram โค๏ธ", + text = "Support FocusGram", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp @@ -339,7 +449,7 @@ fun SettingsScreen( fontSize = 14.sp ) Text( - text = "Your 20 seconds can provide a huge contribution to the continuous development and improvement of FocusGram.\n\nThank you for supporting this project! โค๏ธ", + text = "Your 20 seconds provides a meaningful contribution to the continuous open-source development of FocusGram.\n\nThank you for supporting this project.", color = Color(0xFF8E8E93), fontSize = 13.sp, lineHeight = 18.sp @@ -392,7 +502,6 @@ fun SettingsScreen( .background(IOSBg) .padding(WindowInsets.statusBars.asPaddingValues()) ) { - // Navigation Top Bar Row( modifier = Modifier .fillMaxWidth() @@ -401,7 +510,7 @@ fun SettingsScreen( ) { IconButton(onClick = { if (activeSubpage == SettingsSubpage.CHOOSE_APP_ICON) { - setSubpage(SettingsSubpage.APPEARANCE) + setSubpage(SettingsSubpage.EXTRAS) } else if (activeSubpage != SettingsSubpage.MAIN) { setSubpage(SettingsSubpage.MAIN) } else { @@ -420,12 +529,11 @@ fun SettingsScreen( SettingsSubpage.FOCUS -> "Focus Mode" SettingsSubpage.GUARDRAILS -> "Time Control & Guardrails" SettingsSubpage.EXTRAS -> "Extras" - SettingsSubpage.APPEARANCE -> "Appearance" SettingsSubpage.CHOOSE_APP_ICON -> "Choose App Icon" SettingsSubpage.NOTIFICATIONS -> "Privacy & Notifications" SettingsSubpage.GHOST -> "Ghost Mode" - SettingsSubpage.LICENSES -> "Open Source & Credits" - SettingsSubpage.CHAT_SECURITY -> "Chat Security & DM Lock" + SettingsSubpage.LICENSES -> "Open Source Licenses & Acknowledgements" + SettingsSubpage.CHAT_SECURITY -> "Chat/DM Lock" }, color = IOSTextPrimary, fontSize = 17.sp, @@ -434,7 +542,6 @@ fun SettingsScreen( ) } - // Subpage Router when (activeSubpage) { SettingsSubpage.MAIN -> { MainSettingsContent( @@ -446,7 +553,29 @@ fun SettingsScreen( onNavigateReelsHistory = onNavigateReelsHistory, onWatchAd = onWatchAd, onShowLegal = { showLegalDialog = true }, - onShowDeveloper = { showDeveloperDialog = true }, + onShowAbout = { showAboutDialog = true }, + onNavigateOnboarding = onNavigateOnboarding, + onNavigateDebugMenu = onNavigateDebugMenu, + onCheckUpdates = { + if (!isCheckingUpdate) { + isCheckingUpdate = true + scope.launch { + val service = UpdateCheckerService(context, repository) + when (val res = service.checkForUpdates(ignoreDismissed = true)) { + is UpdateCheckResult.Available -> { + availableUpdate = res.updateInfo + } + is UpdateCheckResult.UpToDate -> { + Toast.makeText(context, "FocusGram is up to date!", Toast.LENGTH_SHORT).show() + } + is UpdateCheckResult.Failed -> { + Toast.makeText(context, "Could not check updates: ${res.reason}", Toast.LENGTH_SHORT).show() + } + } + isCheckingUpdate = false + } + } + }, screenTimeDao = screenTimeDao, reelHistory = reelHistory ) @@ -474,13 +603,7 @@ fun SettingsScreen( onNavigateUrl = { url -> onNavigateUrl(url) onBack() - } - ) - } - SettingsSubpage.APPEARANCE -> { - AppearanceSubpage( - repository = repository, - settings = settings, + }, onNavigateChooseIcon = { setSubpage(SettingsSubpage.CHOOSE_APP_ICON) } ) } @@ -488,14 +611,18 @@ fun SettingsScreen( ChooseAppIconSubpage( repository = repository, settings = settings, - onBack = { setSubpage(SettingsSubpage.APPEARANCE) } + onBack = { setSubpage(SettingsSubpage.EXTRAS) } ) } SettingsSubpage.NOTIFICATIONS -> { NotificationsSubpage(repository = repository, settings = settings) } SettingsSubpage.GHOST -> { - GhostModeSubpage(repository = repository, settings = settings) + GhostModeSubpage( + repository = repository, + settings = settings, + onRequestAdReward = onRequestAdReward + ) } SettingsSubpage.LICENSES -> { LicensesSubpage() @@ -509,11 +636,59 @@ fun SettingsScreen( ) } } + + availableUpdate?.let { update -> + AlertDialog( + onDismissRequest = { availableUpdate = null }, + title = { + Text( + text = "FocusGram v${update.latestVersion} Available", + color = IOSTextPrimary, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column { + Text( + text = "A new release is available on GitHub.", + color = IOSTextSecondary, + fontSize = 13.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = update.whatsNew.ifBlank { "Performance improvements and bug fixes." }, + color = IOSTextPrimary, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + }, + confirmButton = { + Button( + onClick = { + val targetUrl = update.downloadUrl ?: update.releaseUrl + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + availableUpdate = null + }, + colors = ButtonDefaults.buttonColors(containerColor = IOSBlue) + ) { + Text("Download", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + OutlinedButton(onClick = { availableUpdate = null }) { + Text("Later", color = IOSTextSecondary) + } + }, + containerColor = IOSGroupBg + ) + } } } -// โ”€โ”€โ”€ MAIN SETTINGS SCREEN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun MainSettingsContent( settings: FocusSettings, @@ -524,7 +699,10 @@ private fun MainSettingsContent( onNavigateReelsHistory: () -> Unit, onWatchAd: () -> Unit, onShowLegal: () -> Unit, - onShowDeveloper: () -> Unit, + onShowAbout: () -> Unit, + onNavigateOnboarding: () -> Unit, + onNavigateDebugMenu: () -> Unit, + onCheckUpdates: () -> Unit, screenTimeDao: ScreenTimeDao? = null, reelHistory: ReelHistory? = null ) { @@ -534,8 +712,8 @@ private fun MainSettingsContent( val todayRecord by (screenTimeDao?.getForDate(todayStr) ?: flowOf(null)).collectAsState(initial = null) val todayWatchedFromHistory by (reelHistory?.todayCount ?: flowOf(0)).collectAsState(initial = 0) - val todaySeconds = todayRecord?.seconds ?: (settings.instaMinutes * 60) - val todayReelsSeconds = todayRecord?.reelsSeconds ?: (settings.reelsMinutes * 60) + val todaySeconds = todayRecord?.seconds ?: 0 + val todayReelsSeconds = todayRecord?.reelsSeconds ?: 0 val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0) val appTimeText = when { @@ -555,7 +733,6 @@ private fun MainSettingsContent( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // iOS Monochrome Voluntary Support Banner item { IOSGroupedCard(modifier = Modifier.padding(top = 10.dp)) { Column( @@ -672,7 +849,6 @@ private fun MainSettingsContent( } } - // Quick Stats Badge Row (iOS Monochrome) item { IOSGroupedCard(modifier = Modifier.padding(top = 10.dp)) { Row( @@ -700,20 +876,19 @@ private fun MainSettingsContent( } } - // SECTION 1: FOCUS & WELLBEING item { IOSSectionTitle(title = "FOCUS & DIGITAL WELLBEING") IOSGroupedCard { IOSSettingsRow( title = "Focus Mode", - subtitle = if (settings.minimalModeEnabled) "Minimal mode enabled" else "Distraction blocking, feed blur, content hider", + subtitle = if (settings.minimalModeEnabled) "Minimal mode enabled" else "Minimal mode, feed blur, autoplay and distraction blockers", icon = Icons.Default.Block, iconBgColor = IOSRed, onClick = { onNavigateSubpage(SettingsSubpage.FOCUS) } ) IOSSettingsRow( title = "Time Control & Guardrails", - subtitle = "Daily limit timer, cooldown pause, mindful gates", + subtitle = "Daily limit timer, cooldown between sessions, mindful gates", icon = Icons.Default.Timer, iconBgColor = IOSOrange, onClick = { onNavigateSubpage(SettingsSubpage.GUARDRAILS) }, @@ -722,13 +897,12 @@ private fun MainSettingsContent( } } - // SECTION 2: EXTRAS & NAVIGATION item { IOSSectionTitle(title = "EXTRAS") IOSGroupedCard { IOSSettingsRow( title = "Extras", - subtitle = "Startup launch page, media downloader", + subtitle = "Launch page, custom bio fonts, grayscale, app icons & media downloader", icon = Icons.Default.Star, iconBgColor = IOSBlue, onClick = { onNavigateSubpage(SettingsSubpage.EXTRAS) }, @@ -737,22 +911,6 @@ private fun MainSettingsContent( } } - // SECTION 3: APPEARANCE - item { - IOSSectionTitle(title = "APPEARANCE") - IOSGroupedCard { - IOSSettingsRow( - title = "Appearance", - subtitle = if (settings.grayscaleEnabled) "Grayscale on" else "Grayscale mode", - icon = Icons.Default.Palette, - iconBgColor = Color(0xFF636366), - onClick = { onNavigateSubpage(SettingsSubpage.APPEARANCE) }, - showDivider = false - ) - } - } - - // SECTION 4: SECURITY & PRIVACY item { IOSSectionTitle(title = "SECURITY & PRIVACY") IOSGroupedCard { @@ -770,7 +928,7 @@ private fun MainSettingsContent( onClick = onNavigateAppLock ) IOSSettingsRow( - title = "Chat Security & DM Lock", + title = "Chat/DM Lock", subtitle = if (settings.chatLockAll || settings.lockedChatIds.isNotEmpty()) "Active ยท Locked conversations" else "Lock inbox & chats with PIN/Biometrics", icon = Icons.AutoMirrored.Filled.Chat, iconBgColor = IOSBlue, @@ -778,7 +936,7 @@ private fun MainSettingsContent( ) IOSSettingsRow( title = "Ghost Mode (Stealth)", - subtitle = if (settings.ghostModeEnabled) "Active ยท Hiding story & DM seen" else "Off", + subtitle = if (settings.ghostModeEnabled) "Active ยท Hiding story seen & read receipts" else "Off ยท Browse with privacy and stealth", icon = Icons.Default.VisibilityOff, iconBgColor = IOSPurple, trailingText = if (settings.ghostModeEnabled) "On" else "Off", @@ -786,7 +944,7 @@ private fun MainSettingsContent( ) IOSSettingsRow( title = "Privacy & Notifications", - subtitle = "Link sanitization & session alerts", + subtitle = "Link tracking parameter stripper & session timer alerts", icon = Icons.Default.Notifications, iconBgColor = IOSTeal, onClick = { onNavigateSubpage(SettingsSubpage.NOTIFICATIONS) }, @@ -795,20 +953,19 @@ private fun MainSettingsContent( } } - // SECTION 5: STATS & INSIGHTS item { IOSSectionTitle(title = "STATS & HISTORY") IOSGroupedCard { IOSSettingsRow( title = "Screen Time Dashboard", - subtitle = "Daily & weekly usage analysis", + subtitle = "Daily & weekly usage breakdown, reels statistics, and charts", icon = Icons.Default.Timer, iconBgColor = IOSGreen, onClick = onNavigateScreenTime ) IOSSettingsRow( title = "Reels Watch History", - subtitle = "View and export reels you previously viewed", + subtitle = "Search, review, and manage reels you previously watched", icon = Icons.Default.History, iconBgColor = IOSBlue, onClick = onNavigateReelsHistory, @@ -817,26 +974,15 @@ private fun MainSettingsContent( } } - // SECTION 6: ABOUT & SYSTEM item { IOSSectionTitle(title = "ABOUT") IOSGroupedCard { IOSSettingsRow( - title = "Developer", - subtitle = "Ujwal (@Ujwal223)", - icon = Icons.Default.Person, - iconBgColor = Color(0xFF3A3A3C), - onClick = onShowDeveloper - ) - IOSSettingsRow( - title = "GitHub Repository", - subtitle = "Source code, issues, and releases", - icon = Icons.AutoMirrored.Filled.OpenInNew, - iconBgColor = Color(0xFF3A3A3C), - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Ujwal223/FocusGram")) - context.startActivity(intent) - } + title = "About FocusGram", + subtitle = "Overview, developer, and source repository", + icon = Icons.Default.Info, + iconBgColor = Color(0xFF007AFF), + onClick = onShowAbout ) IOSSettingsRow( title = "Legal Disclaimer", @@ -846,25 +992,55 @@ private fun MainSettingsContent( onClick = onShowLegal ) IOSSettingsRow( - title = "Open Source License & Credits", + title = "Open Source Licenses & Acknowledgements", subtitle = "AGPL-3.0 and third-party dependencies", icon = Icons.Default.Security, iconBgColor = Color(0xFF3A3A3C), - trailingText = "v2.0.0", - onClick = { onNavigateSubpage(SettingsSubpage.LICENSES) }, + trailingText = "v${com.ujwal.focusgram.BuildConfig.VERSION_NAME}", + onClick = { onNavigateSubpage(SettingsSubpage.LICENSES) } + ) + if (com.ujwal.focusgram.BuildConfig.DEBUG) { + IOSSettingsRow( + title = "Welcome & Privacy Guide", + subtitle = "Revisit setup, login security & link preferences", + icon = Icons.AutoMirrored.Filled.MenuBook, + iconBgColor = Color(0xFF34C759), + onClick = onNavigateOnboarding + ) + } + IOSSettingsRow( + title = "Check for Updates", + subtitle = "Look for newer FocusGram releases on GitHub", + icon = Icons.Default.SystemUpdate, + iconBgColor = Color(0xFF34C759), + onClick = onCheckUpdates, showDivider = false ) } } + if (com.ujwal.focusgram.BuildConfig.DEBUG) { + item { + IOSSectionTitle(title = "DEVELOPER DIAGNOSTICS") + IOSGroupedCard { + IOSSettingsRow( + title = "Debug Diagnostics Menu", + subtitle = "Adjust session time, skip cooldowns & limits, simulate notices", + icon = Icons.Default.BugReport, + iconBgColor = IOSOrange, + onClick = onNavigateDebugMenu, + showDivider = false + ) + } + } + } + item { Spacer(modifier = Modifier.height(40.dp)) } } } -// โ”€โ”€โ”€ FOCUS MODE SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun FocusModeSubpage( repository: SettingsRepository, @@ -878,7 +1054,6 @@ private fun FocusModeSubpage( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // MINIMALIST MODE item { IOSSectionTitle(title = "MINIMALIST EXPERIENCE") IOSGroupedCard { @@ -902,7 +1077,6 @@ private fun FocusModeSubpage( } } - // CONTENT FILTERING item { IOSSectionTitle(title = "CONTENT FILTERING") IOSGroupedCard { @@ -945,13 +1119,12 @@ private fun FocusModeSubpage( } } - // DISTRACTION BLOCKERS item { IOSSectionTitle(title = "DISTRACTION BLOCKERS") IOSGroupedCard { IOSSettingsRow( title = "Block Video Autoplay", - subtitle = "Stop videos and reels from playing automatically (click audio icon to play)", + subtitle = "Stop videos and reels from playing automatically until tapped", icon = Icons.Default.PlayArrow, iconBgColor = IOSBlue, checked = settings.noAutoplay, @@ -1009,7 +1182,6 @@ private fun FocusModeSubpage( } } - // VANITY METRICS item { IOSSectionTitle(title = "VANITY METRICS") IOSGroupedCard { @@ -1033,7 +1205,6 @@ private fun FocusModeSubpage( } } - // AD BLOCKING item { IOSSectionTitle(title = "AD BLOCKING") IOSGroupedCard { @@ -1075,7 +1246,11 @@ private fun GuardrailsSubpage( val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) } val todayStr = remember { dateFormat.format(Date()) } val todayRecord by (screenTimeDao?.getForDate(todayStr) ?: flowOf(null)).collectAsState(initial = null) - val isDailyLimitExhausted = settings.dailyLimitMinutes > 0 && ((todayRecord?.seconds ?: 0) >= settings.dailyLimitMinutes * 60) + val todaySeconds = todayRecord?.seconds ?: 0 + val baseDailyLimitSec = settings.dailyLimitMinutes * 60 + val isDailyLimitHit = settings.dailyLimitMinutes > 0 && todaySeconds >= baseDailyLimitSec + val isNearDailyLimit = settings.dailyLimitMinutes > 15 && (baseDailyLimitSec - todaySeconds) in 0..300 + val isDailyLimitLocked = settings.dailyLimitMinutes > 0 && (isDailyLimitHit || isNearDailyLimit) val lastSessionEndTimestamp by repository.lastSessionEndTimestampFlow.collectAsState(initial = 0L) val isCooldownActive = settings.cooldownMinutes > 0 && lastSessionEndTimestamp > 0L && @@ -1093,18 +1268,17 @@ private fun GuardrailsSubpage( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // SESSION LIMITS item { IOSSectionTitle(title = "SESSION LIMITS") IOSGroupedCard { IOSSettingsRow( title = "Daily Limit Timer", - subtitle = if (isDailyLimitExhausted) "Limit exhausted for today (Locked)" else "Max session duration before Instagram locks", + subtitle = if (isDailyLimitLocked) "Limit locked to preserve digital discipline" else "Max daily usage before Instagram locks", icon = Icons.Default.Timer, - iconBgColor = if (isDailyLimitExhausted) Color(0xFF636366) else IOSOrange, - trailingText = if (settings.dailyLimitMinutes > 0) "${settings.dailyLimitMinutes} min" else "Off", + iconBgColor = if (isDailyLimitLocked) Color(0xFF636366) else IOSOrange, + trailingText = if (settings.dailyLimitMinutes > 0) TimeFormatters.formatMinutes(settings.dailyLimitMinutes) else "Off", onClick = { - if (isDailyLimitExhausted) { + if (isDailyLimitLocked) { showDailyLimitExhaustedDialog = true } else { showDailyLimitModal = true @@ -1116,7 +1290,7 @@ private fun GuardrailsSubpage( subtitle = if (isCooldownActive) "Cooldown currently active (Locked)" else "Mandatory resting pause between sessions", icon = Icons.Default.Timer, iconBgColor = if (isCooldownActive) Color(0xFF636366) else IOSBlue, - trailingText = if (settings.cooldownMinutes > 0) "${settings.cooldownMinutes} min" else "Off", + trailingText = if (settings.cooldownMinutes > 0) TimeFormatters.formatMinutes(settings.cooldownMinutes) else "Off", onClick = { if (isCooldownActive) { showCooldownActiveDialog = true @@ -1129,7 +1303,6 @@ private fun GuardrailsSubpage( } } - // MINDFUL GATES item { IOSSectionTitle(title = "MINDFUL GATES") IOSGroupedCard { @@ -1171,14 +1344,7 @@ private fun GuardrailsSubpage( initialIndex = selectedDailyIndex, onDismiss = { showDailyLimitModal = false }, onConfirm = { mins -> scope.launch { repository.setDailyLimitMinutes(mins) } }, - labelProvider = { mins -> - when { - mins == 0 -> "Off" - mins < 60 -> "$mins min" - mins % 60 == 0 -> "${mins / 60} hour" + (if (mins / 60 > 1) "s" else "") - else -> "${mins / 60}h ${mins % 60}m" - } - } + labelProvider = { mins -> if (mins == 0) "Off" else TimeFormatters.formatMinutes(mins) } ) } @@ -1190,7 +1356,7 @@ private fun GuardrailsSubpage( } WheelPickerModalDialog( title = "Cooldown Between Sessions", - subtitle = "Choose mandatory resting period after reaching limits", + subtitle = "Choose mandatory resting period after completing a session", options = cooldownOptions, initialIndex = selectedCooldownIndex, onDismiss = { showCooldownModal = false }, @@ -1257,64 +1423,35 @@ private fun GuardrailsSubpage( containerColor = Color(0xFF1C1C1E), title = { Text( - text = "Daily Limit Exhausted", + text = "Daily Limit Locked", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp ) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Your limit is already exhausted, you cannot change this setting today.", - color = Color(0xFF8E8E93), - fontSize = 14.sp, - lineHeight = 20.sp - ) - if (settings.dailyLimitAdClaimedDate == todayStr) { - Text( - text = "Daily +5m extension already claimed today.", - color = IOSOrange, - fontSize = 13.sp, - fontWeight = FontWeight.Medium - ) - } else { - Text( - text = "You may watch a voluntary sponsored ad to extend your daily limit by 5 minutes (once today).", - color = IOSBlue, - fontSize = 13.sp - ) - } + val message = if (isDailyLimitHit) { + "Your daily screen time limit (${TimeFormatters.formatMinutes(settings.dailyLimitMinutes)}) has already been reached today. To protect your digital discipline, this limit cannot be changed or increased today." + } else { + "You are within 5 minutes of reaching your daily limit (${TimeFormatters.formatMinutes(settings.dailyLimitMinutes)}). To prevent impulsive limit increases, the setting is locked while near the limit." } + Text( + text = message, + color = Color(0xFF8E8E93), + fontSize = 14.sp, + lineHeight = 20.sp + ) }, confirmButton = { - if (settings.dailyLimitAdClaimedDate != todayStr) { - Button( - onClick = { - showDailyLimitExhaustedDialog = false - scope.launch { - repository.extendDailyLimit5Minutes(todayStr) - } - onWatchAd() - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black - ), - shape = RoundedCornerShape(20.dp) - ) { - Text("Watch Ad (+5 min)", fontWeight = FontWeight.Bold) - } - } - }, - dismissButton = { - OutlinedButton( + Button( onClick = { showDailyLimitExhaustedDialog = false }, - border = BorderStroke(1.dp, Color(0xFF3A3A3C)), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ), + shape = RoundedCornerShape(20.dp) ) { - Text("Close") + Text("OK", fontWeight = FontWeight.Bold) } } ) @@ -1435,13 +1572,11 @@ private fun WheelPickerModalDialog( ) } - -// โ”€โ”€โ”€ GHOST MODE SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun GhostModeSubpage( repository: SettingsRepository, - settings: FocusSettings + settings: FocusSettings, + onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> } ) { val scope = rememberCoroutineScope() @@ -1459,7 +1594,13 @@ private fun GhostModeSubpage( icon = Icons.Default.VisibilityOff, iconBgColor = IOSPurple, checked = settings.ghostModeEnabled, - onCheckedChange = { scope.launch { repository.setGhostMode(it) } }, + onCheckedChange = { newState -> + val actionDesc = if (newState) "enable" else "disable" + onRequestAdReward( + { repository.setGhostMode(newState) }, + "Please watch an ad in order to $actionDesc Ghost Mode." + ) + }, showDivider = settings.ghostModeEnabled ) @@ -1470,7 +1611,8 @@ private fun GhostModeSubpage( icon = Icons.Default.VisibilityOff, iconBgColor = IOSBlue, checked = settings.ghostHideStorySeen, - onCheckedChange = { scope.launch { repository.setGhostHideStorySeen(it) } } + onCheckedChange = { scope.launch { repository.setGhostHideStorySeen(it) } }, + isDependent = true ) IOSSettingsRow( title = "Hide DM Read Receipts", @@ -1478,7 +1620,8 @@ private fun GhostModeSubpage( icon = Icons.Default.VisibilityOff, iconBgColor = IOSTeal, checked = settings.ghostHideDirectSeen, - onCheckedChange = { scope.launch { repository.setGhostHideDirectSeen(it) } } + onCheckedChange = { scope.launch { repository.setGhostHideDirectSeen(it) } }, + isDependent = true ) IOSSettingsRow( title = "Suppress Typing Indicator", @@ -1487,7 +1630,8 @@ private fun GhostModeSubpage( iconBgColor = IOSOrange, checked = settings.ghostHideTypingIndicator, onCheckedChange = { scope.launch { repository.setGhostHideTypingIndicator(it) } }, - showDivider = false + showDivider = false, + isDependent = true ) } } @@ -1506,14 +1650,13 @@ private fun GhostModeSubpage( } } -// โ”€โ”€โ”€ EXTRAS SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun ExtrasSubpage( repository: SettingsRepository, settings: FocusSettings, onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> }, - onNavigateUrl: (String) -> Unit = {} + onNavigateUrl: (String) -> Unit = {}, + onNavigateChooseIcon: () -> Unit = {} ) { val scope = rememberCoroutineScope() var showLaunchPageModal by remember { mutableStateOf(false) } @@ -1523,9 +1666,8 @@ private fun ExtrasSubpage( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // STARTUP PAGE item { - IOSSectionTitle(title = "STARTUP") + IOSSectionTitle(title = "DISPLAY") IOSGroupedCard { val isCustomLaunch = settings.startupPage != "home" IOSSettingsRow( @@ -1544,7 +1686,7 @@ private fun ExtrasSubpage( } } }, - showDivider = isCustomLaunch + showDivider = true ) if (isCustomLaunch) { IOSSettingsRow( @@ -1557,16 +1699,79 @@ private fun ExtrasSubpage( else -> "Home Feed" }, onClick = { showLaunchPageModal = true }, - showDivider = false + showDivider = true, + isDependent = true + ) + } + + IOSSettingsRow( + title = "Custom Bio Fonts", + subtitle = "Interactive font transformation toolbar on profile edit", + icon = Icons.Default.Edit, + iconBgColor = IOSTeal, + checked = settings.customBioFontsEnabled, + onCheckedChange = { isChecked -> + scope.launch { + repository.setCustomBioFontsEnabled(isChecked) + } + }, + showDivider = true + ) + + IOSSettingsRow( + title = "Grayscale Mode", + subtitle = "Strip high-dopamine saturated feed colors to curb addiction", + icon = Icons.Default.DarkMode, + iconBgColor = Color(0xFF636366), + checked = settings.grayscaleEnabled, + onCheckedChange = { scope.launch { repository.setGrayscale(it) } }, + showDivider = true + ) + + IOSSettingsRow( + title = "Custom Icons", + subtitle = "Enable custom launcher and era icons", + icon = Icons.Default.Palette, + iconBgColor = IOSPurple, + checked = settings.customIconsEnabled, + onCheckedChange = { isChecked -> + scope.launch { repository.setCustomIconsEnabled(isChecked) } + }, + showDivider = settings.customIconsEnabled + ) + if (settings.customIconsEnabled) { + val activeOption = AppIconManager.IconOption.fromId(settings.appIcon) + IOSSettingsRow( + title = "Choose App Icon", + subtitle = "Select launcher icon style", + icon = Icons.Default.Palette, + iconBgColor = IOSBlue, + trailingText = activeOption.displayName, + onClick = onNavigateChooseIcon, + showDivider = false, + isDependent = true ) } } } - // MEDIA item { - IOSSectionTitle(title = "MEDIA") + IOSSectionTitle(title = "MEDIA & TOOLS") IOSGroupedCard { + IOSSettingsRow( + title = "Search Story Viewer List", + subtitle = "Real-time search bar inside story viewers modal", + icon = Icons.Default.Search, + iconBgColor = IOSBlue, + checked = settings.storySearchEnabled, + onCheckedChange = { isChecked -> + scope.launch { + repository.setStorySearchEnabled(isChecked) + } + }, + showDivider = true + ) + IOSSettingsRow( title = "Media Downloader", subtitle = "Download media from Feed, Reels, and Profiles", @@ -1585,9 +1790,8 @@ private fun ExtrasSubpage( } } - // DISPLAY item { - IOSSectionTitle(title = "DISPLAY") + IOSSectionTitle(title = "OTHERS") IOSGroupedCard { IOSSettingsRow( title = "Portrait Only", @@ -1600,15 +1804,23 @@ private fun ExtrasSubpage( repository.setPortraitOnly(isChecked) } }, - showDivider = false + showDivider = true + ) + + IOSSettingsRow( + title = "Show session time in bottom bar", + subtitle = "Render animated golden perimeter progress with sunset color transition on bottom navigation bar", + icon = Icons.Default.Timer, + iconBgColor = IOSOrange, + checked = settings.showSessionTimeInBottomBar, + onCheckedChange = { isChecked -> + scope.launch { + repository.setShowSessionTimeInBottomBar(isChecked) + } + }, + showDivider = true ) - } - } - // INSTAGRAM ACCOUNT PREFERENCES - item { - IOSSectionTitle(title = "INSTAGRAM") - IOSGroupedCard { IOSSettingsRow( title = "Instagram Account Settings", subtitle = "Open official Instagram preferences inside webview", @@ -1653,74 +1865,6 @@ private fun ExtrasSubpage( } } -// โ”€โ”€โ”€ APPEARANCE SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -@Composable -private fun AppearanceSubpage( - repository: SettingsRepository, - settings: FocusSettings, - onNavigateChooseIcon: () -> Unit -) { - val scope = rememberCoroutineScope() - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - item { - IOSSectionTitle(title = "VISUAL ERGONOMICS") - IOSGroupedCard { - IOSSettingsRow( - title = "Grayscale Mode", - subtitle = "Strip high-dopamine saturated feed colors to curb addiction", - icon = Icons.Default.DarkMode, - iconBgColor = Color(0xFF636366), - checked = settings.grayscaleEnabled, - onCheckedChange = { scope.launch { repository.setGrayscale(it) } }, - showDivider = false - ) - } - } - - item { - Spacer(modifier = Modifier.height(16.dp)) - IOSSectionTitle(title = "APP ICON") - IOSGroupedCard { - IOSSettingsRow( - title = "Custom Icons", - subtitle = "Enable custom launcher and era icons", - icon = Icons.Default.Palette, - iconBgColor = IOSPurple, - checked = settings.customIconsEnabled, - onCheckedChange = { isChecked -> - scope.launch { repository.setCustomIconsEnabled(isChecked) } - }, - showDivider = settings.customIconsEnabled - ) - if (settings.customIconsEnabled) { - val activeOption = AppIconManager.IconOption.fromId(settings.appIcon) - IOSSettingsRow( - title = "Choose App Icon", - subtitle = "Select launcher icon style", - icon = Icons.Default.Palette, - iconBgColor = IOSBlue, - trailingText = activeOption.displayName, - onClick = onNavigateChooseIcon, - showDivider = false - ) - } - } - } - - item { - Spacer(modifier = Modifier.height(40.dp)) - } - } -} - -// โ”€โ”€โ”€ CHOOSE APP ICON SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun ChooseAppIconSubpage( repository: SettingsRepository, @@ -1746,7 +1890,6 @@ private fun ChooseAppIconSubpage( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // Section 1: Core System Icons item { IOSSectionTitle(title = "CORE ICONS") IOSGroupedCard { @@ -1825,7 +1968,6 @@ private fun ChooseAppIconSubpage( } } - // Section 2: Originals item { Spacer(modifier = Modifier.height(16.dp)) IOSSectionTitle(title = "ORIGINALS") @@ -1906,7 +2048,6 @@ private fun ChooseAppIconSubpage( } } - // Section 3: Redesigns (alt-logos) item { Spacer(modifier = Modifier.height(16.dp)) IOSSectionTitle(title = "REDESIGNS") @@ -2003,8 +2144,6 @@ private fun ChooseAppIconSubpage( } } -// โ”€โ”€โ”€ NOTIFICATIONS SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun NotificationsSubpage( repository: SettingsRepository, @@ -2037,7 +2176,6 @@ private fun NotificationsSubpage( .fillMaxSize() .padding(horizontal = 16.dp) ) { - // LINK PRIVACY item { IOSSectionTitle(title = "LINK PRIVACY") IOSGroupedCard { @@ -2053,7 +2191,6 @@ private fun NotificationsSubpage( } } - // SESSION ALERTS item { IOSSectionTitle(title = "SESSION ALERTS") IOSGroupedCard { @@ -2075,8 +2212,8 @@ private fun NotificationsSubpage( } ) IOSSettingsRow( - title = "Persistent Status Notification", - subtitle = "Display live session timer countdown in Android notification shade", + title = "Live Session Time Counter", + subtitle = "Display live session elapsed time in Android notification shade", icon = Icons.Default.Timer, iconBgColor = IOSBlue, checked = settings.notifyPersistent, @@ -2090,8 +2227,29 @@ private fun NotificationsSubpage( scope.launch { repository.setNotifyPersistent(false) } } }, - showDivider = false + showDivider = settings.notifyPersistent ) + if (settings.notifyPersistent) { + IOSSettingsRow( + title = "Interval Alerts (Every 5 min)", + subtitle = "Play sound and show popup notification every 5 minutes", + icon = Icons.Default.NotificationsActive, + iconBgColor = IOSOrange, + checked = settings.notifyEvery5Minutes, + onCheckedChange = { willEnable -> + if (willEnable) { + checkAndRequestNotificationPermission { + scope.launch { repository.setNotifyEvery5Minutes(true) } + } + scope.launch { repository.setNotifyEvery5Minutes(true) } + } else { + scope.launch { repository.setNotifyEvery5Minutes(false) } + } + }, + showDivider = false, + isDependent = true + ) + } } } @@ -2101,8 +2259,6 @@ private fun NotificationsSubpage( } } -// โ”€โ”€โ”€ LICENSES SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun LicensesSubpage() { val context = LocalContext.current @@ -2156,7 +2312,7 @@ private fun LicensesSubpage() { } item { - IOSSectionTitle(title = "THIRD-PARTY OPEN SOURCE CREDITS") + IOSSectionTitle(title = "THIRD-PARTY OPEN SOURCE LIBRARIES") IOSGroupedCard { IOSSettingsRow( title = "Mozilla GeckoView", @@ -2230,8 +2386,6 @@ private fun LicensesSubpage() { } } -// โ”€โ”€โ”€ CHAT SECURITY & DM PRIVACY SUBPAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @Composable private fun ChatSecuritySubpage( repository: SettingsRepository, @@ -2320,7 +2474,8 @@ private fun ChatSecuritySubpage( icon = Icons.Default.Lock, iconBgColor = Color(0xFF636366), onClick = onNavigateChatPinSetup, - showDivider = false + showDivider = false, + isDependent = true ) } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt similarity index 97% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt index 69d05f9..13e026a 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/snapshots/SnapshotManagerScreen.kt @@ -52,10 +52,9 @@ import java.util.Date import java.util.Locale /** - * SnapshotManagerScreen * Offline Feed Snapshot Viewer & Bookmark Manager. - * Unlocks at Level 5. - * Enables users to browse previously loaded pages offline without issuing external network calls. + * + * Browses locally cached GeckoView pages without issuing network requests. */ @Composable fun SnapshotManagerScreen( diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateBanner.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateBanner.kt new file mode 100644 index 0000000..213fe2e --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateBanner.kt @@ -0,0 +1,236 @@ +package com.ujwal.focusgram.feature.update + +import android.content.Intent +import android.net.Uri +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ujwal.focusgram.core.ui.theme.DarkBorder +import com.ujwal.focusgram.core.ui.theme.DarkCard +import com.ujwal.focusgram.core.ui.theme.DarkCardElevated +import com.ujwal.focusgram.core.ui.theme.FocusGreen +import com.ujwal.focusgram.core.ui.theme.InstagramBlue +import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.ui.theme.TextMuted +import com.ujwal.focusgram.core.ui.theme.TextPrimary +import com.ujwal.focusgram.core.ui.theme.TextSecondary + +/** + * An Apple/Cupertino-styled in-app banner notifying the user when a new FocusGram + * GitHub release is available, with expandable markdown release notes and one-tap download. + */ +@Composable +fun UpdateBanner( + updateInfo: UpdateInfo, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val view = LocalView.current + var isExpanded by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(DarkCard) + .border(1.dp, DarkBorder, RoundedCornerShape(16.dp)) + .padding(14.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(InstagramBlue.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SystemUpdate, + contentDescription = "Update available", + tint = InstagramBlue, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Update Available", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = InstagramBlue, + fontSize = 11.sp + ) + ) + } + Text( + text = "FocusGram v${updateInfo.latestVersion}", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + color = TextPrimary + ) + ) + } + + IconButton( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + isExpanded = !isExpanded + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = TextSecondary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + onDismiss() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss update", + tint = TextMuted, + modifier = Modifier.size(18.dp) + ) + } + } + + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column(modifier = Modifier.padding(top = 12.dp)) { + HorizontalDivider( + color = DarkBorder, + thickness = 0.5.dp, + modifier = Modifier.padding(bottom = 10.dp) + ) + + Text( + text = "What's New in v${updateInfo.latestVersion}", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = formatReleaseNotes(updateInfo.whatsNew), + style = MaterialTheme.typography.bodySmall.copy( + color = TextSecondary, + lineHeight = 18.sp + ), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(DarkCardElevated) + .padding(10.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + val targetUrl = updateInfo.downloadUrl ?: updateInfo.releaseUrl + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(targetUrl)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + colors = ButtonDefaults.buttonColors(containerColor = InstagramBlue), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + ) { + Text( + text = "Download on GitHub", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + color = PureBlack + ) + ) + } + } + } + } + } +} + +/** + * Strips raw markdown syntax markers so release notes render cleanly in plain text. + */ +private fun formatReleaseNotes(raw: String): String { + if (raw.isBlank()) return "General bug fixes and performance improvements." + return raw + .replace(Regex("#{1,6}\\s*"), "") + .replace(Regex("\\*\\*(.*?)\\*\\*"), "$1") + .replace(Regex("\\*(.*?)\\*"), "$1") + .replace(Regex("\\[(.*?)\\]\\((.*?)\\)"), "$1") + .replace(Regex("`([^`]+)`"), "$1") + .trim() +} diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateCheckerService.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateCheckerService.kt new file mode 100644 index 0000000..33a2368 --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateCheckerService.kt @@ -0,0 +1,159 @@ +package com.ujwal.focusgram.feature.update + +import android.content.Context +import android.util.Log +import com.ujwal.focusgram.core.datastore.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +/** + * Checks GitHub Releases for new FocusGram versions and exposes update state. + * + * All network requests are read-only HTTP GET calls targeting the public GitHub API, + * keeping FocusGram fully independent of any third-party app store or proprietary telemetry. + */ +class UpdateCheckerService( + private val context: Context, + private val settingsRepository: SettingsRepository +) { + companion object { + private const val TAG = "UpdateChecker" + private const val GITHUB_LATEST_RELEASE_URL = + "https://api.github.com/repos/Ujwal223/FocusGram/releases/latest" + private const val CONNECT_TIMEOUT_MS = 5000 + private const val READ_TIMEOUT_MS = 5000 + + /** + * Compares semver version strings (e.g., "v2.1.0" or "2.0.0"). + * Ignores leading 'v' and trailing prerelease qualifiers for numeric segments. + */ + fun isNewerVersion(remoteTag: String, localVersion: String): Boolean { + val cleanRemote = remoteTag.trim().removePrefix("v").substringBefore("-") + val cleanLocal = localVersion.trim().removePrefix("v").substringBefore("-") + + val remoteParts = cleanRemote.split(".").map { it.toIntOrNull() ?: 0 } + val localParts = cleanLocal.split(".").map { it.toIntOrNull() ?: 0 } + + val maxLen = maxOf(remoteParts.size, localParts.size) + for (i in 0 until maxLen) { + val r = remoteParts.getOrElse(i) { 0 } + val l = localParts.getOrElse(i) { 0 } + if (r > l) return true + if (r < l) return false + } + return false + } + + fun getMockUpdateInfo(): UpdateInfo { + return UpdateInfo( + latestVersion = "3.1.0", + releaseUrl = "https://github.com/Ujwal223/FocusGram/releases", + downloadUrl = "https://github.com/Ujwal223/FocusGram/releases/latest", + whatsNew = "FocusGram v3.1.0 (Simulation Test)\nโ€ข Verified update notification & dialog rendering\nโ€ข Liquid glass components styling\nโ€ข Client-side reels deduplication and rewatch count", + isUpdateAvailable = true + ) + } + } + + /** + * Queries GitHub for the latest release and checks if it exceeds the running app version. + * + * @param ignoreDismissed If true, returns [UpdateCheckResult.Available] even if the user + * previously dismissed this version banner (useful for manual settings check). + */ + suspend fun checkForUpdates(ignoreDismissed: Boolean = false): UpdateCheckResult = + withContext(Dispatchers.IO) { + try { + val url = URL(GITHUB_LATEST_RELEASE_URL) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", "FocusGram-Android-Client") + } + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + return@withContext UpdateCheckResult.Failed("Server returned HTTP $responseCode") + } + + val responseText = connection.inputStream.bufferedReader().use(BufferedReader::readText) + val json = JSONObject(responseText) + + val tagName = json.optString("tag_name", "").trim() + val htmlUrl = json.optString("html_url", "").trim() + val rawBody = json.optString("body", "").trim() + + if (tagName.isEmpty() || htmlUrl.isEmpty()) { + return@withContext UpdateCheckResult.Failed("Malformed release payload from GitHub") + } + + val currentVersion = getCurrentVersionName() + if (!isNewerVersion(tagName, currentVersion)) { + return@withContext UpdateCheckResult.UpToDate + } + + val cleanVersion = tagName.removePrefix("v") + if (!ignoreDismissed) { + val dismissedVersion = settingsRepository.getDismissedUpdateVersion() + if (dismissedVersion == cleanVersion || dismissedVersion == tagName) { + return@withContext UpdateCheckResult.UpToDate + } + } + + var downloadUrl: String? = null + val assets = json.optJSONArray("assets") + if (assets != null) { + for (i in 0 until assets.length()) { + val asset = assets.optJSONObject(i) ?: continue + val name = asset.optString("name", "") + if (name.endsWith(".apk", ignoreCase = true)) { + val assetUrl = asset.optString("browser_download_url", "") + if (assetUrl.isNotEmpty()) { + if (name.contains("universal", ignoreCase = true) || downloadUrl == null) { + downloadUrl = assetUrl + } + } + } + } + } + + val trimmedNotes = if (rawBody.length > 2000) { + rawBody.substring(0, 2000).trim() + "..." + } else rawBody + + val info = UpdateInfo( + latestVersion = cleanVersion, + releaseUrl = htmlUrl, + downloadUrl = downloadUrl ?: htmlUrl, + whatsNew = trimmedNotes, + isUpdateAvailable = true + ) + + UpdateCheckResult.Available(info) + } catch (e: Exception) { + Log.w(TAG, "Update check failed: ${e.message}") + UpdateCheckResult.Failed(e.message ?: "Network error checking updates") + } + } + + suspend fun dismissVersion(version: String) { + settingsRepository.setDismissedUpdateVersion(version) + } + + private fun getCurrentVersionName(): String { + return try { + val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) + pInfo.versionName ?: "3.0.0" + } catch (_: Exception) { + "3.0.0" + } + } +} diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateInfo.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateInfo.kt new file mode 100644 index 0000000..2f5e84d --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/update/UpdateInfo.kt @@ -0,0 +1,18 @@ +package com.ujwal.focusgram.feature.update + +/** + * Encapsulates version metadata retrieved from the GitHub Releases API. + */ +data class UpdateInfo( + val latestVersion: String, + val releaseUrl: String, + val downloadUrl: String?, + val whatsNew: String, + val isUpdateAvailable: Boolean +) + +sealed interface UpdateCheckResult { + data class Available(val updateInfo: UpdateInfo) : UpdateCheckResult + data object UpToDate : UpdateCheckResult + data class Failed(val reason: String) : UpdateCheckResult +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt similarity index 91% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt index 0e3aaaa..0aecc70 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/webview/GeckoViewContainer.kt @@ -37,14 +37,14 @@ import com.ujwal.focusgram.core.ui.theme.PureBlack import org.mozilla.geckoview.GeckoView /** - * GeckoViewContainer - * Jetpack Compose host for Mozilla GeckoView. - * Provides edge-to-edge window insets, top progress indicator, and OLED skeleton loader. + * Hosts [GeckoView] inside Compose with edge-to-edge IME insets, + * cold-start shimmer loading, and top navigation progress indicators. */ @Composable fun GeckoViewContainer( sessionManager: GeckoSessionManager, grayscale: Boolean = false, + isVisible: Boolean = true, modifier: Modifier = Modifier ) { val isLoading by sessionManager.isLoading.collectAsState() @@ -63,7 +63,6 @@ fun GeckoViewContainer( val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> when (event) { androidx.lifecycle.Lifecycle.Event.ON_STOP -> sessionManager.onPause() - androidx.lifecycle.Lifecycle.Event.ON_START -> sessionManager.onResume() else -> Unit } } @@ -79,7 +78,6 @@ fun GeckoViewContainer( .background(PureBlack) .padding(WindowInsets.ime.asPaddingValues()) ) { - // Native GeckoView host AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> @@ -95,13 +93,13 @@ fun GeckoViewContainer( if (geckoView.session != sessionManager.session) { geckoView.setSession(sessionManager.session) } + geckoView.visibility = if (isVisible) android.view.View.VISIBLE else android.view.View.INVISIBLE }, onRelease = { geckoView -> geckoView.releaseSession() } ) - // Initial skeleton shimmer screen during cold start AnimatedVisibility( visible = !initialLoadCompleted && isLoading, enter = fadeIn(), @@ -110,7 +108,6 @@ fun GeckoViewContainer( SkeletonScreen(type = getSkeletonTypeFromUrl(currentUrl)) } - // Top progress indicator for incremental page navigation AnimatedVisibility( visible = isLoading, enter = fadeIn(), diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt similarity index 83% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt index 28804c5..9cd061d 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt @@ -60,6 +60,26 @@ import androidx.compose.ui.viewinterop.AndroidView import com.ujwal.focusgram.core.ui.theme.PureBlack import kotlinx.coroutines.delay +private val ADSTERRA_INTERNAL_DOMAINS = listOf( + "effectivecpmnetwork.com", + "highperformanceformat.com", + "effectivegatecpm.com", + "profitableratecpm.com", + "profitablecpmratenetwork.com", + "profitableratecpmnetwork.com", + "aqml.org", + "afders.org", + "bicea.org", + "arwf.org", + "cdn.show-sb.com", + "api3.adsterratools.com", + "adsterra.com" +) + +private fun isAdsterraInternalDomain(url: String): Boolean { + return ADSTERRA_INTERNAL_DOMAINS.any { url.contains(it, ignoreCase = true) } +} + private const val AD_HTML = """ @@ -141,9 +161,10 @@ private const val AD_HTML = """ """ /** - * AdsterraAdScreen - * Premium iOS monochrome rewarded ad viewing container running in a dedicated WebView. - * Enforces a 20s countdown timer and 2 external browser ad clicks to claim focus minutes. + * Isolated WebView container for opt-in rewarded ad viewing. + * + * Runs inside standard Android system WebView completely decoupled from GeckoView + * to preserve uBlock Origin blocking in Instagram browsing sessions. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -161,20 +182,24 @@ fun AdsterraAdScreen( var webViewRef by remember { mutableStateOf(null) } val isSettingToggle = sessionType == "setting_toggle" + val isVoluntarySupport = sessionType == "voluntary" + val isEffortFriction = sessionType == "effort_friction" + val isSessionExtend = sessionType == "session_extend" + val timerDone = elapsedSeconds >= requiredSeconds val bothClicked = adsClicked >= 2 - val isComplete = if (isSettingToggle) timerDone else (timerDone && bothClicked) + val isComplete = if (isSettingToggle || isVoluntarySupport || isEffortFriction) timerDone else (timerDone && bothClicked) - // Ensure clean countdown and state when entering a setting toggle session + // Ensure clean countdown and state when entering a setting toggle, voluntary support, or friction session LaunchedEffect(sessionType) { - if (isSettingToggle) { + if (isSettingToggle || isVoluntarySupport || isEffortFriction) { elapsedSeconds = 0 adsClicked = 0 timerCycle++ } } - // 20s Timer Loop - continues cleanly from saved elapsedSeconds without resetting + // Preserves elapsed countdown when re-entering the screen LaunchedEffect(timerCycle) { while (elapsedSeconds < requiredSeconds) { delay(1000L) @@ -183,11 +208,16 @@ fun AdsterraAdScreen( } val statusText = when { - isComplete -> if (isSettingToggle) "Ad viewed! Tap below to apply your setting." else "Reward Unlocked! Tap below to claim focus minutes." - isSettingToggle -> "Viewing ad (${requiredSeconds - elapsedSeconds}s remaining)..." + isComplete -> when { + isVoluntarySupport -> "Thank you for supporting FocusGram! Your contribution keeps this project free." + isSettingToggle -> "Ad viewed! Tap below to apply your setting." + isEffortFriction -> "Ad viewed! Tap below to proceed to Instagram." + else -> "Reward Unlocked! Tap below to extend session by 5 minutes." + } + isSettingToggle || isVoluntarySupport || isEffortFriction -> "Viewing ad (${requiredSeconds - elapsedSeconds}s remaining)..." bothClicked -> "Both ads clicked! Waiting for countdown (${requiredSeconds - elapsedSeconds}s)..." timerDone -> "Timer completed. Please tap both ads ($adsClicked/2) to continue." - else -> "Tap BOTH ads below to earn reward credits ($adsClicked/2 clicked)" + else -> "Tap BOTH ads below to unlock your reward ($adsClicked/2 clicked)" } Surface( @@ -199,7 +229,6 @@ fun AdsterraAdScreen( .fillMaxSize() .systemBarsPadding() ) { - // iOS Monochrome Header Bar Row( modifier = Modifier .fillMaxWidth() @@ -226,19 +255,28 @@ fun AdsterraAdScreen( Column(modifier = Modifier.weight(1f)) { Text( - text = "Support FocusGram", + text = when { + isVoluntarySupport -> "Support FocusGram" + isSettingToggle -> "Unlock Setting" + isEffortFriction -> "Focus Bypass" + else -> "Extend Session" + }, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold ) Text( - text = "Sponsored Reward", + text = when { + isVoluntarySupport -> "Voluntary Contribution" + isSettingToggle -> "Sponsored Verification" + isEffortFriction -> "Sponsored Access" + else -> "Sponsored Reward" + }, color = Color(0xFF8E8E93), fontSize = 12.sp ) } - // Apple Style Timer Pill Badge Box( modifier = Modifier .clip(RoundedCornerShape(12.dp)) @@ -255,7 +293,6 @@ fun AdsterraAdScreen( } } - // Monochrome Progress Bar LinearProgressIndicator( progress = { (elapsedSeconds.toFloat() / requiredSeconds).coerceIn(0f, 1f) }, modifier = Modifier @@ -265,15 +302,14 @@ fun AdsterraAdScreen( trackColor = Color(0xFF2C2C2E) ) - // Status Card Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 10.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFF1C1C1E)) - .border(0.5.dp, Color(0xFF2C2C2E), RoundedCornerShape(10.dp)) - .padding(horizontal = 14.dp, vertical = 8.dp), + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF1C1C1E)) + .border(0.5.dp, Color(0xFF2C2C2E), RoundedCornerShape(10.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { Text( @@ -284,7 +320,6 @@ fun AdsterraAdScreen( ) } - // Dedicated ad WebView host container Box( modifier = Modifier .weight(1f) @@ -321,7 +356,7 @@ fun AdsterraAdScreen( fun handleAdClick(url: String): Boolean { val clean = url.trim() - if (clean.isNotBlank() && !clean.contains("effectivecpmnetwork.com") && !clean.contains("highperformanceformat.com") && !clean.startsWith("about:") && !clean.startsWith("data:")) { + if (clean.isNotBlank() && !isAdsterraInternalDomain(clean) && !clean.startsWith("about:") && !clean.startsWith("data:")) { val now = System.currentTimeMillis() if (clean != lastClickedUrl || now - lastClickTime > 1500L) { lastClickedUrl = clean @@ -392,7 +427,7 @@ fun AdsterraAdScreen( super.onPageStarted(view, url, favicon) val u = url ?: "" if (u.isNotBlank() && !u.startsWith("about:") && !u.startsWith("data:")) { - if (!u.contains("effectivecpmnetwork.com") && !u.contains("highperformanceformat.com")) { + if (!isAdsterraInternalDomain(u)) { handleAdClick(u) } } @@ -412,14 +447,12 @@ fun AdsterraAdScreen( ) } - // Bottom Action Area (iOS Monochrome Buttons) Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - // Primary Claim Button: Apple Style Pill Button Button( onClick = { elapsedSeconds = 0 @@ -445,9 +478,15 @@ fun AdsterraAdScreen( ) Spacer(modifier = Modifier.width(6.dp)) val buttonLabel = when { - !isComplete -> if (isSettingToggle) "Viewing Ad (${(requiredSeconds - elapsedSeconds).coerceAtLeast(0)}s)" else "Complete Ad Actions to Unlock" + !isComplete -> if (isSettingToggle || isVoluntarySupport || isEffortFriction) { + "Viewing Ad (${(requiredSeconds - elapsedSeconds).coerceAtLeast(0)}s)" + } else { + "Complete Ad Actions to Unlock" + } + isVoluntarySupport -> "Done (Return to Settings)" isSettingToggle -> "Continue & Apply Setting" - else -> "Continue & Earn Reward (+2 min)" + isEffortFriction -> "Continue to Instagram" + else -> "Continue & Extend Session (+5 min)" } Text( text = buttonLabel, @@ -456,8 +495,7 @@ fun AdsterraAdScreen( ) } - // Retry Button if ads fail to load - if (timerDone && !bothClicked) { + if (!isComplete && timerDone && !bothClicked) { Button( onClick = { timerCycle++ @@ -496,16 +534,10 @@ fun AdsterraAdScreen( } /** - * AdPreloader - * Pre-warms Android System WebView process & Chromium runtime into memory. - * Runs completely isolated from GeckoView, having zero effect on Instagram's uBlock Origin adblocker. - * - * IMPORTANT FOR MAX EARNINGS & IVT SAFETY: - * Pre-warming uses "about:blank" to warm up the Chromium rendering engine and V8 runtime in RAM - * so AdsterraAdScreen opens with 0ms startup lag. - * Actual ad tags are loaded LIVE only when the user opens AdsterraAdScreen. - * This guarantees 100% viewable impressions, fresh click tokens, high CTR, and protects your account - * from Adsterra's Invalid Traffic (IVT) / phantom impression filters. + * Pre-warms the Android system WebView and Chromium V8 runtime in the background using about:blank. + * + * Runs isolated from GeckoView to avoid cold-start latency when opening [AdsterraAdScreen], + * without touching Instagram browsing sessions or uBlock Origin state. */ object AdPreloader { @Volatile diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt similarity index 96% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt index 9640831..5e382df 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt @@ -47,9 +47,7 @@ import com.ujwal.focusgram.core.utils.HapticHelper import kotlinx.coroutines.delay /** - * BreathGateScreen - * Mindful breathing countdown gate designed to interrupt habitual app opening - * and restore conscious intentionality. + * Interstitial countdown screen enforcing a mandatory breathing delay before app access. */ @Composable fun BreathGateScreen( @@ -61,7 +59,6 @@ fun BreathGateScreen( var secondsRemaining by remember { mutableIntStateOf(initialSeconds) } val canContinue = secondsRemaining <= 0 - // Countdown Timer Loop LaunchedEffect(Unit) { while (secondsRemaining > 0) { delay(1000L) @@ -70,7 +67,6 @@ fun BreathGateScreen( HapticHelper.performSuccessPulse(context) } - // Mindful Breathing Expansion/Contraction Animation val infiniteTransition = rememberInfiniteTransition(label = "breath_circle_transition") val breathScale by infiniteTransition.animateFloat( initialValue = 1.0f, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt similarity index 83% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt index 0352453..c6e0acc 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt @@ -48,17 +48,19 @@ import com.ujwal.focusgram.core.utils.HapticHelper import kotlinx.coroutines.delay /** - * CooldownGateScreen - * Enforces mandatory resting cooldown intervals between Instagram usage sessions. + * Fullscreen lock requiring the user to wait out a cooldown period between sessions. */ @Composable fun CooldownGateScreen( - remainingSecondsInitial: Int, + remainingSeconds: Int = 0, + remainingSecondsInitial: Int = remainingSeconds, onCooldownExpired: () -> Unit, - onExit: () -> Unit + onExit: () -> Unit, + onDebugBypass: (() -> Unit)? = null ) { val context = LocalContext.current - var remainingSeconds by remember { mutableIntStateOf(remainingSecondsInitial.coerceAtLeast(0)) } + val effectiveRemaining = (if (remainingSeconds > 0) remainingSeconds else remainingSecondsInitial).coerceAtLeast(0) + val debugBypassAction = onDebugBypass ?: onCooldownExpired val quotes = remember { listOf( @@ -72,18 +74,15 @@ fun CooldownGateScreen( val quote = remember { quotes.random() } - LaunchedEffect(remainingSeconds) { - if (remainingSeconds <= 0) { + LaunchedEffect(effectiveRemaining) { + if (effectiveRemaining <= 0) { HapticHelper.performSuccessPulse(context) onCooldownExpired() - } else { - delay(1000L) - remainingSeconds-- } } - val minutes = remainingSeconds / 60 - val seconds = remainingSeconds % 60 + val minutes = effectiveRemaining / 60 + val seconds = effectiveRemaining % 60 val formattedTime = String.format("%02d:%02d", minutes, seconds) Surface( @@ -98,14 +97,19 @@ fun CooldownGateScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - // Hourglass Icon with Warm Amber Glow Box( modifier = Modifier .size(80.dp) .clip(CircleShape) .background(Color(0xFFFF9800).copy(alpha = 0.12f)) .border(1.5.dp, Color(0xFFFF9800).copy(alpha = 0.4f), CircleShape) - .clickable { onCooldownExpired() }, + .then( + if (com.ujwal.focusgram.BuildConfig.DEBUG) { + Modifier.clickable { debugBypassAction() } + } else { + Modifier + } + ), contentAlignment = Alignment.Center ) { Icon( @@ -138,7 +142,6 @@ fun CooldownGateScreen( Spacer(modifier = Modifier.height(40.dp)) - // Big Timer Display Card Box( modifier = Modifier .fillMaxWidth() @@ -168,7 +171,6 @@ fun CooldownGateScreen( Spacer(modifier = Modifier.height(36.dp)) - // Motivational Quote Text( text = quote, style = MaterialTheme.typography.bodyMedium.copy( @@ -181,7 +183,6 @@ fun CooldownGateScreen( Spacer(modifier = Modifier.height(48.dp)) - // Exit Button OutlinedButton( onClick = onExit, modifier = Modifier @@ -196,16 +197,19 @@ fun CooldownGateScreen( ) } - Spacer(modifier = Modifier.height(12.dp)) + if (com.ujwal.focusgram.BuildConfig.DEBUG) { + Spacer(modifier = Modifier.height(12.dp)) - TextButton( - onClick = onCooldownExpired - ) { - Text( - text = "Skip Cooldown", - color = TextMuted, - fontSize = 14.sp - ) + TextButton( + onClick = debugBypassAction + ) { + Text( + text = "Skip Cooldown (Debug)", + color = Color(0xFFFF9800), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } } } } diff --git a/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/DisciplineMantraRepository.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/DisciplineMantraRepository.kt new file mode 100644 index 0000000..5a84b8a --- /dev/null +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/DisciplineMantraRepository.kt @@ -0,0 +1,214 @@ +package com.ujwal.focusgram.feature.wellness + +/** + * Repository of inspiring discipline mantras grouped by word count (10, 15, 20, 30, 50). + * + * Each word count list contains at least 30 unique sentences crafted to match the exact word count. + * Shuffled non-repeating queues ensure sentences cycle randomly without duplicates until all + * items for that word count are exhausted. + */ +object DisciplineMantraRepository { + private val unexhaustedPools = mutableMapOf>() + + val mantras10: List = listOf( + "I control my attention and choose presence over endless scrolling.", + "My focus is valuable and I will not waste it.", + "Real life is unfolding outside this small illuminated digital screen.", + "I enter this application with clear purpose and calm awareness.", + "Discipline creates genuine freedom and shields my precious daily energy.", + "I will not let an algorithm dictate my mental state.", + "Calm awareness guides every action I take in this moment.", + "I master my digital habits instead of serving shallow impulses.", + "Every conscious breath returns my focus to what truly matters.", + "I step back from mindless consumption to protect my peace.", + "Intentional choices today build the strong discipline I need tomorrow.", + "My peace of mind is worth more than digital approval.", + "I observe the impulse to scroll and calmly let go.", + "True clarity emerges when I silence constant unnecessary digital noise.", + "I am fully present in reality rather than lost online.", + "Deep focus is a skill that I practice with intention.", + "I honor my time by choosing deliberate focus over distraction.", + "Today I cultivate mental clarity and resist shallow algorithmic dopamine.", + "My attention belongs to my goals and my real life.", + "One intentional minute is worth hours of mindless social browsing.", + "I choose to disconnect and protect my inner mental sanctuary.", + "Quiet discipline today creates meaningful accomplishments for my future self.", + "I consume information intentionally and never let feeds consume me.", + "Patience and awareness empower me to control every digital impulse.", + "I value deep concentration over brief moments of fleeting novelty.", + "Mindful living begins with guarding how I spend my time.", + "I release the urge to seek superficial digital social validation.", + "Focused effort on what matters will transform my personal journey.", + "I pause, breathe deeply, and direct my energy toward purpose.", + "My daily habits shape my destiny and I choose presence.", + ) + + val mantras15: List = listOf( + "I refuse to surrender my sacred attention to an algorithm designed for endless addictive engagement.", + "True inner power begins the moment I decide to control my own digital screen time.", + "My creative potential flourishes only when I consciously protect my mind from shallow continuous distractions.", + "I use social platforms as intentional tools rather than allowing them to dictate my life.", + "Every mindful pause between urges strengthens my willpower and deepens my everyday clear mental clarity.", + "Distraction is costly, but deliberate attention yields profound focus and peaceful personal accomplishments every day.", + "I honor the precious hours of my day by doing real work in physical reality.", + "Stepping away from superficial feeds allows my genuine creativity and deeper focus to freely flourish.", + "I am the conscious gatekeeper of my mind and refuse to indulge impulsive online consumption.", + "Living intentionally means knowing exactly when to close this screen and return to real life.", + "A disciplined mind navigates the modern world with supreme grace, calm purpose, and unwavering resolve.", + "I choose conscious self-restraint over effortless dopamine hits that leave me feeling exhausted and unfocused.", + "My highest aspirations require undivided daily focus that no superficial digital feed can ever provide.", + "Peace comes from deliberate restraint and prioritizing meaningful relationships over endless virtual feeds and notifications.", + "I master this digital technology with disciplined poise instead of letting it dictate my day.", + "Clear intention guides my browsing so I leave this app fulfilled rather than quietly depleted.", + "Every second given to shallow feeds is stolen from my greatest personal goals and dreams.", + "I choose to experience reality directly rather than watching others live through digital screens today.", + "Self-control is a daily practice that transforms restless impulses into unshakable inner focus and clarity.", + "I cultivate deep concentration today by saying a firm no to cheap, endless online distractions.", + "My presence of mind is my greatest superpower in a world full of noisy distractions.", + "Deliberate choices protect my energy, allowing me to build a truly remarkable and focused life.", + "I release the compulsive urge to refresh and reclaim full ownership of my valuable attention.", + "Digital serenity begins when I silence unnecessary notifications and focus deeply on what truly matters.", + "I am committed to living deeply in the present moment without seeking empty digital validation.", + "When I conquer the impulse to scroll, I unlock hours of deep, rewarding creative work.", + "My attention is sacred, so I guard it against addictive algorithmic loops with fierce discipline.", + "I deliberately choose silence and deep contemplation over the endless roar of modern social media.", + "Intentional habits build the quiet confidence and mental strength necessary to achieve lasting personal greatness.", + "I step forward with conscious purpose, leaving behind mindless scrolling to embrace real world living.", + ) + + val mantras20: List = listOf( + "By choosing mindful focus over endless scrolling, I actively reclaim the time, energy, and mental peace needed for my goals.", + "I refuse to let engineered digital feeds capture my thoughts when genuine life experiences are waiting for me outside today.", + "Discipline is the ultimate bridge between empty intentions and real accomplishments, so I choose deliberate focus over mindless digital consumption.", + "Technology should elevate my daily existence rather than trap my attention in an endless loop of superficial and fleeting dopamine.", + "I take complete responsibility for where I place my attention, choosing intentional presence over reactive digital browsing every single hour.", + "When I pause and breathe, I regain conscious command over my impulses and remember what truly matters in my life.", + "Every moment spent away from algorithmic feeds is an investment in my mental health, personal relationships, and deep creative clarity.", + "I honor my mind by consuming only what nourishes my intellect, refusing to waste precious hours on shallow social noise.", + "The world needs my authentic contribution, not my passive consumption of endless videos and superficial images on a small screen.", + "Mastering my digital impulses gives me the freedom to think deeply, create meaningful work, and live with genuine peace today.", + "I step into this digital space with absolute clarity of purpose, determined to leave as soon as my task finishes.", + "True satisfaction comes from real-world accomplishments and heartfelt human connections, never from the synthetic validation of social media engagement metrics.", + "I protect my mental clarity with unwavering discipline, knowing that my attention determines the entire quality of my everyday experience.", + "A calm and focused mind is capable of extraordinary achievements when freed from the constant pull of compulsive digital notifications.", + "I practice healthy boundaries with my smartphone so that my creative energy flows freely toward the projects that inspire me.", + "Mindful living requires conscious resistance against the addictive pull of endless feeds designed to keep my mind permanently scattered today.", + "I choose to be the active creator of my life rather than a passive observer of someone else's curated reality.", + "The discipline I practice right now strengthens my character and preserves the precious hours I need for personal growth today.", + "I reject the illusion of continuous connectivity in favor of deep presence, quiet contemplation, and intentional daily productive action now.", + "My daily focus is far too valuable to be traded away for fleeting entertainment and manipulative advertising on algorithmic platforms.", + "I cultivate stillness amidst modern digital chaos, ensuring that my mind remains calm, centered, and deeply focused on my priorities.", + "Every conscious decision to close an addictive feed reinforces my self-respect and empowers me to live with genuine intentionality today.", + "I direct my energy toward meaningful pursuits that endure, letting go of the constant urge to check trivial digital updates.", + "True freedom is the ability to direct my attention wherever I choose without being pulled by compulsive digital habits today.", + "I respect my future self by making disciplined choices now, refusing to sacrifice long-term fulfillment for brief moments of amusement.", + "My peace of mind flourishes when I disconnect from endless feeds and reconnect with the quiet beauty of physical reality.", + "I choose to be fully awake to my own life rather than mesmerized by the glowing pixels of my screen.", + "Clear boundaries around my digital consumption allow my thoughts to deepen and my authentic voice to emerge with great clarity.", + "I possess the strength to resist shallow distractions and channel my focused attention into work that makes a real difference.", + "With every deliberate breath, I anchor myself in the present moment and release all desire for mindless online scrolling today.", + ) + + val mantras30: List = listOf( + "I choose to control my focus and not let algorithmic feeds govern my attention. Real life happens outside this screen, and my precious time deserves deliberate purpose and disciplined presence.", + "Technology is a tool for my growth, not a trap for dopamine. I enter with conscious intent, observe with clear detachment, and leave before endless scrolling steals my peaceful day.", + "True freedom comes from voluntary discipline. I possess mastery over my impulses, refusing to mindlessly consume superficial digital noise when real-world accomplishments demand my undivided attention and creative energy today.", + "My attention is my most precious asset, shaping what I experience. I refuse to trade it away for mindless videos, choosing instead to invest in my own genuine personal growth.", + "When I silence the constant clamor of digital notifications, I discover deep calm clarity. I step away from endless scrolling to protect my mental health, focus, and inner peace today.", + "Every time I choose deliberate focus over compulsive browsing, my discipline grows stronger. I am the conscious master of my habits, and I decide where my precious attention flows today.", + "The modern world is designed to fragment attention, but I choose concentration. I will not sacrifice my greatest ambitions on the altar of cheap, fleeting digital distractions in this moment.", + "I enter this app with a clear, singular objective. Once finished, I will promptly close it, returning to the rich and tangible reality of my actual daily life and responsibilities.", + "Mindful living means recognizing when an impulse does not serve purpose. I observe the urge to scroll, breathe through restlessness, and consciously return to what truly matters most in life.", + "Superficial metrics and endless feeds cannot bring lasting fulfillment. I choose joy in authentic human connections, deep creative work, and the quiet satisfaction of meaningful personal daily accomplishments every day.", + "I guard my focus against manipulative algorithms seeking to monetize time. My life is far too precious to spend passively consuming the endless opinions and highlights of total strangers online.", + "A disciplined mind is a peaceful sanctuary amidst the digital age. I cultivate intentionality in every action, ensuring that my smartphone remains a helpful servant, never my master in life.", + "Real accomplishments require uninterrupted hours of patient effort. I consciously resist the temptation of effortless entertainment so that I can produce work of genuine quality and lasting personal value today.", + "I respect my intellect enough to feed it substance rather than trivia. By choosing presence over pixels, I honor my potential and nurture my own creative spirit with pure intention.", + "The urge to scroll is merely a craving for novelty. By sitting with boredom and embracing stillness, I unlock the deep creativity and calm clarity that truly transform my life.", + "I commit to being fully present for people around me, rather than dividing attention between conversations and glowing screens. True connection happens when I look up and listen with care.", + "Each day gives me finite time and energy to build future goals. I will not squander this gift on endless feeds that leave me feeling scattered, tired, and unfulfilled today.", + "Conscious restraint is the foundation of freedom and self-respect. I choose to say no to addictive digital rabbit holes so that I can say a wholehearted yes to living deeply.", + "I release the compulsive habit of checking my phone every spare minute. In quiet spaces between tasks, I choose to breathe, reflect, and appreciate the richness of the present moment.", + "By taking charge of my digital habits, I regain lost time. I dedicate these precious hours to learning, moving, creating, and living with authentic joyful purpose in the real world.", + "I refuse to let synthetic notifications dictate the tempo of life. I decide when to engage, when to pause, and when to completely disconnect from the online world around me.", + "True wisdom begins with understanding the difference between urgent noise and what is truly important. I filter out the trivial so that I can dedicate myself to what matters most.", + "My mind deserves rest, clarity, and spaciousness rather than an unrelenting barrage. Today, I practice intentional digital minimalism and rediscover profound peace from quiet focused attention in every single task.", + "I choose to live an active, courageous life rather than a passive bystander watching people live. I step away from the feed and step boldly into my own journey today.", + "When I resist infinite feeds, I reclaim my ability to think deeply, read patiently, and reflect meaningfully. Deliberate discipline protects the very essence of my human intellect and daily focus.", + "I am not obligated to witness every trend or opinion on the internet. I choose peace of mind over irrelevant information, protecting my emotional bandwidth for what truly counts today.", + "Mastering my attention is the most important skill of the modern era. Today I choose to sharpen that skill by rejecting mindless consumption and embracing deep, purposeful mental engagement always.", + "Every intentional pause before unlocking my phone reminds me I am in control. I choose focus over distraction, intention over impulse, and real-world living over virtual digital illusions right now.", + "The satisfaction of finishing a meaningful project exceeds the thrill of scrolling through feeds. I choose lasting pride over momentary amusement, directing energy toward purposeful creative endeavors with steadfast discipline.", + "I walk away from digital noise with a clear conscience and calm heart. My time is reclaimed, my focus is restored, and my life is lived with intentional presence today.", + ) + + val mantras50: List = listOf( + "I recognize that infinite scrolling feeds are carefully engineered to capture my attention for corporate profit, but my life belongs to me alone. I choose voluntary discipline over impulsive consumption, stepping away from this screen to pursue meaningful real-world accomplishments, cultivate deep personal relationships, and protect my sacred mental peace.", + "True mastery over technology means using it as a deliberate instrument for my education and growth, rather than allowing restless impulses to dictate how I spend my time. Today, I establish healthy digital boundaries, breathe with conscious intention, and return to physical reality where my genuine purpose and creativity reside.", + "The greatest achievements in history required unbroken hours of deep concentration and effort, qualities that mindless social media browsing relentlessly erodes. I refuse to trade my highest aspirations for superficial digital novelty, choosing instead to guard my focus fiercely and direct my undivided energy toward creating work of lasting value.", + "When I silence the endless barrage of notifications, algorithmic recommendations, and curated digital highlights, I rediscover the quiet power of my own inner voice. I choose to live deeply in the present moment, experiencing life firsthand rather than passively observing a distorted virtual world through a small glowing glass display.", + "Every minute spent lost in algorithmic feeds is a minute stolen from my health, my intellect, my passions, and the people I love. I commit to treating my attention with respect it deserves, replacing compulsive browsing with mindful presence, purposeful action, and the genuine satisfaction that comes from real achievements.", + "Distraction is the silent thief of human potential, constantly tempting me to settle for fleeting dopamine instead of enduring fulfillment. By practicing conscious restraint and saying a resolute no to endless scrolling, I reclaim my time and build the unshakable mental discipline necessary to achieve all my most ambitious goals.", + "I understand that the urge to mindlessly check social media is merely a reaction to boredom or discomfort, not an authentic need. I choose to meet this moment with calm awareness, breathing deeply through the impulse, and choosing purposeful constructive action over passive digital consumption that leaves my spirit exhausted.", + "Living an intentional life means deciding beforehand what deserves my attention and refusing to let commercial algorithms make that choice for me. I enter this digital space with clear purpose, complete what is strictly necessary, and depart promptly so that I can invest my finite life energy into what matters.", + "A calm, focused, and disciplined mind is the most valuable asset I can cultivate in an increasingly distracted world full of noisy demands. I deliberately protect my mental sanctuary by filtering out trivial digital clutter, honoring my intellect with deep thoughts, and grounding my daily awareness firmly in physical reality.", + "The modern attention economy seeks to keep me perpetually dissatisfied and reactive, but I possess the power to walk away at any moment. Today, I embrace the quiet beauty of intentional living, choosing deep focus over surface-level entertainment, and finding genuine contentment in the simple reality of my everyday life.", + "Real confidence comes from self-control and the knowledge that my actions align with my deepest personal values, not from superficial online engagement metrics. I release the need for digital validation, turning my gaze inward to cultivate genuine wisdom, resilient character, and the quiet strength that comes from disciplined daily habits.", + "I refuse to let the continuous stream of internet chatter fragment my concentration and rob my creative voice. By setting strict boundaries with social media, I grant my mind spaciousness to think deeply, solve difficult problems, and produce work that makes a positive difference in the real world around me.", + "Every conscious choice to put down my phone is a victory for my independence, my focus, and my personal freedom. I am not a passive spectator in my life; I am the author of my own days, and I choose to write them with presence, passion, and quiet dedicated effort.", + "In a culture that glorifies constant connectivity and instant responses, I choose the quiet rebellion of stillness, patience, and deliberate unplugged living. My best ideas emerge when I allow my mind to wander freely without the intrusive stimulation of curated feeds, endless videos, and repetitive digital conversations that lead nowhere.", + "I understand that life is finite and that the hours I spend staring at algorithmic screens will never be returned to me. Therefore, I choose to invest my precious time wisely, prioritizing physical health, intellectual curiosity, genuine community, and meaningful creative pursuits that will bring me lasting pride and happiness.", + "Mindfulness is the practice of returning home to this very moment, finding wonder and peace in what is already here before me. I step out of virtual comparisons and digital envy, choosing instead to appreciate my unique journey and celebrate the everyday gifts of physical existence with a joyful heart.", + "When I conquer the compulsion to constantly check feeds, I unlock an abundance of time and energy that transforms my daily productivity. I commit to showing up fully for my responsibilities, treating my work as a craft, and giving my absolute best effort without being derailed by petty digital distractions.", + "The convenience of modern technology must never become an excuse for a fragmented mind and an undisciplined life. I hold the power to dictate the role of digital devices in my routine, ensuring they serve my goals while strictly preserving my capacity for deep, quiet, uninterrupted reflection and serious study.", + "I choose to measure the quality of my day by the depth of my focus, the kindness of my interactions, and the progress of my goals. Sensational headlines and trivial updates have no power over my peace of mind when I deliberately anchor my attention in purposeful and disciplined habits.", + "Inner strength is forged in the small, unseen moments when I decide to resist instant gratification in favor of long-term integrity. Choosing to close an addictive app and return to my real-world duties is an act of self-respect that elevates my character and builds lasting confidence in my own willpower.", + "I recognize that behind every infinite feed lies an artificial system optimized to keep me watching, but my attention is not for sale. I choose to break free from passive consumerism, reclaiming my autonomy and investing my mental energy into learning, building, and contributing meaningfully to the world around me.", + "A life well-lived is characterized by depth, purpose, and deliberate focus, not by the superficial breadth of content consumed on a screen. Today, I give my undivided attention to the task at hand, knowing that true excellence is born from the patient refusal to be distracted by trivial digital novelties.", + "I let go of the fear of missing out, realizing that the only thing I truly risk missing is my own precious life passing by unlived. By stepping back from social media feeds, I create room for genuine presence, peaceful solitude, and deep restorative rest that revitalizes my mind completely.", + "My intellect is a sacred garden that deserves to be tended with great care, not overwhelmed by endless streams of algorithmic digital weeds. I choose to nourish my curiosity with books, nature, meaningful conversations, and creative challenges that expand my perspective and strengthen my capacity for independent and critical thought.", + "When I embrace the discipline of mindful browsing, I demonstrate respect for my time, my intelligence, and my future well-being. I refuse to let digital noise drain my mental vitality, choosing instead to direct my focused efforts toward building a life of purpose, honor, and true authentic real-world fulfillment today.", + "The urge to scroll will pass if I simply pause, breathe, and observe it without judgment or compliance. In that brief space between stimulus and response lies my ultimate freedom to choose wisdom over impulse, peace over agitation, and intentional living over mindless digital habits that erode my personal potential.", + "I remember that the curated online world presents an illusion of perfection that bears little resemblance to the rich complexity of reality. I choose to embrace my authentic life with all its challenges, finding deep joy in real effort, genuine connections, and the honest pursuit of personal growth and self-mastery.", + "True digital wellness is not about deprivation, but about intentionality and alignment with what truly matters to my core values. I choose to engage with technology consciously, leaving behind addictive patterns and rediscovering the profound liberty of being fully awake, attentive, and engaged with the physical world around me today.", + "I dedicate myself to doing fewer things with greater depth and excellence, refusing to let shallow online browsing dilute my creative power. By eliminating compulsive screen time, I make space for deep relationships, craft, physical vitality, and the quiet satisfaction of knowing that my attention was spent with deliberate purpose.", + "Today I reclaim my attention, my time, and my peace of mind from the grip of mindless digital habits. I step forward into the world with clear eyes, a steady heart, and an unwavering commitment to living fully, deeply, and intentionally in the reality that truly matters most to me.", + ) + + val mantrasByWordCount: Map> = mapOf( + 10 to mantras10, + 15 to mantras15, + 20 to mantras20, + 30 to mantras30, + 50 to mantras50 + ) + + /** + * Retrieves the next random mantra for the specified word count, cycling through all + * available sentences without duplicates until the full pool is exhausted. + */ + @Synchronized + fun getNextMantra(wordCount: Int): String { + val list = mantrasByWordCount[wordCount] ?: mantras30 + var pool = unexhaustedPools[wordCount] + if (pool.isNullOrEmpty()) { + pool = list.indices.toMutableList() + pool.shuffle() + unexhaustedPools[wordCount] = pool + } + val chosenIndex = pool.removeAt(0) + return list[chosenIndex] + } + + /** + * Resets the exhaustion tracking pool, forcing a reshuffle on the next request. + */ + @Synchronized + fun resetPool(wordCount: Int? = null) { + if (wordCount != null) { + unexhaustedPools.remove(wordCount) + } else { + unexhaustedPools.clear() + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt similarity index 71% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt index 7d7c646..8cbfdeb 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/EffortFrictionGate.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.FitnessCenter -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -58,39 +57,29 @@ import com.ujwal.focusgram.core.ui.theme.TextMuted import com.ujwal.focusgram.core.ui.theme.TextSecondary /** - * EffortFrictionGate - * Imposes intentional friction via a 30-word mindfulness discipline mantra - * or an optional rewarded ad bypass (+2 minutes) before allowing Instagram access. + * Friction gate requiring the user to type an intentional discipline mantra to unlock an intentional browsing window. */ @Composable fun EffortFrictionGate( sessionType: String = "Instagram", // "Instagram" or "Reels" - remainingCreditMinutes: Int = 0, + wordCount: Int = 30, onProceed: () -> Unit, - onWatchAd: () -> Unit, onCancel: () -> Unit ) { val focusManager = LocalFocusManager.current val scrollState = rememberScrollState() - // Curated 30-word discipline mantras - val mantras = remember { - listOf( - "I choose to control my focus and not let algorithmic feeds govern my attention. Real life happens outside this screen, and my precious time deserves deliberate purpose and unwavering disciplined presence.", - "Technology is a tool for my growth, not a trap for my restless dopamine. I enter with conscious intent, observe with clear detachment, and leave before endless scrolling steals my peaceful day.", - "True freedom comes from voluntary discipline. I possess mastery over my impulses, refusing to mindlessly consume superficial digital noise when profound real-world accomplishments demand my undivided attention and creative energy today." - ) + val targetMantra = remember(wordCount) { + DisciplineMantraRepository.getNextMantra(wordCount) } - - val targetMantra = remember { mantras.random() } var userInput by remember { mutableStateOf("") } - // Normalize text for comparison + // Normalize whitespace and case to ensure fair match against prompt val normalizedTarget = remember(targetMantra) { targetMantra.trim().lowercase().replace(Regex("\\s+"), " ") } val normalizedInput = userInput.trim().lowercase().replace(Regex("\\s+"), " ") val isCompleted = normalizedInput == normalizedTarget - val targetWords = remember(normalizedTarget) { normalizedTarget.split(" ") } + val targetWords = remember(normalizedTarget) { normalizedTarget.split(" ").filter { it.isNotBlank() } } val inputWords = normalizedInput.split(" ").filter { it.isNotBlank() } val matchingWords = inputWords.filterIndexed { index, word -> index < targetWords.size && targetWords[index] == word @@ -110,7 +99,6 @@ fun EffortFrictionGate( ) { Spacer(modifier = Modifier.height(16.dp)) - // Friction Header Icon Box( modifier = Modifier .size(72.dp) @@ -139,42 +127,13 @@ fun EffortFrictionGate( ) Text( - text = "Type the 30-word discipline mantra or watch a rewarded ad to earn $sessionType time.", + text = "Type the ${targetWords.size}-word discipline mantra to earn $sessionType time.", style = MaterialTheme.typography.bodyMedium, color = TextSecondary, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 6.dp, bottom = 20.dp) ) - // Current credit banner - if (remainingCreditMinutes > 0) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(FocusGreen.copy(alpha = 0.15f)) - .border(1.dp, FocusGreen.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Default.AccessTime, - contentDescription = null, - tint = FocusGreen, - modifier = Modifier.size(20.dp) - ) - Text( - text = " You have $remainingCreditMinutes min remaining", - color = FocusGreen, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - - // Target Mantra Card Column( modifier = Modifier .fillMaxWidth() @@ -215,7 +174,6 @@ fun EffortFrictionGate( Spacer(modifier = Modifier.height(16.dp)) - // User Typing Field OutlinedTextField( value = userInput, onValueChange = { userInput = it }, @@ -239,10 +197,9 @@ fun EffortFrictionGate( Spacer(modifier = Modifier.height(24.dp)) - // Button 1: Complete Challenge Button( onClick = onProceed, - enabled = isCompleted || remainingCreditMinutes > 0, + enabled = isCompleted, modifier = Modifier .fillMaxWidth() .height(52.dp), @@ -268,33 +225,6 @@ fun EffortFrictionGate( Spacer(modifier = Modifier.height(12.dp)) - // Button 2: Rewarded Ad Bypass - Button( - onClick = onWatchAd, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF9800), - contentColor = PureBlack - ) - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Text( - text = " Watch Rewarded Ad (+2 min)", - fontWeight = FontWeight.Bold, - fontSize = 15.sp - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Button 3: Cancel / Back Out OutlinedButton( onClick = onCancel, modifier = Modifier diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt similarity index 76% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt index 4b7140c..d6c0e80 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/GuardrailsDashboardScreen.kt @@ -39,19 +39,36 @@ import com.ujwal.focusgram.core.ui.theme.TextPrimary import com.ujwal.focusgram.core.ui.theme.TextSecondary import kotlinx.coroutines.launch +import com.ujwal.focusgram.core.database.ScreenTimeDao +import com.ujwal.focusgram.core.utils.TimeFormatters +import kotlinx.coroutines.flow.flowOf +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import androidx.compose.runtime.remember + /** - * GuardrailsDashboardScreen - * Central control panel for Daily usage limits, Cooldown timers, and Scheduled Blocking. + * Settings dashboard for configuring daily usage caps, cooldown intervals, and strict Reels blocking. */ @Composable fun GuardrailsDashboardScreen( repository: SettingsRepository, onBack: () -> Unit, + screenTimeDao: ScreenTimeDao? = null, modifier: Modifier = Modifier ) { val settings by repository.settingsFlow.collectAsState(initial = FocusSettings()) val scope = rememberCoroutineScope() + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) } + val todayStr = remember { dateFormat.format(Date()) } + val todayRecord by (screenTimeDao?.getForDate(todayStr) ?: flowOf(null)).collectAsState(initial = null) + val todaySeconds = todayRecord?.seconds ?: 0 + val baseDailyLimitSec = settings.dailyLimitMinutes * 60 + val isDailyLimitHit = settings.dailyLimitMinutes > 0 && todaySeconds >= baseDailyLimitSec + val isNearDailyLimit = settings.dailyLimitMinutes > 15 && (baseDailyLimitSec - todaySeconds) in 0..300 + val isDailyLimitLocked = settings.dailyLimitMinutes > 0 && (isDailyLimitHit || isNearDailyLimit) + Column( modifier = modifier .fillMaxSize() @@ -90,20 +107,29 @@ fun GuardrailsDashboardScreen( color = TextPrimary ) Text( - text = "${settings.dailyLimitMinutes} minutes", + text = if (isDailyLimitLocked) { + "${TimeFormatters.formatMinutes(settings.dailyLimitMinutes)} (Locked)" + } else { + TimeFormatters.formatMinutes(settings.dailyLimitMinutes) + }, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = InstagramBlue + color = if (isDailyLimitLocked) TextSecondary else InstagramBlue ) } Spacer(modifier = Modifier.height(8.dp)) Slider( value = settings.dailyLimitMinutes.toFloat(), - onValueChange = { scope.launch { repository.setDailyLimitMinutes(it.toInt()) } }, + onValueChange = { + if (!isDailyLimitLocked) { + scope.launch { repository.setDailyLimitMinutes(it.toInt()) } + } + }, + enabled = !isDailyLimitLocked, valueRange = 15f..180f, steps = 10, colors = SliderDefaults.colors( - thumbColor = InstagramBlue, - activeTrackColor = InstagramBlue, + thumbColor = if (isDailyLimitLocked) TextSecondary else InstagramBlue, + activeTrackColor = if (isDailyLimitLocked) TextSecondary else InstagramBlue, inactiveTrackColor = DarkCardElevated ) ) @@ -132,7 +158,7 @@ fun GuardrailsDashboardScreen( color = TextPrimary ) Text( - text = "${settings.cooldownMinutes} minutes", + text = TimeFormatters.formatMinutes(settings.cooldownMinutes), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = InstagramBlue ) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt similarity index 95% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt rename to app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt index fb721a4..7bddcb2 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/TimerFallbackScreen.kt @@ -45,9 +45,7 @@ import com.ujwal.focusgram.core.ui.theme.TextSecondary import kotlinx.coroutines.delay /** - * TimerFallbackScreen - * Graceful 15-second timer fallback that guarantees reward granting (+2 minutes) - * even when rewarded ads cannot be served. + * 15-second timer granting +2 minutes of session time when ad networks fail or are offline. */ @Composable fun TimerFallbackScreen( @@ -108,7 +106,7 @@ fun TimerFallbackScreen( Spacer(modifier = Modifier.height(28.dp)) Text( - text = "Earning $sessionType Time", + text = "Unlocking Reward", style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold), color = Color.White ) @@ -116,7 +114,7 @@ fun TimerFallbackScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Hold on for a few moments while your focus credit is unlocked.", + text = "Hold on for a few moments while your reward is unlocked.", style = MaterialTheme.typography.bodyMedium, color = TextSecondary, textAlign = TextAlign.Center diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt b/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt similarity index 73% rename from Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt rename to app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt index 79e7c11..08ac533 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt +++ b/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt @@ -58,6 +58,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -93,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.ujwal.focusgram.core.database.FocusGramDatabase @@ -117,7 +121,9 @@ import com.ujwal.focusgram.feature.history.ReelsHistoryScreen import com.ujwal.focusgram.feature.navigation.NativeBottomNavBar import com.ujwal.focusgram.feature.navigation.ReelsTopBar import com.ujwal.focusgram.feature.session.AppSessionPicker +import com.ujwal.focusgram.feature.session.DailyLimitGateScreen import com.ujwal.focusgram.feature.session.SessionExhaustedScreen +import com.ujwal.focusgram.core.utils.TimeFormatters import com.ujwal.focusgram.feature.snapshots.SnapshotManagerScreen import com.ujwal.focusgram.feature.webview.GeckoViewContainer import com.ujwal.focusgram.feature.screentime.ScreenTimeScreen @@ -127,6 +133,13 @@ import com.ujwal.focusgram.feature.wellness.BreathGateScreen import com.ujwal.focusgram.feature.wellness.CooldownGateScreen import com.ujwal.focusgram.feature.wellness.EffortFrictionGate import com.ujwal.focusgram.feature.wellness.GuardrailsDashboardScreen +import com.ujwal.focusgram.feature.onboarding.OnboardingScreen +import com.ujwal.focusgram.feature.update.UpdateBanner +import com.ujwal.focusgram.feature.update.UpdateCheckResult +import com.ujwal.focusgram.feature.update.UpdateCheckerService +import com.ujwal.focusgram.feature.update.UpdateInfo +import com.ujwal.focusgram.feature.notice.RemoteNotice +import com.ujwal.focusgram.feature.notice.RemoteNoticeService import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -143,7 +156,8 @@ enum class AppDestination { APP_LOCK_SETTINGS, CHAT_LOCK_SETUP, DEBUG_MENU, - ADSTERRA_REWARDED + ADSTERRA_REWARDED, + ONBOARDING } data class PendingChatUnlock( @@ -153,10 +167,8 @@ data class PendingChatUnlock( ) /** - * FocusGramApp - * Primary Jetpack Compose application composable. - * Orchestrates Mozilla GeckoView, built-in content blocker, mindful intention timers, - * App Lock biometric security, and native bottom navigation. + * Root composable binding Mozilla GeckoView, client-side content blocker rules, + * biometric gates, usage tracking, and the floating navigation shell. */ @Composable fun FocusGramApp() { @@ -172,8 +184,9 @@ fun FocusGramApp() { val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() - // Core Managers & Repositories val settingsRepository = remember { SettingsRepository(context) } + val initialSettings = remember { settingsRepository.getSettingsSync() } + val settings by settingsRepository.settingsFlow.collectAsState(initial = initialSettings) val encryptedStorage = remember { EncryptedStorage(context) } val database = remember { FocusGramDatabase.getInstance(context) } val reelsRepository = remember { ReelsHistoryRepository(database.reelsHistoryDao()) } @@ -181,17 +194,22 @@ fun FocusGramApp() { val geckoManager = remember { GeckoRuntimeManager.getInstance(context) } val runtime = geckoManager.runtime val extensionManager = geckoManager.extensionManager - val reelHistory = remember { geckoManager.reelHistory } + val reelHistory = remember { + geckoManager.reelHistory.apply { + if (initialSettings.activeSessionIsActive && initialSettings.activeSessionRemainingSeconds > 0) { + restoreSession(initialSettings.activeSessionReelCodes, initialSettings.activeSessionReelsWatched) + } + } + } val reelsWatchedSessionCount by reelHistory.sessionCount.collectAsState() val userScriptManager = remember { UserScriptManager(context) } val sessionManager = remember { GeckoSessionManager(runtime).apply { this.userScriptManager = userScriptManager + this.extensionManager = extensionManager } } - val initialSettings = remember { settingsRepository.getSettingsSync() } - val settings by settingsRepository.settingsFlow.collectAsState(initial = initialSettings) val currentUrl by sessionManager.currentUrl.collectAsState() var isAppInForeground by remember { mutableStateOf(true) } var destination by rememberSaveable { mutableStateOf(AppDestination.MAIN_WEBVIEW) } @@ -200,7 +218,24 @@ fun FocusGramApp() { var isAppUnlocked by rememberSaveable { mutableStateOf(false) } val shouldShowLock = settings.appLockEnabled && hasPin && !isAppUnlocked - // Direct Messages & Chat Lock Evaluation + val updateCheckerService = remember { UpdateCheckerService(context, settingsRepository) } + var activeUpdateInfo by remember { mutableStateOf(null) } + val remoteNoticeService = remember { RemoteNoticeService(context) } + var activeRemoteNotice by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + when (val res = updateCheckerService.checkForUpdates(ignoreDismissed = false)) { + is UpdateCheckResult.Available -> { + activeUpdateInfo = res.updateInfo + } + else -> {} + } + val notice = remoteNoticeService.fetchNotice() + if (notice != null && remoteNoticeService.shouldShow(notice)) { + activeRemoteNotice = notice + } + } + val threadToUsernameMap = remember { androidx.compose.runtime.mutableStateMapOf>() } var unlockedChatThreadIds by remember { mutableStateOf(emptySet()) } var isDirectAllUnlocked by remember { mutableStateOf(false) } @@ -261,6 +296,20 @@ fun FocusGramApp() { activeThreadId = currentThreadId } + LaunchedEffect(isDirectRoute) { + if (!isDirectRoute) { + isDirectAllUnlocked = false + unlockedChatThreadIds = emptySet() + extensionManager.notifyChatLocked("") + sessionManager.userScriptManager?.evaluate(sessionManager.session, """ + window.__fgUnlockedThreads = []; + window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { + detail: { unlockedThreads: [] } + })); + """) + } + } + val isChatLockActive = remember( currentUrl, currentPath, @@ -294,7 +343,9 @@ fun FocusGramApp() { } false } - var hasPassedSessionPicker by rememberSaveable { mutableStateOf(false) } + var hasPassedSessionPicker by rememberSaveable { + mutableStateOf(initialSettings.activeSessionIsActive && initialSettings.activeSessionRemainingSeconds > 0) + } var hasPassedBreathGate by rememberSaveable { mutableStateOf(!initialSettings.showBreathGate || initialSettings.breathGateSeconds <= 0) } var hasPassedEffortFriction by rememberSaveable { mutableStateOf(!initialSettings.effortFrictionEnabled || initialSettings.wordChallengeCount <= 0) } val lastSessionEndTimestamp by settingsRepository.lastSessionEndTimestampFlow.collectAsState(initial = 0L) @@ -303,16 +354,40 @@ fun FocusGramApp() { val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) } val today = remember { dateFormat.format(Date()) } val todayRecord by database.screenTimeDao().getForDate(today).collectAsState(initial = null) - val isDailyLimitExhausted = settings.dailyLimitMinutes > 0 && ((todayRecord?.seconds ?: 0) >= settings.dailyLimitMinutes * 60) + val todayDailySeconds = todayRecord?.seconds ?: 0 + val dailyExtensionsUsed = if (settings.dailyLimitAdClaimedDate == today) settings.dailyLimitExtensionsCount else 0 + val effectiveDailyLimitMinutes = settings.dailyLimitMinutes + (dailyExtensionsUsed * 5) + var isDebugDailyLimitBypassed by rememberSaveable { mutableStateOf(false) } + val isDailyLimitExhausted = !isDebugDailyLimitBypassed && settings.dailyLimitMinutes > 0 && (todayDailySeconds >= effectiveDailyLimitMinutes * 60) + val dailyExtensionsRemaining = (2 - dailyExtensionsUsed).coerceAtLeast(0) - val remainingCooldownSeconds = remember(lastSessionEndTimestamp, settings.cooldownMinutes) { - if (settings.cooldownMinutes <= 0 || lastSessionEndTimestamp <= 0L) { - 0 + val targetExpiryMillis = remember(lastSessionEndTimestamp, settings.cooldownMinutes) { + if (settings.cooldownMinutes > 0 && lastSessionEndTimestamp > 0L) { + lastSessionEndTimestamp + (settings.cooldownMinutes * 60 * 1000L) } else { - val elapsedMillis = System.currentTimeMillis() - lastSessionEndTimestamp - val cooldownMillis = settings.cooldownMinutes * 60 * 1000L - val remainingMillis = cooldownMillis - elapsedMillis - if (remainingMillis > 0) (remainingMillis / 1000).toInt() else 0 + 0L + } + } + + var remainingCooldownSeconds by remember { + val initialRemaining = if (targetExpiryMillis > 0L) { + val rem = targetExpiryMillis - System.currentTimeMillis() + if (rem > 0) ((rem + 999) / 1000).toInt() else 0 + } else 0 + mutableIntStateOf(initialRemaining) + } + + LaunchedEffect(targetExpiryMillis, isAppInForeground) { + if (targetExpiryMillis <= 0L) { + remainingCooldownSeconds = 0 + return@LaunchedEffect + } + while (isActive) { + val remainingMillis = targetExpiryMillis - System.currentTimeMillis() + val remainingSec = if (remainingMillis > 0) ((remainingMillis + 999) / 1000).toInt() else 0 + remainingCooldownSeconds = remainingSec + if (remainingSec <= 0) break + delay(1000L) } } @@ -323,30 +398,34 @@ fun FocusGramApp() { var isUserAuthenticated by remember { mutableStateOf(false) } var isAuthPage by remember { mutableStateOf(false) } - // Session intention countdown timer state - var sessionDurationMinutes by remember { mutableIntStateOf(15) } - var sessionRemainingSeconds by remember { mutableIntStateOf(15 * 60) } - var totalElapsedSeconds by remember { mutableIntStateOf(0) } - var adExtensionsUsed by remember { mutableIntStateOf(0) } + var sessionDurationMinutes by rememberSaveable { + mutableIntStateOf(if (initialSettings.activeSessionIsActive && initialSettings.activeSessionDurationMinutes > 0) initialSettings.activeSessionDurationMinutes else 15) + } + var sessionRemainingSeconds by rememberSaveable { + mutableIntStateOf(if (initialSettings.activeSessionIsActive && initialSettings.activeSessionRemainingSeconds > 0) initialSettings.activeSessionRemainingSeconds else 15 * 60) + } + var totalElapsedSeconds by rememberSaveable { + mutableIntStateOf(if (initialSettings.activeSessionIsActive) initialSettings.activeSessionElapsedSeconds else 0) + } + var sessionExtensionsUsed by remember { mutableIntStateOf(0) } + val sessionExtensionsRemaining = (3 - sessionExtensionsUsed).coerceAtLeast(0) var isSessionExhausted by remember { mutableStateOf(false) } var adRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) } var settingsSubpage by rememberSaveable { mutableStateOf(com.ujwal.focusgram.feature.settings.SettingsSubpage.MAIN) } - // Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active val isGateActive = destination != AppDestination.ADSTERRA_REWARDED && ( shouldShowLock || isDailyLimitExhausted || isSessionExhausted || - (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) || + (!hasPassedSessionPicker && remainingCooldownSeconds > 0 && !hasPassedCooldownGate) || (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) || (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) || !hasPassedSessionPicker ) - // System File Chooser / Camera / Gallery Launchers for Story, Post, DMs & Profile Uploads val pendingFilePickerCallback = remember { AtomicReference<((List) -> Unit)?>(null) } val pendingCameraCaptureUri = remember { AtomicReference(null) } - // 1. Android Modern Photo & Video Pickers (Official AndroidX contracts, zero permissions needed) + // AndroidX Photo Picker delegates across Android 11+ without storage permissions. val singlePhotoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> @@ -363,7 +442,7 @@ fun FocusGramApp() { callback?.invoke(uris) } - // 2. Direct Camera Capture Launcher (Used when HTML input requests camera capture) + // FileProvider camera capture invoked when input element specifies capture attributes. val takePictureLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicture() ) { success -> @@ -377,7 +456,7 @@ fun FocusGramApp() { } } - // 3. System Document/Gallery Fallback Launcher + // DocumentsUI / SAF fallback for older Android releases or third-party file managers. val fallbackFilePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> @@ -399,7 +478,6 @@ fun FocusGramApp() { callback?.invoke(uris) } - // Android Runtime Permissions Launcher (Camera, Audio, Storage) val pendingPermissionCallback = remember { AtomicReference<((Boolean) -> Unit)?>(null) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() @@ -480,14 +558,13 @@ fun FocusGramApp() { val currentUrlLower = sessionManager.currentUrl.value.lowercase() val isProfilePictureEdit = currentUrlLower.contains("/accounts/edit") - // In Instagram, DMs (/direct/), Stories, and Feed Posts all support videos (MP4, MOV, WebM). - // Unless explicitly updating an account avatar/profile picture, always allow both Photos and Videos. + // Instagram direct messages, stories, and feed posts accept videos (MP4, MOV, WebM). + // Profile avatar uploads are the only route strictly limited to images. val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages) val allowsImages = hasImages || isAnyMedia var launched = false - // 1. Direct Camera Capture Mode (Explicitly requested by web page via capture attribute) if (capture != org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.NONE) { try { val cameraDir = File(context.cacheDir, "camera").apply { mkdirs() } @@ -506,8 +583,7 @@ fun FocusGramApp() { } } - // 2. Primary Media Picker: Modern Android Photo & Video Picker (PickVisualMedia) - // Works seamlessly on Android 11+ and Samsung Galaxy OneUI without any permissions + // PickVisualMedia delegates without storage permission prompts on Android 11+ and Samsung OneUI. if (!launched) { try { val visualMediaType = when { @@ -530,7 +606,7 @@ fun FocusGramApp() { } } - // 3. Fallback 1: ACTION_GET_CONTENT (with CATEGORY_OPENABLE for Samsung & AOSP DocumentsUI) + // ACTION_GET_CONTENT with CATEGORY_OPENABLE handles AOSP DocumentsUI and custom ROM pickers. if (!launched) { try { val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { @@ -568,7 +644,7 @@ fun FocusGramApp() { } } - // 4. Fallback 2: ACTION_PICK from MediaStore + // Final fallback targeting platform MediaStore URIs when DocumentsUI rejects the intent. if (!launched) { try { val pickIntent = if (allowsVideos && !allowsImages) { @@ -587,14 +663,15 @@ fun FocusGramApp() { } } - // Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView) + // Pre-loads isolated webview container for voluntary ad credits in background. LaunchedEffect(Unit) { AdPreloader.prewarm(context) } - // Foreground Session Limit Ticker & Real-Time Screen / Reels Time Tracker - LaunchedEffect(hasPassedSessionPicker, isSessionExhausted, isAppInForeground) { - if (hasPassedSessionPicker && !isSessionExhausted && isAppInForeground) { + // Foreground ticker decrementing session allocation and logging per-second screen time. + // Pauses immediately when user navigates away from webview (e.g. Settings, Diagnostics). + LaunchedEffect(hasPassedSessionPicker, isSessionExhausted, isDailyLimitExhausted, isAppInForeground, destination, isGateActive) { + if (hasPassedSessionPicker && !isSessionExhausted && !isDailyLimitExhausted && isAppInForeground && destination == AppDestination.MAIN_WEBVIEW && !isGateActive) { val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US) var unsavedSeconds = 0 var unsavedReelsSeconds = 0 @@ -613,7 +690,6 @@ fun FocusGramApp() { unsavedReelsSeconds += 1 } - // Persist to Room SQLite every second for real-time responsiveness if (unsavedSeconds >= 1) { val todayStr = dateFormat.format(java.util.Date()) database.screenTimeDao().addSeconds(todayStr, unsavedSeconds, unsavedReelsSeconds) @@ -621,30 +697,21 @@ fun FocusGramApp() { unsavedReelsSeconds = 0 } - // Every 60 seconds: deduct minute credits & enforce daily limits - if (totalElapsedSeconds % 60 == 0) { - if (onReels) { - settingsRepository.deductReelsMinute() - } else { - settingsRepository.deductInstaMinute() - } - - if (settings.dailyLimitMinutes > 0) { - val today = dateFormat.format(java.util.Date()) - val todayRecord = database.screenTimeDao().getForDateSync(today) - val totalTodaySeconds = (todayRecord?.seconds ?: 0) + unsavedSeconds - if (totalTodaySeconds >= settings.dailyLimitMinutes * 60) { - isSessionExhausted = true - settingsRepository.recordSessionEnd() - HapticHelper.performSessionCompletionAlert(context) - break - } - } + if (totalElapsedSeconds % 5 == 0) { + settingsRepository.saveActiveSession( + durationMinutes = sessionDurationMinutes, + remainingSeconds = sessionRemainingSeconds, + elapsedSeconds = totalElapsedSeconds, + reelsWatched = reelsWatchedSessionCount, + reelCodes = reelHistory.getSessionCodes(), + isActive = true + ) } if (sessionRemainingSeconds <= 0) { isSessionExhausted = true - settingsRepository.recordSessionEnd() + reelHistory.resetSession() + settingsRepository.clearActiveSession() HapticHelper.performSessionCompletionAlert(context) if (settings.notifySessionEnd) { postSessionExpiredNotification(context) @@ -653,39 +720,67 @@ fun FocusGramApp() { } } } finally { - // Ensure any pending seconds are atomically written to Room on cancel or pause + // Flush pending seconds on cancellation or app backgrounding. if (unsavedSeconds > 0) { withContext(NonCancellable) { - val today = dateFormat.format(java.util.Date()) - database.screenTimeDao().addSeconds(today, unsavedSeconds, unsavedReelsSeconds) + val todayStr = dateFormat.format(java.util.Date()) + database.screenTimeDao().addSeconds(todayStr, unsavedSeconds, unsavedReelsSeconds) } } + withContext(NonCancellable) { + settingsRepository.saveActiveSession( + durationMinutes = sessionDurationMinutes, + remainingSeconds = sessionRemainingSeconds, + elapsedSeconds = totalElapsedSeconds, + reelsWatched = reelsWatchedSessionCount, + reelCodes = reelHistory.getSessionCodes(), + isActive = sessionRemainingSeconds > 0 + ) + } } } } - // Update persistent notification route state if ScreenTimeService is running - LaunchedEffect(isViewingReels, settings.notifyPersistent, hasPassedSessionPicker, isSessionExhausted) { - if (settings.notifyPersistent && hasPassedSessionPicker && !isSessionExhausted) { + LaunchedEffect(reelsWatchedSessionCount) { + if (hasPassedSessionPicker && !isSessionExhausted && sessionRemainingSeconds > 0) { + settingsRepository.saveActiveSession( + durationMinutes = sessionDurationMinutes, + remainingSeconds = sessionRemainingSeconds, + elapsedSeconds = totalElapsedSeconds, + reelsWatched = reelsWatchedSessionCount, + reelCodes = reelHistory.getSessionCodes(), + isActive = true + ) + } + } + + LaunchedEffect(isViewingReels, settings.notifyPersistent, hasPassedSessionPicker, isSessionExhausted, isDailyLimitExhausted) { + if (settings.notifyPersistent && hasPassedSessionPicker && !isSessionExhausted && !isDailyLimitExhausted) { ScreenTimeService.updateRoute(context, isViewingReels) } } - // Manage persistent countdown notification via ScreenTimeService - DisposableEffect(hasPassedSessionPicker, isSessionExhausted, settings.notifyPersistent) { - if (hasPassedSessionPicker && !isSessionExhausted && settings.notifyPersistent) { - ScreenTimeService.startSession(context, isViewingReels) + LaunchedEffect(hasPassedSessionPicker, isSessionExhausted, isDailyLimitExhausted, settings.notifyPersistent, isAppInForeground, destination, isGateActive) { + if (!settings.notifyPersistent || !hasPassedSessionPicker || isSessionExhausted || isDailyLimitExhausted || !isAppInForeground) { + ScreenTimeService.stopSession(context) + } else if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive) { + ScreenTimeService.startSession(context, isViewingReels, totalElapsedSeconds, sessionDurationMinutes) + } else { + ScreenTimeService.pauseSession(context, totalElapsedSeconds, sessionDurationMinutes) } + } + + DisposableEffect(Unit) { onDispose { ScreenTimeService.stopSession(context) } } - // Media pausing: pause media when leaving webview or when barriers/gates are active, resume when returning + // Halt HTML5 media players when navigating away from webview or when security gates engage. LaunchedEffect(destination, isGateActive) { if (destination != AppDestination.MAIN_WEBVIEW || isGateActive) { sessionManager.pauseMedia() - } else { + } else if (isAppInForeground) { sessionManager.resumeMedia() } } @@ -693,17 +788,30 @@ fun FocusGramApp() { val currentDestination by rememberUpdatedState(destination) val currentHasPassedSessionPicker by rememberUpdatedState(hasPassedSessionPicker) val currentTotalElapsedSeconds by rememberUpdatedState(totalElapsedSeconds) + val currentSessionRemainingSeconds by rememberUpdatedState(sessionRemainingSeconds) + val currentSessionDurationMinutes by rememberUpdatedState(sessionDurationMinutes) + val currentIsSessionExhausted by rememberUpdatedState(isSessionExhausted) + val currentIsGateActive by rememberUpdatedState(isGateActive) - // Lifecycle observer for foreground/background state, media pausing, and auto-lock DisposableEffect(lifecycleOwner, settings.appLockEnabled, settings.autoLockOnBackground) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { isAppInForeground = false + ScreenTimeService.stopSession(context) sessionManager.pauseMedia() - if (currentHasPassedSessionPicker && currentTotalElapsedSeconds >= 15) { - scope.launch { settingsRepository.recordSessionEnd() } + if (currentHasPassedSessionPicker && !currentIsSessionExhausted) { + scope.launch(NonCancellable) { + settingsRepository.saveActiveSession( + durationMinutes = currentSessionDurationMinutes, + remainingSeconds = currentSessionRemainingSeconds, + elapsedSeconds = currentTotalElapsedSeconds, + reelsWatched = reelsWatchedSessionCount, + reelCodes = reelHistory.getSessionCodes(), + isActive = currentSessionRemainingSeconds > 0 + ) + } } - // Do not lock the app if the user is currently on the rewarded ad screen (e.g. clicking out to view an ad in browser) + // Avoid relocking if user navigated out to an external browser tab to view an ad. if (currentDestination != AppDestination.ADSTERRA_REWARDED) { if (settings.appLockEnabled && (settings.autoLockOnBackground || !currentHasPassedSessionPicker)) { isAppUnlocked = false @@ -715,8 +823,10 @@ fun FocusGramApp() { } } else if (event == Lifecycle.Event.ON_START) { isAppInForeground = true - if (currentDestination == AppDestination.MAIN_WEBVIEW && !isGateActive) { + if (currentDestination == AppDestination.MAIN_WEBVIEW && !currentIsGateActive) { sessionManager.resumeMedia() + } else { + sessionManager.pauseMedia() } } } @@ -726,7 +836,6 @@ fun FocusGramApp() { } } - // Connect WebExtension event callbacks LaunchedEffect(extensionManager) { extensionManager.onOpenSettings = { destination = AppDestination.SETTINGS @@ -739,11 +848,10 @@ fun FocusGramApp() { } extensionManager.onUrlChanged = { newUrl -> val mode = IslandMode.fromUrl(newUrl) - val lower = newUrl.lowercase() - val isAuthRoute = (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || lower.contains("/login") || lower.contains("/signup") + val isAuthRoute = isAuthUrl(newUrl) isAuthPage = isAuthRoute - if (!isAuthRoute) { - isUserAuthenticated = true + if (isAuthRoute) { + isUserAuthenticated = false } sessionManager.updateCurrentUrl(newUrl) sessionManager.updateIslandMode(mode) @@ -755,7 +863,7 @@ fun FocusGramApp() { extensionManager.onModalOrChatActive = { active -> isModalActive = active val currentMode = IslandMode.fromUrl(sessionManager.currentUrl.value) - // On Reels (FLOATING), maintain FLOATING layout so the comments sheet sits above the bottom nav bar + // On Reels, keep FLOATING layout so comments sheets position above the bottom bar. val mode = if (active && currentMode != IslandMode.FLOATING) IslandMode.HIDDEN else currentMode sessionManager.updateIslandMode(mode) extensionManager.setIslandMode(mode) @@ -773,8 +881,15 @@ fun FocusGramApp() { } val cleanCode = code.trim().removePrefix("/").removeSuffix("/") val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories") - if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) { - reelHistory.onWatched(cleanCode, author, title) + val targetCode = if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) { + cleanCode + } else if (author.isNotBlank()) { + author.trim().removePrefix("@") + } else { + "" + } + if (targetCode.length >= 3) { + reelHistory.onWatched(targetCode, author, title) } scope.launch { reelsRepository.addReel(url, title, thumb) @@ -790,10 +905,15 @@ fun FocusGramApp() { } val cleanCode = code.trim().removePrefix("/").removeSuffix("/") val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories") - if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) { - if (title.isNotBlank() || author.isNotBlank()) { - reelHistory.onMeta(cleanCode, author, title) - } + val targetCode = if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) { + cleanCode + } else if (author.isNotBlank()) { + author.trim().removePrefix("@") + } else { + "" + } + if (targetCode.length >= 3 && (title.isNotBlank() || author.isNotBlank())) { + reelHistory.onMeta(targetCode, author, title) } } extensionManager.onDownloadStarted = { filename -> @@ -807,7 +927,7 @@ fun FocusGramApp() { } extensionManager.onAuthStateChanged = { authenticated, authPage -> isUserAuthenticated = authenticated - isAuthPage = authPage + isAuthPage = authPage || !authenticated || isAuthUrl(sessionManager.currentUrl.value) sessionManager.setAuthenticated(authenticated) } } @@ -819,7 +939,6 @@ fun FocusGramApp() { } } - // Mount Content Blocker extension and configure delegates LaunchedEffect(Unit) { extensionManager.initializeUBlockOrigin() @@ -828,11 +947,10 @@ fun FocusGramApp() { sessionManager = sessionManager, onRouteChanged = { newUrl -> val mode = IslandMode.fromUrl(newUrl) - val lower = newUrl.lowercase() - val isAuthRoute = (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || lower.contains("/login") || lower.contains("/signup") + val isAuthRoute = isAuthUrl(newUrl) isAuthPage = isAuthRoute - if (!isAuthRoute) { - isUserAuthenticated = true + if (isAuthRoute) { + isUserAuthenticated = false } sessionManager.updateCurrentUrl(newUrl) sessionManager.updateIslandMode(mode) @@ -866,7 +984,6 @@ fun FocusGramApp() { sessionManager.openAndLoad(initialUrl) } - // Reactively update web environment and extension when settings change var previousReloadSettings by remember { mutableStateOf(null) } LaunchedEffect(settings) { sessionManager.updateSettings(settings) @@ -876,19 +993,7 @@ fun FocusGramApp() { val prev = previousReloadSettings if (prev != null) { - val needsReload = prev.grayscaleEnabled != settings.grayscaleEnabled || - prev.blurExplore != settings.blurExplore || - prev.blurReels != settings.blurReels || - prev.blurFeed != settings.blurFeed || - prev.unblurMode != settings.unblurMode || - prev.hideLikeCounts != settings.hideLikeCounts || - prev.hideFollowerCounts != settings.hideFollowerCounts || - prev.noAutoplay != settings.noAutoplay || - prev.blockDmReelsSwiping != settings.blockDmReelsSwiping || - prev.videoDownloadEnabled != settings.videoDownloadEnabled || - prev.minimalModeEnabled != settings.minimalModeEnabled || - prev.hideSuggestedPosts != settings.hideSuggestedPosts || - prev.noAds != settings.noAds + val needsReload = prev.noAds != settings.noAds if (needsReload) { sessionManager.session.reload() } @@ -896,7 +1001,6 @@ fun FocusGramApp() { previousReloadSettings = settings } - // Dynamic screen orientation lock: enforce portrait-only when enabled LaunchedEffect(settings.portraitOnly, activity) { activity?.requestedOrientation = if (settings.portraitOnly) { ActivityInfo.SCREEN_ORIENTATION_PORTRAIT @@ -905,19 +1009,25 @@ fun FocusGramApp() { } } - // When any barrier, gate, lock, or intention picker is active, pressing back exits the app cleanly + // Exit immediately on back press when locked behind an active mindful barrier or PIN gate. BackHandler(enabled = isGateActive && destination != AppDestination.ADSTERRA_REWARDED) { activity?.finish() } - // Predictive back gesture: when at the root destination and cannot go back in WebView, - // disabling BackHandler allows Android's predictive back-to-home animation to trigger cleanly. + val currentUrlStr = sessionManager.currentUrl.value.lowercase() + val cleanCurrentPath = remember(currentUrlStr) { + IslandMode.extractPath(currentUrlStr).substringBefore('?').substringBefore('#').trimEnd('/').ifEmpty { "/" } + } + val isDmThread = cleanCurrentPath.startsWith("/direct/t/") || cleanCurrentPath.startsWith("/direct/threads/") || (cleanCurrentPath.startsWith("/direct/") && cleanCurrentPath != "/direct/inbox" && cleanCurrentPath != "/direct") + val isDmInbox = cleanCurrentPath == "/direct" || cleanCurrentPath == "/direct/inbox" + val isRootHome = cleanCurrentPath == "/" + + // Disabling BackHandler at root URL lets the system gesture invoke predictive back-to-home. val canHandleBack = !isGateActive && ( destination != AppDestination.MAIN_WEBVIEW || - sessionManager.canGoBack.value || - sessionManager.currentUrl.value.lowercase().let { - it.contains("/direct") || (!it.endsWith("instagram.com/") && !it.endsWith("instagram.com")) - } + isDmThread || + isDmInbox || + (!isRootHome && (sessionManager.canGoBack.value || (!currentUrlStr.endsWith("instagram.com/") && !currentUrlStr.endsWith("instagram.com")))) ) BackHandler(enabled = canHandleBack) { @@ -931,7 +1041,8 @@ fun FocusGramApp() { AppDestination.REELS_HISTORY, AppDestination.SNAPSHOTS, AppDestination.GUARDRAILS, - AppDestination.DEBUG_MENU + AppDestination.DEBUG_MENU, + AppDestination.ONBOARDING ) -> { destination = AppDestination.SETTINGS } @@ -945,17 +1056,27 @@ fun FocusGramApp() { destination == AppDestination.SETTINGS -> { destination = AppDestination.MAIN_WEBVIEW } - sessionManager.canGoBack.value -> { + destination == AppDestination.MAIN_WEBVIEW && isDmThread -> { isWebInputFocused = false isModalActive = false - sessionManager.goBack() + // Override back action in user's DM to navigate strictly to /direct/inbox without history traversal + sessionManager.navigateTo("https://www.instagram.com/direct/inbox/") } - else -> { - val currentUrlStr = sessionManager.currentUrl.value.lowercase() - if (currentUrlStr.contains("/direct") || (!currentUrlStr.endsWith("instagram.com/") && !currentUrlStr.endsWith("instagram.com"))) { - isWebInputFocused = false - isModalActive = false + destination == AppDestination.MAIN_WEBVIEW && isDmInbox -> { + isWebInputFocused = false + isModalActive = false + // Override back action in DM inbox to navigate strictly to home screen without history traversal + sessionManager.navigateTo("https://www.instagram.com/") + } + destination == AppDestination.MAIN_WEBVIEW && !isRootHome -> { + isWebInputFocused = false + isModalActive = false + if (cleanCurrentPath == "/explore" || cleanCurrentPath == "/reels" || (!cleanCurrentPath.contains('/') && cleanCurrentPath.length > 1)) { + sessionManager.navigateTo("https://www.instagram.com/") + } else if (sessionManager.canGoBack.value) { sessionManager.goBack() + } else { + sessionManager.navigateTo("https://www.instagram.com/") } } } @@ -969,6 +1090,23 @@ fun FocusGramApp() { else -> !isSystemDark } + if (settings.isFirstRun) { + OnboardingScreen( + settings = settings, + settingsRepository = settingsRepository, + onComplete = { + scope.launch { + settingsRepository.setFirstRunCompleted() + hasPassedBreathGate = !settings.showBreathGate || settings.breathGateSeconds <= 0 + hasPassedEffortFriction = true + hasPassedSessionPicker = false + hasPassedCooldownGate = true + } + } + ) + return + } + Surface( modifier = Modifier.fillMaxSize(), color = if (isAppLight) Color.White else PureBlack @@ -985,12 +1123,13 @@ fun FocusGramApp() { if (!isKeyboardOrInputActive) navBarTotalHeight else 16.dp } else 0.dp - // Persistent GeckoView layer: keeps GPU compositor surface alive through all transitions. - // On FLOATING (Reels): viewport ends cleanly above the bottom nav bar with ambient light below. - // On NORMAL routes: statusBarsPadding at top. + // Keeps the GeckoView GPU compositor surface attached throughout transitions. + // Under FLOATING mode (Reels), shifts padding so controls avoid bottom bar occlusion. + val isWebviewVisible = destination == AppDestination.MAIN_WEBVIEW && !isGateActive GeckoViewContainer( sessionManager = sessionManager, grayscale = settings.grayscaleEnabled, + isVisible = isWebviewVisible, modifier = Modifier .fillMaxSize() .then( @@ -1002,7 +1141,6 @@ fun FocusGramApp() { ) ) - // Instagram Reels Native Top Header with watched counter and media download ReelsTopBar( isVisible = !isGateActive && destination == AppDestination.MAIN_WEBVIEW && @@ -1025,7 +1163,24 @@ fun FocusGramApp() { modifier = Modifier.align(Alignment.TopCenter) ) - // Native DM Chat Header Lock Overlay Button (positioned adjacent to Instagram's Info button) + if (activeUpdateInfo != null && !isGateActive && destination == AppDestination.MAIN_WEBVIEW && !isViewingReels) { + UpdateBanner( + updateInfo = activeUpdateInfo!!, + onDismiss = { + val ver = activeUpdateInfo!!.latestVersion + activeUpdateInfo = null + scope.launch { + updateCheckerService.dismissVersion(ver) + } + }, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + // Overlay lock trigger positioned adjacent to Instagram's native (i) button in DM headers. if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && currentThreadId != null && settings.chatLockMode == "individual") { val isChatExplicitlyLocked = remember(currentThreadId, settings.lockedChatIds, threadToUsernameMap.size, settings.lockedChatUsernames.size) { val associatedUsers = threadToUsernameMap[currentThreadId] ?: emptySet() @@ -1063,7 +1218,7 @@ fun FocusGramApp() { if (username.isNullOrBlank()) { try { sessionManager.userScriptManager?.evaluate( - sessionManager.session, + sessionManager.session, "if (typeof window.__fgGetThreadParticipant === 'function') { window.__fgGetThreadParticipant(); }" ) } catch (_: Exception) {} @@ -1072,11 +1227,9 @@ fun FocusGramApp() { } val isNowLocked = settingsRepository.toggleLockedChat(currentThreadId, username) if (isNowLocked) { - // Conversation is now locked: engage lock immediately unlockedChatThreadIds = unlockedChatThreadIds - currentThreadId extensionManager.notifyChatLocked(currentThreadId) } else { - // Conversation is now unlocked: keep unlocked in current session unlockedChatThreadIds = unlockedChatThreadIds + currentThreadId extensionManager.notifyChatUnlocked(currentThreadId, isAll = false) } @@ -1101,7 +1254,7 @@ fun FocusGramApp() { } } - // Ambient background behind the floating nav bar on Reels (seamless gradient, no harsh border line) + // Gradient fill beneath the floating navigation bar to smooth video edge transitions on Reels. if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) { Box( modifier = Modifier @@ -1130,10 +1283,15 @@ fun FocusGramApp() { ) } - // Native Bottom Navigation Bar (OLED floating island) - if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) { + if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && !isAuthPage && isUserAuthenticated) { val instagramTheme by sessionManager.instagramTheme.collectAsState() val downloadState by MediaDownloadHelper.downloadProgressFlow.collectAsState() + val sessionTotalSec = sessionDurationMinutes * 60 + val sessionProgress = if (sessionTotalSec > 0 && hasPassedSessionPicker && !isSessionExhausted) { + (totalElapsedSeconds.toFloat() / sessionTotalSec.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } NativeBottomNavBar( currentUrl = currentUrl, settings = settings, @@ -1145,6 +1303,7 @@ fun FocusGramApp() { isDownloading = downloadState.isDownloading, isDownloadCompleted = downloadState.isCompleted, isDownloadFailed = downloadState.isFailed, + sessionProgress = sessionProgress, isAuthenticated = isUserAuthenticated, isAuthPage = isAuthPage, onNavClick = { target -> @@ -1169,8 +1328,8 @@ fun FocusGramApp() { ) } - // Native Floating Bio Font Stylist (Anchored directly above the Soft Keyboard via imePadding) - if (isBioFocused && !isGateActive && destination == AppDestination.MAIN_WEBVIEW) { + // Font transformation bar anchored above the soft keyboard on profile editing inputs. + if (settings.customBioFontsEnabled && isBioFocused && !isGateActive && destination == AppDestination.MAIN_WEBVIEW) { Surface( modifier = Modifier .align(Alignment.BottomCenter) @@ -1227,7 +1386,6 @@ fun FocusGramApp() { } } - // Sub-screen Animated Overlay (Settings, Reels History, App Lock, etc.) AnimatedVisibility( visible = !isGateActive && destination != AppDestination.MAIN_WEBVIEW, enter = slideInHorizontally(animationSpec = tween(280)) { it } + fadeIn(animationSpec = tween(250)), @@ -1251,7 +1409,6 @@ fun FocusGramApp() { ) { currentDestination -> when (currentDestination) { AppDestination.MAIN_WEBVIEW -> { - // Rendered by underlying Box } AppDestination.SETTINGS -> { @@ -1276,7 +1433,9 @@ fun FocusGramApp() { currentSubpage = settingsSubpage, onSubpageChange = { settingsSubpage = it }, encryptedStorage = encryptedStorage, - onNavigateChatPinSetup = { destination = AppDestination.CHAT_LOCK_SETUP } + onNavigateChatPinSetup = { destination = AppDestination.CHAT_LOCK_SETUP }, + onNavigateOnboarding = { destination = AppDestination.ONBOARDING }, + onNavigateDebugMenu = { destination = AppDestination.DEBUG_MENU } ) } @@ -1314,6 +1473,7 @@ fun FocusGramApp() { AppDestination.GUARDRAILS -> { GuardrailsDashboardScreen( repository = settingsRepository, + screenTimeDao = database.screenTimeDao(), onBack = { destination = AppDestination.SETTINGS } ) } @@ -1352,13 +1512,58 @@ fun FocusGramApp() { AppDestination.DEBUG_MENU -> { DebugMenuScreen( repository = settingsRepository, - onBack = { destination = AppDestination.SETTINGS } + onBack = { destination = AppDestination.SETTINGS }, + onSimulateUpdate = { + activeUpdateInfo = UpdateCheckerService.getMockUpdateInfo() + destination = AppDestination.MAIN_WEBVIEW + }, + onSimulateNotice = { + activeRemoteNotice = RemoteNoticeService.getMockNotice() + }, + isLimitsBypassed = isDebugDailyLimitBypassed && hasPassedCooldownGate, + onToggleLimitsBypass = { + val willBypass = !(isDebugDailyLimitBypassed && hasPassedCooldownGate) + isDebugDailyLimitBypassed = willBypass + hasPassedCooldownGate = willBypass + if (willBypass) { + isSessionExhausted = false + } + }, + currentSessionDurationMinutes = sessionDurationMinutes, + currentSessionRemainingSeconds = sessionRemainingSeconds, + currentSessionElapsedSeconds = totalElapsedSeconds, + onAdjustSessionTime = { newDuration, newRemaining, newElapsed -> + sessionDurationMinutes = newDuration + sessionRemainingSeconds = newRemaining + totalElapsedSeconds = newElapsed + hasPassedSessionPicker = true + isSessionExhausted = newRemaining <= 0 + scope.launch { + settingsRepository.saveActiveSession( + durationMinutes = newDuration, + remainingSeconds = newRemaining, + elapsedSeconds = newElapsed, + reelsWatched = reelsWatchedSessionCount, + reelCodes = reelHistory.getSessionCodes(), + isActive = newRemaining > 0 + ) + } + if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive) { + ScreenTimeService.startSession(context, isViewingReels, newElapsed, newDuration) + } else { + ScreenTimeService.pauseSession(context, newElapsed, newDuration) + } + } ) } AppDestination.ADSTERRA_REWARDED -> { AdsterraAdScreen( - sessionType = if (adRewardAction != null) "setting_toggle" else if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary", + sessionType = when { + adRewardAction != null -> "setting_toggle" + isDailyLimitExhausted || isSessionExhausted -> "session_extend" + else -> "voluntary" + }, onRewardClaimed = { val pendingAction = adRewardAction adRewardAction = null @@ -1379,14 +1584,11 @@ fun FocusGramApp() { isSessionExhausted = false destination = AppDestination.MAIN_WEBVIEW } else if (isSessionExhausted) { - adExtensionsUsed += 1 + sessionExtensionsUsed += 1 sessionRemainingSeconds += 3 * 60 sessionDurationMinutes += 3 isSessionExhausted = false destination = AppDestination.MAIN_WEBVIEW - } else if (!hasPassedEffortFriction) { - hasPassedEffortFriction = true - destination = AppDestination.MAIN_WEBVIEW } else { destination = AppDestination.SETTINGS } @@ -1401,12 +1603,19 @@ fun FocusGramApp() { } ) } + + AppDestination.ONBOARDING -> { + OnboardingScreen( + settings = settings, + settingsRepository = settingsRepository, + onComplete = { destination = AppDestination.SETTINGS } + ) + } } } } - // Full-screen Gate & Mindful Session Picker Overlays - // Rendered on top of GeckoView so Instagram preloads seamlessly in the background. + // Overlays rendered atop GeckoView so web assets continue loading in background. if (isGateActive) { Box( modifier = Modifier @@ -1423,28 +1632,53 @@ fun FocusGramApp() { onUnlockSuccess = { isAppUnlocked = true }, isBiometricAllowed = settings.biometricEnabled ) - } else if ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) { - val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60) - val extensionsLeft = if (isDailyLimitExhausted) { - if (settings.dailyLimitAdClaimedDate == today) 0 else 1 - } else { - (3 - adExtensionsUsed).coerceAtLeast(0) - } - SessionExhaustedScreen( - elapsedMinutes = elapsedMins, - extensionsRemaining = extensionsLeft, + } else if (isDailyLimitExhausted && destination != AppDestination.ADSTERRA_REWARDED) { + DailyLimitGateScreen( + dailyLimitMinutes = settings.dailyLimitMinutes, + todayMinutesUsed = (todayDailySeconds + 59) / 60, + extensionsRemaining = dailyExtensionsRemaining, onExit = { activity?.finish() }, onWatchAdToExtend = { - if (extensionsLeft > 0) { + if (dailyExtensionsRemaining > 0) { destination = AppDestination.ADSTERRA_REWARDED } + }, + onDebugBypass = { + isDebugDailyLimitBypassed = true + } + ) + } else if (isSessionExhausted && destination != AppDestination.ADSTERRA_REWARDED) { + val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60) + SessionExhaustedScreen( + elapsedMinutes = elapsedMins, + extensionsRemaining = sessionExtensionsRemaining, + onExit = { + isSessionExhausted = false + hasPassedSessionPicker = false + hasPassedCooldownGate = false + reelHistory.resetSession() + scope.launch { + settingsRepository.recordSessionEnd() + settingsRepository.clearActiveSession() + } + activity?.finish() + }, + onWatchAdToExtend = { + if (sessionExtensionsRemaining > 0) { + destination = AppDestination.ADSTERRA_REWARDED + } + }, + onDebugBypass = { + isSessionExhausted = false + hasPassedSessionPicker = true } ) } else if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) { CooldownGateScreen( - remainingSecondsInitial = remainingCooldownSeconds, + remainingSeconds = remainingCooldownSeconds, onCooldownExpired = { hasPassedCooldownGate = true }, - onExit = { activity?.finish() } + onExit = { activity?.finish() }, + onDebugBypass = { hasPassedCooldownGate = true } ) } else if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) { BreathGateScreen( @@ -1454,11 +1688,8 @@ fun FocusGramApp() { } else if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) { EffortFrictionGate( sessionType = "Instagram", - remainingCreditMinutes = settings.instaMinutes, + wordCount = settings.wordChallengeCount, onProceed = { hasPassedEffortFriction = true }, - onWatchAd = { - destination = AppDestination.ADSTERRA_REWARDED - }, onCancel = { activity?.finish() } ) } else if (!hasPassedSessionPicker) { @@ -1467,16 +1698,27 @@ fun FocusGramApp() { sessionDurationMinutes = minutes sessionRemainingSeconds = minutes * 60 totalElapsedSeconds = 0 - adExtensionsUsed = 0 + sessionExtensionsUsed = 0 isSessionExhausted = false hasPassedSessionPicker = true + reelHistory.resetSession() + scope.launch { + settingsRepository.saveActiveSession( + durationMinutes = minutes, + remainingSeconds = minutes * 60, + elapsedSeconds = 0, + reelsWatched = 0, + reelCodes = emptySet(), + isActive = true + ) + } } ) } } } - // Chat Lock Privacy Screen (Instant Black Cover + Hardware Biometric / PIN Gate) + // Veil rendering pure black over DOM conversation content while awaiting biometric or PIN auth. if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive && isChatLockActive) { Box( modifier = Modifier @@ -1544,9 +1786,7 @@ fun FocusGramApp() { val dest = pending.targetUrl ?: "https://www.instagram.com/direct/t/${pending.threadId}/" val current = sessionManager.currentUrl.value - if (current.contains(pending.threadId)) { - sessionManager.session.reload() - } else { + if (!current.contains(pending.threadId)) { sessionManager.navigateTo(dest) } } else if ((settings.chatLockMode == "whole_dm" || settings.chatLockAll) && currentThreadId == null) { @@ -1580,7 +1820,6 @@ fun FocusGramApp() { detail: { unlockedThreads: window.__fgUnlockedThreads } })); """) - sessionManager.session.reload() } } ) @@ -1588,11 +1827,62 @@ fun FocusGramApp() { } } } + + if (activeRemoteNotice != null) { + val notice = activeRemoteNotice!! + AlertDialog( + onDismissRequest = { + remoteNoticeService.markShown(notice.id) + activeRemoteNotice = null + }, + containerColor = Color(0xEB1C1C1E), + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .border( + BorderStroke( + 1.dp, + Brush.verticalGradient(listOf(Color(0x38FFFFFF), Color(0x10FFFFFF))) + ), + RoundedCornerShape(24.dp) + ), + title = { + Text( + text = notice.header, + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + }, + text = { + Text( + text = notice.body, + color = Color(0xFFC7C7CC), + fontSize = 14.sp, + lineHeight = 20.sp + ) + }, + confirmButton = { + Button( + onClick = { + remoteNoticeService.markShown(notice.id) + activeRemoteNotice = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ), + shape = RoundedCornerShape(20.dp) + ) { + Text(notice.buttonText.ifBlank { "OK" }, fontWeight = FontWeight.Bold) + } + } + ) + } } private fun postSessionExpiredNotification(context: Context) { try { - // Direct physical vibration ensures the user feels the alert regardless of notification channel mutes + // Trigger haptic feedback directly so alert registers even when system notification sound is muted. HapticHelper.performSessionCompletionAlert(context) val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -1635,3 +1925,15 @@ private fun postSessionExpiredNotification(context: Context) { Log.e("FocusGramApp", "Failed to post session notification: ${e.message}", e) } } + +private fun isAuthUrl(url: String): Boolean { + val lower = url.lowercase() + return (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || + lower.contains("/login") || + lower.contains("/signup") || + lower.contains("/two_factor") || + lower.contains("/challenge") || + lower.contains("/onetap") || + lower.contains("accountscenter.") || + lower.contains("facebook.com") +} diff --git a/Ported/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png similarity index 100% rename from Ported/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png rename to app/src/main/res/drawable-hdpi/ic_launcher_foreground.png diff --git a/Ported/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png similarity index 100% rename from Ported/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png rename to app/src/main/res/drawable-mdpi/ic_launcher_foreground.png diff --git a/Ported/app/src/main/res/drawable-v21/launch_background.xml b/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from Ported/app/src/main/res/drawable-v21/launch_background.xml rename to app/src/main/res/drawable-v21/launch_background.xml diff --git a/Ported/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png similarity index 100% rename from Ported/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png rename to app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png diff --git a/Ported/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png rename to app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_abstract.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_forest.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_golden.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_metal.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_minimalist.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_polaroid.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_redesign.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_scribble.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_vibrant.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_fg_viewfinder.png diff --git a/Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from Ported/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png rename to app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png diff --git a/Ported/app/src/main/res/drawable/ic_chat_lock_closed.xml b/app/src/main/res/drawable/ic_chat_lock_closed.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_chat_lock_closed.xml rename to app/src/main/res/drawable/ic_chat_lock_closed.xml diff --git a/Ported/app/src/main/res/drawable/ic_chat_lock_open.xml b/app/src/main/res/drawable/ic_chat_lock_open.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_chat_lock_open.xml rename to app/src/main/res/drawable/ic_chat_lock_open.xml diff --git a/Ported/app/src/main/res/drawable/ic_credit_downloader.png b/app/src/main/res/drawable/ic_credit_downloader.png similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_downloader.png rename to app/src/main/res/drawable/ic_credit_downloader.png diff --git a/Ported/app/src/main/res/drawable/ic_credit_geckoview.xml b/app/src/main/res/drawable/ic_credit_geckoview.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_geckoview.xml rename to app/src/main/res/drawable/ic_credit_geckoview.xml diff --git a/Ported/app/src/main/res/drawable/ic_credit_iconify.xml b/app/src/main/res/drawable/ic_credit_iconify.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_iconify.xml rename to app/src/main/res/drawable/ic_credit_iconify.xml diff --git a/Ported/app/src/main/res/drawable/ic_credit_jetpack_compose.xml b/app/src/main/res/drawable/ic_credit_jetpack_compose.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_jetpack_compose.xml rename to app/src/main/res/drawable/ic_credit_jetpack_compose.xml diff --git a/Ported/app/src/main/res/drawable/ic_credit_sqlite.xml b/app/src/main/res/drawable/ic_credit_sqlite.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_sqlite.xml rename to app/src/main/res/drawable/ic_credit_sqlite.xml diff --git a/Ported/app/src/main/res/drawable/ic_credit_ublock.xml b/app/src/main/res/drawable/ic_credit_ublock.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_credit_ublock.xml rename to app/src/main/res/drawable/ic_credit_ublock.xml diff --git a/Ported/app/src/main/res/drawable/ic_download_fill.xml b/app/src/main/res/drawable/ic_download_fill.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_download_fill.xml rename to app/src/main/res/drawable/ic_download_fill.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_bg_instagram.xml b/app/src/main/res/drawable/ic_launcher_bg_instagram.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_bg_instagram.xml rename to app/src/main/res/drawable/ic_launcher_bg_instagram.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_all_white.xml b/app/src/main/res/drawable/ic_launcher_fg_all_white.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_all_white.xml rename to app/src/main/res/drawable/ic_launcher_fg_all_white.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_classic.xml b/app/src/main/res/drawable/ic_launcher_fg_classic.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_classic.xml rename to app/src/main/res/drawable/ic_launcher_fg_classic.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_discreet.xml b/app/src/main/res/drawable/ic_launcher_fg_discreet.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_discreet.xml rename to app/src/main/res/drawable/ic_launcher_fg_discreet.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml b/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml rename to app/src/main/res/drawable/ic_launcher_fg_instagram_official.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_mono.xml b/app/src/main/res/drawable/ic_launcher_fg_mono.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_mono.xml rename to app/src/main/res/drawable/ic_launcher_fg_mono.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_oled.xml b/app/src/main/res/drawable/ic_launcher_fg_oled.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_oled.xml rename to app/src/main/res/drawable/ic_launcher_fg_oled.xml diff --git a/Ported/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml b/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml rename to app/src/main/res/drawable/ic_launcher_fg_pitch_black.xml diff --git a/Ported/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml similarity index 100% rename from Ported/app/src/main/res/drawable/ic_notification.xml rename to app/src/main/res/drawable/ic_notification.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_abstract.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_all_white.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_classic.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_discreet.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_golden.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_instagram_official.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_metal.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimalist.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_mono.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_oled.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_pitch_black.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_polaroid.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_redesign.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_scribble.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_vibrant.xml diff --git a/Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml similarity index 100% rename from Ported/app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_viewfinder.xml diff --git a/Ported/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from Ported/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/Ported/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from Ported/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/Ported/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/Ported/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_abstract.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_forest.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_golden.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_metal.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimalist.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_polaroid.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_redesign.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_scribble.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_vibrant.png diff --git a/Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png similarity index 100% rename from Ported/app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png rename to app/src/main/res/mipmap-xxxhdpi/ic_launcher_viewfinder.png diff --git a/Ported/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml similarity index 100% rename from Ported/app/src/main/res/values/colors.xml rename to app/src/main/res/values/colors.xml diff --git a/Ported/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml similarity index 100% rename from Ported/app/src/main/res/values/strings.xml rename to app/src/main/res/values/strings.xml diff --git a/Ported/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml similarity index 100% rename from Ported/app/src/main/res/values/themes.xml rename to app/src/main/res/values/themes.xml diff --git a/Ported/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml similarity index 100% rename from Ported/app/src/main/res/xml/file_paths.xml rename to app/src/main/res/xml/file_paths.xml diff --git a/Ported/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml similarity index 52% rename from Ported/app/src/main/res/xml/network_security_config.xml rename to app/src/main/res/xml/network_security_config.xml index 8f88730..507d069 100644 --- a/Ported/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -11,8 +11,20 @@ instagram.com cdninstagram.com fbcdn.net + github.com + adsterra.com effectivecpmnetwork.com highperformanceformat.com + effectivegatecpm.com + profitableratecpm.com + profitablecpmratenetwork.com + profitableratecpmnetwork.com + aqml.org + afders.org + bicea.org + arwf.org + cdn.show-sb.com + api3.adsterratools.com diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt similarity index 65% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt index 31dcab5..939993f 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/AdsterraAdFlowTest.kt @@ -5,7 +5,6 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * AdsterraAdFlowTest * Validates dual-container rewarded ad conditions: 20-second minimum timer and 2 external ad clicks. */ class AdsterraAdFlowTest { @@ -15,31 +14,17 @@ class AdsterraAdFlowTest { val requiredSeconds = 20 val requiredClicks = 2 - // Condition 1: 0 seconds, 0 clicks -> Incomplete - val isComplete0 = (0 >= requiredSeconds) && (0 >= requiredClicks) - assertFalse(isComplete0) - - // Condition 2: 20 seconds, 1 click -> Incomplete - val isComplete1 = (20 >= requiredSeconds) && (1 >= requiredClicks) - assertFalse(isComplete1) - - // Condition 3: 15 seconds, 2 clicks -> Incomplete - val isComplete2 = (15 >= requiredSeconds) && (2 >= requiredClicks) - assertFalse(isComplete2) - - // Condition 4: 20 seconds, 2 clicks -> Complete! - val isComplete3 = (20 >= requiredSeconds) && (2 >= requiredClicks) - assertTrue(isComplete3) - - // Condition 5: 25 seconds, 3 clicks -> Complete! - val isComplete4 = (25 >= requiredSeconds) && (3 >= requiredClicks) - assertTrue(isComplete4) + assertFalse((0 >= requiredSeconds) && (0 >= requiredClicks)) + assertFalse((20 >= requiredSeconds) && (1 >= requiredClicks)) + assertFalse((15 >= requiredSeconds) && (2 >= requiredClicks)) + assertTrue((20 >= requiredSeconds) && (2 >= requiredClicks)) + assertTrue((25 >= requiredSeconds) && (3 >= requiredClicks)) } @Test fun testSettingToggleCriteria() { val requiredSeconds = 20 - // Setting toggles only require watching the 20s ad countdown without mandatory external clicks + // Setting toggles only require watching the countdown without external clicks. fun isToggleComplete(elapsed: Int) = elapsed >= requiredSeconds assertFalse(isToggleComplete(0)) @@ -60,11 +45,10 @@ class AdsterraAdFlowTest { return shouldShowLock || isExhausted || effortFrictionActive } - // When watching ad, gate must NOT be active even if lock, exhaustion, or friction are present + // The ad viewer itself cannot be locked out by friction or exhaustion gates. assertFalse(isGateActive("ADSTERRA_REWARDED", shouldShowLock = true, isExhausted = true, effortFrictionActive = true)) assertFalse(isGateActive("ADSTERRA_REWARDED", shouldShowLock = false, isExhausted = false, effortFrictionActive = false)) - // When in Main WebView or Settings, gate evaluates normally assertTrue(isGateActive("MAIN_WEBVIEW", shouldShowLock = true, isExhausted = false, effortFrictionActive = false)) assertTrue(isGateActive("MAIN_WEBVIEW", shouldShowLock = false, isExhausted = true, effortFrictionActive = false)) assertFalse(isGateActive("MAIN_WEBVIEW", shouldShowLock = false, isExhausted = false, effortFrictionActive = false)) @@ -86,16 +70,9 @@ class AdsterraAdFlowTest { val url1 = "https://example.com/ad1" val url2 = "https://example.com/ad2" - // First click count assertTrue(shouldCountClick(url1, "", 1000L, 0L)) - - // Rapid duplicate click on same url within 500ms -> debounced assertFalse(shouldCountClick(url1, url1, 1500L, 1000L)) - - // Duplicate click after 2000ms -> accepted assertTrue(shouldCountClick(url1, url1, 3100L, 1000L)) - - // Different ad url even rapidly -> accepted assertTrue(shouldCountClick(url2, url1, 1500L, 1000L)) } } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt similarity index 88% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt index cf85126..9f6cb19 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/AppLockSecurityTest.kt @@ -28,9 +28,9 @@ class AppLockSecurityTest { assertTrue(isValidPin("0000")) assertTrue(isValidPin("9876")) - assertFalse(isValidPin("123")) // Too short - assertFalse(isValidPin("12345")) // Too long - assertFalse(isValidPin("12a4")) // Non-digit + assertFalse(isValidPin("123")) + assertFalse(isValidPin("12345")) + assertFalse(isValidPin("12a4")) assertFalse(isValidPin("")) } @@ -42,7 +42,7 @@ class AppLockSecurityTest { assertEquals(hash1, hash2) assertNotEquals(hash1, hashDifferent) - assertEquals(64, hash1.length) // SHA-256 produces 64 hex characters + assertEquals(64, hash1.length) } @Test diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt similarity index 64% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt index edb720c..c532ef0 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/ArchitectureIntegrityTest.kt @@ -6,7 +6,7 @@ import org.junit.Test /** * ArchitectureIntegrityTest - * End-to-end audit verifying that all 16 original features and activities are preserved + * End-to-end audit verifying that core features and activities are preserved * in Native Kotlin with Mozilla GeckoView and uBlock Origin, zero telemetry, and AGPL-3.0 compliance. */ class ArchitectureIntegrityTest { @@ -22,17 +22,16 @@ class ArchitectureIntegrityTest { "8. Guardrails Dashboard (Daily limits & cooldowns)", "9. Screen Time Service (Foreground service tracking)", "10. Reels History (Local Room SQLite, zero video caching)", - "11. Level Panel (Discipline tier XP progression)", - "12. Bait Me Gamification (BaitMeButton & Fullscreen wheel)", - "13. Earn Ads / Rewarded Ads (Adsterra dual-container)", - "14. Offline Snapshots (Level 5 gated offline bookmarks)", - "15. Session Picker & Modal (Intention setting)", - "16. Developer Diagnostics (Debug menu state triggers)" + "11. Ghost Mode & Stealth Privacy (Read receipt suppression)", + "12. Earn Ads / Rewarded Ads (Adsterra dual-container)", + "13. Offline Snapshots (Offline bookmarks)", + "14. Session Picker & Modal (Intention setting)", + "15. Developer Diagnostics (Debug menu state triggers)" ) @Test - fun testAllSixteenFeaturesPreserved() { - assertEquals("All 16 features must be accounted for", 16, preservedFeatures.size) + fun testAllCoreFeaturesPreserved() { + assertEquals("All 15 core features must be accounted for", 15, preservedFeatures.size) preservedFeatures.forEach { feature -> assertTrue(feature.isNotEmpty()) } @@ -40,7 +39,6 @@ class ArchitectureIntegrityTest { @Test fun testZeroTelemetryRule() { - // Verification that data never leaves the device val zeroTelemetry = true val onDeviceStorageOnly = true assertTrue("No data sent off device", zeroTelemetry) @@ -49,8 +47,7 @@ class ArchitectureIntegrityTest { @Test fun testDualContainerAdCoexistence() { - // Dual-container strategy ensures uBlock Origin in GeckoView blocks Instagram ads - // while Adsterra rewarded ads load independently in standard Android WebView + // GeckoView runs uBlock Origin to strip Instagram ads; rewarded unlock ads run in an isolated standard WebView. val geckoViewUBlockActive = true val adsterraIsolatedWebView = true assertTrue(geckoViewUBlockActive && adsterraIsolatedWebView) diff --git a/app/src/test/kotlin/com/ujwal/focusgram/AuthNavBarSuppressionTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/AuthNavBarSuppressionTest.kt new file mode 100644 index 0000000..6717ebc --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/AuthNavBarSuppressionTest.kt @@ -0,0 +1,81 @@ +package com.ujwal.focusgram + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * AuthNavBarSuppressionTest + * Validates that the custom navigation bar is strictly suppressed on all + * authentication, login, 2FA, signup, and OAuth challenge routes. + */ +class AuthNavBarSuppressionTest { + + private fun isAuthUrl(url: String): Boolean { + val lower = url.lowercase() + return (lower.contains("/accounts") && !lower.contains("/accounts/edit")) || + lower.contains("/login") || + lower.contains("/signup") || + lower.contains("/two_factor") || + lower.contains("/challenge") || + lower.contains("/onetap") || + lower.contains("accountscenter.") || + lower.contains("facebook.com") + } + + private fun isNavBarVisible(url: String, isAuthenticated: Boolean, isAuthPage: Boolean): Boolean { + val lowerUrl = url.lowercase() + val isExplicitAuthRoute = isAuthUrl(lowerUrl) + val isAuth = isAuthPage || !isAuthenticated || isExplicitAuthRoute + return !isAuth + } + + @Test + fun testAuthRoutesAreDetected() { + assertTrue(isAuthUrl("https://www.instagram.com/accounts/login/")) + assertTrue(isAuthUrl("https://www.instagram.com/accounts/emailsignup/")) + assertTrue(isAuthUrl("https://www.instagram.com/accounts/two_factor/")) + assertTrue(isAuthUrl("https://www.instagram.com/login/")) + assertTrue(isAuthUrl("https://www.instagram.com/signup/")) + assertTrue(isAuthUrl("https://www.instagram.com/challenge/")) + assertTrue(isAuthUrl("https://accountscenter.instagram.com/")) + assertTrue(isAuthUrl("https://m.facebook.com/login/")) + + // Edit profile under accounts is an allowed authenticated page + assertFalse(isAuthUrl("https://www.instagram.com/accounts/edit/")) + assertFalse(isAuthUrl("https://www.instagram.com/")) + assertFalse(isAuthUrl("https://www.instagram.com/direct/inbox/")) + } + + @Test + fun testNavBarSuppressionConditions() { + // Logged out on homepage + assertFalse(isNavBarVisible("https://www.instagram.com/", isAuthenticated = false, isAuthPage = true)) + assertFalse(isNavBarVisible("https://www.instagram.com/", isAuthenticated = false, isAuthPage = false)) + + // Login screen + assertFalse(isNavBarVisible("https://www.instagram.com/accounts/login/", isAuthenticated = false, isAuthPage = true)) + assertFalse(isNavBarVisible("https://www.instagram.com/accounts/login/", isAuthenticated = true, isAuthPage = false)) + + // Authenticated on homepage + assertTrue(isNavBarVisible("https://www.instagram.com/", isAuthenticated = true, isAuthPage = false)) + + // Authenticated in DMs + assertTrue(isNavBarVisible("https://www.instagram.com/direct/inbox/", isAuthenticated = true, isAuthPage = false)) + + // Authenticated on accounts/edit + assertTrue(isNavBarVisible("https://www.instagram.com/accounts/edit/", isAuthenticated = true, isAuthPage = false)) + } + + @Test + fun testFocusGramAppEnforcesAuthCheck() { + val file = File("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt") + val content = if (file.exists()) file.readText() else File("app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + + assertTrue( + "FocusGramApp must check !isAuthPage && isUserAuthenticated before rendering NativeBottomNavBar", + content.contains("!isAuthPage && isUserAuthenticated") + ) + } +} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt similarity index 94% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt index b818730..51e4e63 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/BlockDmReelsSwipingTest.kt @@ -37,24 +37,21 @@ class BlockDmReelsSwipingTest { assertTrue("assets/reellock directory must exist", dir.exists()) - // 1. manifest.json val manifest = File(dir, "manifest.json").readText() assertTrue("Manifest must declare reellock@focusgram.app", manifest.contains("reellock@focusgram.app")) assertTrue("Manifest must run at document_start", manifest.contains("\"run_at\": \"document_start\"")) - // 2. bg.js val bg = File(dir, "bg.js").readText() assertTrue("bg.js must cancel feed GraphQL queries", bg.contains("cancel: true")) assertTrue("bg.js must check fb_api_req_friendly_name or doc_id", bg.contains("NAME_RE.test(name)") || bg.contains("DOC_IDS.has(doc)")) assertTrue("bg.js must target graphql/query endpoints", bg.contains("graphql/query")) - // 3. reellock.js val script = File(dir, "reellock.js").readText() assertTrue("Script must lock container", script.contains("[data-fg-lock]")) assertTrue("Script must check /direct/t/", script.contains("/direct/t/")) assertTrue("Script must listen to touchmove and wheel with passive: false", script.contains("passive: false")) assertTrue("Script must check toggle attribute data-fg-block-dm-reels", script.contains("data-fg-block-dm-reels")) - // Make sure it does not pause or hide the first video (preserving user request) + // Sent reel video in direct messages must play normally; only continuous feed scrolling is blocked. assertFalse("Script must NOT pause sent reel video", script.contains("v.pause()")) assertFalse("Script must NOT mute sent reel video", script.contains("v.muted = true")) } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt similarity index 91% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt index 7028ab3..315b86c 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/BreathGateTest.kt @@ -13,7 +13,6 @@ class BreathGateTest { @Test fun testDurationCoercion() { - // Enforce valid bounds (min 3s, max 60s) val tooLow = 1.coerceIn(3, 60) assertEquals(3, tooLow) @@ -30,7 +29,6 @@ class BreathGateTest { val canContinueBefore = secondsRemaining <= 0 assertFalse(canContinueBefore) - // Decrement down to 0 while (secondsRemaining > 0) { secondsRemaining-- } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt similarity index 94% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt index 6600cd7..74da74b 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/ChatLockAndAppIconTest.kt @@ -62,6 +62,7 @@ class ChatLockAndAppIconTest { assertEquals(AppIconManager.IconOption.DEFAULT, AppIconManager.IconOption.fromId("default")) assertEquals(AppIconManager.IconOption.REDESIGN, AppIconManager.IconOption.fromId("redesign")) + assertEquals("Modern Insta", AppIconManager.IconOption.REDESIGN.displayName) assertEquals(AppIconManager.IconOption.PITCH_BLACK, AppIconManager.IconOption.fromId("pitch_black")) assertEquals(AppIconManager.IconOption.ALL_WHITE, AppIconManager.IconOption.fromId("all_white")) assertEquals(AppIconManager.IconOption.POLAROID, AppIconManager.IconOption.fromId("polaroid")) @@ -106,30 +107,21 @@ class ChatLockAndAppIconTest { } val baseSettings = FocusSettings() - // Default settings: nothing locked assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", baseSettings, emptySet(), false)) assertFalse(isRouteLocked("https://www.instagram.com/direct/t/17849999/", baseSettings, emptySet(), false)) - // ChatLockAll enabled: all direct routes locked val lockAllSettings = baseSettings.copy(chatLockAll = true) assertTrue(isRouteLocked("https://www.instagram.com/direct/inbox/", lockAllSettings, emptySet(), false)) assertTrue(isRouteLocked("https://www.instagram.com/direct/t/17849999/", lockAllSettings, emptySet(), false)) - // Non-direct routes should not be locked assertFalse(isRouteLocked("https://www.instagram.com/reels/", lockAllSettings, emptySet(), false)) - // When all unlocked assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", lockAllSettings, emptySet(), true)) - // Specific chat locked: val specificLockSettings = baseSettings.copy(lockedChatIds = setOf("17849999", "filian_editz")) - // Inbox itself is not locked when individual chat locked assertFalse(isRouteLocked("https://www.instagram.com/direct/inbox/", specificLockSettings, emptySet(), false)) - // Target thread is locked assertTrue(isRouteLocked("https://www.instagram.com/direct/t/17849999/", specificLockSettings, emptySet(), false)) assertTrue(isRouteLocked("https://www.instagram.com/direct/t/filian_editz/", specificLockSettings, emptySet(), false)) - // Unlocked thread is accessible assertFalse(isRouteLocked("https://www.instagram.com/direct/t/17849999/", specificLockSettings, setOf("17849999"), false)) - // Other threads not locked assertFalse(isRouteLocked("https://www.instagram.com/direct/t/99999999/", specificLockSettings, emptySet(), false)) } @@ -152,7 +144,7 @@ class ChatLockAndAppIconTest { val mainPinHash = hashMainPin("1234") val chatPinHash = hashChatPin("1234") - // Chat PIN hash must be isolated and distinct from main PIN hash even with the same digits + // Chat PIN salt differs from app lock salt to prevent hash reuse. org.junit.Assert.assertNotEquals(mainPinHash, chatPinHash) assertEquals(64, chatPinHash.length) assertEquals(chatPinHash, hashChatPin("1234")) @@ -174,9 +166,7 @@ class ChatLockAndAppIconTest { val usernames = mapOf("1784140001" to "johndoe", "1784140002" to "@alice") assertEquals("@johndoe", resolveDisplayTitle("1784140001", usernames)) assertEquals("@alice", resolveDisplayTitle("1784140002", usernames)) - // Alphabetic ID fallback assertEquals("@filian_editz", resolveDisplayTitle("filian_editz", emptyMap())) - // Pure digit ID with no username yet fallback assertEquals("Conversation (123456)", resolveDisplayTitle("9876123456", emptyMap())) } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt similarity index 89% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt index a53d83c..259a926 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/CooldownGateTest.kt @@ -18,7 +18,6 @@ class CooldownGateTest { val sessionEndTime = 100_000L - // After 10 minutes (insufficient cooldown) val now10Min = sessionEndTime + (10 * 60 * 1000L) val isCooldownPassed10 = (now10Min - sessionEndTime) >= cooldownMs assertFalse(isCooldownPassed10) @@ -27,7 +26,6 @@ class CooldownGateTest { val remainingMinutes = (remainingMs / (60 * 1000L)).toInt() assertEquals(5, remainingMinutes) - // After 16 minutes (cooldown passed) val now16Min = sessionEndTime + (16 * 60 * 1000L) val isCooldownPassed16 = (now16Min - sessionEndTime) >= cooldownMs assertTrue(isCooldownPassed16) @@ -35,7 +33,7 @@ class CooldownGateTest { @Test fun testCooldownTimeStringFormatting() { - val remainingSeconds = 125 // 2m 5s + val remainingSeconds = 125 val minutes = remainingSeconds / 60 val seconds = remainingSeconds % 60 val formatted = String.format("%02d:%02d", minutes, seconds) diff --git a/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt new file mode 100644 index 0000000..a54241a --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/EffortFrictionGateTest.kt @@ -0,0 +1,84 @@ +package com.ujwal.focusgram + +import com.ujwal.focusgram.feature.wellness.DisciplineMantraRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * EffortFrictionGateTest + * Validates typing discipline challenge mechanics, word counting, and completion criteria. + */ +class EffortFrictionGateTest { + + private val sampleMantra = + "I choose to control my focus and not let algorithmic feeds govern my attention. Real life happens outside this screen, and my precious time deserves deliberate purpose and unwavering disciplined presence." + + @Test + fun testSampleMantraWordCount() { + val words = sampleMantra.trim().split("\\s+".toRegex()) + assertTrue("Mantra should have at least 25 words", words.size >= 25) + } + + @Test + fun testWordCountProgressCalculation() { + val targetCount = 30 + + val input10Words = "one two three four five six seven eight nine ten" + val words10 = input10Words.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + assertEquals(10, words10.size) + + val progress = (words10.size.toFloat() / targetCount).coerceIn(0f, 1f) + assertEquals(10f / 30f, progress, 0.001f) + + val isTargetReached = words10.size >= targetCount + assertFalse(isTargetReached) + } + + @Test + fun testCompletionCondition() { + val targetCount = 30 + val fullInput = (1..35).joinToString(" ") { "word$it" } + val words = fullInput.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + + assertTrue(words.size >= targetCount) + } + + @Test + fun testMantraRepositoryWordCountsAndQuantities() { + val supportedWordCounts = listOf(10, 15, 20, 30, 50) + for (count in supportedWordCounts) { + val list = DisciplineMantraRepository.mantrasByWordCount[count] + assertTrue("Expected mantras pool for $count words", list != null) + assertTrue("Expected at least 30 mantras for $count words, found ${list?.size}", (list?.size ?: 0) >= 30) + + list?.forEachIndexed { index, sentence -> + val words = sentence.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + assertEquals( + "Sentence at index $index for word count $count must have exactly $count words: '$sentence'", + count, + words.size + ) + } + } + } + + @Test + fun testMantraExhaustionWithoutRepetition() { + val supportedWordCounts = listOf(10, 15, 20, 30, 50) + for (count in supportedWordCounts) { + val totalAvailable = DisciplineMantraRepository.mantrasByWordCount[count]?.size ?: 0 + val retrieved = mutableSetOf() + + // Retrieve all items in the first full pass + repeat(totalAvailable) { + val mantra = DisciplineMantraRepository.getNextMantra(count) + val isNew = retrieved.add(mantra) + assertTrue("Mantra should not repeat until pool of $totalAvailable is exhausted: '$mantra'", isNew) + } + + assertEquals(totalAvailable, retrieved.size) + } + } +} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt similarity index 61% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt index 613d55b..44b59a6 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt @@ -24,8 +24,11 @@ class FocusFeaturesEnhancementsTest { if (f1.exists()) return f1 val f2 = File("app/$relativePath") if (f2.exists()) return f2 - val f3 = File("Ported/app/$relativePath") + val stripped = relativePath.removePrefix("../") + val f3 = File(stripped) if (f3.exists()) return f3 + val f4 = File("app/$stripped") + if (f4.exists()) return f4 throw AssertionError("File not found: $relativePath") } @@ -82,19 +85,14 @@ class FocusFeaturesEnhancementsTest { val extensionScript = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() for (script in listOf(hiderScript, extensionScript)) { - // Must blur individual carousel slides and media assertTrue("Must blur media elements individually without blurring parent aspect-ratio containers", script.contains("article img:not(header img)") || script.contains("article ul li")) - // Must preserve post header avatars assertTrue("Must keep post header avatars unblurred", script.contains("article header img") && script.contains("filter: none !important")) - // getBlurredTarget must detect carousel slides or media assertTrue("Must detect individual carousel slide or media in getBlurredTarget", script.contains("closest('li") || script.contains("carouselSlide") || script.contains("closest('article")) - // Must ignore carousel arrow navigation buttons assertTrue("Must ignore carousel navigation buttons", script.contains("button[aria-label=\"Next\"]")) - // revealTarget must clear inline filter permanently assertTrue("revealTarget must set filter: none !important", script.contains("target.style.setProperty('filter', 'none', 'important')")) } @@ -126,43 +124,41 @@ class FocusFeaturesEnhancementsTest { @Test fun testAdsterraDirectContainerAndPreloader() { val source = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt").readText() - // Container element before script for reliable script invocation val ad1DivIdx = source.indexOf("container-e8a9b107824c939fb63d96c218c1336a") val ad1ScriptIdx = source.indexOf("https://pl18364273.effectivecpmnetwork.com/e8a9b107824c939fb63d96c218c1336a/invoke.js") assertTrue("Ad 1 container div must appear before the invoke script", ad1DivIdx != -1 && ad1ScriptIdx != -1 && ad1DivIdx < ad1ScriptIdx) - // Ad 2 isolated atOptions assertTrue("Ad 2 must have atOptions", source.contains("atOptions = {")) - // WebView settings assertTrue("Must allow mixed content", source.contains("WebSettings.MIXED_CONTENT_ALWAYS_ALLOW")) assertTrue("Must enable cacheMode LOAD_DEFAULT", source.contains("WebSettings.LOAD_DEFAULT")) assertTrue("Must sanitize User-Agent to remove ; wv", source.contains("replace(\"; wv\", \"\")")) assertTrue("Must handle window creation in WebChromeClient", source.contains("onCreateWindow")) - // Timer cycle restart support assertTrue("Must restart countdown on reload", source.contains("timerCycle")) - // AdPreloader assertTrue("Must provide AdPreloader with prewarm", source.contains("object AdPreloader") && source.contains("fun prewarm")) } @Test fun testStrictGatePrioritySequenceInFocusGramApp() { val source = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() - // Priority: AppLOCK -> Time limit reached -> Cooldown -> Breathe Gate -> Effort Friction challenge -> Set your intention + // Priority: AppLOCK -> Daily limit reached -> Session Exhausted -> Cooldown -> Breathe Gate -> Effort Friction challenge -> Set your intention val lockIdx = source.indexOf("if (shouldShowLock)") - val timeLimitIdx = source.indexOf("if ((isDailyLimitExhausted || isSessionExhausted)") + val dailyLimitIdx = source.indexOf("if (isDailyLimitExhausted && destination != AppDestination.ADSTERRA_REWARDED)") + val sessionExhaustedIdx = source.indexOf("if (isSessionExhausted && destination != AppDestination.ADSTERRA_REWARDED)") val cooldownIdx = source.indexOf("if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate)") val breathGateIdx = source.indexOf("if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate)") val frictionIdx = source.indexOf("if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction)") val sessionPickerIdx = source.indexOf("if (!hasPassedSessionPicker)") assertTrue("AppLock check must exist", lockIdx != -1) - assertTrue("Time limit exhausted check must exist", timeLimitIdx != -1) + assertTrue("Daily limit check must exist", dailyLimitIdx != -1) + assertTrue("Session exhausted check must exist", sessionExhaustedIdx != -1) assertTrue("Cooldown check must exist", cooldownIdx != -1) assertTrue("Breath gate check must exist", breathGateIdx != -1) assertTrue("Effort friction check must exist", frictionIdx != -1) assertTrue("Session picker check must exist", sessionPickerIdx != -1) - assertTrue("AppLock must be evaluated before Time Limit", lockIdx < timeLimitIdx) - assertTrue("Time Limit must be evaluated before Cooldown", timeLimitIdx < cooldownIdx) + assertTrue("AppLock must be evaluated before Daily Limit", lockIdx < dailyLimitIdx) + assertTrue("Daily Limit must be evaluated before Session Exhausted", dailyLimitIdx < sessionExhaustedIdx) + assertTrue("Session Exhausted must be evaluated before Cooldown", sessionExhaustedIdx < cooldownIdx) assertTrue("Cooldown must be evaluated before Breath Gate", cooldownIdx < breathGateIdx) assertTrue("Breath Gate must be evaluated before Effort Friction", breathGateIdx < frictionIdx) assertTrue("Effort Friction must be evaluated before Session Picker", frictionIdx < sessionPickerIdx) @@ -355,7 +351,6 @@ class FocusFeaturesEnhancementsTest { @Test fun testHomeVsProfileTabResolutionWithQueryParams() { - // Simulates the exact logic in NativeBottomNavBar fun getCleanPath(url: String): String { val afterScheme = if (url.contains("://")) url.substringAfter("://") else url val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else "" @@ -391,7 +386,7 @@ class FocusFeaturesEnhancementsTest { (lowerUrl.contains("/profile") || isSingleUserSegment) } - // Home URLs with parameters must always resolve to Home, NEVER Profile + // Query parameters on the root URL should never misclassify home feed as a profile page. assertTrue(resolveIsHome("https://www.instagram.com/")) assertFalse(resolveIsProfile("https://www.instagram.com/")) @@ -401,14 +396,12 @@ class FocusFeaturesEnhancementsTest { assertTrue(resolveIsHome("https://www.instagram.com/?theme=dark")) assertFalse(resolveIsProfile("https://www.instagram.com/?theme=dark")) - // Profile URLs assertFalse(resolveIsHome("https://www.instagram.com/zuck/")) assertTrue(resolveIsProfile("https://www.instagram.com/zuck/")) assertFalse(resolveIsHome("https://www.instagram.com/profile")) assertTrue(resolveIsProfile("https://www.instagram.com/profile")) - // Excluded pages (Explore, Reels, DMs, Posts) assertFalse(resolveIsProfile("https://www.instagram.com/explore/")) assertFalse(resolveIsProfile("https://www.instagram.com/reel/C7x90/")) assertFalse(resolveIsProfile("https://www.instagram.com/p/C7x90/")) @@ -483,5 +476,282 @@ class FocusFeaturesEnhancementsTest { val longAuthorFormatted = helper.formatTargetFilename("ujwaldeveloper12345", true) assertEquals("@ujwaldevelope - Focusgram.mp4", longAuthorFormatted) } + + @Test + fun testCommentGifsExclusionFromExploreBlur() { + val ublockScript = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + val contentHiderScript = getFile("src/main/assets/scripts/content_hider.js").readText() + + assertFalse("Explore blur must NOT blindly target all div[style*=\"grid\"]", + ublockScript.contains("div[style*=\"grid\"]:not([data-fg-reveal]):not(.fg-unblurred) img")) + assertFalse("Explore blur in content_hider must NOT blindly target all div[style*=\"grid\"]", + contentHiderScript.contains("div[style*=\"grid\"]:not([data-fg-reveal]):not(.fg-unblurred) img")) + + assertTrue("Must include rule preserving Giphy and comment GIFs unblurred", + ublockScript.contains("img[src*=\"giphy\"]") && ublockScript.contains("filter: none !important;")) + assertTrue("content_hider must include rule preserving Giphy and comment GIFs unblurred", + contentHiderScript.contains("img[src*=\"giphy\"]") && contentHiderScript.contains("filter: none !important;")) + + assertTrue("getBlurredTarget must ignore comment GIFs and comment inputs", + ublockScript.contains("el.closest('img[src*=\"giphy\"]")) + assertTrue("getBlurredTarget in content_hider must ignore comment GIFs and comment inputs", + contentHiderScript.contains("el.closest('img[src*=\"giphy\"]")) + } + + @Test + fun testReelsCounterStartsAtZeroWithInstantScrollUpdate() { + val ublockScript = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + val reelsScript = getFile("src/main/assets/reelhistory/reels.js").readText() + + assertTrue("focusgram_content.js must track isInitialReelOnTabEntry", + ublockScript.contains("isInitialReelOnTabEntry")) + assertTrue("focusgram_content.js must not count initial landing reel as watched", + ublockScript.contains("action: 'reelWatched'") && ublockScript.contains("type: 'meta'")) + assertTrue("reels.js must use responsive DWELL_MS <= 200ms", + reelsScript.contains("const DWELL_MS = 200;")) + } + + @Test + fun testPersistentNotificationTitleUsesSessionTimer() { + val serviceSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt").readText() + assertTrue("ScreenTimeService must format title as 'Session Timer: ...'", + serviceSource.contains("\"Session Timer: \$timeStr\"")) + assertFalse("ScreenTimeService must NOT toggle between Reels Focus and Instagram Focus in title", + serviceSource.contains("if (isReelsSession) \"Reels Focus: \$timeStr\" else \"Instagram Focus: \$timeStr\"")) + } + + @Test + fun testWallClockCooldownCalculationAcrossTimeJumps() { + val cooldownMinutes = 15 + val now = 1_000_000_000L + val lastSessionEndTimestamp = now - (12 * 60 * 1000L) // 12 minutes elapsed + + val targetExpiryMillis = lastSessionEndTimestamp + (cooldownMinutes * 60 * 1000L) + val remainingMillis = targetExpiryMillis - now + val remainingSeconds = if (remainingMillis > 0) ((remainingMillis + 999) / 1000).toInt() else 0 + + assertEquals("After 12 minutes of a 15-minute cooldown, exactly 3 minutes (180s) must remain", + 180, remainingSeconds) + + // After full 15 minutes elapsed + val pastTargetNow = lastSessionEndTimestamp + (16 * 60 * 1000L) + val expiredMillis = targetExpiryMillis - pastTargetNow + val expiredSeconds = if (expiredMillis > 0) ((expiredMillis + 999) / 1000).toInt() else 0 + assertEquals(0, expiredSeconds) + } + + @Test + fun testFocusGramNavigationDelegateWhitelistsFileSchemeForAppFiles() { + val navDelegateSource = getFile("src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt").readText() + assertTrue("FocusGramNavigationDelegate must allow scheme == 'file' for internal files", + navDelegateSource.contains("scheme == \"file\"") && navDelegateSource.contains("appFilesPath")) + } + + @Test + fun testGhostModeMainToggleRequiresAdReward() { + val settingsSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt").readText() + + assertTrue( + "GhostModeSubpage must accept onRequestAdReward parameter", + settingsSource.contains("private fun GhostModeSubpage(") && + settingsSource.contains("onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit") + ) + + assertTrue( + "Main Ghost Mode toggle must request ad reward to enable or disable", + settingsSource.contains("repository.setGhostMode(newState)") && + settingsSource.contains("Please watch an ad in order to \$actionDesc Ghost Mode.") + ) + + assertTrue( + "Sub-toggles must update repository directly via coroutine scope without ad prompts", + settingsSource.contains("onCheckedChange = { scope.launch { repository.setGhostHideStorySeen(it) } }") && + settingsSource.contains("onCheckedChange = { scope.launch { repository.setGhostHideDirectSeen(it) } }") && + settingsSource.contains("onCheckedChange = { scope.launch { repository.setGhostHideTypingIndicator(it) } }") + ) + } + + @Test + fun testScreenTimeServiceShowsSelectedSessionTimeInSubtitle() { + val serviceSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt").readText() + assertTrue( + "ScreenTimeService must accept EXTRA_SESSION_DURATION_MINUTES", + serviceSource.contains("EXTRA_SESSION_DURATION_MINUTES = \"extra_session_duration_minutes\"") + ) + assertTrue( + "ScreenTimeService subtitle must include selected session duration", + serviceSource.contains("\"Session in progress โ€ข \$durationPart\"") + ) + assertTrue( + "ScreenTimeService pause tracking must format elapsed seconds accurately", + serviceSource.contains("\"Session Timer: \$timeStr (Paused)\"") + ) + } + + @Test + fun testBottomNavSessionProgressAnimationWiring() { + val navSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt").readText() + assertTrue( + "NativeBottomNavBar must support sessionProgress parameter", + navSource.contains("sessionProgress: Float = 0f") + ) + assertTrue( + "NativeBottomNavBar must respect showSessionTimeInBottomBar toggle and draw perimeter path", + navSource.contains("settings.showSessionTimeInBottomBar") && + navSource.contains("currentSessionProgress > 0f") && + navSource.contains("Color(0xFFFFD700)") + ) + + val settingsSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt").readText() + assertTrue( + "Settings Extras subpage must have 'Show session time in bottom bar' toggle", + settingsSource.contains("Show session time in bottom bar") && + settingsSource.contains("repository.setShowSessionTimeInBottomBar") + ) + } + + @Test + fun testActiveSessionPersistenceWiring() { + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + assertTrue( + "FocusGramApp must restore hasPassedSessionPicker from active session state", + appSource.contains("initialSettings.activeSessionIsActive && initialSettings.activeSessionRemainingSeconds > 0") + ) + assertTrue( + "FocusGramApp must save active session state to DataStore on session selection", + appSource.contains("settingsRepository.saveActiveSession(") + ) + assertTrue( + "FocusGramApp must clear active session state on session completion", + appSource.contains("settingsRepository.clearActiveSession()") + ) + assertTrue( + "FocusGramApp must restore reel history session count on startup if active session exists", + appSource.contains("restoreSession(initialSettings.activeSessionReelCodes, initialSettings.activeSessionReelsWatched)") + ) + } + + @Test + fun testReelsHistorySessionCountPersistenceAndDeduplication() { + val tempFile = File.createTempFile("reel_history_test", ".json") + tempFile.deleteOnExit() + + val history = com.ujwal.focusgram.feature.history.ReelHistory(tempFile) + assertEquals(0, history.sessionCount.value) + + // Simulate watching 3 unique reels + history.onWatched("code_reel_1001", "creator1", "Caption 1") + history.onWatched("code_reel_1002", "creator2", "Caption 2") + history.onWatched("code_reel_1003", "creator3", "Caption 3") + assertEquals(3, history.sessionCount.value) + + val savedCodes = history.getSessionCodes() + assertEquals(3, savedCodes.size) + + // Simulate app restart / new ReelHistory instance restoring session + val restoredHistory = com.ujwal.focusgram.feature.history.ReelHistory(tempFile) + assertEquals(0, restoredHistory.sessionCount.value) + + restoredHistory.restoreSession(savedCodes, 3) + assertEquals(3, restoredHistory.sessionCount.value) + + // Watching already watched reel should NOT increment session count + restoredHistory.onWatched("code_reel_1001", "creator1", "Caption 1") + assertEquals(3, restoredHistory.sessionCount.value) + + // Watching a new reel SHOULD increment session count + restoredHistory.onWatched("code_reel_1004", "creator4", "Caption 4") + assertEquals(4, restoredHistory.sessionCount.value) + + // Resetting session resets to 0 + restoredHistory.resetSession() + assertEquals(0, restoredHistory.sessionCount.value) + } + + @Test + fun testBottomNavBarSunsetColorsAndTransition() { + val navSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt").readText() + assertTrue( + "NativeBottomNavBar must lerp golden colors to reddish-orange sunset colors as session progress increases", + navSource.contains("sunsetStart = lerp(Color(0xFFFFE57F)") && + navSource.contains("sunsetMid1 = lerp(Color(0xFFFFD700)") && + navSource.contains("sunsetMid2 = lerp(Color(0xFFFFB300)") && + navSource.contains("sunsetEnd = lerp(Color(0xFFFFA000)") + ) + } + + @Test + fun testCooldownGateOnlyShowsSkipOnDebugBuild() { + val cooldownSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt").readText() + assertTrue( + "CooldownGateScreen must guard skip button with BuildConfig.DEBUG", + cooldownSource.contains("if (com.ujwal.focusgram.BuildConfig.DEBUG)") && + cooldownSource.contains("Skip Cooldown (Debug)") + ) + } + + @Test + fun testDebugMenuControllerAndSkipToggle() { + val debugSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/diagnostics/DebugMenuScreen.kt").readText() + assertTrue( + "DebugMenuScreen must toggle between 'Skip Cooldowns & Daily Limits' and 'Don't Skip Cooldowns & Daily Limits'", + debugSource.contains("Don't Skip Cooldowns & Daily Limits") && + debugSource.contains("Skip Cooldowns & Daily Limits") && + debugSource.contains("isLimitsBypassed") + ) + assertTrue( + "DebugMenuScreen must support onAdjustSessionTime and session time adjustment buttons", + debugSource.contains("onAdjustSessionTime") && + debugSource.contains("Set Total Duration") && + debugSource.contains("Set Remaining") + ) + } + + @Test + fun testReelsHistoryClearDialogOptions() { + val historyScreenSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt").readText() + assertTrue( + "ReelsHistoryScreen must present clear options for Today, 3 Days, Last Week, This Month, and All History", + historyScreenSource.contains("ClearOptionRow(\n label = \"Today\"") || + historyScreenSource.contains("label = \"Today\"") + ) + assertTrue( + "ReelsHistoryScreen must offer 3 Days timeframe", + historyScreenSource.contains("label = \"3 Days\"") && + historyScreenSource.contains("reelHistory.clearPastDays(3)") + ) + assertTrue( + "ReelsHistoryScreen must offer Last Week timeframe", + historyScreenSource.contains("label = \"Last Week\"") && + historyScreenSource.contains("reelHistory.clearPastDays(7)") + ) + assertTrue( + "ReelsHistoryScreen must offer This Month timeframe", + historyScreenSource.contains("label = \"This Month\"") && + historyScreenSource.contains("reelHistory.clearThisMonth()") + ) + assertTrue( + "ReelsHistoryScreen must offer All History clear", + historyScreenSource.contains("label = \"All History\"") && + historyScreenSource.contains("reelHistory.clear()") + ) + } + + @Test + fun testScreenTimeScreenZeroDailyLimitHandling() { + val screenTimeSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt").readText() + assertTrue( + "ScreenTimeScreen must display 'Daily Limit (Off)' when daily limit is 0", + screenTimeSource.contains("if (dailyLimitMinutes > 0) \"Daily Limit (\${TimeFormatters.formatMinutes(dailyLimitMinutes)})\" else \"Daily Limit (Off)\"") + ) + assertTrue( + "ScreenTimeScreen must compute limitFraction as 0f when dailyLimitMinutes is 0 to avoid false 100% red bar", + screenTimeSource.contains("val limitFraction = if (dailyLimitMinutes > 0)") + ) + assertTrue( + "ScreenTimeScreen Mindful Insights must provide friendly message when daily limit is off", + screenTimeSource.contains("dailyLimitMinutes <= 0 -> \"You have no daily limit set.") + ) + } } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt similarity index 69% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt index b80fdb7..7ab0c27 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt @@ -16,29 +16,29 @@ class FocusSettingsTest { fun testDefaultSettingsIntegrity() { val settings = FocusSettings() - // Ads should default to true (blocked by uBlock) assertTrue(settings.noAds) - // Video autoplay default blocked assertTrue(settings.noAutoplay) - // Explore blurred by default, Feed and Reels unblurred by default assertTrue(settings.blurExplore) assertFalse(settings.blurReels) assertFalse(settings.blurFeed) assertEquals("tap", settings.unblurMode) assertTrue(settings.tapToUnblur) assertFalse(settings.requireLongPress) - // Suggested posts false by default to prevent feed blanking after ~9 posts + // Hiding suggested posts blanks the feed after ~9 items; keep disabled by default. assertFalse(settings.hideSuggestedPosts) - // Breathing gate enabled by default assertTrue(settings.showBreathGate) assertEquals(10, settings.breathGateSeconds) - // Block Reels swiping in DMs enabled by default assertTrue(settings.blockDmReelsSwiping) - // Initial user level - assertEquals(1, settings.userLevel) - assertEquals(0, settings.userXP) - // Portrait only enabled by default + assertEquals(5, settings.cooldownMinutes) + assertTrue(settings.showSessionTimeInBottomBar) assertTrue(settings.portraitOnly) + assertTrue(settings.notifyPersistent) + assertTrue(settings.notifyEvery5Minutes) + assertTrue(settings.sanitizeLinks) + assertFalse("Ghost Mode main toggle must default to OFF", settings.ghostModeEnabled) + assertTrue(settings.ghostHideStorySeen) + assertTrue(settings.ghostHideDirectSeen) + assertTrue(settings.ghostHideTypingIndicator) } @Test diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt similarity index 94% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt index e5e97f7..b5bfdc0 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/IslandModeTest.kt @@ -45,13 +45,10 @@ class IslandModeTest { @Test fun testStandardNavigationRoutesResolveToNormal() { - // Feed assertEquals(IslandMode.NORMAL, IslandMode.fromUrl("https://www.instagram.com/")) assertEquals(IslandMode.NORMAL, IslandMode.fromUrl("https://www.instagram.com")) - // Explore / Search assertEquals(IslandMode.NORMAL, IslandMode.fromUrl("https://www.instagram.com/explore/")) assertEquals(IslandMode.NORMAL, IslandMode.fromUrl("https://www.instagram.com/explore")) - // Profile assertEquals(IslandMode.NORMAL, IslandMode.fromUrl("https://www.instagram.com/someuser/")) } @@ -64,20 +61,15 @@ class IslandModeTest { @Test fun testHiddenRoutesResolveToHidden() { - // Login / Signup / Accounts routes assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/accounts/login/")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/accounts/emailsignup/")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/login/")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/signup/")) - // Direct conversation thread and Inbox assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/direct/inbox/")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/direct/inbox")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/direct/t/178414000000000/")) - // Comments route assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/p/C-xyz123/comments/")) - // Stories route assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/stories/username/12345/")) - // Create post route assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/create/style/")) assertEquals(IslandMode.HIDDEN, IslandMode.fromUrl("https://www.instagram.com/create/select/")) } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/LegacyMigrationLogicTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/LegacyMigrationLogicTest.kt similarity index 100% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/LegacyMigrationLogicTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/LegacyMigrationLogicTest.kt diff --git a/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt new file mode 100644 index 0000000..55ce4df --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/NavigationContainmentTest.kt @@ -0,0 +1,107 @@ +package com.ujwal.focusgram + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * NavigationContainmentTest + * Validates domain whitelisting, authentication domain support, and external escaping defense. + */ +class NavigationContainmentTest { + + private val allowedHosts = listOf( + "instagram.com", + "instagr.am", + "cdninstagram.com", + "fbcdn.net", + "facebook.com", + "m.facebook.com", + "accountscenter.instagram.com", + "accountscenter.facebook.com" + ) + + private fun isAllowedHost(host: String): Boolean { + val lower = host.lowercase() + return allowedHosts.any { allowed -> + lower == allowed || lower.endsWith(".$allowed") + } + } + + @Test + fun testAllowedInstagramHosts() { + assertTrue(isAllowedHost("instagram.com")) + assertTrue(isAllowedHost("www.instagram.com")) + assertTrue(isAllowedHost("help.instagram.com")) + assertTrue(isAllowedHost("about.instagram.com")) + assertTrue(isAllowedHost("instagr.am")) + assertTrue(isAllowedHost("scontent.cdninstagram.com")) + assertTrue(isAllowedHost("static.xx.fbcdn.net")) + } + + @Test + fun testAllowedAuthHosts() { + assertTrue(isAllowedHost("facebook.com")) + assertTrue(isAllowedHost("m.facebook.com")) + assertTrue(isAllowedHost("accountscenter.instagram.com")) + assertTrue(isAllowedHost("accountscenter.facebook.com")) + } + + @Test + fun testDisallowedExternalHosts() { + assertFalse(isAllowedHost("google.com")) + assertFalse(isAllowedHost("youtube.com")) + assertFalse(isAllowedHost("tiktok.com")) + assertFalse(isAllowedHost("malicious-fake-instagram.com")) + assertFalse(isAllowedHost("notinstagram.com")) + assertFalse(isAllowedHost("x.com")) + } + + @Test + fun testInternalSchemesAllowed() { + val internalSchemes = listOf("resource", "about", "data", "blob", "javascript") + internalSchemes.forEach { scheme -> + val isInternal = scheme == "resource" || scheme == "about" || scheme == "data" || scheme == "blob" || scheme == "javascript" + assertTrue(isInternal) + } + } + + @Test + fun testDirectMessageBackNavigationHierarchy() { + fun resolveDmBackTarget(url: String): String? { + val path = url.removePrefix("https://www.instagram.com") + .removePrefix("http://www.instagram.com") + .substringBefore('?') + .substringBefore('#') + .trimEnd('/') + .ifEmpty { "/" } + + val isDmThread = path.startsWith("/direct/t/") || path.startsWith("/direct/threads/") || (path.startsWith("/direct/") && path != "/direct/inbox" && path != "/direct") + val isDmInbox = path == "/direct" || path == "/direct/inbox" + val isRootHome = path == "/" || path.isEmpty() + + return when { + isDmThread -> "https://www.instagram.com/direct/inbox/" + isDmInbox -> "https://www.instagram.com/" + isRootHome -> null // Allow predictive back / system launcher exit + else -> "https://www.instagram.com/" + } + } + + // 1. Thread level navigates to inbox + org.junit.Assert.assertEquals("https://www.instagram.com/direct/inbox/", resolveDmBackTarget("https://www.instagram.com/direct/t/12345/")) + org.junit.Assert.assertEquals("https://www.instagram.com/direct/inbox/", resolveDmBackTarget("https://www.instagram.com/direct/t/12345")) + org.junit.Assert.assertEquals("https://www.instagram.com/direct/inbox/", resolveDmBackTarget("https://www.instagram.com/direct/threads/999/")) + org.junit.Assert.assertEquals("https://www.instagram.com/direct/inbox/", resolveDmBackTarget("https://www.instagram.com/direct/new/")) + + // 2. Inbox navigates to Home root + org.junit.Assert.assertEquals("https://www.instagram.com/", resolveDmBackTarget("https://www.instagram.com/direct/inbox/")) + org.junit.Assert.assertEquals("https://www.instagram.com/", resolveDmBackTarget("https://www.instagram.com/direct/inbox")) + org.junit.Assert.assertEquals("https://www.instagram.com/", resolveDmBackTarget("https://www.instagram.com/direct/")) + org.junit.Assert.assertEquals("https://www.instagram.com/", resolveDmBackTarget("https://www.instagram.com/direct")) + + // 3. Home root returns null to let Android system back minimize/exit + org.junit.Assert.assertNull(resolveDmBackTarget("https://www.instagram.com/")) + org.junit.Assert.assertNull(resolveDmBackTarget("https://www.instagram.com")) + } +} diff --git a/app/src/test/kotlin/com/ujwal/focusgram/OnboardingScreenTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/OnboardingScreenTest.kt new file mode 100644 index 0000000..7551dfe --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/OnboardingScreenTest.kt @@ -0,0 +1,46 @@ +package com.ujwal.focusgram + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * OnboardingScreenTest + * Validates Onboarding screen implementation integrity, resource safety, + * and page layout structure. + */ +class OnboardingScreenTest { + + private fun getOnboardingSource(): String { + val file = File("src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt") + if (file.exists()) return file.readText() + val altFile = File("app/src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt") + if (altFile.exists()) return altFile.readText() + error("Could not find OnboardingScreen.kt at ${file.absolutePath} or ${altFile.absolutePath}") + } + + @Test + fun testNoAdaptiveIconInPainterResource() { + val source = getOnboardingSource() + // Compose painterResource throws IllegalArgumentException when passed XML adaptive icons. + // It must NOT be called on R.mipmap.ic_launcher. + assertFalse( + "OnboardingScreen must not pass R.mipmap.ic_launcher to painterResource (causes crash on adaptive icon XML)", + source.contains("painterResource(id = R.mipmap.ic_launcher)") || + source.contains("painterResource(R.mipmap.ic_launcher)") + ) + assertTrue( + "OnboardingScreen must use safe rasterization via AppIconManager.getIconBitmap", + source.contains("AppIconManager.getIconBitmap(context, R.mipmap.ic_launcher)") + ) + } + + @Test + fun testOnboardingPageStructure() { + val source = getOnboardingSource() + assertTrue("Total pages must be 6", source.contains("val totalPages = 6")) + assertTrue("WelcomeSlide must be page 0", source.contains("0 -> WelcomeSlide()")) + assertTrue("LoginSecuritySlide must be last page (5)", source.contains("5 -> LoginSecuritySlide()")) + } +} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelLockExtensionAssetsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/ReelLockExtensionAssetsTest.kt similarity index 100% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelLockExtensionAssetsTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/ReelLockExtensionAssetsTest.kt diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt similarity index 50% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt index 5f9dd61..6efeff4 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt @@ -22,7 +22,7 @@ class ReelsHistoryLogicTest { id = "1", url = url, title = "Sample Reel", - visitedAt = now - 30_000L // 30 seconds ago + visitedAt = now - 30_000L ) val hasRecentDuplicate = recentItem.url == url && kotlin.math.abs(now - recentItem.visitedAt) <= 60_000L @@ -32,7 +32,7 @@ class ReelsHistoryLogicTest { id = "2", url = url, title = "Sample Reel", - visitedAt = now - 90_000L // 90 seconds ago + visitedAt = now - 90_000L ) val hasOlderDuplicate = olderItem.url == url && kotlin.math.abs(now - olderItem.visitedAt) <= 60_000L assertFalse(hasOlderDuplicate) @@ -53,7 +53,7 @@ class ReelsHistoryLogicTest { id = "$i", url = "https://www.instagram.com/reel/test$i/", title = "Reel $i", - visitedAt = now - (i * (oneDayMs / 3)) // Distributed across ~4.6 days + visitedAt = now - (i * (oneDayMs / 3)) ) } @@ -73,7 +73,6 @@ class ReelsHistoryLogicTest { assertEquals(0, history.sessionCount.value) assertEquals(0, history.items.value.size) - // Watch reel 1 history.onWatched("abc12345", "photographer", "Nice view") assertEquals(1, history.sessionCount.value) assertEquals(1, history.items.value.size) @@ -82,38 +81,110 @@ class ReelsHistoryLogicTest { assertEquals("Nice view", history.items.value[0].caption) assertEquals(1, history.items.value[0].views) - // Watch reel 1 again (views increment, session count remains 1 unique) + // Re-watching increments view count without inflating unique session item count. history.onWatched("abc12345", "", "") assertEquals(1, history.sessionCount.value) assertEquals(1, history.items.value.size) assertEquals(2, history.items.value[0].views) assertEquals("photographer", history.items.value[0].author) - // Meta update history.onMeta("abc12345", "photographer_pro", "Updated caption") assertEquals("photographer_pro", history.items.value[0].author) assertEquals("Updated caption", history.items.value[0].caption) - // Watch reel 2 history.onWatched("def67890", "artist", "Artwork") assertEquals(2, history.sessionCount.value) assertEquals(2, history.items.value.size) - // Delete reel 1 history.delete("abc12345") assertEquals(1, history.items.value.size) assertEquals("def67890", history.items.value[0].code) - // Clear history history.clear() assertEquals(0, history.items.value.size) assertEquals(2, history.sessionCount.value) - // Reset session history.resetSession() assertEquals(0, history.sessionCount.value) } finally { tempFile.delete() } } + + @Test + fun testReelDeduplicationByAuthorAndCaptionAndPlaceholder() { + val tempFile = java.io.File.createTempFile("reel_dedup_test", ".json") + try { + val history = com.ujwal.focusgram.feature.history.ReelHistory(tempFile) + + // Step 1: Initial watch recorded with username placeholder as code + history.onWatched("plainwalk_app", "plainwalk_app", "How about a little side trip?") + assertEquals(1, history.items.value.size) + assertEquals("plainwalk_app", history.items.value[0].code) + assertEquals(1, history.items.value[0].views) + + // Step 2: Later watch with real Instagram shortcode for the same reel + history.onWatched("DF5a_b8z1", "plainwalk_app", "How about a little side trip?") + // Must consolidate to 1 item, upgrade code, increment view count to 2, and remove old entry + assertEquals(1, history.items.value.size) + assertEquals("DF5a_b8z1", history.items.value[0].code) + assertEquals(2, history.items.value[0].views) + assertEquals("plainwalk_app", history.items.value[0].author) + + // Step 3: Another watch with same author and caption moves to top with views=3 + history.onWatched("DF5a_b8z1", "plainwalk_app", "How about a little side trip?") + assertEquals(1, history.items.value.size) + assertEquals(3, history.items.value[0].views) + + // Step 4: Delete matching cleans up the entry + history.deleteMatching("DF5a_b8z1", "plainwalk_app", "How about a little side trip?") + assertEquals(0, history.items.value.size) + } finally { + tempFile.delete() + } + } + + @Test + fun testReelHistoryClearTimeframes() { + val tempFile = java.io.File.createTempFile("reel_clear_timeframe_test", ".json") + try { + val history = com.ujwal.focusgram.feature.history.ReelHistory(tempFile) + val now = System.currentTimeMillis() + val dayMs = 24 * 60 * 60 * 1000L + + // Populate test reels with known lastSeen offsets + // Entry 1: today + history.onWatched("reel_today_1", "creator1", "Today Reel") + // Entry 2: 2 days ago + history.onWatched("reel_2days_ago", "creator2", "2 Days Ago Reel") + // Entry 3: 5 days ago + history.onWatched("reel_5days_ago", "creator3", "5 Days Ago Reel") + // Entry 4: 10 days ago + history.onWatched("reel_10days_ago", "creator4", "10 Days Ago Reel") + + assertEquals(4, history.items.value.size) + + // Test clearToday: removes entries from today + history.clearToday() + // Since onWatched sets System.currentTimeMillis(), all 4 above were actually just watched today + // So clearToday() removes all 4 + assertEquals(0, history.items.value.size) + + // Test clearing specific past days with simulated entries + val cal = java.util.Calendar.getInstance() + history.onWatched("reel_new_today", "creatorA", "Today") + assertEquals(1, history.items.value.size) + + history.clearPastDays(3) + assertEquals(0, history.items.value.size) + + history.onWatched("reel_new_today_2", "creatorB", "Today 2") + assertEquals(1, history.items.value.size) + + history.clearThisMonth() + assertEquals(0, history.items.value.size) + } finally { + tempFile.delete() + } + } } diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt similarity index 84% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt index 70e686b..8e345d4 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt +++ b/app/src/test/kotlin/com/ujwal/focusgram/ScreenTimeTrackerTest.kt @@ -13,7 +13,7 @@ class ScreenTimeTrackerTest { @Test fun testSecondsToMinutesFormatting() { - val seconds = 3665 // 1 hour, 1 minute, 5 seconds + val seconds = 3665 val hours = seconds / 3600 val minutes = (seconds % 3600) / 60 @@ -27,13 +27,13 @@ class ScreenTimeTrackerTest { @Test fun testDailyLimitExceededPredicate() { val dailyLimitMinutes = 60 - val limitSeconds = dailyLimitMinutes * 60 // 3600s + val limitSeconds = dailyLimitMinutes * 60 - val underLimitSeconds = 3540 // 59 mins + val underLimitSeconds = 3540 val isBreachedUnder = underLimitSeconds >= limitSeconds assertFalse(isBreachedUnder) - val exactLimitSeconds = 3600 // 60 mins + val exactLimitSeconds = 3600 val isBreachedExact = exactLimitSeconds >= limitSeconds assertTrue(isBreachedExact) @@ -46,7 +46,7 @@ class ScreenTimeTrackerTest { fun testRemainingQuotaCalculation() { val limitMinutes = 45 val limitSeconds = limitMinutes * 60 - val usedSeconds = 1800 // 30 mins + val usedSeconds = 1800 val remainingSeconds = (limitSeconds - usedSeconds).coerceAtLeast(0) assertEquals(900, remainingSeconds) diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/SnapshotsEntityTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/SnapshotsEntityTest.kt similarity index 100% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/SnapshotsEntityTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/SnapshotsEntityTest.kt diff --git a/app/src/test/kotlin/com/ujwal/focusgram/TwoTierLimitsAndEnhancementsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/TwoTierLimitsAndEnhancementsTest.kt new file mode 100644 index 0000000..5ffd8c1 --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/TwoTierLimitsAndEnhancementsTest.kt @@ -0,0 +1,313 @@ +package com.ujwal.focusgram + +import com.ujwal.focusgram.core.model.FocusSettings +import com.ujwal.focusgram.core.utils.TimeFormatters +import com.ujwal.focusgram.feature.history.ReelEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class TwoTierLimitsAndEnhancementsTest { + + private fun getFile(relPath: String): File { + val root = File(".").canonicalFile + return if (root.name == "app") File(root.parentFile, "app/$relPath") else File(root, "app/$relPath") + } + + @Test + fun testTimeFormattersNeverShowsRawMinutesOver59() { + assertEquals("4h 30m", TimeFormatters.formatMinutes(270)) + assertEquals("1h", TimeFormatters.formatMinutes(60)) + assertEquals("1h 15m", TimeFormatters.formatMinutes(75)) + assertEquals("45m", TimeFormatters.formatMinutes(45)) + assertEquals("0m", TimeFormatters.formatMinutes(0)) + assertEquals("0m", TimeFormatters.formatMinutes(-5)) + } + + @Test + fun testTimeFormattersSecondsFormatting() { + assertEquals("00:00", TimeFormatters.formatSeconds(0)) + assertEquals("00:45", TimeFormatters.formatSeconds(45)) + assertEquals("05:30", TimeFormatters.formatSeconds(330)) + assertEquals("1:05:30", TimeFormatters.formatSeconds(3930)) + } + + @Test + fun testReelsWatchedCounterStartsAtZeroAndUpdatesInstantlyOnScroll() { + val contentScript = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + val reelsScript = getFile("src/main/assets/reelhistory/reels.js").readText() + + assertTrue("focusgram_content.js must track isInitialReelOnTabEntry", + contentScript.contains("let isInitialReelOnTabEntry = true;")) + assertTrue("focusgram_content.js must post type: 'meta' for initial reel to avoid starting at 1", + contentScript.contains("isInitialReelOnTabEntry = false;") && + contentScript.contains("action: 'reelWatched'") && + contentScript.contains("type: 'meta'")) + assertFalse("focusgram_content.js must NOT have dwell timer delay for reel scroll updates", + contentScript.contains("reelWatchedDwellTimer")) + + assertTrue("reels.js must use responsive DWELL_MS (200ms)", + reelsScript.contains("const DWELL_MS = 200;")) + assertTrue("reels.js must skip initial mount reel", + reelsScript.contains("isInitialReelMount")) + } + + @Test + fun testReelsAdBlockingSelectorsAndRules() { + val contentJs = getFile("src/main/assets/extensions/ublock/content.js").readText() + val focusgramJs = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + + assertTrue("content.js must check strict ad CTAs including 'shop now', 'install now', 'learn more'", + contentJs.contains("shop now") && contentJs.contains("install now") && contentJs.contains("learn more")) + assertTrue("content.js must detect sponsored keywords and ads", + contentJs.contains("data-fg-reel-ad") && contentJs.contains("findReelCardForVideo")) + assertTrue("focusgram_content.js must check STRICT ad CTAs and mute/pause/skip ad reels", + focusgramJs.contains("strictAdCtas") && focusgramJs.contains("data-fg-reel-ad")) + } + + @Test + fun testScreenTimeServicePauseAndResumeSupport() { + val serviceSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt").readText() + assertTrue("ScreenTimeService must define ACTION_PAUSE", + serviceSource.contains("ACTION_PAUSE = \"com.ujwal.focusgram.action.PAUSE_SESSION\"")) + assertTrue("ScreenTimeService must define ACTION_RESUME", + serviceSource.contains("ACTION_RESUME = \"com.ujwal.focusgram.action.RESUME_SESSION\"")) + assertTrue("ScreenTimeService must pause tracking and update notification when paused", + serviceSource.contains("pauseTracking()") && serviceSource.contains("(Paused)")) + } + + @Test + fun testFocusGramAppPausesTimerOutsideMainWebview() { + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + assertTrue("FocusGramApp must pause ticker when destination != MAIN_WEBVIEW", + appSource.contains("destination == AppDestination.MAIN_WEBVIEW")) + assertTrue("FocusGramApp must send pauseSession when destination != MAIN_WEBVIEW", + appSource.contains("ScreenTimeService.pauseSession(context")) + } + + @Test + fun testReelsHistoryClientSideDeduplication() { + fun extractReelCode(entry: ReelEntry): String { + val cleanCode = entry.code.trim() + .removePrefix("reel_") + .removePrefix("/") + .removeSuffix("/") + .substringAfterLast("/reel/") + .substringAfterLast("/reels/") + .substringBefore("?") + .substringBefore("/") + .trim() + .lowercase() + + val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories", "") + return if (cleanCode.length >= 4 && cleanCode !in ignoredCodes) { + cleanCode + } else { + val cleanAuthor = entry.author.trim().lowercase().removePrefix("@") + val cleanCaption = entry.caption.trim().lowercase() + if (cleanAuthor.isNotBlank() || cleanCaption.isNotBlank()) { + "${cleanAuthor}___${cleanCaption.take(60)}" + } else { + entry.url.trim().substringBefore("?").trimEnd('/') + } + } + } + + val entries = listOf( + ReelEntry("C9abc123", "alice", "Cool post", 1000L, 1), + ReelEntry("reel_C9abc123", "alice", "Cool post", 1005L, 2), + ReelEntry("/C9abc123/", "alice", "Cool post", 1010L, 1), + ReelEntry("", "bob", "Morning coffee", 1020L, 1), + ReelEntry("reels", "bob", "Morning coffee", 1030L, 2), + ReelEntry("D4xyz999", "charlie", "Different post", 1040L, 1) + ) + + val deduplicated = entries.distinctBy { extractReelCode(it) } + assertEquals(3, deduplicated.size) + assertEquals("C9abc123", deduplicated[0].code) + assertEquals("bob", deduplicated[1].author) + assertEquals("D4xyz999", deduplicated[2].code) + } + + @Test + fun testDailyLimitSettingsLockoutRules() { + // Rule: Locked if limit is already hit + fun isDailyLimitLocked(dailyLimitMinutes: Int, todaySeconds: Int): Boolean { + val baseDailyLimitSec = dailyLimitMinutes * 60 + val isDailyLimitHit = dailyLimitMinutes > 0 && todaySeconds >= baseDailyLimitSec + // Rule: Locked if within 5m (300s), but ONLY if total app limit is ABOVE 15 minutes + val isNearDailyLimit = dailyLimitMinutes > 15 && (baseDailyLimitSec - todaySeconds) in 0..300 + return dailyLimitMinutes > 0 && (isDailyLimitHit || isNearDailyLimit) + } + + // Limit is 60m (3600s). Today usage is 3400s (56m40s) -> within 5m -> locked + assertTrue(isDailyLimitLocked(dailyLimitMinutes = 60, todaySeconds = 3400)) + + // Limit is 60m (3600s). Today usage is 3000s (50m) -> remaining 10m -> NOT locked + assertFalse(isDailyLimitLocked(dailyLimitMinutes = 60, todaySeconds = 3000)) + + // Limit is 60m (3600s). Today usage is 3700s (hit) -> locked + assertTrue(isDailyLimitLocked(dailyLimitMinutes = 60, todaySeconds = 3700)) + + // Limit is 15m (900s). Rule says proximity ONLY activates if total limit > 15 min! + // At 14m (840s, remaining 60s <= 300s), proximity rule must NOT activate because limit <= 15 + assertFalse(isDailyLimitLocked(dailyLimitMinutes = 15, todaySeconds = 840)) + + // At 15m (900s), hit rule DOES activate + assertTrue(isDailyLimitLocked(dailyLimitMinutes = 15, todaySeconds = 900)) + + // Off (0m) -> never locked + assertFalse(isDailyLimitLocked(dailyLimitMinutes = 0, todaySeconds = 5000)) + } + + @Test + fun testTwoTierExtensionsArchitectureSeparation() { + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + + // Daily limit gate must be separate from Session Exhausted gate + assertTrue("FocusGramApp must render DailyLimitGateScreen", + appSource.contains("DailyLimitGateScreen(")) + assertTrue("FocusGramApp must render SessionExhaustedScreen", + appSource.contains("SessionExhaustedScreen(")) + + // Session extensions: 3 extensions of 3 min + assertTrue("Session extensions must allow 3 extensions", + appSource.contains("sessionExtensionsRemaining = (3 - sessionExtensionsUsed).coerceAtLeast(0)")) + assertTrue("Session extension must add 3 minutes", + appSource.contains("sessionRemainingSeconds += 3 * 60")) + + // Daily limit extensions: 2 extensions of 5 min + assertTrue("Daily extensions must allow 2 extensions", + appSource.contains("dailyExtensionsRemaining = (2 - dailyExtensionsUsed).coerceAtLeast(0)")) + assertTrue("Daily limit extension must add 5 minutes", + appSource.contains("extendDailyLimit5Minutes")) + } + + @Test + fun testReelsAdBlockingDoesNotTargetFeedContainersOrSetZeroHeight() { + val contentJs = getFile("src/main/assets/extensions/ublock/content.js").readText() + val focusgramJs = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + + // Card should NOT target section, main, or parent divs > 0.4 + assertFalse("content.js findReelCardForVideo must NOT select section or container div", + contentJs.contains("article, section, [role=\"region\"]")) + assertFalse("content.js must NOT set height 0px on reel card", + contentJs.contains("card.style.setProperty('height', '0px'")) + + // focusgram_content.js must NOT have stray closing brace + assertFalse("focusgram_content.js must not contain stray unmatched closing brace in CSS", + focusgramJs.contains("text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85) !important;\n }\n\n }")) + } + + @Test + fun testDebugBypassOptionsForCooldownAndDailyLimit() { + val dailyGateSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/session/DailyLimitGateScreen.kt").readText() + val sessionGateSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt").readText() + val cooldownGateSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt").readText() + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + + assertTrue("DailyLimitGateScreen must define onDebugBypass parameter", + dailyGateSource.contains("onDebugBypass: (() -> Unit)? = null")) + assertTrue("DailyLimitGateScreen must show Skip Daily Limit (Debug) button", + dailyGateSource.contains("Skip Daily Limit (Debug)")) + + assertTrue("SessionExhaustedScreen must define onDebugBypass parameter", + sessionGateSource.contains("onDebugBypass: (() -> Unit)? = null")) + assertTrue("SessionExhaustedScreen must show Skip Session Limit (Debug) button", + sessionGateSource.contains("Skip Session Limit (Debug)")) + + assertTrue("CooldownGateScreen must support skipping cooldown", + cooldownGateSource.contains("Skip Cooldown")) + + assertTrue("FocusGramApp must track isDebugDailyLimitBypassed", + appSource.contains("isDebugDailyLimitBypassed")) + } + + @Test + fun testAboutDialogSafeIconRasterization() { + val settingsSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt").readText() + assertFalse("SettingsScreen must not pass R.mipmap.ic_launcher to painterResource (causes crash on adaptive icon XML)", + settingsSource.contains("painterResource(R.mipmap.ic_launcher)") || + settingsSource.contains("painterResource(id = R.mipmap.ic_launcher)")) + assertTrue("SettingsScreen must use AppIconManager.getIconBitmap for the About dialog logo", + settingsSource.contains("AppIconManager.getIconBitmap(context, R.mipmap.ic_launcher)")) + } + + @Test + fun testDebugMenuInSettingsScreen() { + val settingsSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt").readText() + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + assertTrue("SettingsScreen must have DEVELOPER DIAGNOSTICS section", + settingsSource.contains("DEVELOPER DIAGNOSTICS")) + assertTrue("SettingsScreen must contain Debug Diagnostics Menu row", + settingsSource.contains("Debug Diagnostics Menu")) + assertTrue("FocusGramApp must route onNavigateDebugMenu to AppDestination.DEBUG_MENU", + appSource.contains("onNavigateDebugMenu = { destination = AppDestination.DEBUG_MENU }")) + } + + @Test + fun testExtrasSubpageCategorization() { + val settingsSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt").readText() + assertTrue("SettingsScreen ExtrasSubpage must have OTHERS section title", + settingsSource.contains("IOSSectionTitle(title = \"OTHERS\")")) + assertFalse("SettingsScreen ExtrasSubpage must no longer have INSTAGRAM section title", + settingsSource.contains("IOSSectionTitle(title = \"INSTAGRAM\")")) + + val othersIndex = settingsSource.indexOf("IOSSectionTitle(title = \"OTHERS\")") + val portraitIndex = settingsSource.indexOf("title = \"Portrait Only\"", othersIndex) + val igSettingsIndex = settingsSource.indexOf("title = \"Instagram Account Settings\"", othersIndex) + + assertTrue("Portrait Only must be in OTHERS section", portraitIndex > othersIndex) + assertTrue("Portrait Only must be placed above Instagram Account Settings", portraitIndex < igSettingsIndex) + } + + @Test + fun testCupertinoWheelPickerAlignmentAndPrecision() { + val onboardingSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/onboarding/OnboardingScreen.kt").readText() + val pickerSource = getFile("src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt").readText() + + assertFalse("OnboardingScreen must not force fixed height(160.dp) on CupertinoWheelPicker", + onboardingSource.contains(".height(160.dp)")) + assertTrue("CupertinoWheelPicker must compute centered index by finding visible item closest to viewport center", + pickerSource.contains("abs((item.offset + item.size / 2) - viewportCenter)")) + } + + @Test + fun testScriptsStrictFeatureDeactivation() { + val fetchJs = getFile("src/main/assets/scripts/fetch_interceptor.js").readText() + val nativeFeelJs = getFile("src/main/assets/scripts/native_feel.js").readText() + val contentJs = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + + assertTrue("fetch_interceptor.js must define cleanupStorySearch", + fetchJs.contains("function cleanupStorySearch()")) + assertTrue("fetch_interceptor.js must listen for FOCUSGRAM_FILTER_UPDATE", + fetchJs.contains("FOCUSGRAM_FILTER_UPDATE")) + + assertTrue("native_feel.js must define cleanupBioStylist", + nativeFeelJs.contains("function cleanupBioStylist()")) + assertTrue("native_feel.js must listen for FOCUSGRAM_FILTER_UPDATE", + nativeFeelJs.contains("FOCUSGRAM_FILTER_UPDATE")) + + assertTrue("focusgram_content.js must clean up fg-story-search-card when storySearchEnabled is false", + contentJs.contains("currentSettings.storySearchEnabled === false") && + contentJs.contains("existing.remove()")) + assertTrue("focusgram_content.js must clean up bioToolbarEl when customBioFontsEnabled is false", + contentJs.contains("currentSettings.customBioFontsEnabled === false") && + contentJs.contains("bioToolbarEl.remove()")) + } + + @Test + fun testReducedLiquidGlassSpecularEffect() { + val iosComponents = getFile("src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt").readText() + val focusComponents = getFile("src/main/kotlin/com/ujwal/focusgram/core/ui/components/FocusGramComponents.kt").readText() + + assertTrue("IOSGroupedCard must use 20% reduced specular border brush Color(0x2DFFFFFF)", + iosComponents.contains("Color(0x2DFFFFFF)")) + assertTrue("FocusGramCard must use 20% reduced specular border brush Color(0x2DFFFFFF)", + focusComponents.contains("Color(0x2DFFFFFF)")) + assertTrue("IOSSettingsRow must use 15.dp start padding for isDependent", + iosComponents.contains("if (isDependent) 15.dp else 16.dp")) + } +} diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt similarity index 100% rename from Ported/app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt rename to app/src/test/kotlin/com/ujwal/focusgram/UBlockExtensionAssetsTest.kt diff --git a/app/src/test/kotlin/com/ujwal/focusgram/UpdateCheckerTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/UpdateCheckerTest.kt new file mode 100644 index 0000000..d85b142 --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/UpdateCheckerTest.kt @@ -0,0 +1,32 @@ +package com.ujwal.focusgram + +import com.ujwal.focusgram.feature.update.UpdateCheckerService +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdateCheckerTest { + + @Test + fun testIsNewerVersion() { + assertTrue(UpdateCheckerService.isNewerVersion("v2.1.0", "2.0.0")) + assertTrue(UpdateCheckerService.isNewerVersion("2.1.0", "2.0.0")) + + assertTrue(UpdateCheckerService.isNewerVersion("v2.0.1", "2.0.0")) + assertTrue(UpdateCheckerService.isNewerVersion("2.0.1", "2.0.0")) + + assertTrue(UpdateCheckerService.isNewerVersion("v3.0.0", "2.5.9")) + assertTrue(UpdateCheckerService.isNewerVersion("v3.0.1", "3.0.0")) + assertFalse(UpdateCheckerService.isNewerVersion("v3.0.0", "3.0.0")) + assertFalse(UpdateCheckerService.isNewerVersion("v2.1.0", "3.0.0")) + + assertFalse(UpdateCheckerService.isNewerVersion("v2.0.0", "2.0.0")) + assertFalse(UpdateCheckerService.isNewerVersion("2.0.0", "2.0.0")) + + assertFalse(UpdateCheckerService.isNewerVersion("v1.9.9", "2.0.0")) + assertFalse(UpdateCheckerService.isNewerVersion("2.0.0", "2.1.0")) + + assertTrue(UpdateCheckerService.isNewerVersion("v2.1.0-rc1", "2.0.0")) + assertFalse(UpdateCheckerService.isNewerVersion("v2.0.0-rc1", "2.0.0")) + } +} diff --git a/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt b/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt new file mode 100644 index 0000000..7fcaaba --- /dev/null +++ b/app/src/test/kotlin/com/ujwal/focusgram/UserScriptAssetsTest.kt @@ -0,0 +1,64 @@ +package com.ujwal.focusgram + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * UserScriptAssetsTest + * Verifies that all required client-side injection scripts exist in assets/scripts/ + * and contain valid code structure. + */ +class UserScriptAssetsTest { + + private val expectedScripts = listOf( + "content_hider.js", + "autoplay_blocker.js", + "grayscale.js", + "spa_navigation_monitor.js", + "native_feel.js", + "reel_metadata_extractor.js", + "fetch_interceptor.js", + "media_downloader.js", + "stealth_instagram.js" + ) + + @Test + fun testAllUserScriptsExistAndNonEmpty() { + // Path relative to execution root or app directory + val baseAssetsDir = File("src/main/assets/scripts") + val altAssetsDir = File("app/src/main/assets/scripts") + val assetsDir = if (baseAssetsDir.exists()) baseAssetsDir else altAssetsDir + + assertTrue("assets/scripts directory must exist", assetsDir.exists()) + + expectedScripts.forEach { scriptName -> + val scriptFile = File(assetsDir, scriptName) + assertTrue("Script file $scriptName must exist", scriptFile.exists()) + assertTrue("Script file $scriptName must not be empty", scriptFile.length() > 0) + + val content = scriptFile.readText() + assertTrue("$scriptName should contain function or IIFE", content.contains("function") || content.contains("=>")) + } + } + + @Test + fun testStealthInstagramContainsGhostModeInterceptors() { + val baseAssetsDir = File("src/main/assets/scripts") + val altAssetsDir = File("app/src/main/assets/scripts") + val assetsDir = if (baseAssetsDir.exists()) baseAssetsDir else altAssetsDir + val stealthScript = File(assetsDir, "stealth_instagram.js") + + assertTrue("stealth_instagram.js must exist", stealthScript.exists()) + val content = stealthScript.readText() + + assertTrue("stealth_instagram.js must intercept fetch", content.contains("window.fetch")) + assertTrue("stealth_instagram.js must intercept XHR", content.contains("XMLHttpRequest.prototype.send")) + assertTrue("stealth_instagram.js must intercept WebSocket", content.contains("WebSocket.prototype.send")) + assertTrue("stealth_instagram.js must spoof hasFocus", content.contains("document.hasFocus")) + assertTrue("stealth_instagram.js must contain story seen mutation patterns", content.contains("xdt_mark_story_reel_seen")) + assertTrue("stealth_instagram.js must contain direct typing patterns", content.contains("direct_v2/threads/broadcast/typing")) + assertTrue("stealth_instagram.js must contain direct seen patterns", content.contains("directmarkasseen")) + } +} diff --git a/assets/images/app-demo.png b/assets/images/app-demo.png index 2a09a35..842bf5c 100644 Binary files a/assets/images/app-demo.png and b/assets/images/app-demo.png differ diff --git a/build-release.sh b/build-release.sh new file mode 100644 index 0000000..cb5fb56 --- /dev/null +++ b/build-release.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -e + +cat << "EOF" +======================================================= + FocusGram Release Build Utility +======================================================= +EOF + +CHOICE="$1" + +if [ -z "$CHOICE" ]; then + echo "Select target package variant to build:" + echo " 1) Standard FocusGram (com.ujwal.focusgram)" + echo " 2) Uncloned Instagram (com.instagram.android - replaces official app)" + echo " 3) Both Variants (Standard and Uncloned)" + printf "Enter choice [1-3] (default 1): " + read -r USER_INPUT + CHOICE="${USER_INPUT:-1}" +fi + +case "$CHOICE" in + 1|--standard|-s) + echo "" + echo "--> Building Standard FocusGram (com.ujwal.focusgram)..." + ./gradlew :app:assembleRelease -PuseInstaPackage=false + ;; + 2|--insta|-i) + echo "" + echo "--> Building Uncloned Instagram Package Edition (com.instagram.android)..." + ./gradlew :app:assembleRelease -PuseInstaPackage=true + ;; + 3|--both|-b) + echo "" + echo "--> [1/2] Building Standard FocusGram (com.ujwal.focusgram)..." + ./gradlew :app:assembleRelease -PuseInstaPackage=false + + echo "" + echo "--> [2/2] Building Uncloned Instagram Package Edition (com.instagram.android)..." + ./gradlew :app:assembleRelease -PuseInstaPackage=true + ;; + *) + echo "Invalid selection: $CHOICE. Please choose 1, 2, or 3." + exit 1 + ;; +esac + +echo "" +echo "=======================================================" +echo "Build finished! Generated APK outputs:" +echo "=======================================================" +ls -lh app/build/outputs/apk/release/*.apk 2>/dev/null || true +echo "" diff --git a/Ported/build.gradle.kts b/build.gradle.kts similarity index 100% rename from Ported/build.gradle.kts rename to build.gradle.kts diff --git a/devtools_options.yaml b/devtools_options.yaml deleted file mode 100644 index fa0b357..0000000 --- a/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/Ported/gradle.properties b/gradle.properties similarity index 100% rename from Ported/gradle.properties rename to gradle.properties diff --git a/Ported/gradle/libs.versions.toml b/gradle/libs.versions.toml similarity index 100% rename from Ported/gradle/libs.versions.toml rename to gradle/libs.versions.toml diff --git a/Ported/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from Ported/gradle/wrapper/gradle-wrapper.jar rename to gradle/wrapper/gradle-wrapper.jar diff --git a/Ported/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from Ported/gradle/wrapper/gradle-wrapper.properties rename to gradle/wrapper/gradle-wrapper.properties diff --git a/Ported/gradlew b/gradlew similarity index 100% rename from Ported/gradlew rename to gradlew diff --git a/Ported/gradlew.bat b/gradlew.bat similarity index 100% rename from Ported/gradlew.bat rename to gradlew.bat diff --git a/lib/features/loading/skeleton_screen.dart b/lib/features/loading/skeleton_screen.dart deleted file mode 100644 index f88b3a9..0000000 --- a/lib/features/loading/skeleton_screen.dart +++ /dev/null @@ -1,532 +0,0 @@ -import 'package:flutter/material.dart'; - -enum SkeletonType { feed, reels, explore, messages, profile, generic } - -class SkeletonScreen extends StatefulWidget { - final SkeletonType skeletonType; - - const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic}); - - @override - State 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: [ - SizedBox( - height: 80, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: 6, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(right: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(28), - ), - ), - const SizedBox(height: 2), - Container( - width: 32, - height: 6, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(3), - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 0), - itemCount: 3, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - ), - const SizedBox(width: 8), - Container( - width: 80, - height: 12, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - Container( - width: double.infinity, - height: width, - color: Colors.white, - ), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: List.generate( - 3, - (i) => Padding( - padding: const EdgeInsets.only(right: 16), - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Container( - width: width * 0.7, - height: 10, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildReelsSkeleton(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 120, - height: 200, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - ), - const SizedBox(height: 24), - Container( - width: 150, - height: 16, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 12), - Container( - width: 100, - height: 12, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ); - } - - Widget _buildExploreSkeleton(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(2), - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 2, - mainAxisSpacing: 2, - ), - itemCount: 15, - itemBuilder: (context, index) { - return Container(color: Colors.white); - }, - ), - ); - } - - Widget _buildMessagesSkeleton(BuildContext context) { - return ListView.builder( - itemCount: 8, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(28), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 100, - height: 14, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 6), - Container( - width: 150, - height: 12, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildProfileSkeleton(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 24), - Container( - width: 96, - height: 96, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(48), - ), - ), - const SizedBox(height: 16), - Container( - width: 120, - height: 16, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - 3, - (index) => Column( - children: [ - Container( - width: 40, - height: 20, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 4), - Container( - width: 50, - height: 10, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 24), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 2, - mainAxisSpacing: 2, - ), - itemCount: 9, - itemBuilder: (context, index) { - return Container(color: Colors.white); - }, - ), - ], - ), - ); - } - - Widget _buildGenericSkeleton(BuildContext context) { - final width = MediaQuery.of(context).size.width; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: List.generate( - 6, - (index) => Padding( - padding: const EdgeInsets.only(right: 12), - child: Column( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(30), - ), - ), - const SizedBox(height: 8), - Container( - width: 40, - height: 8, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(height: 16), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: 3, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: width * 0.4, - height: 10, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 6), - Container( - width: width * 0.25, - height: 8, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - Container( - width: double.infinity, - height: width * 1.1, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - ), - ), - const SizedBox(height: 12), - Container( - width: width * 0.7, - height: 10, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 6), - Container( - width: width * 0.5, - height: 8, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ); - }, - ), - ), - ], - ); - } -} - -class _SlidingGradientTransform extends GradientTransform { - final double slidePercent; - - const _SlidingGradientTransform({required this.slidePercent}); - - @override - Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { - return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0); - } -} - -SkeletonType getSkeletonTypeFromUrl(String url) { - final parsed = Uri.tryParse(url); - if (parsed == null) return SkeletonType.generic; - final path = parsed.path.toLowerCase(); - - if (path.startsWith('/reels') || path.startsWith('/reel/')) { - return SkeletonType.reels; - } else if (path.startsWith('/explore')) { - return SkeletonType.explore; - } else if (path.startsWith('/messages') || path.startsWith('/inbox')) { - return SkeletonType.messages; - } else if (path.startsWith('/') && !path.startsWith('/accounts')) { - if (path.split('/').length <= 2) { - return SkeletonType.feed; - } - return SkeletonType.profile; - } - return SkeletonType.generic; -} diff --git a/lib/features/native_nav/native_bottom_nav.dart b/lib/features/native_nav/native_bottom_nav.dart deleted file mode 100644 index 270f5fd..0000000 --- a/lib/features/native_nav/native_bottom_nav.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:flutter/material.dart'; - -class NativeBottomNav extends StatelessWidget { - final String currentUrl; - final bool reelsEnabled; - final bool exploreEnabled; - final bool minimalMode; - final Function(String path) onNavigate; - - const NativeBottomNav({ - super.key, - required this.currentUrl, - required this.reelsEnabled, - required this.exploreEnabled, - required this.minimalMode, - required this.onNavigate, - }); - - String get _path { - final parsed = Uri.tryParse(currentUrl); - if (parsed != null && parsed.path.isNotEmpty) return parsed.path; - return currentUrl; // may already be a path from SPA callbacks - } - - bool get _onHome => _path == '/' || _path.isEmpty; - bool get _onExplore => _path.startsWith('/explore'); - bool get _onReels => _path.startsWith('/reels') || _path.startsWith('/reel/'); - bool get _onProfile => - _path.startsWith('/accounts') || - _path.contains('/profile') || - _path.split('/').where((p) => p.isNotEmpty).length == 1; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - final bgColor = theme.colorScheme.surface.withValues( - alpha: isDark ? 0.95 : 0.98, - ); - final iconColorInactive = isDark ? Colors.white70 : Colors.black54; - final iconColorActive = theme.colorScheme.primary; - - final tabs = <_NavItem>[ - _NavItem( - icon: Icons.home_outlined, - activeIcon: Icons.home, - label: 'Home', - path: '/', - active: _onHome, - enabled: true, - ), - if (!minimalMode) - _NavItem( - icon: Icons.search_outlined, - activeIcon: Icons.search, - label: 'Search', - path: '/explore/', - active: _onExplore, - enabled: exploreEnabled, - ), - _NavItem( - icon: Icons.add_box_outlined, - activeIcon: Icons.add_box, - label: 'New', - path: '/create/select/', - active: false, - enabled: true, - ), - if (!minimalMode) - _NavItem( - icon: Icons.play_circle_outline, - activeIcon: Icons.play_circle, - label: 'Reels', - path: '/reels/', - active: _onReels, - enabled: reelsEnabled, - ), - _NavItem( - icon: Icons.person_outline, - activeIcon: Icons.person, - label: 'Profile', - path: '/accounts/edit/', - active: _onProfile, - enabled: true, - ), - ]; - - return SafeArea( - top: false, - child: Container( - decoration: BoxDecoration( - color: bgColor, - border: Border( - top: BorderSide( - color: isDark ? Colors.white10 : Colors.black12, - width: 0.5, - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: tabs.map((item) { - final color = item.active ? iconColorActive : iconColorInactive; - final opacity = item.enabled ? 1.0 : 0.35; - - return Expanded( - child: Opacity( - opacity: opacity, - child: InkWell( - onTap: item.enabled ? () => onNavigate(item.path) : null, - borderRadius: BorderRadius.circular(24), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - item.active ? item.activeIcon : item.icon, - size: 24, - color: color, - ), - const SizedBox(height: 2), - Text( - item.label, - style: TextStyle(fontSize: 10, color: color), - ), - ], - ), - ), - ), - ), - ); - }).toList(), - ), - ), - ); - } -} - -class _NavItem { - final IconData icon; - final IconData activeIcon; - final String label; - final String path; - final bool active; - final bool enabled; - - _NavItem({ - required this.icon, - required this.activeIcon, - required this.label, - required this.path, - required this.active, - required this.enabled, - }); -} diff --git a/lib/features/preloader/instagram_preloader.dart b/lib/features/preloader/instagram_preloader.dart deleted file mode 100644 index 48d9ce0..0000000 --- a/lib/features/preloader/instagram_preloader.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:collection'; - -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - -import '../../scripts/spa_navigation_monitor.dart'; -import '../../scripts/native_feel.dart'; -import '../../scripts/focus_scripts.dart'; - -class InstagramPreloader { - static HeadlessInAppWebView? _headlessWebView; - static InAppWebViewController? controller; - static final InAppWebViewKeepAlive keepAlive = InAppWebViewKeepAlive(); - static bool isReady = false; - - static Future start(String userAgent) async { - if (_headlessWebView != null) return; - - _headlessWebView = HeadlessInAppWebView( - keepAlive: keepAlive, - initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')), - initialSettings: InAppWebViewSettings( - userAgent: userAgent, - mediaPlaybackRequiresUserGesture: true, - useHybridComposition: true, - cacheEnabled: true, - cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK, - domStorageEnabled: true, - databaseEnabled: true, - hardwareAcceleration: true, - transparentBackground: true, - safeBrowsingEnabled: false, - ), - initialUserScripts: UnmodifiableListView([ - // DM Ghost โ€” comprehensive blocking, gated by window.__fgFullDmGhost flag. - // it should have worked, but sadly it didnt - UserScript( - source: kFullDmGhostJS, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - UserScript( - source: kSpaNavigationMonitorScript, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - UserScript( - source: kNativeFeelingScript, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - // ReelMetadataExtractor removed โ€” reel history feature deleted - ]), - onWebViewCreated: (c) { - controller = c; - }, - onLoadStop: (c, url) async { - isReady = true; - await c.evaluateJavascript(source: kNativeFeelingPostLoadScript); - }, - ); - - await _headlessWebView!.run(); - } - - static void dispose() { - _headlessWebView?.dispose(); - _headlessWebView = null; - controller = null; - isReady = false; - } -} diff --git a/lib/features/reels_history/reels_history_screen.dart b/lib/features/reels_history/reels_history_screen.dart deleted file mode 100644 index 3e5f70c..0000000 --- a/lib/features/reels_history/reels_history_screen.dart +++ /dev/null @@ -1,252 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import 'reels_history_service.dart'; - -class ReelsHistoryScreen extends StatefulWidget { - const ReelsHistoryScreen({super.key}); - - @override - State 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 deleted file mode 100644 index 5d76b04..0000000 --- a/lib/features/reels_history/reels_history_service.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:convert'; - -import 'package:shared_preferences/shared_preferences.dart'; - -class ReelsHistoryEntry { - final String id; - final String url; - final String title; - final String thumbnailUrl; - final DateTime visitedAt; - final int durationSeconds; // How long the session lasted - final int adsWatchedInSession; // How many ads watched during this session - - const ReelsHistoryEntry({ - required this.id, - required this.url, - required this.title, - required this.thumbnailUrl, - required this.visitedAt, - this.durationSeconds = 0, - this.adsWatchedInSession = 0, - }); - - Map toJson() => { - 'id': id, - 'url': url, - 'title': title, - 'thumbnailUrl': thumbnailUrl, - 'visitedAt': visitedAt.toUtc().toIso8601String(), - 'durationSeconds': durationSeconds, - 'adsWatchedInSession': adsWatchedInSession, - }; - - 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(), - durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0, - adsWatchedInSession: (json['adsWatchedInSession'] as num?)?.toInt() ?? 0, - ); - } -} - -class ReelsHistoryService { - static const String _prefsKey = 'reels_history'; - static const int _maxEntries = 200; - - SharedPreferences? _prefs; - - Future _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, - int durationSeconds = 0, - int adsWatchedInSession = 0, - }) async { - if (url.isEmpty) return; - final now = DateTime.now().toUtc(); - - final entries = await getEntries(); - final recentDuplicate = entries.any((e) { - if (e.url != url) return false; - final diff = now.difference(e.visitedAt).inSeconds.abs(); - return diff <= 60; - }); - if (recentDuplicate) return; - - final entry = ReelsHistoryEntry( - id: DateTime.now().microsecondsSinceEpoch.toString(), - url: url, - title: title.isEmpty ? 'Instagram Reel' : title, - thumbnailUrl: thumbnailUrl, - visitedAt: now, - durationSeconds: durationSeconds, - adsWatchedInSession: adsWatchedInSession, - ); - - final updated = [entry, ...entries]; - if (updated.length > _maxEntries) { - updated.removeRange(_maxEntries, updated.length); - } - await _save(updated); - } - - Future deleteEntry(String id) async { - final entries = await getEntries(); - entries.removeWhere((e) => e.id == id); - await _save(entries); - } - - /// Get average reels watched per day in the last 7 days. - Future getWeeklyAverageReels() async { - final entries = await getEntries(); - if (entries.isEmpty) return 0; - - final now = DateTime.now(); - final sevenDaysAgo = now.subtract(const Duration(days: 7)); - final recent = entries - .where((e) => e.visitedAt.isAfter(sevenDaysAgo)) - .toList(); - - if (recent.isEmpty) return 0; - return recent.length / 7.0; - } - - /// Get reel counts grouped by day (for the level system). - Future> getDailyReelCounts({int days = 30}) async { - final entries = await getEntries(); - final now = DateTime.now(); - final cutoff = now.subtract(Duration(days: days)); - final recent = entries.where((e) => e.visitedAt.isAfter(cutoff)).toList(); - - final Map counts = {}; - for (final entry in recent) { - final dayKey = - '${entry.visitedAt.year}-' - '${entry.visitedAt.month.toString().padLeft(2, '0')}-' - '${entry.visitedAt.day.toString().padLeft(2, '0')}'; - counts[dayKey] = (counts[dayKey] ?? 0) + 1; - } - return counts; - } - - /// Get total reels watched in the last [days] days. - Future getRecentReelCount({int days = 7}) async { - final entries = await getEntries(); - final now = DateTime.now(); - final cutoff = now.subtract(Duration(days: days)); - return entries.where((e) => e.visitedAt.isAfter(cutoff)).length; - } - - Future 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 deleted file mode 100644 index d0da8fe..0000000 --- a/lib/features/screen_time/screen_time_screen.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../services/screen_time_service.dart'; - -class ScreenTimeScreen extends StatelessWidget { - const ScreenTimeScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text( - 'Screen Time', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: Consumer( - 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 deleted file mode 100644 index 69051f8..0000000 --- a/lib/features/update_checker/update_banner.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'update_checker_service.dart'; - -class UpdateBanner extends StatefulWidget { - final UpdateInfo updateInfo; - final VoidCallback onDismiss; - - const UpdateBanner({ - super.key, - required this.updateInfo, - required this.onDismiss, - }); - - @override - State 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 deleted file mode 100644 index 2ffd2ff..0000000 --- a/lib/features/update_checker/update_checker_service.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class UpdateInfo { - final String latestVersion; // e.g. "1.0.0" - final String releaseUrl; // html_url - final String whatsNew; // trimmed body - final bool isUpdateAvailable; - - const UpdateInfo({ - required this.latestVersion, - required this.releaseUrl, - required this.whatsNew, - required this.isUpdateAvailable, - }); -} - -class UpdateCheckerService extends ChangeNotifier { - static const String _lastDismissedKey = 'last_dismissed_update_version'; - static const String _githubUrl = - 'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest'; - - UpdateInfo? _updateInfo; - bool _isDismissed = false; - - bool get hasUpdate => _updateInfo != null && !_isDismissed; - UpdateInfo? get updateInfo => hasUpdate ? _updateInfo : null; - - Future 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/focus_settings.dart b/lib/focus_settings.dart deleted file mode 100644 index 7a4b454..0000000 --- a/lib/focus_settings.dart +++ /dev/null @@ -1,17 +0,0 @@ -class FocusSettings { - final bool ghostMode; // DM ghost โ€” blocks seen/DM signals comprehensively - final bool noAds; // strip ads and sponsored posts - final bool noStories; // hide story tray - final bool noReels; // hide reels tab - final bool noAutoplay; // stop videos autoplaying - final bool noDMs; // block direct messages - - const FocusSettings({ - this.ghostMode = false, - this.noAds = true, - this.noStories = false, - this.noReels = false, - this.noAutoplay = false, - this.noDMs = false, - }); -} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index 2511a94..0000000 --- a/lib/main.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:app_links/app_links.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -// google_mobile_ads removed โ€” switched to Adsterra only -import 'services/session_manager.dart'; -import 'services/settings_service.dart'; -import 'services/screen_time_service.dart'; -import 'services/focusgram_router.dart'; -import 'services/injection_controller.dart'; -import 'services/credit_store.dart'; -import 'services/bait_engine.dart'; -import 'services/app_lock_service.dart'; -import 'services/level_service.dart'; -import 'services/snapshot_service.dart'; -import 'screens/app_lock_screen.dart'; -import 'screens/onboarding_page.dart'; -import 'screens/main_webview_page.dart'; -import 'screens/breath_gate_screen.dart'; -import 'screens/app_session_picker.dart'; -import 'screens/cooldown_gate_screen.dart'; -import 'services/notification_service.dart'; -import 'features/update_checker/update_checker_service.dart'; -import 'features/preloader/instagram_preloader.dart'; -import 'widgets/remote_popup_handler.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // Lock to portrait - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - - // โ”€โ”€ Initialise storage & SDKs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - await Hive.initFlutter(); - final creditStore = CreditStore(); - final baitEngine = BaitEngine(); - final levelService = LevelService(); - final appLockService = AppLockService(); - final snapshotService = SnapshotService(); - - final sessionManager = SessionManager(); - final settingsService = SettingsService(); - final screenTimeService = ScreenTimeService(); - - final updateChecker = UpdateCheckerService(); - - await creditStore.init(); - await baitEngine.init(); - await appLockService.init(); - await levelService.init(); - await snapshotService.init(); - await sessionManager.init(); - await settingsService.init(); - await screenTimeService.init(); - await NotificationService().init(requestPermissions: true); - runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: sessionManager), - ChangeNotifierProvider.value(value: settingsService), - ChangeNotifierProvider.value(value: screenTimeService), - ChangeNotifierProvider.value(value: creditStore), - ChangeNotifierProvider.value(value: baitEngine), - ChangeNotifierProvider.value(value: levelService), - ChangeNotifierProvider.value(value: appLockService), - ChangeNotifierProvider.value(value: snapshotService), - ChangeNotifierProvider.value(value: updateChecker), - ], - child: const FocusGramApp(), - ), - ); - - // Fire and forget โ€” preloads Instagram while app UI initialises. - unawaited(InstagramPreloader.start(InjectionController.iOSUserAgent)); -} - -class FocusGramApp extends StatelessWidget { - const FocusGramApp({super.key}); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return MaterialApp( - title: 'FocusGram', - debugShowCheckedModeBanner: false, - theme: ThemeData( - brightness: isDark ? Brightness.dark : Brightness.light, - colorScheme: isDark - ? ColorScheme.dark( - primary: Colors.blue.shade400, - surface: Colors.black, - ) - : ColorScheme.light(primary: Colors.blue), - scaffoldBackgroundColor: isDark ? Colors.black : Colors.white, - useMaterial3: true, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - home: const InitialRouteHandler(), - ); - } -} - -/// Flow on every cold open: -/// 1. Onboarding (if first run) -/// 2. Cooldown Gate (if app-open cooldown active) -/// 3. Breath Gate (if enabled in settings) -/// 4. If an app session is already active, resume it -/// otherwise show App Session Picker -/// 5. Main WebView -class InitialRouteHandler extends StatefulWidget { - const InitialRouteHandler({super.key}); - - @override - State createState() => _InitialRouteHandlerState(); -} - -class _InitialRouteHandlerState extends State - with WidgetsBindingObserver { - bool _breathCompleted = false; - bool _appSessionStarted = false; - bool _onboardingCompleted = false; - bool _lockScreenDismissed = false; - late AppLinks _appLinks; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _appLinks = AppLinks(); - _initDeepLinks(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - RemotePopupHandler.checkAndShow(context); - }); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - final appLock = context.read(); - if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive) { - appLock.onBackgrounded(); - } else if (state == AppLifecycleState.resumed) { - if (appLock.shouldLockOnResume) { - appLock.onLockScreenShown(); - _showLockScreen(); - } - } - } - - Future _showLockScreen() async { - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)), - ); - if (result == true && mounted) { - setState(() => _lockScreenDismissed = true); - } - } - - Future _initDeepLinks() async { - // 1. Handle background links while app is running - _appLinks.uriLinkStream.listen((uri) { - // debugPrint('Incoming Deep Link: $uri'); - FocusGramRouter.pendingUrl.value = uri.toString(); - }); - - // 2. Handle the initial link that opened the app - final initialUri = await _appLinks.getInitialLink(); - if (initialUri != null) { - // debugPrint('Initial Deep Link: $initialUri'); - FocusGramRouter.pendingUrl.value = initialUri.toString(); - } - } - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final settings = context.watch(); - final appLock = context.watch(); - - // Step 0: App-wide lock (shows before everything, once per cold start) - if (appLock.needsUnlockOnStart && !_lockScreenDismissed) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!appLock.isShowingLock) { - appLock.onLockScreenShown(); - _showLockScreen(); - } - }); - } - - // Step 1: Onboarding - if (settings.isFirstRun && !_onboardingCompleted) { - return OnboardingPage( - onFinish: () => setState(() => _onboardingCompleted = true), - ); - } - - // Step 2: Cooldown gate โ€” if too soon since last session - if (sm.isAppOpenCooldownActive) { - return const CooldownGateScreen(); - } - - // Step 3: Breath gate - if (settings.showBreathGate && !_breathCompleted) { - return BreathGateScreen( - durationSeconds: settings.breathGateSeconds, - onFinish: () => setState(() => _breathCompleted = true), - ); - } - - // Step 4: App session picker / resume existing session - if (!_appSessionStarted) { - if (sm.isAppSessionActive) { - // User already has an active app session โ€” don't ask intention again. - _appSessionStarted = true; - } else { - return AppSessionPickerScreen( - onSessionStarted: () => setState(() => _appSessionStarted = true), - ); - } - } - - // Step 5: Main app - return const MainWebViewPage(); - } -} diff --git a/lib/screens/adsterra_ad_screen.dart b/lib/screens/adsterra_ad_screen.dart deleted file mode 100644 index 37caf0a..0000000 --- a/lib/screens/adsterra_ad_screen.dart +++ /dev/null @@ -1,320 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:url_launcher/url_launcher.dart'; - -/// Full-screen ad page. User MUST click the ad to earn the reward. -/// -/// Flow: -/// 1. Ad loads in WebView for 20s -/// 2. User taps the ad โ†’ opens in external browser via url_launcher -/// 3. Timer continues counting to 20s regardless -/// 4. After 20s, "Continue & Earn Reward" button unlocks if BOTH ads clicked -/// 5. If ads not clicked within time, a Retry button appears to reload - -const String _kAdHtml = ''' - - - - - - - -
-
Ad 1
- -
-
-
-
Ad 2
- - -
- - -'''; - -class AdsterraAdScreen extends StatefulWidget { - final String sessionType; - final int requiredSeconds; - - const AdsterraAdScreen({ - super.key, - required this.sessionType, - this.requiredSeconds = 20, - }); - - @override - State createState() => _AdsterraAdScreenState(); -} - -class _AdsterraAdScreenState extends State { - int _elapsed = 0; - Timer? _timer; - int _adsClicked = 0; // count of ad clicks (need 2 for reward) - bool _retrying = false; - InAppWebViewController? _webController; - - @override - void initState() { - super.initState(); - _startTimer(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - void _startTimer() { - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() => _elapsed++); - }); - } - - Future _retry() async { - setState(() { - _retrying = true; - _elapsed = 0; - _adsClicked = 0; - }); - _startTimer(); - try { - await _webController?.loadData( - data: _kAdHtml, - mimeType: 'text/html', - encoding: 'utf-8', - baseUrl: WebUri('https://adsterra.com'), - ); - } catch (_) {} - if (mounted) setState(() => _retrying = false); - } - - @override - Widget build(BuildContext context) { - final timerDone = _elapsed >= widget.requiredSeconds; - final bothClicked = _adsClicked >= 2; - final done = timerDone && bothClicked; - - // When timer expired but ads not clicked, wait a bit then allow skip - final canSkip = timerDone && !bothClicked; - - String statusText; - Color statusColor; - if (bothClicked && timerDone) { - statusText = 'Ready!'; - statusColor = Colors.greenAccent; - } else if (bothClicked) { - statusText = 'Both ads clicked! Waiting for timerโ€ฆ'; - statusColor = Colors.greenAccent; - } else { - statusText = 'Tap BOTH ads below to earn XP ($_adsClicked/2)'; - statusColor = Colors.white.withValues(alpha: 0.4); - } - - String buttonText; - bool buttonEnabled; - VoidCallback? buttonAction; - - if (done) { - buttonText = 'Continue & Earn Reward'; - buttonEnabled = true; - buttonAction = () => Navigator.pop(context, true); - } else if (timerDone && !bothClicked) { - buttonText = 'Tap both ads to continue'; - buttonEnabled = false; - buttonAction = null; - } else { - final remaining = widget.requiredSeconds - _elapsed; - buttonText = 'Wait ${remaining > 0 ? remaining : 0}s'; - buttonEnabled = false; - buttonAction = null; - } - - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Column( - children: [ - // Top bar - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Icon(Icons.videocam, color: Colors.white54, size: 18), - const SizedBox(width: 8), - const Text( - 'Sponsored', - style: TextStyle(color: Colors.white54, fontSize: 13), - ), - const Spacer(), - Text( - '${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s', - style: TextStyle( - color: done ? Colors.greenAccent : Colors.white54, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - // Progress bar - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: (_elapsed / widget.requiredSeconds).clamp(0.0, 1.0), - minHeight: 3, - backgroundColor: Colors.white12, - valueColor: AlwaysStoppedAnimation( - done ? Colors.greenAccent : Colors.blueAccent, - ), - ), - ), - // Hint text - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Text( - statusText, - style: TextStyle(color: statusColor, fontSize: 11), - ), - ), - // Ad WebView - Expanded( - child: InAppWebView( - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - domStorageEnabled: true, - useHybridComposition: true, - transparentBackground: true, - cacheEnabled: false, - safeBrowsingEnabled: false, - ), - onWebViewCreated: (c) async { - _webController = c; - await c.loadData( - data: _kAdHtml, - mimeType: 'text/html', - encoding: 'utf-8', - baseUrl: WebUri('https://adsterra.com'), - ); - }, - onLoadStop: (_, url) { - // ad loaded - }, - shouldOverrideUrlLoading: (controller, nav) async { - final url = nav.request.url?.toString() ?? ''; - if (url.isNotEmpty && - !url.contains('adsterra.com') && - !url.startsWith('about:')) { - if (_adsClicked < 2) _adsClicked++; - if (mounted) setState(() {}); - await launchUrl( - Uri.parse(url), - mode: LaunchMode.externalApplication, - ); - return NavigationActionPolicy.CANCEL; - } - return NavigationActionPolicy.ALLOW; - }, - ), - ), - // Button area - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton.icon( - onPressed: buttonEnabled ? buttonAction : null, - style: ElevatedButton.styleFrom( - backgroundColor: done - ? Colors.greenAccent - : Colors.grey, - foregroundColor: done ? Colors.black : Colors.white38, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - icon: Icon( - done ? Icons.check_circle : Icons.timer_outlined, - size: 22, - ), - label: Text( - buttonText, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - ), - ), - // Retry / Skip buttons when timer done but ads not clicked - if (canSkip && !_retrying) ...[ - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - height: 40, - child: OutlinedButton.icon( - onPressed: _retry, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.orangeAccent, - side: BorderSide( - color: Colors.orangeAccent.withValues(alpha: 0.4), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.refresh, size: 18), - label: const Text( - 'Retry โ€” Reload Ads', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), - ), - const SizedBox(height: 4), - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Skip (no reward)', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.3), - fontSize: 13, - ), - ), - ), - ], - if (_retrying) - const Padding( - padding: EdgeInsets.only(top: 12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.orangeAccent, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart deleted file mode 100644 index 1652120..0000000 --- a/lib/screens/app_lock_screen.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../services/app_lock_service.dart'; - -/// The lock screen shown when FocusGram is locked. -/// -/// Supports PIN entry with optional scrambled keypad. -/// [forAppWide] controls which PIN to verify: true = app-wide, false = messages. -/// [title] lets the screen show context (e.g. "Messages Locked"). -class AppLockScreen extends StatefulWidget { - final bool forAppWide; - final String? title; - final String? subtitle; - - const AppLockScreen({ - super.key, - this.forAppWide = true, - this.title, - this.subtitle, - }); - - @override - State createState() => _AppLockScreenState(); -} - -class _AppLockScreenState extends State { - String _enteredPin = ''; - bool _showError = false; - String _errorMsg = ''; - bool _isVerifying = false; - List _scrambledDigits = []; - - @override - void initState() { - super.initState(); - _refreshScrambled(); - } - - void _refreshScrambled() { - setState(() { - _scrambledDigits = context.read().getScrambledDigits(); - }); - } - - @override - Widget build(BuildContext context) { - final appLock = context.watch(); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDark ? Colors.black : Colors.white, - body: SafeArea( - child: Column( - children: [ - const Spacer(flex: 2), - // Icon - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.blue.withValues(alpha: 0.1), - ), - child: const Icon( - Icons.lock_outline, - color: Colors.blueAccent, - size: 32, - ), - ), - const SizedBox(height: 20), - - // Title - Text( - widget.title ?? 'FocusGram is Locked', - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - widget.subtitle ?? 'Enter your PIN to unlock', - style: TextStyle( - fontSize: 14, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - - const SizedBox(height: 32), - - // PIN dots - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(4, (i) { - final filled = i < _enteredPin.length; - return Container( - width: 16, - height: 16, - margin: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: filled - ? Colors.blueAccent - : (isDark ? Colors.white24 : Colors.black12), - ), - ); - }), - ), - - // Error text - if (_showError) - Padding( - padding: const EdgeInsets.only(top: 16), - child: Text( - _errorMsg, - style: const TextStyle(color: Colors.redAccent, fontSize: 13), - ), - ), - - if (_isVerifying) - const Padding( - padding: EdgeInsets.only(top: 16), - child: CircularProgressIndicator(strokeWidth: 2), - ), - - const Spacer(), - - // Keypad - _buildKeypad(appLock), - ], - ), - ), - ); - } - - Widget _buildKeypad(AppLockService appLock) { - final useScrambled = appLock.scrambleKeypad; - - // Build digit labels - final digitLabels = useScrambled - ? _scrambledDigits.map((d) => d.toString()).toList() - : List.generate(10, (i) => i.toString()); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), - child: Column( - children: [ - // Row 1: 1 2 3 - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _KeypadButton( - label: digitLabels[1], - onTap: () => _onDigit(digitLabels[1]), - ), - _KeypadButton( - label: digitLabels[2], - onTap: () => _onDigit(digitLabels[2]), - ), - _KeypadButton( - label: digitLabels[3], - onTap: () => _onDigit(digitLabels[3]), - ), - ], - ), - // Row 2: 4 5 6 - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _KeypadButton( - label: digitLabels[4], - onTap: () => _onDigit(digitLabels[4]), - ), - _KeypadButton( - label: digitLabels[5], - onTap: () => _onDigit(digitLabels[5]), - ), - _KeypadButton( - label: digitLabels[6], - onTap: () => _onDigit(digitLabels[6]), - ), - ], - ), - // Row 3: 7 8 9 - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _KeypadButton( - label: digitLabels[7], - onTap: () => _onDigit(digitLabels[7]), - ), - _KeypadButton( - label: digitLabels[8], - onTap: () => _onDigit(digitLabels[8]), - ), - _KeypadButton( - label: digitLabels[9], - onTap: () => _onDigit(digitLabels[9]), - ), - ], - ), - // Row 4: delete 0 scramble-refresh - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _KeypadButton(label: 'โŒซ', onTap: _onDelete, isFunction: true), - _KeypadButton( - label: digitLabels[0], - onTap: () => _onDigit(digitLabels[0]), - ), - if (useScrambled) - _KeypadButton( - label: 'โŸณ', - onTap: _refreshScrambled, - isFunction: true, - ) - else - const SizedBox(width: 72), // Placeholder - ], - ), - ], - ), - ); - } - - void _onDigit(String digit) { - if (_enteredPin.length >= 4) return; - setState(() { - _enteredPin += digit; - _showError = false; - }); - - if (_enteredPin.length == 4) { - _verifyPin(); - } - } - - void _onDelete() { - if (_enteredPin.isEmpty) return; - setState( - () => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1), - ); - } - - Future _verifyPin() async { - setState(() => _isVerifying = true); - - final appLock = context.read(); - final valid = await appLock.verifyPin( - _enteredPin, - forAppWide: widget.forAppWide, - ); - - if (!mounted) return; - - if (valid) { - HapticFeedback.heavyImpact(); - appLock.onUnlocked(); - Navigator.of(context).pop(true); - } else { - setState(() { - _showError = true; - _errorMsg = 'Wrong PIN. Try again.'; - _enteredPin = ''; - _isVerifying = false; - }); - HapticFeedback.heavyImpact(); - } - } - - -} - -class _KeypadButton extends StatelessWidget { - final String label; - final VoidCallback onTap; - final bool isFunction; - - const _KeypadButton({ - required this.label, - required this.onTap, - this.isFunction = false, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - return SizedBox( - width: 72, - height: 72, - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(36), - onTap: onTap, - child: Center( - child: Text( - label, - style: TextStyle( - fontSize: isFunction ? 28 : 24, - fontWeight: FontWeight.w500, - color: isFunction - ? Colors.blueAccent - : (isDark ? Colors.white : Colors.black87), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/app_lock_settings_page.dart b/lib/screens/app_lock_settings_page.dart deleted file mode 100644 index 30167b7..0000000 --- a/lib/screens/app_lock_settings_page.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../services/app_lock_service.dart'; -import 'app_lock_setup_screen.dart'; - -/// App Lock settings โ€” two independent lock modes (app-wide + messages tab), -/// each with their own toggle, all backed by a single PIN. -class AppLockSettingsPage extends StatefulWidget { - const AppLockSettingsPage({super.key}); - - @override - State createState() => _AppLockSettingsPageState(); -} - -class _AppLockSettingsPageState extends State { - Future _ensurePin() async { - final appLock = context.read(); - if (appLock.hasPin) return true; - final ok = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AppLockSetupScreen()), - ); - return ok == true; - } - - @override - Widget build(BuildContext context) { - final a = context.watch(); - final isDark = Theme.of(context).brightness == Brightness.dark; - final anythingOn = a.lockAppWide || a.lockMessages; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'App Lock', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - // โ”€โ”€ Status card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: anythingOn - ? [ - Colors.blueAccent.withValues(alpha: 0.15), - Colors.blue.withValues(alpha: 0.05), - ] - : [ - Colors.grey.withValues(alpha: 0.1), - Colors.grey.withValues(alpha: 0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: anythingOn - ? Colors.blueAccent.withValues(alpha: 0.3) - : Colors.grey.withValues(alpha: 0.2), - ), - ), - child: Column( - children: [ - Icon( - anythingOn ? Icons.lock_rounded : Icons.lock_open_rounded, - color: anythingOn ? Colors.blueAccent : Colors.grey, - size: 48, - ), - const SizedBox(height: 12), - Text( - anythingOn ? 'Lock Active' : 'No Lock', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: anythingOn ? Colors.blueAccent : Colors.grey, - ), - ), - const SizedBox(height: 6), - Text( - _statusText(a), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - ], - ), - ), - - const _SectionHeader(title: 'LOCK MODES'), - // โ”€โ”€ App-wide lock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - SwitchListTile( - title: const Text('Lock Entire App'), - subtitle: const Text('Require PIN when opening FocusGram.'), - value: a.lockAppWide, - onChanged: (v) async { - if (v && !a.hasPin) { - if (!await _ensurePin()) return; - } - await a.setLockAppWide(v); - HapticFeedback.selectionClick(); - }, - ), - // โ”€โ”€ Messages tab lock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - SwitchListTile( - title: const Text('Lock Messages Tab'), - subtitle: const Text( - 'Require PIN to open Instagram Direct Messages', - ), - value: a.lockMessages, - onChanged: (v) async { - if (v && !a.hasPin) { - if (!await _ensurePin()) return; - } - await a.setLockMessages(v); - HapticFeedback.selectionClick(); - }, - ), - - // โ”€โ”€โ”€ PIN & extras โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (a.hasPin) ...[ - const _SectionHeader(title: 'PIN & SECURITY'), - ListTile( - title: const Text('Change PIN'), - subtitle: const Text('Set a new 4-digit code'), - trailing: const Icon(Icons.arrow_forward_ios, size: 14), - onTap: () async { - final ok = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AppLockSetupScreen()), - ); - if (ok == true && mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('PIN updated'))); - } - }, - ), - SwitchListTile( - title: const Text('Scrambled Keypad'), - subtitle: const Text('Shuffle digits on the lock screen'), - value: a.scrambleKeypad, - onChanged: (v) async { - await a.setScrambleKeypad(v); - HapticFeedback.selectionClick(); - }, - ), - // Biometrics option removed - ], - - // โ”€โ”€ Hint if no PIN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (!a.hasPin) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(10), - ), - child: const Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: Colors.blueAccent, - ), - SizedBox(width: 8), - Expanded( - child: Text( - 'Enable any lock mode above to set up your PIN.', - style: TextStyle(fontSize: 13), - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 40), - ], - ), - ); - } - - String _statusText(AppLockService a) { - if (!a.hasPin) return 'Set a PIN to enable any lock mode.'; - final parts = []; - if (a.lockAppWide) parts.add('App-wide'); - if (a.lockMessages) parts.add('Messages tab'); - if (parts.isEmpty) return 'Both modes are off โ€” enable one above.'; - return '${parts.join(' + ')} lock is active.'; - } -} - -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Text( - title, - style: const TextStyle( - color: Colors.grey, - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ), - ); - } -} diff --git a/lib/screens/app_lock_setup_screen.dart b/lib/screens/app_lock_setup_screen.dart deleted file mode 100644 index b9ebfb0..0000000 --- a/lib/screens/app_lock_setup_screen.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../services/app_lock_service.dart'; - -/// First-time setup screen for App Lock. -/// User enters PIN twice, then optionally enables biometrics. -class AppLockSetupScreen extends StatefulWidget { - const AppLockSetupScreen({super.key}); - - @override - State createState() => _AppLockSetupScreenState(); -} - -class _AppLockSetupScreenState extends State { - final _pinController = TextEditingController(); - final _confirmController = TextEditingController(); - bool _obscurePin = true; - bool _obscureConfirm = true; - String? _error; - - @override - void dispose() { - _pinController.dispose(); - _confirmController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Set App Lock PIN'), centerTitle: true), - body: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16), - const Text( - 'Choose a 4-digit PIN to lock FocusGram.', - style: TextStyle(fontSize: 15, height: 1.5), - ), - const SizedBox(height: 32), - - // PIN field - TextField( - controller: _pinController, - obscureText: _obscurePin, - maxLength: 4, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: 'Enter PIN', - counterText: '', - suffixIcon: IconButton( - icon: Icon( - _obscurePin ? Icons.visibility_off : Icons.visibility, - ), - onPressed: () => setState(() => _obscurePin = !_obscurePin), - ), - border: const OutlineInputBorder(), - ), - onChanged: (_) => setState(() => _error = null), - ), - const SizedBox(height: 16), - - // Confirm PIN field - TextField( - controller: _confirmController, - obscureText: _obscureConfirm, - maxLength: 4, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: 'Confirm PIN', - counterText: '', - suffixIcon: IconButton( - icon: Icon( - _obscureConfirm ? Icons.visibility_off : Icons.visibility, - ), - onPressed: () => - setState(() => _obscureConfirm = !_obscureConfirm), - ), - border: const OutlineInputBorder(), - ), - onChanged: (_) => setState(() => _error = null), - ), - - // Error - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _error!, - style: const TextStyle(color: Colors.redAccent), - ), - ), - - const Spacer(), - - // Save button - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: _savePin, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: const Text( - 'Enable App Lock', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - ), - ), - const SizedBox(height: 24), - ], - ), - ), - ); - } - - Future _savePin() async { - final pin = _pinController.text.trim(); - final confirm = _confirmController.text.trim(); - - if (pin.length != 4) { - setState(() => _error = 'PIN must be exactly 4 digits.'); - return; - } - if (pin != confirm) { - setState(() => _error = 'PINs do not match.'); - return; - } - if (pin == pin.split('').toSet().join('') && pin.length == 4) { - // Allow any 4-digit PIN - } - - final appLock = context.read(); - // Set both PINs to the same value for simplicity - await appLock.setPin(pin, forAppWide: true); - await appLock.setPin(pin, forAppWide: false); - - HapticFeedback.heavyImpact(); - if (mounted) { - Navigator.pop(context, true); - } - } -} diff --git a/lib/screens/app_session_picker.dart b/lib/screens/app_session_picker.dart deleted file mode 100644 index 34493a6..0000000 --- a/lib/screens/app_session_picker.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/session_manager.dart'; - -/// Shown on every cold app open. Asks the user how long they plan to use -/// Instagram today. Uses an iOS-style scroll picker (ListWheelScrollView). -class AppSessionPickerScreen extends StatefulWidget { - final VoidCallback onSessionStarted; - const AppSessionPickerScreen({super.key, required this.onSessionStarted}); - - @override - State createState() => _AppSessionPickerScreenState(); -} - -class _AppSessionPickerScreenState extends State { - static final List _minuteOptions = [ - 5, - 10, - 15, - 20, - 25, - 30, - 35, - 40, - 45, - 50, - 55, - 60, - ]; - int _selectedIndex = 0; // default: 5 min unless a previous choice exists - late final FixedExtentScrollController _scrollController; - - @override - void initState() { - super.initState(); - final lastMinutes = context.read().lastAppSessionMinutes; - final lastIndex = _minuteOptions.indexOf(lastMinutes); - _selectedIndex = lastIndex >= 0 ? lastIndex : 0; - _scrollController = FixedExtentScrollController( - initialItem: _selectedIndex, - ); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final selectedMinutes = _minuteOptions[_selectedIndex]; - - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(flex: 2), - - // Icon - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Colors.blue.shade700, Colors.blue.shade400], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: Colors.blue.withValues(alpha: 0.4), - blurRadius: 24, - spreadRadius: 4, - ), - ], - ), - child: const Icon( - Icons.timer_outlined, - color: Colors.white, - size: 36, - ), - ), - - const SizedBox(height: 28), - - const Text( - 'Set Your Intention', - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 10), - const Text( - 'How long do you plan to use\nInstagram right now?', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white54, - fontSize: 15, - height: 1.5, - ), - ), - - const Spacer(flex: 1), - - // iOS-style scroll picker - SizedBox( - height: 220, - child: Stack( - alignment: Alignment.center, - children: [ - // Selection highlight - Container( - height: 50, - margin: const EdgeInsets.symmetric(horizontal: 0), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.blue.withValues(alpha: 0.3), - width: 1, - ), - ), - ), - ListWheelScrollView.useDelegate( - itemExtent: 50, - physics: const FixedExtentScrollPhysics(), - perspective: 0.003, - squeeze: 1.1, - diameterRatio: 2.5, - controller: _scrollController, - onSelectedItemChanged: (i) { - setState(() => _selectedIndex = i); - }, - childDelegate: ListWheelChildListDelegate( - children: _minuteOptions.asMap().entries.map((entry) { - final isSelected = entry.key == _selectedIndex; - return Center( - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${entry.value}', - style: TextStyle( - fontSize: isSelected ? 28 : 22, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.w300, - color: isSelected - ? Colors.white - : Colors.white38, - ), - ), - TextSpan( - text: ' min', - style: TextStyle( - fontSize: isSelected ? 16 : 14, - color: isSelected - ? Colors.white70 - : Colors.white24, - ), - ), - ], - ), - ), - ); - }).toList(), - ), - ), - ], - ), - ), - - const Spacer(flex: 1), - - // Confirm button - SizedBox( - width: double.infinity, - height: 54, - child: ElevatedButton( - onPressed: () => _confirm(context, selectedMinutes), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 0, - ), - child: Text( - 'Start $selectedMinutes-Minute Session', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - - const SizedBox(height: 16), - const Text( - 'You\'ll be prompted to close the app when your time is up.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white24, fontSize: 12), - ), - const Spacer(flex: 1), - ], - ), - ), - ), - ); - } - - void _confirm(BuildContext context, int minutes) { - context.read().startAppSession(minutes); - widget.onSessionStarted(); - } -} diff --git a/lib/screens/bait_me_button.dart b/lib/screens/bait_me_button.dart deleted file mode 100644 index f7d510d..0000000 --- a/lib/screens/bait_me_button.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../services/bait_engine.dart'; -import '../services/credit_store.dart'; -import '../services/level_service.dart'; -import '../services/session_manager.dart'; - -/// The Bait Me button widget. -/// -/// Shows a gamble-themed button that triggers random outcomes. -/// Gated behind Level 3. Cooldown prevents spam. -class BaitMeButton extends StatefulWidget { - const BaitMeButton({super.key}); - - @override - State createState() => _BaitMeButtonState(); -} - -class _BaitMeButtonState extends State - with SingleTickerProviderStateMixin { - bool _isSpinning = false; - late AnimationController _spinController; - late Animation _spinAnimation; - - @override - void initState() { - super.initState(); - _spinController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - ); - _spinAnimation = Tween(begin: 0, end: 1).animate( - CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack), - ); - } - - @override - void dispose() { - _spinController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final levelService = context.watch(); - final baitEngine = context.read(); - final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe); - - if (!isUnlocked) { - return const SizedBox.shrink(); - } - - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // The button - SizedBox( - width: 48, - height: 48, - child: Stack( - children: [ - AnimatedBuilder( - animation: _spinAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _isSpinning - ? _spinAnimation.value * 2 * pi * 3 - : 0, - child: child, - ); - }, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: baitEngine.isOnCooldown - ? Colors.grey.withValues(alpha: 0.3) - : Colors.purpleAccent.withValues(alpha: 0.2), - border: Border.all( - color: baitEngine.isOnCooldown - ? Colors.grey - : Colors.purpleAccent, - width: 2, - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: baitEngine.isOnCooldown ? null : _onBaitMe, - child: Center( - child: Icon( - Icons.casino_rounded, - color: baitEngine.isOnCooldown - ? Colors.grey - : Colors.purpleAccent, - size: 22, - ), - ), - ), - ), - ), - ), - // Cooldown badge - if (baitEngine.isOnCooldown) - Positioned( - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${baitEngine.cooldownRemainingMinutes}m', - style: const TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 2), - Text( - 'Bait Me', - style: TextStyle( - fontSize: 9, - color: isDark ? Colors.white60 : Colors.black54, - ), - ), - ], - ), - ); - } - - Future _onBaitMe() async { - HapticFeedback.mediumImpact(); - - setState(() { - _isSpinning = true; - }); - - _spinController.forward(from: 0); - - // Wait for spin animation - await Future.delayed(const Duration(milliseconds: 1200)); - - if (!mounted) return; - - final baitEngine = context.read(); - final creditStore = context.read(); - final sessionManager = context.read(); - - // Wire callbacks - baitEngine.onAddMinutes = (minutes) { - creditStore.addBonusMinutes(minutes); - HapticFeedback.heavyImpact(); - }; - - baitEngine.onResetSession = () { - creditStore.resetBalances(); - sessionManager.endSession(); - HapticFeedback.heavyImpact(); - }; - - baitEngine.onReduceSessionTime = (minutes) { - // Deduct from reel credits - for (var i = 0; i < minutes; i++) { - creditStore.drainReelsMinute(); - } - HapticFeedback.heavyImpact(); - }; - - baitEngine.onIncreaseCooldown = (minutes) { - // Increase cooldown by adding to the last session end time - // Session manager handles cooldown via _lastSessionEnd - HapticFeedback.heavyImpact(); - }; - - baitEngine.onEndReelSession = () { - sessionManager.endSession(); - HapticFeedback.heavyImpact(); - }; - - baitEngine.onEndAppSession = () { - sessionManager.endAppSession(); - HapticFeedback.heavyImpact(); - }; - - baitEngine.onOpenUrl = (url) async { - final uri = Uri.tryParse(url); - if (uri != null) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - }; - - // Activate - final outcome = await baitEngine.activate(); - - if (!mounted) return; - - setState(() { - _isSpinning = false; - }); - - // Show result dialog - _showOutcomeDialog(context, outcome); - } - - void _showOutcomeDialog(BuildContext context, BaitOutcome outcome) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - BaitEngine.outcomeLabel(outcome), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: outcome == BaitOutcome.addTenMinutes - ? Colors.greenAccent - : Colors.redAccent, - ), - ), - const SizedBox(height: 12), - Text( - BaitEngine.outcomeSubtext(outcome), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: isDark ? Colors.white70 : Colors.black87, - height: 1.4, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('OK'), - ), - ], - ), - ); - } -} diff --git a/lib/screens/bait_me_full_screen.dart b/lib/screens/bait_me_full_screen.dart deleted file mode 100644 index 62a3797..0000000 --- a/lib/screens/bait_me_full_screen.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../services/bait_engine.dart'; -import '../services/credit_store.dart'; -// import '../services/level_service.dart'; // unused -import '../services/session_manager.dart'; - -/// Full-screen Bait Me page with big spin animation. -class BaitMeFullScreen extends StatefulWidget { - const BaitMeFullScreen({super.key}); - - @override - State createState() => _BaitMeFullScreenState(); -} - -class _BaitMeFullScreenState extends State - with SingleTickerProviderStateMixin { - bool _isSpinning = false; - bool _done = false; - BaitOutcome? _lastOutcome; - late AnimationController _spinController; - late Animation _spinAnimation; - - @override - void initState() { - super.initState(); - _spinController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1800), - ); - _spinAnimation = Tween(begin: 0, end: 1).animate( - CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack), - ); - } - - @override - void dispose() { - _spinController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Spacer(), - // Title - Text( - _done ? '๐ŸŽฒ Result!' : '๐ŸŽฒ Bait Me', - style: const TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - _done - ? BaitEngine.outcomeSubtext( - _lastOutcome ?? BaitOutcome.addTenMinutes, - ) - : 'Tap the button to test your luck!', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 15, - ), - ), - const Spacer(), - - // Spinning icon - AnimatedBuilder( - animation: _spinAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _isSpinning - ? _spinAnimation.value * 2 * pi * 5 - : 0, - child: child, - ); - }, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _done - ? Colors.green.withValues(alpha: 0.15) - : Colors.purpleAccent.withValues(alpha: 0.15), - border: Border.all( - color: _done ? Colors.greenAccent : Colors.purpleAccent, - width: 3, - ), - ), - child: Center( - child: Icon( - _done ? Icons.check_circle : Icons.casino_rounded, - color: _done ? Colors.greenAccent : Colors.purpleAccent, - size: 56, - ), - ), - ), - ), - - const Spacer(), - - // Outcome description - if (_done && _lastOutcome != null) - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Text( - BaitEngine.outcomeLabel(_lastOutcome!), - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: _lastOutcome == BaitOutcome.addTenMinutes - ? Colors.greenAccent - : Colors.redAccent, - ), - ), - const SizedBox(height: 8), - Text( - BaitEngine.outcomeSubtext(_lastOutcome!), - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 14, - height: 1.4, - ), - ), - ], - ), - ), - - const Spacer(flex: 2), - - // Big button - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton.icon( - onPressed: _isSpinning ? null : _onBaitMe, - style: ElevatedButton.styleFrom( - backgroundColor: _done - ? Colors.greenAccent - : Colors.purpleAccent, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 4, - ), - icon: Icon( - _isSpinning - ? Icons.hourglass_top - : _done - ? Icons.check_circle - : Icons.casino_rounded, - size: 24, - ), - label: Text( - _isSpinning - ? 'Rollingโ€ฆ' - : _done - ? 'Done โ€” Close' - : '๐ŸŽฒ Spin the Wheel!', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - ), - - if (!_done) - Padding( - padding: const EdgeInsets.only(top: 12), - child: TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Not now', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.3), - ), - ), - ), - ), - - const Spacer(), - ], - ), - ), - ), - ), - ); - } - - Future _onBaitMe() async { - HapticFeedback.mediumImpact(); - setState(() => _isSpinning = true); - - _spinController.forward(from: 0); - await Future.delayed(const Duration(milliseconds: 1800)); - if (!mounted) return; - - final baitEngine = context.read(); - final creditStore = context.read(); - final sessionManager = context.read(); - - baitEngine.onAddMinutes = (m) => creditStore.addBonusMinutes(m); - baitEngine.onResetSession = () => creditStore.resetBalances(); - baitEngine.onReduceSessionTime = (m) { - for (var i = 0; i < m; i++) { - creditStore.drainReelsMinute(); - } - }; - baitEngine.onEndReelSession = () => sessionManager.endSession(); - baitEngine.onEndAppSession = () => sessionManager.endAppSession(); - baitEngine.onOpenUrl = (url) async { - final uri = Uri.tryParse(url); - if (uri != null) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - }; - - final outcome = await baitEngine.activate(); - if (!mounted) return; - - setState(() { - _isSpinning = false; - _done = true; - _lastOutcome = outcome; - }); - HapticFeedback.heavyImpact(); - } -} diff --git a/lib/screens/breath_gate_screen.dart b/lib/screens/breath_gate_screen.dart deleted file mode 100644 index 83b1bb3..0000000 --- a/lib/screens/breath_gate_screen.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; - -/// A mindfulness screen shown before Instagram opens. -class BreathGateScreen extends StatefulWidget { - final VoidCallback onFinish; - final int durationSeconds; - - const BreathGateScreen({ - super.key, - required this.onFinish, - this.durationSeconds = 10, - }); - - @override - State createState() => _BreathGateScreenState(); -} - -class _BreathGateScreenState extends State - with TickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - late int _secondsRemaining; - Timer? _timer; - bool _canContinue = false; - - @override - void initState() { - super.initState(); - _secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt(); - - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 5), - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 1.5, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); - - _controller.repeat(reverse: true); - - _startCountdown(); - } - - void _startCountdown() { - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_secondsRemaining > 0) { - setState(() => _secondsRemaining--); - } else { - setState(() { - _canContinue = true; - _timer?.cancel(); - }); - } - }); - } - - @override - void dispose() { - _controller.dispose(); - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 40.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Are you sure you want to open Instagram?', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w300, - ), - ), - const SizedBox(height: 80), - - // Animated Breath Circle - ScaleTransition( - scale: _scaleAnimation, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.blue.withValues(alpha: 0.3), - blurRadius: 30, - spreadRadius: 10, - ), - ], - gradient: const RadialGradient( - colors: [Colors.blue, Colors.black], - ), - ), - ), - ), - - const SizedBox(height: 80), - - Text( - _canContinue - ? 'Breathed.' - : 'Take a deep breath for $_secondsRemaining seconds...', - style: const TextStyle( - color: Colors.white70, - fontSize: 16, - fontStyle: FontStyle.italic, - ), - ), - - const SizedBox(height: 40), - - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _canContinue ? widget.onFinish : null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - disabledBackgroundColor: Colors.white10, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), - ), - ), - child: const Text('Continue to Instagram'), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/cooldown_gate_screen.dart b/lib/screens/cooldown_gate_screen.dart deleted file mode 100644 index 0904292..0000000 --- a/lib/screens/cooldown_gate_screen.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/session_manager.dart'; - -/// Blocking screen shown when the user tries to reopen the app too soon -/// after their last session ended. Shows a countdown and a motivational quote. -class CooldownGateScreen extends StatefulWidget { - const CooldownGateScreen({super.key}); - - @override - State createState() => _CooldownGateScreenState(); -} - -class _CooldownGateScreenState extends State { - Timer? _timer; - static const List _quotes = [ - '"The discipline you show offline\nshapes the clarity you experience online."', - '"Every moment away from the screen\nis a moment given back to yourself."', - '"Boredom is the birthplace of creativity.\nLet it breathe."', - '"Your attention is your most valuable asset.\nSpend it wisely."', - '"Presence is a gift you give yourself first."', - '"Rest is not wasted time.\nIt is the foundation of focused action."', - ]; - late final String _quote; - - @override - void initState() { - super.initState(); - _quote = _quotes[DateTime.now().second % _quotes.length]; - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final remaining = sm.appOpenCooldownRemainingSeconds; - final minutes = remaining ~/ 60; - final seconds = remaining % 60; - - // If cooldown expired, pop this gate - if (!sm.isAppOpenCooldownActive) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).maybePop(); - }); - } - - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Icon - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.orange.withValues(alpha: 0.12), - border: Border.all( - color: Colors.orangeAccent.withValues(alpha: 0.4), - width: 1.5, - ), - ), - child: const Icon( - Icons.hourglass_top_rounded, - color: Colors.orangeAccent, - size: 38, - ), - ), - - const SizedBox(height: 32), - const Text( - 'Take a Break', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - ), - - const SizedBox(height: 12), - const Text( - 'Your session has ended.\nCome back when the timer expires.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white54, - fontSize: 15, - height: 1.5, - ), - ), - - const SizedBox(height: 48), - - // Countdown - Container( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 20, - ), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Colors.orangeAccent.withValues(alpha: 0.25), - width: 1, - ), - ), - child: Column( - children: [ - const Text( - 'Return in', - style: TextStyle( - color: Colors.white38, - fontSize: 13, - letterSpacing: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', - style: const TextStyle( - color: Colors.orangeAccent, - fontSize: 52, - fontWeight: FontWeight.w200, - letterSpacing: 4, - ), - ), - ], - ), - ), - - const SizedBox(height: 60), - - // Quote - Text( - _quote, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white30, - fontSize: 13, - height: 1.7, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/debug_menu_screen.dart b/lib/screens/debug_menu_screen.dart deleted file mode 100644 index 78ac4e8..0000000 --- a/lib/screens/debug_menu_screen.dart +++ /dev/null @@ -1,342 +0,0 @@ -/*import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../services/level_service.dart'; - -/// A hidden debug menu for development & testing. -/// -/// Access: tap the app version in settings 7 times. -/// Allows manually setting XP/level to test feature gating. -class DebugMenuScreen extends StatefulWidget { - const DebugMenuScreen({super.key}); - - @override - State createState() => _DebugMenuScreenState(); -} - -class _DebugMenuScreenState extends State { - int _customLevel = 1; - int _customXp = 0; - - @override - void initState() { - super.initState(); - final levelService = context.read(); - _customLevel = levelService.level; - _customXp = levelService.xp; - } - - @override - Widget build(BuildContext context) { - final levelService = context.watch(); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Debug Menu', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Current state - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.amber.withValues(alpha: 0.3), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.bug_report, color: Colors.amber, size: 20), - const SizedBox(width: 8), - const Text( - 'Developer Tools', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Current: Level ${levelService.level} ยท ${levelService.xp} XP', - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 4), - Text( - 'Progress: ${(levelService.levelProgress * 100).toStringAsFixed(0)}% to next level', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Manual level setter - const Text( - 'SET LEVEL', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 12), - - // Quick level buttons - Wrap( - spacing: 8, - runSpacing: 8, - children: List.generate(5, (i) { - final lvl = i + 1; - final selected = _customLevel == lvl; - return ElevatedButton( - onPressed: () => setState(() => _customLevel = lvl), - style: ElevatedButton.styleFrom( - backgroundColor: selected ? Colors.blueAccent : null, - foregroundColor: selected ? Colors.white : null, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - ), - child: Text('Level $lvl'), - ); - }), - ), - - const SizedBox(height: 16), - - // Set XP field - const Text( - 'SET XP', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: 'XP Amount', - border: OutlineInputBorder(), - isDense: true, - ), - keyboardType: TextInputType.number, - controller: TextEditingController(text: '$_customXp'), - onChanged: (v) { - _customXp = int.tryParse(v) ?? 0; - }, - ), - - const SizedBox(height: 20), - - // Apply button - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton.icon( - onPressed: () => _applyDebugSettings(levelService), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.amber, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.warning_amber_rounded, size: 20), - label: const Text( - 'Apply Debug Settings', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ), - - const SizedBox(height: 32), - - // Feature unlock preview - const Text( - 'FEATURE UNLOCK STATUS', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - ...AppFeature.all.map((feature) { - final unlocked = _customLevel >= feature.requiredLevel; - return Container( - margin: const EdgeInsets.only(bottom: 4), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black) - .withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - unlocked ? Icons.check_circle : Icons.lock_outline, - color: unlocked ? Colors.greenAccent : Colors.grey, - size: 16, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - feature.name, - style: TextStyle( - fontSize: 13, - color: unlocked ? null : Colors.grey, - ), - ), - ), - Text( - 'Lv ${feature.requiredLevel}', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - ], - ), - ); - }), - - const SizedBox(height: 32), - - const SizedBox(height: 40), - - // Danger zone - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.red.withValues(alpha: 0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(Icons.dangerous_outlined, color: Colors.redAccent, size: 18), - SizedBox(width: 8), - Text( - 'Danger Zone', - style: TextStyle( - color: Colors.redAccent, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _resetAllData(levelService), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent), - ), - icon: const Icon(Icons.delete_forever, size: 18), - label: const Text('Reset All Level Data'), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Future _applyDebugSettings(LevelService levelService) async { - HapticFeedback.heavyImpact(); - // Use reflection-like approach: set the private fields via a method - // Since LevelService doesn't expose a raw setter, we provide one here. - await _forceSetLevel(levelService, _customLevel, _customXp); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Set to Level $_customLevel with $_customXp XP'), - backgroundColor: Colors.amber.shade800, - ), - ); - } - } - - Future _forceSetLevel(LevelService levelService, int level, int xp) async { - // The LevelService stores data in Hive (local only). - // We bypass the normal XP system by writing directly to cache. - await levelService.debugSetLevel(level, xp); - await Future.delayed(const Duration(milliseconds: 100)); - if (mounted) setState(() {}); - } - - Future _resetAllData(LevelService levelService) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Reset All Level Data?'), - content: const Text( - 'This will reset your level, XP, and all history to defaults. ' - 'This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: Colors.redAccent), - child: const Text('Reset'), - ), - ], - ), - ); - if (confirmed == true && mounted) { - await levelService.debugReset(); - if (mounted) { - setState(() { - _customLevel = 1; - _customXp = 0; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Level data reset')), - ); - } - } - } -} -*/ diff --git a/lib/screens/effort_friction_gate.dart b/lib/screens/effort_friction_gate.dart deleted file mode 100644 index 748e08c..0000000 --- a/lib/screens/effort_friction_gate.dart +++ /dev/null @@ -1,325 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../services/credit_store.dart'; -import '../services/level_service.dart'; -import 'adsterra_ad_screen.dart'; -import 'timer_fallback_screen.dart'; -import '../widgets/native_ad_banner.dart'; - -/// Shown before a reel or Instagram session when credits are zero -/// and Effort Friction Mode is enabled. -/// -/// Fallback chain: Adsterra Social Bar (WebView) โ†’ Timer fallback. -class EffortFrictionGate extends StatefulWidget { - final String sessionType; // 'reels' or 'insta' - final VoidCallback onProceed; - final VoidCallback? onCancel; - - const EffortFrictionGate({ - super.key, - required this.sessionType, - required this.onProceed, - this.onCancel, - }); - - @override - State createState() => _EffortFrictionGateState(); -} - -class _EffortFrictionGateState extends State { - bool _isWorking = false; - String _status = ''; - - @override - Widget build(BuildContext context) { - final creditStore = context.watch(); - final isReels = widget.sessionType == 'reels'; - final credits = isReels - ? creditStore.reelsMinutes - : creditStore.instaMinutes; - - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(flex: 2), - - // Icon - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [Colors.orange.shade800, Colors.orange.shade500], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: Colors.orange.withValues(alpha: 0.3), - blurRadius: 24, - spreadRadius: 4, - ), - ], - ), - child: const Icon( - Icons.play_circle_fill_rounded, - color: Colors.white, - size: 40, - ), - ), - - const SizedBox(height: 28), - - Text( - isReels ? 'Earn Reels Time' : 'Earn Instagram Time', - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - 'Watch a short ad to earn ${CreditStore.minutesPerAd} minutes ' - 'of ${isReels ? 'reel' : 'Instagram'} time.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 15, - height: 1.5, - ), - ), - - const SizedBox(height: 32), - - // Credit balance display - if (credits > 0) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.green.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.access_time, - color: Colors.greenAccent, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'You have $credits min remaining', - style: const TextStyle( - color: Colors.greenAccent, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - - const SizedBox(height: 20), - - // Status message - if (_status.isNotEmpty) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - const Icon( - Icons.info_outline, - color: Colors.blueAccent, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _status, - style: const TextStyle( - color: Colors.blueAccent, - fontSize: 13, - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 12), - - // Watch ad button - SizedBox( - width: double.infinity, - height: 54, - child: ElevatedButton.icon( - onPressed: _isWorking ? null : _startFallbackChain, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 0, - ), - icon: _isWorking - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.play_arrow_rounded, size: 22), - label: Text( - _isWorking - ? 'Workingโ€ฆ' - : 'Watch Ad (+${CreditStore.minutesPerAd} min)', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - - const SizedBox(height: 12), - - // Proceed button - if (credits > 0) - SizedBox( - width: double.infinity, - height: 48, - child: OutlinedButton( - onPressed: widget.onProceed, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: BorderSide( - color: Colors.white.withValues(alpha: 0.2), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: const Text('Proceed with earned time'), - ), - ), - - const SizedBox(height: 16), - - // Cancel - TextButton( - onPressed: widget.onCancel ?? () => Navigator.pop(context), - child: Text( - credits > 0 ? 'Skip for now' : 'Not now', - style: TextStyle(color: Colors.white.withValues(alpha: 0.4)), - ), - ), - - const Spacer(flex: 1), - Text( - 'Ads by Adsterra', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.15), - fontSize: 10, - ), - ), - const SizedBox(height: 4), - // Native banner ad at bottom - const NativeAdBanner(height: 50), - const SizedBox(height: 8), - ], - ), - ), - ), - ); - } - - // โ”€โ”€ Fallback Chain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - Future _startFallbackChain() async { - setState(() => _isWorking = true); - - // Tier 1: Adsterra ad (full-screen WebView) - setState(() => _status = ''); - - if (mounted) { - final adsterraResult = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AdsterraAdScreen( - sessionType: widget.sessionType, - requiredSeconds: 15, - ), - ), - ); - - if (adsterraResult == true && mounted) { - _grantReward(); - setState(() { - _isWorking = false; - _status = ''; - }); - return; - } - - if (!mounted) return; - } - - // Tier 2: Timer fallback (always works) - setState(() => _status = 'Using timer fallbackโ€ฆ'); - - if (mounted) { - final timerResult = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => TimerFallbackScreen( - sessionType: widget.sessionType, - requiredSeconds: 15, - ), - ), - ); - - if (timerResult == true && mounted) { - _grantReward(); - } - } - - if (mounted) { - setState(() { - _isWorking = false; - _status = ''; - }); - } - } - - void _grantReward() { - final creditStore = context.read(); - final levelService = context.read(); - - if (widget.sessionType == 'reels') { - creditStore.addReelsMinutes(); - } else { - creditStore.addInstaMinutes(); - } - levelService.addXpForAd(); - HapticFeedback.heavyImpact(); - } -} diff --git a/lib/screens/extras_settings_page.dart b/lib/screens/extras_settings_page.dart deleted file mode 100644 index b134167..0000000 --- a/lib/screens/extras_settings_page.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import '../services/settings_service.dart'; -import 'ghost_mode_submenu_page.dart'; - -class ExtrasSettingsPage extends StatelessWidget { - const ExtrasSettingsPage({super.key}); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - return Scaffold( - appBar: AppBar( - title: const Text( - 'Extras', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - const _SectionHeader(title: 'STARTUP'), - _LaunchPagePicker(settings: settings), - const SizedBox(height: 8), - - const _SectionHeader(title: 'MEDIA'), - _SwitchTile( - title: 'Download Media (Feed + Reels)', - subtitle: 'Adds a download icon on posts and reels', - value: settings.videoDownloadEnabled, - onChanged: (v) async { - await settings.setVideoDownloadEnabled(v); - HapticFeedback.selectionClick(); - }, - ), - - const _SectionHeader(title: 'FOCUS'), - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: settings.ghostMode - ? Colors.purple.withValues(alpha: 0.15) - : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.visibility_off_rounded, - color: settings.ghostMode ? Colors.purpleAccent : Colors.grey, - size: 20, - ), - ), - title: const Text('Ghost Mode', style: TextStyle(fontSize: 15)), - subtitle: Text( - _ghostSubtitle(settings), - style: const TextStyle(fontSize: 12), - ), - trailing: const Icon(Icons.chevron_right, size: 20), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const GhostModeSubmenuPage()), - ), - ), - - const SizedBox(height: 40), - ], - ), - ); - } -} - -String _ghostSubtitle(SettingsService s) { - if (s.ghostMode) return 'DM Ghost active โ€” works inside chat only'; - return 'Tap to configure ghost modes'; -} - -class _LaunchPagePicker extends StatelessWidget { - final SettingsService settings; - const _LaunchPagePicker({required this.settings}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final options = ['home', 'following', 'favorites', 'direct']; - final labels = { - 'home': 'Home Feed', - 'following': 'Following', - 'favorites': 'Favorites', - 'direct': 'Direct Messages', - }; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - initialValue: settings.startupPage, - decoration: const InputDecoration( - labelText: 'Launch Page', - border: OutlineInputBorder(), - isDense: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - items: options - .map( - (p) => DropdownMenuItem( - value: p, - child: Text( - labels[p] ?? p, - style: const TextStyle(fontSize: 14), - ), - ), - ) - .toList(), - onChanged: (v) { - if (v != null) settings.setStartupPage(v); - HapticFeedback.selectionClick(); - }, - ), - const SizedBox(height: 6), - Text( - 'Choose which page opens when you launch Focusgram.', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white38 : Colors.black38, - ), - ), - ], - ), - ); - } -} - -class _SwitchTile extends StatelessWidget { - final String title; - final String? subtitle; - final bool value; - final ValueChanged onChanged; - const _SwitchTile({ - required this.title, - this.subtitle, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return SwitchListTile( - title: Text(title, style: const TextStyle(fontSize: 15)), - subtitle: subtitle != null - ? Text(subtitle!, style: const TextStyle(fontSize: 12)) - : null, - value: value, - onChanged: onChanged, - ); - } -} - -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Text( - title, - style: const TextStyle( - color: Colors.grey, - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ), - ); - } -} diff --git a/lib/screens/ghost_mode_submenu_page.dart b/lib/screens/ghost_mode_submenu_page.dart deleted file mode 100644 index 98c83ec..0000000 --- a/lib/screens/ghost_mode_submenu_page.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/settings_service.dart'; - -/// Ghost Mode submenu โ€” tap "Ghost Mode" in Extras to open this. -/// Single mode: DM Ghost (comprehensive seen-signal blocking). -class GhostModeSubmenuPage extends StatelessWidget { - const GhostModeSubmenuPage({super.key}); - - @override - Widget build(BuildContext context) { - final s = context.watch(); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Ghost Mode', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // โ”€โ”€ DM Ghost โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _GhostCard( - icon: Icons.visibility_off_rounded, - title: 'DM Ghost', - subtitle: 'Read messages without the person knowing (works inside chat interface โ€” first entry only)', - value: s.ghostMode, - warning: - 'When DM Ghost is enabled, you can\'t send messages or react to any, you can just receive messages. You can turn ghost mode off anytime from topbar button.', - onChanged: (v) => s.setGhostMode(v), - isDark: isDark, - danger: true, - ), - const SizedBox(height: 24), - const SizedBox(height: 40), - ], - ), - ); - } -} - -class _GhostCard extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final bool value; - final String warning; - final ValueChanged onChanged; - final bool isDark; - final bool danger; - - const _GhostCard({ - required this.icon, - required this.title, - required this.subtitle, - required this.value, - required this.warning, - required this.onChanged, - required this.isDark, - this.danger = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey) - .withValues(alpha: value ? 0.08 : 0.03), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey) - .withValues(alpha: value ? 0.25 : 0.1), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - icon, - color: value - ? (danger ? Colors.redAccent : Colors.blueAccent) - : Colors.grey, - size: 22, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontWeight: FontWeight.w600, - color: value - ? (danger ? Colors.redAccent : null) - : Colors.grey, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - ], - ), - ), - Switch( - value: value, - activeThumbColor: danger ? Colors.redAccent : null, - onChanged: onChanged, - ), - ], - ), - if (value) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: (danger ? Colors.red : Colors.amber).withValues( - alpha: 0.1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - danger ? Icons.warning_amber_rounded : Icons.info_outline, - size: 14, - color: danger ? Colors.redAccent : Colors.amber, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - warning, - style: TextStyle( - fontSize: 11, - color: danger - ? Colors.redAccent - : Colors.amber.shade800, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/guardrails_page.dart b/lib/screens/guardrails_page.dart deleted file mode 100644 index de92f02..0000000 --- a/lib/screens/guardrails_page.dart +++ /dev/null @@ -1,458 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/session_manager.dart'; -import '../services/settings_service.dart'; -import '../services/level_service.dart'; -import 'adsterra_ad_screen.dart'; -import '../utils/discipline_challenge.dart'; - -class GuardrailsPage extends StatefulWidget { - const GuardrailsPage({super.key}); - - @override - State createState() => _GuardrailsPageState(); -} - -class _GuardrailsPageState extends State { - Future _handleScheduleAction( - BuildContext context, - SessionManager sm, - Future Function() action, - ) async { - if (sm.isScheduledBlockActive) { - final settings = context.read(); - final ok = await DisciplineChallenge.show( - context, - count: settings.resolvedWordChallengeCount(), - ); - if (!context.mounted || !ok) return; - } - await action(); - } - - Future _pickNewSchedule(BuildContext context, SessionManager sm) async { - final start = await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 22, minute: 0), - helpText: 'Select Start Time', - ); - if (!context.mounted || start == null) return; - - final end = await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - helpText: 'Select End Time', - ); - if (!context.mounted || end == null) return; - - await sm.addSchedule( - FocusSchedule( - startHour: start.hour, - startMinute: start.minute, - endHour: end.hour, - endMinute: end.minute, - ), - ); - } - - Future _editExistingSchedule( - BuildContext context, - SessionManager sm, - int index, - FocusSchedule s, - ) async { - final start = await showTimePicker( - context: context, - initialTime: TimeOfDay(hour: s.startHour, minute: s.startMinute), - helpText: 'Edit Start Time', - ); - if (!context.mounted || start == null) return; - - final end = await showTimePicker( - context: context, - initialTime: TimeOfDay(hour: s.endHour, minute: s.endMinute), - helpText: 'Edit End Time', - ); - if (!context.mounted || end == null) return; - - await sm.updateScheduleAt( - index, - FocusSchedule( - startHour: start.hour, - startMinute: start.minute, - endHour: end.hour, - endMinute: end.minute, - ), - ); - } - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Guardrails', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Set your limits to stay focused. Changes to these settings require a challenge.', - style: TextStyle( - color: isDark ? Colors.white54 : Colors.black54, - fontSize: 13, - ), - ), - ), - // If quota used up, show earn page instead of slider - if (sm.dailyRemainingSeconds <= 0) - _buildQuotaExhaustedTile(context, sm) - else - _buildFrictionSliderTile( - context: context, - sm: sm, - title: 'Daily Reel Limit', - subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day', - value: (sm.dailyLimitSeconds ~/ 60).toDouble(), - min: 5, - max: 120, - divisor: 5, - isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60), - warningText: - 'Increasing your limit makes it easier to scroll. Are you sure?', - onConfirmed: (v) async { - // XP penalty for increasing limit - final increase = (v.toInt() - (sm.dailyLimitSeconds ~/ 60)); - if (increase > 0) { - // context.read().grantDebugXp( - // -increase * 5, 'Penalty: increased reel limit', - // ); - } - await sm.setDailyLimitMinutes(v.toInt()); - }, - ), - _buildFrictionSliderTile( - context: context, - sm: sm, - title: 'Session Cooldown', - subtitle: '${sm.cooldownSeconds ~/ 60} min between sessions', - value: (sm.cooldownSeconds ~/ 60).toDouble(), - min: 5, - max: 180, - divisor: 5, - isMorePermissive: (v) => v < (sm.cooldownSeconds ~/ 60), - warningText: - 'Reducing cooldown makes it easier to start new sessions. Are you sure?', - onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()), - ), - Divider(color: isDark ? Colors.white10 : Colors.black12, height: 32), - SwitchListTile( - title: const Text('Scheduled Blocking'), - subtitle: Text( - 'Block Instagram during specific hours', - style: TextStyle( - color: isDark ? Colors.white54 : Colors.black54, - fontSize: 13, - ), - ), - value: sm.scheduleEnabled, - onChanged: (v) => sm.setScheduleEnabled(v), - ), - if (sm.scheduleEnabled) ...[ - ...sm.schedules.asMap().entries.map((entry) { - final idx = entry.key; - final s = entry.value; - return ListTile( - title: Text( - 'Schedule ${idx + 1}', - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - '${sm.formatTime12h(s.startHour, s.startMinute)} - ${sm.formatTime12h(s.endHour, s.endMinute)}', - style: TextStyle( - color: isDark ? Colors.white54 : Colors.black54, - fontSize: 13, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon( - Icons.edit, - color: Colors.blue, - size: 20, - ), - onPressed: () => _handleScheduleAction( - context, - sm, - () => _editExistingSchedule(context, sm, idx, s), - ), - ), - IconButton( - icon: const Icon( - Icons.delete_outline, - color: Colors.redAccent, - size: 20, - ), - onPressed: () => _handleScheduleAction( - context, - sm, - () => sm.removeScheduleAt(idx), - ), - ), - ], - ), - ); - }), - ListTile( - leading: const Icon( - Icons.add_circle_outline, - color: Colors.blueAccent, - ), - title: const Text( - 'Add Focus Hours', - style: TextStyle( - color: Colors.blueAccent, - fontWeight: FontWeight.w600, - ), - ), - onTap: () => _handleScheduleAction( - context, - sm, - () => _pickNewSchedule(context, sm), - ), - ), - ], - ], - ), - ); - } - - Widget _buildQuotaExhaustedTile(BuildContext context, SessionManager sm) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.orange.withValues(alpha: 0.2)), - ), - child: Column( - children: [ - const Icon( - Icons.hourglass_empty, - color: Colors.orangeAccent, - size: 36, - ), - const SizedBox(height: 8), - const Text( - 'Daily Reel Quota Used Up', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 4), - const Text( - 'Watch an ad to earn 3 more minutes of reel time.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => _earnQuota(context, sm), - icon: const Icon(Icons.play_circle_fill_rounded, size: 20), - label: const Text('Watch Ad (+3 min reels)'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - ), - ); - } - - Future _earnQuota(BuildContext context, SessionManager sm) async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const AdsterraAdScreen(sessionType: 'reels'), - ), - ); - if (result == true && context.mounted) { - sm.increaseDailyLimit(3); - context.read().addXpForAd(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('+3 min reel quota earned!')), - ); - } - } - - Widget _buildFrictionSliderTile({ - required BuildContext context, - required SessionManager sm, - required String title, - required String subtitle, - required double value, - required double min, - required double max, - required int divisor, - required bool Function(double) isMorePermissive, - required String warningText, - required Future Function(double) onConfirmed, - }) { - return _FrictionSliderTile( - title: title, - subtitle: subtitle, - value: value, - min: min, - max: max, - divisor: divisor, - isMorePermissive: isMorePermissive, - warningText: warningText, - onConfirmed: onConfirmed, - ); - } -} - -class _FrictionSliderTile extends StatefulWidget { - final String title; - final String subtitle; - final double value; - final double min; - final double max; - final int divisor; - final bool Function(double) isMorePermissive; - final String warningText; - final Future Function(double) onConfirmed; - - const _FrictionSliderTile({ - required this.title, - required this.subtitle, - required this.value, - required this.min, - required this.max, - required this.divisor, - required this.isMorePermissive, - required this.warningText, - required this.onConfirmed, - }); - - @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); - if (!_pendingConfirm) _draftValue = widget.value; - } - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - final divisions = ((widget.max - widget.min) / widget.divisor).round(); - - return Column( - children: [ - ListTile( - title: Text(widget.title), - subtitle: Text( - '${_draftValue.toInt()} min', - style: TextStyle(color: isDark ? Colors.white70 : Colors.black54), - ), - trailing: _pendingConfirm - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - setState(() { - _draftValue = widget.value; - _pendingConfirm = false; - }); - }, - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () async { - final sm = context.read(); - final settings = context.read(); - int wordCount = settings.resolvedWordChallengeCount(); - // If we are at 0 quota, increase difficulty to 35 words - if (widget.title.contains('Daily Reel Limit') && - sm.dailyRemainingSeconds <= 0) { - wordCount = 35; - } - final success = await DisciplineChallenge.show( - context, - count: wordCount, - ); - if (!context.mounted || !success) return; - await widget.onConfirmed(_draftValue); - setState(() => _pendingConfirm = false); - }, - child: const Text('Apply'), - ), - ], - ) - : null, - ), - if (_pendingConfirm) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - widget.warningText, - style: const TextStyle(color: Colors.orangeAccent, fontSize: 12), - ), - ), - Slider( - value: _draftValue, - min: widget.min, - max: widget.max, - divisions: divisions, - onChanged: (v) { - setState(() { - _draftValue = v; - _pendingConfirm = widget.isMorePermissive(v); - }); - }, - onChangeEnd: (v) { - if (!widget.isMorePermissive(v)) { - widget.onConfirmed(v); - setState(() => _pendingConfirm = false); - } - }, - ), - ], - ); - } -} diff --git a/lib/screens/level_panel_screen.dart b/lib/screens/level_panel_screen.dart deleted file mode 100644 index fb1337b..0000000 --- a/lib/screens/level_panel_screen.dart +++ /dev/null @@ -1,537 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../services/level_service.dart'; -import '../services/settings_service.dart'; -import '../services/credit_store.dart'; -import 'adsterra_ad_screen.dart'; - -/// Displays current level, XP progress, and locked/preview features. -class LevelPanelScreen extends StatelessWidget { - const LevelPanelScreen({super.key}); - - @override - Widget build(BuildContext context) { - final levelService = context.watch(); - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Your Journey', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // โ”€โ”€ Level Header Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: _levelColors(levelService.level, isDark), - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: _levelColors( - levelService.level, - isDark, - )[0].withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - // Level badge - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.2), - border: Border.all( - color: Colors.white.withValues(alpha: 0.4), - width: 3, - ), - ), - child: Center( - child: Text( - '${levelService.level}', - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(height: 16), - Text( - _levelTitle(levelService.level), - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - // XP progress bar - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: levelService.levelProgress, - minHeight: 8, - backgroundColor: Colors.white.withValues(alpha: 0.2), - valueColor: const AlwaysStoppedAnimation( - Colors.white, - ), - ), - ), - const SizedBox(height: 8), - Text( - '${levelService.xp} / ${levelService.xpForNextLevel} XP', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 14, - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // โ”€โ”€ Next Unlock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (levelService.nextLockedFeature != null) ...[ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black).withValues( - alpha: 0.05, - ), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: (isDark ? Colors.white : Colors.black).withValues( - alpha: 0.1, - ), - ), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.lock_outline, - color: Colors.amber, - size: 22, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Next at Level ${levelService.nextLockedFeature!.requiredLevel}', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 2), - Text( - 'Unlock ${levelService.nextLockedFeature!.name}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 24), - ], - - // โ”€โ”€ Feature Unlock Table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const Text( - 'FEATURES', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - ...AppFeature.all.map((feature) { - final unlocked = levelService.isFeatureUnlocked(feature); - return Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black).withValues( - alpha: unlocked ? 0.04 : 0.02, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: unlocked - ? Colors.greenAccent.withValues(alpha: 0.2) - : (isDark ? Colors.white : Colors.black).withValues( - alpha: 0.08, - ), - ), - ), - child: Row( - children: [ - Icon( - unlocked ? Icons.check_circle : Icons.lock_outline, - color: unlocked ? Colors.greenAccent : Colors.grey, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - feature.name, - style: TextStyle( - fontSize: 14, - fontWeight: unlocked - ? FontWeight.w600 - : FontWeight.normal, - color: unlocked ? null : Colors.grey, - ), - ), - ), - Text( - unlocked ? 'Unlocked' : 'Level ${feature.requiredLevel}', - style: TextStyle( - fontSize: 12, - color: unlocked ? Colors.greenAccent : Colors.grey, - ), - ), - ], - ), - ); - }), - - const SizedBox(height: 24), - - // โ”€โ”€ XP Rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const Text( - 'HOW TO EARN XP', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - _XpRuleTile( - icon: Icons.play_circle_outline, - label: 'Watch a rewarded ad', - value: '+2 XP (up to 20/day)', - isDark: isDark, - ), - _XpRuleTile( - icon: Icons.trending_down, - label: 'Watch fewer reels than your weekly average', - value: '+10 XP per reel saved', - isDark: isDark, - ), - _XpRuleTile( - icon: Icons.check_circle_outline, - label: 'Stay under your daily reel limit', - value: '+15 XP per day', - isDark: isDark, - ), - _XpRuleTile( - icon: Icons.login, - label: 'Open the app and check in', - value: '+1 XP per day', - isDark: isDark, - ), - const SizedBox(height: 16), - - // โ”€โ”€ Watch Ad to earn XP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton.icon( - onPressed: () => _watchAdForXp(context), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.play_circle_fill_rounded, size: 20), - label: const Text( - 'Watch Ad to Earn +2 XP', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ), - ), - const SizedBox(height: 16), - - // โ”€โ”€ XP History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const Text( - 'RECENT XP', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - ...levelService.recentXpLog.take(10).map((entry) { - final dt = DateTime.tryParse(entry['time'] as String? ?? ''); - final timeStr = dt != null - ? '${dt.hour}:${dt.minute.toString().padLeft(2, '0')}' - : ''; - final amount = entry['amount'] as int; - return Container( - margin: const EdgeInsets.only(bottom: 4), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black).withValues( - alpha: 0.04, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - amount > 0 ? Icons.add_circle : Icons.remove_circle, - color: amount > 0 ? Colors.greenAccent : Colors.redAccent, - size: 16, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - entry['reason'] as String? ?? '', - style: const TextStyle(fontSize: 13), - ), - ), - Text( - amount > 0 ? '+$amount XP' : '$amount XP', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: amount > 0 ? Colors.greenAccent : Colors.redAccent, - ), - ), - const SizedBox(width: 8), - Text( - timeStr, - style: const TextStyle(fontSize: 10, color: Colors.grey), - ), - ], - ), - ); - }), - if (levelService.recentXpLog.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text( - 'No XP earned yet โ€” watch an ad above or reduce reel time!', - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white38 : Colors.black38, - ), - ), - ), - const SizedBox(height: 20), - - const Text( - 'DEGRADATION', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Colors.grey, - ), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.redAccent.withValues(alpha: 0.15), - ), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.warning_amber_rounded, - color: Colors.redAccent, - size: 18, - ), - SizedBox(width: 8), - Text( - 'XP decays if you backslide', - style: TextStyle( - color: Colors.redAccent, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - SizedBox(height: 6), - Text( - 'โ€ข Watching more reels than your weekly average deducts XP\n' - 'โ€ข Exceeding limits for 3 consecutive days drops a level\n' - 'โ€ข Levels are preserved on monthly reset, but XP resets', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - height: 1.5, - ), - ), - ], - ), - ), - const SizedBox(height: 40), - ], - ), - ); - } - - Color _levelColor(int level) { - switch (level) { - case 1: - return Colors.grey; - case 2: - return Colors.blue; - case 3: - return Colors.purple; - case 4: - return Colors.orange; - case 5: - return Colors.amber; - default: - return Colors.grey; - } - } - - List _levelColors(int level, bool isDark) { - final base = _levelColor(level); - // MaterialColor supports .shadeXXX; plain Color doesn't. - if (base is MaterialColor) { - return isDark - ? [base.shade800, base.shade900] - : [base.shade400, base.shade700]; - } - return [base, base]; - } - - /// Navigate to Adsterra ad -> grant XP on completion. - Future _watchAdForXp(BuildContext context) async { - // Try Adsterra Social Bar first - final adResult = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const AdsterraAdScreen(sessionType: 'reels'), - ), - ); - - if (adResult == true && context.mounted) { - context.read().addXpForAd(); - context.read().addReelsMinutes(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('+10 XP earned!'), - duration: Duration(seconds: 2), - ), - ); - } - } - - String _levelTitle(int level) { - switch (level) { - case 1: - return 'Beginner'; - case 2: - return 'Mindful Scroller'; - case 3: - return 'Disciplined'; - case 4: - return 'Focus Master'; - case 5: - return 'Digital Monk'; - default: - return 'Level $level'; - } - } -} - -class _XpRuleTile extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final bool isDark; - - const _XpRuleTile({ - required this.icon, - required this.label, - required this.value, - required this.isDark, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Icon(icon, size: 18, color: Colors.greenAccent), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white70 : Colors.black87, - ), - ), - ), - Text( - value, - style: const TextStyle( - fontSize: 12, - color: Colors.greenAccent, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart deleted file mode 100644 index 5dab374..0000000 --- a/lib/screens/main_webview_page.dart +++ /dev/null @@ -1,3144 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.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/native_feel.dart'; -import '../scripts/grayscale.dart' as grayscale; -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 '../services/bait_engine.dart'; -import '../services/credit_store.dart'; -import '../services/level_service.dart'; -import 'bait_me_full_screen.dart'; -import '../services/app_lock_service.dart'; -// snapshot_service import removed โ€” offline feature deleted -// reels_history_service import removed โ€” feature deleted -import 'app_lock_screen.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'; -import '../v2_integration/script_engine_v2_overlay.dart'; -import '../v2_integration/script_registry_v2_overlay.dart'; -import '../scripts/focus_scripts.dart'; -import 'adsterra_ad_screen.dart'; -import '../focus_settings.dart'; - -import '../services/adblock/adblock_content_blocker_loader.dart'; - -/// Core validator/dispatcher for the JS โ†’ Flutter bridge: -/// `window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON)` -Future handleFocusGramMediaDownload({ - required String raw, - required Future Function(Uri uri) launch, -}) async { - try { - final payload = jsonDecode(raw) as Map; - - final url = payload['url'] as String?; - if (url == null || url.isEmpty) return false; - - final uri = Uri.tryParse(url); - if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) { - return false; - } - - // Best-effort origin allow-list (Instagram/CDN). Kept permissive to avoid - // breaking legitimate downloads while still blocking obvious abuse. - final host = uri.host.toLowerCase(); - final looksInstagramCdn = - host.contains('cdninstagram.com') || - host.contains('fbcdn.net') || - host.contains('instagram.com'); - - if (!looksInstagramCdn) return false; - - await launch(uri); - return true; - } catch (_) { - // Best-effort only; never crash UI. - return false; - } -} - -class MainWebViewPage extends StatefulWidget { - const MainWebViewPage({super.key}); - - @override - State createState() => _MainWebViewPageState(); -} - -class _MainWebViewPageState extends State - with WidgetsBindingObserver { - static const String _donationPopupShownKey = 'donation_popup_shown_once'; - static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal'); - - InAppWebViewController? _controller; - - AdblockContentBlockerData? _adblockData; - late final PullToRefreshController _pullToRefreshController; - InjectionManager? _injectionManager; - ScriptEngineV2Overlay? _v2Engine; - 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 _exploreBlockedOverlay = false; - bool _isPreloaded = false; - bool _minimalModeBannerDismissed = false; - bool _isInDirectThread = false; - DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0); - SkeletonType _skeletonType = SkeletonType.generic; - - /// True when on the homepage and should block api/graphql + gateway. - /// Updated in onLoadStart / UrlChange before shouldInterceptRequest fires. - bool _blockHomepageGraphql = false; - - /// Helper to determine if we are on a login/onboarding page. - bool get _isOnOnboardingPage { - final path = Uri.tryParse(_currentUrl)?.path ?? ''; - final lowerPath = path.toLowerCase(); - return lowerPath.contains('/accounts/login') || - lowerPath.contains('/accounts/emailsignup') || - lowerPath.contains('/accounts/signup') || - lowerPath.contains('/legal/') || - lowerPath.contains('/help/') || - _currentUrl.contains('instagram.com/accounts/login'); - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _initPullToRefresh(); - _initWebView(); - _startWatchdog(); - - // Check for updates on launch - context.read().checkForUpdates(); - - // Load adblock data early. If adblock is enabled, we wait for initial data - // to be loaded so the WebView can apply contentBlockers on first render. - // This prevents ads from loading before filters are applied. - unawaited(_loadAdblockerDataEarly()); - - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().addListener(_onSessionChanged); - context.read().addListener(_onSettingsChanged); - context.read().addListener(_onScreenTimeChanged); - context.read().startTracking(); - _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; - _lastBlockHomeFeedScroll = settings.blockHomeFeedScroll; - _lastBlockAutoplay = settings.blockAutoplay; - _lastGhostMode = settings.ghostMode; - _lastNoAds = settings.noAds; - _lastNoStories = settings.noStories; - _lastNoReels = settings.noReels; - _lastNoAutoplay = settings.noAutoplay; - _lastNoDMs = settings.noDMs; - _lastV2GhostModeEnabled = settings.ghostMode; - _lastV2AdBlockerDomEnabled = settings.v2AdBlockerDomEnabled; - _lastV2ContentHiderEnabled = settings.v2ContentHiderEnabled; - _lastV2FetchInterceptorEnabled = _shouldEnableFetchInterceptor(settings); - _lastV2AutoplayBlockerEnabled = settings.blockAutoplay; - _lastAdblockToggleValue = settings.v2AdBlockerDomEnabled; - _onScreenTimeChanged(); - - // Load full adblock data with longer timeout after UI is initialized - unawaited(_loadAdblockerData()); - }); - - FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged); - } - - void _onPendingUrlChanged() { - final url = FocusGramRouter.pendingUrl.value; - if (url != null && url.isNotEmpty) { - FocusGramRouter.pendingUrl.value = null; - _controller?.loadUrl(urlRequest: URLRequest(url: WebUri(url))); - } - } - - bool _shouldEnableFetchInterceptor(SettingsService settings) { - return settings.ghostMode || - settings.noAds || - settings.v2AdBlockerDomEnabled || - settings.noReels || - settings.hideSuggestedPosts || - (settings.v2ContentHiderEnabled && - (settings.contentPosts || - settings.contentReels || - settings.contentSuggested)); - } - - Future _onScreenTimeChanged() async { - if (!mounted) return; - if (context.read().totalSeconds < 300) return; - - final prefs = await SharedPreferences.getInstance(); - if (prefs.getBool(_donationPopupShownKey) ?? false) return; - await prefs.setBool(_donationPopupShownKey, true); - if (!mounted) return; - - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Support FocusGram'), - content: const Text( - 'Please donate to support the development of this project.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Not now'), - ), - FilledButton( - onPressed: () { - Navigator.pop(context); - launchUrl(_donateUri, mode: LaunchMode.externalApplication); - }, - child: const Text('Donate'), - ), - ], - ), - ); - } - - /// 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?.evaluateJavascript( - source: 'window.__focusgramIsolatedPlayer = $active;', - ); - } - - /// Show a full-screen lock gate when navigating to Instagram DMs. - void _showDmLockGate() { - Navigator.push( - context, - MaterialPageRoute( - builder: (ctx) => Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.lock_outline, - color: Colors.white54, - size: 64, - ), - const SizedBox(height: 24), - const Text( - 'Messages Locked', - style: TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - const Text( - 'Enter your PIN to access Direct Messages', - style: TextStyle(color: Colors.white54, fontSize: 14), - ), - const SizedBox(height: 40), - ElevatedButton.icon( - onPressed: () async { - final result = await Navigator.push( - ctx, - MaterialPageRoute( - builder: (_) => const AppLockScreen( - forAppWide: false, - title: 'Messages Locked', - subtitle: - 'Enter your PIN to access Direct Messages', - ), - ), - ); - if (!ctx.mounted) return; - if (result == true) { - _dmLockOverride = true; - Navigator.pop(ctx); - } else { - _controller?.evaluateJavascript( - source: 'window.location.href = "/";', - ); - Navigator.pop(ctx); - } - }, - icon: const Icon(Icons.lock_open_rounded), - label: const Text('Unlock'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 14, - ), - ), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () { - _controller?.evaluateJavascript( - source: 'window.location.href = "/";', - ); - Navigator.pop(ctx); - }, - child: const Text( - 'Cancel โ€” Go to Home', - style: TextStyle(color: Colors.white38), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } - - /// Set ghost mode flags in the WebView so the pre-injected scripts activate. - void _setGhostModeFlags(InAppWebViewController c, SettingsService s) { - c.evaluateJavascript( - source: - ''' -window.__fgFullDmGhost = ${s.ghostMode}; -''', - ); - } - - /// Re-inject grayscale on app resume (fixes cold-start persistence bug - /// where the preloader cache can bypass onLoadStop). - void _syncGrayscaleOnResume(SettingsService settings) { - if (_injectionManager == null || _controller == null) return; - if (settings.isGrayscaleActiveNow) { - _injectionManager!.runAllPostLoadInjections(_currentUrl); - } else { - // Explicitly remove grayscale - _controller?.evaluateJavascript(source: grayscale.kGrayscaleOffJS); - } - } - - void _onSessionChanged() { - if (!mounted) return; - final sm = context.read(); - if (_lastSessionActive != sm.isSessionActive) { - _lastSessionActive = sm.isSessionActive; - - 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) { - setState(() => _reelsBlockedOverlay = false); - } - } - 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 _lastBlockHomeFeedScroll = false; - bool _lastBlockAutoplay = false; - bool _lastGhostMode = false; - bool _lastNoAds = false; - bool _lastNoStories = false; - bool _lastNoReels = false; - bool _lastNoAutoplay = false; - bool _lastNoDMs = false; - bool _lastV2GhostModeEnabled = false; - bool _lastV2AdBlockerDomEnabled = false; - bool _lastV2ContentHiderEnabled = false; - bool _lastV2FetchInterceptorEnabled = false; - bool _lastV2AutoplayBlockerEnabled = false; - - // Tracks v2 adblock toggle to know when to reload WebView for ContentBlocker changes - bool _lastAdblockToggleValue = false; - - void _onSettingsChanged() { - if (!mounted) return; - final settings = context.read(); - - // If adblock toggle flipped, rebuild WebView so `contentBlockers` applies. - // IMPORTANT: do NOT early-return, otherwise we skip the v2 overlay prefs sync - // (which is what enables the DOM fallback script + other v2 toggles). - if (_lastAdblockToggleValue != settings.v2AdBlockerDomEnabled) { - _lastAdblockToggleValue = settings.v2AdBlockerDomEnabled; - _adblockData = null; - _loadAdblockerData(); - _controller?.reload(); - } - - // 0. V2 overlay sync (prefs must be updated before toggling) - unawaited(() async { - final prefs = await SharedPreferences.getInstance(); - if (_v2Engine != null) { - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.ghostMode.name}_enabled', - settings.ghostMode, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.adBlockerDom.name}_enabled', - settings.v2AdBlockerDomEnabled, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.contentHider.name}_enabled', - settings.v2ContentHiderEnabled, - ); - - final bool fetchInterceptorEnabled = _shouldEnableFetchInterceptor( - settings, - ); - final bool autoplayBlockerEnabled = settings.blockAutoplay; - - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.fetchInterceptor.name}_enabled', - fetchInterceptorEnabled, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.autoplayBlocker.name}_enabled', - autoplayBlockerEnabled, - ); - - await prefs.setBool('content_stories', settings.contentStories); - await prefs.setBool('content_posts', settings.contentPosts); - await prefs.setBool('content_reels', settings.contentReels); - await prefs.setBool('content_suggested', settings.contentSuggested); - - final shouldReloadV2 = - _lastV2GhostModeEnabled != settings.ghostMode || - _lastV2AdBlockerDomEnabled != settings.v2AdBlockerDomEnabled || - _lastV2ContentHiderEnabled != settings.v2ContentHiderEnabled || - _lastV2FetchInterceptorEnabled != fetchInterceptorEnabled || - _lastV2AutoplayBlockerEnabled != autoplayBlockerEnabled; - - _lastV2GhostModeEnabled = settings.ghostMode; - _lastV2AdBlockerDomEnabled = settings.v2AdBlockerDomEnabled; - _lastV2ContentHiderEnabled = settings.v2ContentHiderEnabled; - _lastV2FetchInterceptorEnabled = fetchInterceptorEnabled; - _lastV2AutoplayBlockerEnabled = autoplayBlockerEnabled; - - if (shouldReloadV2) { - _reloadDebounce?.cancel(); - _reloadDebounce = Timer(const Duration(milliseconds: 600), () { - if (mounted) _controller?.reload(); - }); - } else { - await _v2Engine?.injectDocumentEndScripts(); - } - } - }()); - - // 1. Apply all cosmetic changes immediately via injection - if (_controller != null) { - _controller!.evaluateJavascript( - source: - 'window.__fgSetBlockAutoplay?.(${settings.blockAutoplay}); window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur};', - ); - } - if (_controller != null && _injectionManager != null) { - _injectionManager!.runAllPostLoadInjections(_currentUrl); - } - - // Ghost mode flags update + reload (scripts already injected by preloader, - // but need to reload so the fetch/XHR interceptors see the new flags from - // the start of page load). - if (_lastGhostMode != settings.ghostMode) { - _lastGhostMode = settings.ghostMode; - if (_controller != null) { - _setGhostModeFlags(_controller!, settings); - // Schedule a reload so the flags take effect on fresh page load - _reloadDebounce?.cancel(); - _reloadDebounce = Timer(const Duration(milliseconds: 300), () { - if (mounted) _controller?.reload(); - }); - } - } - - // 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.blockHomeFeedScroll != _lastBlockHomeFeedScroll || - settings.blockAutoplay != _lastBlockAutoplay || - settings.ghostMode != _lastGhostMode || - settings.noAds != _lastNoAds || - settings.noStories != _lastNoStories || - settings.noReels != _lastNoReels || - settings.noAutoplay != _lastNoAutoplay || - settings.noDMs != _lastNoDMs; - - _lastMinimalMode = settings.minimalModeEnabled; - _lastDisableReels = settings.disableReelsEntirely; - _lastDisableExplore = settings.disableExploreEntirely; - _lastBlockHomeFeedScroll = settings.blockHomeFeedScroll; - _lastBlockAutoplay = settings.blockAutoplay; - _lastGhostMode = settings.ghostMode; - _lastNoAds = settings.noAds; - _lastNoStories = settings.noStories; - _lastNoReels = settings.noReels; - _lastNoAutoplay = settings.noAutoplay; - _lastNoDMs = settings.noDMs; - - 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); - context.read().removeListener(_onScreenTimeChanged); - context.read().stopTracking(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (!mounted) return; - final sm = context.read(); - final screenTime = context.read(); - final settings = context.read(); - - if (state == AppLifecycleState.resumed) { - sm.setAppForeground(true); - screenTime.startTracking(); - // Cancel persistent notification when app comes to foreground - NotificationService().cancelPersistentNotification(id: 5001); - - // Re-inject grayscale on resume โ€” schedules may have changed - // while the app was backgrounded, and injection can be lost on cold - // start due to the preloader cache bypassing onLoadStop. - _syncGrayscaleOnResume(settings); - } else if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive || - state == AppLifecycleState.detached) { - sm.setAppForeground(false); - screenTime.stopTracking(); - - // Show persistent notification when schedules are active (if enabled) - if (settings.notifyPersistent) { - final isScheduleActive = sm.isScheduledBlockActive; - final isGrayscaleActive = settings.isGrayscaleActiveNow; - - if (isScheduleActive) { - NotificationService().showPersistentNotification( - id: 5001, - title: 'FocusGram - Schedule Active', - body: 'Instagram is blocked during your focus hours', - ); - } else if (isGrayscaleActive) { - NotificationService().showPersistentNotification( - id: 5001, - title: 'FocusGram - Grayscale Active', - body: 'Instagram is in grayscale mode', - ); - } - } - } - } - - void _startWatchdog() { - _watchdog = Timer.periodic(const Duration(seconds: 15), (_) { - if (!mounted) return; - final sm = context.read(); - if (sm.isAppSessionExpired && !_extensionDialogShown) { - _extensionDialogShown = true; - _showSessionExpiredDialog(sm); - } - }); - } - - // 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) { - // Helper function to handle "Close App" action - void closeApp() { - Navigator.of(context, rootNavigator: true).pop(); - sm.endAppSession(); - SystemNavigator.pop(); - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - // Intercept back button - treat it as "Close App" action - if (!didPop) { - closeApp(); - } - }, - child: AlertDialog( - backgroundColor: const Color(0xFF1A1A1A), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - title: const Text( - 'Session Complete โœ“', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Your planned Instagram time is up.', - style: TextStyle(color: Colors.white70), - ), - if (sm.canExtendAppSession) ...[ - const SizedBox(height: 8), - const Text( - 'You can extend once by 10 minutes.', - style: TextStyle(color: Colors.white54, fontSize: 13), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: closeApp, - child: const Text( - 'Close App', - style: TextStyle(color: Colors.redAccent), - ), - ), - if (sm.canExtendAppSession) - ElevatedButton( - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - // Keep _extensionDialogShown = true while ad runs so the - // watchdog timer doesn't re-show the dialog over the ad screen. - if (!mounted) return; - final adResult = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const AdsterraAdScreen( - sessionType: 'reels', - requiredSeconds: 20, - ), - ), - ); - _extensionDialogShown = false; - if (adResult == true && mounted) { - sm.extendAppSession(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: const Text('Watch Ad (+10 min)'), - ), - ], - ), - ), - ); - } - - void _initPullToRefresh() { - _pullToRefreshController = PullToRefreshController( - settings: PullToRefreshSettings(color: Colors.blue), - onRefresh: () async { - await _controller?.reload(); - }, - ); - } - - Future _loadAdblockerData() async { - final settings = context.read(); - final prefs = await SharedPreferences.getInstance(); - final previousHosts = _adblockData?.blockedHosts; - - final loader = AdblockContentBlockerLoader(); - final data = await loader.loadOrUpdateIfNeeded( - enabled: settings.v2AdBlockerDomEnabled, - prefs: prefs, - ); - - if (mounted) { - setState(() => _adblockData = data); - if (settings.v2AdBlockerDomEnabled && - data.blockedHosts.isNotEmpty && - _controller != null && - (previousHosts == null || - !setEquals(previousHosts, data.blockedHosts))) { - unawaited(_controller?.reload()); - } - } - return data; - } - - Future _loadAdblockerDataEarly() async { - final settings = context.read(); - if (!settings.v2AdBlockerDomEnabled) return; - - try { - final prefs = await SharedPreferences.getInstance(); - final loader = AdblockContentBlockerLoader(); - final data = await loader.loadOrUpdateIfNeeded( - enabled: true, - prefs: prefs, - timeoutMs: 5000, // Short timeout for early load - ); - - if (mounted) { - setState(() => _adblockData = data); - } - } catch (_) { - // If loading fails, continue without blocking app startup - // AdblockData will be retried in _loadAdblockerData() - } - } - - bool _isBlockedByAdblockHostList(WebUri uri, Set? blockedHosts) { - if (blockedHosts == null || blockedHosts.isEmpty) return false; - - var host = uri.host.toLowerCase(); - if (blockedHosts.contains(host)) return true; - - while (true) { - final dot = host.indexOf('.'); - if (dot < 0 || dot == host.length - 1) return false; - host = host.substring(dot + 1); - if (blockedHosts.contains(host)) return true; - } - } - - 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; - - 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 cookieManager = CookieManager.instance(); - await cookieManager.deleteAllCookies(); - await InAppWebViewController.clearAllCache(); - if (mounted) { - setState(() { - _showSkeleton = true; - _isLoading = true; - _reelsBlockedOverlay = false; - }); - await _controller?.loadUrl( - urlRequest: URLRequest( - url: WebUri('https://www.instagram.com/accounts/login/'), - ), - ); - } - } - - /// Formats [seconds] as `MM:SS` for the cooldown countdown display. - static String _fmtSeconds(int seconds) { - final m = (seconds ~/ 60).toString().padLeft(2, '0'); - final s = (seconds % 60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - static bool _isHomeFeedUrl(String url) { - final uri = Uri.tryParse(url); - if (uri == null) return url == '/' || url.isEmpty; - final path = uri.path.isEmpty ? '/' : uri.path; - return uri.host.contains('instagram.com') && path == '/'; - } - - static bool _isDirectThreadUrl(String url) { - final path = Uri.tryParse(url)?.path ?? url; - // Match both /direct/inbox/ and /direct/t/{thread_id} - return RegExp(r'^/direct/').hasMatch(path); - } - - /* unused after CDN block was removed - static bool _isFktmInstagramCdn(String url) { - final host = Uri.tryParse(url)?.host.toLowerCase() ?? ''; - return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host); - } - */ - - void _syncDirectThreadState(String url) { - final active = _isDirectThreadUrl(url); - if (_isInDirectThread == active) return; - _isInDirectThread = active; - - // Reset override when leaving DMs - if (!active) _dmLockOverride = false; - - // If Messages Tab Lock is enabled and user navigated to DMs, - // show a lock overlay. - if (active && mounted) { - final appLock = context.read(); - if (appLock.messagesLockReady && !_dmLockOverride) { - _showDmLockGate(); - } - } - } - - bool _dmLockOverride = false; - - Future _showReelSessionPicker() async { - final settings = context.read(); - if (settings.requireWordChallenge) { - final passed = await DisciplineChallenge.show( - context, - count: settings.resolvedWordChallengeCount(), - ); - if (!passed || !mounted) return; - } - _showReelSessionPickerBottomSheet(); - } - - void _showReelSessionPickerBottomSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => Container( - height: MediaQuery.of(context).size.height * 0.7, - decoration: BoxDecoration( - color: const Color(0xFF121212), - borderRadius: BorderRadius.circular(25), - ), - child: Column( - children: [ - const SizedBox(height: 16), - const Text( - 'Start Reel Session', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'Reels will be unblocked for the duration you choose.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white54, fontSize: 13), - ), - ), - Expanded( - child: ListView( - padding: const EdgeInsets.symmetric(horizontal: 20), - children: [ - _buildReelSessionTile(1), - _buildReelSessionTile(3), - _buildReelSessionTile(5), - _buildReelSessionTile(10), - _buildReelSessionTile(15), - _buildReelSessionTile(20), - _buildReelSessionTile(30), - const SizedBox(height: 40), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text( - 'Cancel', - style: TextStyle(color: Colors.white38), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildReelSessionTile(int mins) { - final sm = context.read(); - return ListTile( - title: Text('$mins Minutes', style: const TextStyle(color: Colors.white)), - trailing: const Icon( - Icons.arrow_forward_ios, - color: Colors.white24, - size: 14, - ), - onTap: () async { - Navigator.pop(context); - if (sm.startSession(mins)) { - HapticFeedback.mediumImpact(); - setState(() => _reelsBlockedOverlay = false); - await _controller?.loadUrl( - urlRequest: URLRequest( - url: WebUri('https://www.instagram.com/reels/'), - ), - ); - } - }, - ); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - if (_reelsBlockedOverlay) { - setState(() => _reelsBlockedOverlay = false); - await _controller?.goBack(); - return; - } - if (_isHomeFeedUrl(_currentUrl)) { - SystemNavigator.pop(); - return; - } - 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() ?? Future.value(false))) { - await _controller?.goBack(); - } else { - SystemNavigator.pop(); - } - }, - child: Scaffold( - // 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: () => - _edgePanelKey.currentState?._toggleExpansion(), - onDmGhostToggle: () { - context.read().setGhostMode(false); - _controller?.reload(); - }, - onReload: () => _controller?.reload(), - currentUrl: _currentUrl, - dmGhostActive: context.read().ghostMode, - ), - Expanded( - child: Consumer( - builder: (ctx, sm, _) { - if (sm.isScheduledBlockActive) { - return Container( - color: Colors.black, - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.bedtime_rounded, - color: Colors.blueAccent, - size: 80, - ), - const SizedBox(height: 24), - Text( - 'Focus Hours Active', - style: GoogleFonts.grandHotel( - color: Colors.white, - fontSize: 42, - ), - ), - const SizedBox(height: 12), - Text( - 'Instagram is blocked according to your schedule (${sm.activeScheduleText ?? 'Focus Hours'}).', - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white70, - fontSize: 15, - height: 1.5, - ), - ), - const SizedBox(height: 48), - const Text( - 'Your future self will thank you for the extra sleep and focus.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white38, - fontSize: 12, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ); - } - - 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, - thirdPartyCookiesEnabled: false, - 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, - contentBlockers: - _adblockData?.contentBlockers ?? const [], - ), - initialUserScripts: UnmodifiableListView([ - ...const [], - ...buildUserScripts( - FocusSettings( - ghostMode: settings.ghostMode, - noAds: settings.noAds, - noStories: settings.noStories, - noReels: settings.noReels, - noAutoplay: settings.noAutoplay, - noDMs: settings.noDMs, - ), - ), - ]), - pullToRefreshController: _pullToRefreshController, - shouldInterceptRequest: (controller, request) async { - final url = request.url.toString(); - - const adDomains = [ - 'an.facebook.com', - 'connect.facebook.net', - 'pixel.facebook.com', - 'graph.facebook.com/logging', - 'www.instagram.com/ajax/bz', - 'www.instagram.com/api/v1/web/comet/logcalls', - 'doubleclick.net', - 'googletagmanager.com', - 'scorecardresearch.com', - ]; - if (adDomains.any(url.contains)) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - final referrer = - request.headers?['Referer'] ?? - request.headers?['referer']; - if (referrer != null && - _isDirectThreadUrl(referrer)) { - _syncDirectThreadState(referrer); - } - - /*if (_isInDirectThread && - _isFktmInstagramCdn(url)) { - if (_dmThreadCdnBlockArmed) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - _dmThreadCdnBlockArmed = true; - } -*/ - // Strict/high-priority domain blocking from uBlock-style lists. - final adblockHosts = _adblockData?.blockedHosts; - if (_isBlockedByAdblockHostList( - request.url, - adblockHosts, - )) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - // Block trackers + paid pixel iframes (hardcoded safety) - const blockedDomains = [ - 'fbsbx.com/paid_ads_pixel', - 'fbsbx.com/paid_ads', - 'facebook.com/tr', - 'instagram.com/paid_ads', - 'analytics.facebook.com', - 'facebook.com/tracking', - ]; - if (blockedDomains.any( - (d) => url.contains(d), - )) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - // Also block any IG paid-pixel iframe HTML documents - if (url.contains('/paid_ads_pixel/iframe/') || - url.contains('/generete_pixels/')) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - // Block Reels API - if (settings.noReels && - (url.contains('/api/v1/clips/') || - url.contains('/api/v1/discover/'))) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - // Block DMs API - if (settings.noDMs && - (url.contains('edge-chat.instagram.com') || - url.contains('/api/v1/direct_v2/'))) { - return WebResourceResponse( - data: Uint8List(0), - ); - } - - // โ”€โ”€ DM Ghost: block ALL seen signals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Like Chrome DevTools "Block request URL" โ€” catches all - // sources at the native WebView level. - // - // Rules: - // 1. Block specific seen endpoint patterns everywhere - // 2. Block /api/graphql on homepage (/) and DM threads - // (/direct/t/*). Allow on /direct/inbox/ so inbox loads. - if (settings.ghostMode) { - // โ€” Seen endpoint patterns (always block) โ€” - final seenBlocked = RegExp( - r'/api/v1/media/[\w-]+/seen/|' - r'/api/v1/stories/reel/seen/|' - r'/api/v1/direct_v2/threads/[\w-]+/seen/|' - r'/api/v1/direct_v2/visual_message/[\w-]+/seen/|' - r'/api/v1/live/[\w-]+/comment/seen/|' - r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/|' - r'/api/v1/direct_v2/mark_item_seen/|' - r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/|' - r'/api/v1/direct_v2/visual_thread/[^/]+/seen/|' - r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/|' - r'/api/v1/live/[^/]+/join/|' - r'/api/v1/live/[^/]+/get_join_requests/|' - r'/api/v1/media/seen/|' - r'/api/v1/feed/viewed_story/|' - r'/api/v1/feed/reels_tray/seen/|' - r'/api/v1/qe/|' - r'/api/v1/launcher/sync/|' - r'/api/v1/logging/|' - r'/api/v1/fb_onetap_logging/|' - r'/ajax/bz|' - r'/ajax/logging/|' - r'/api/v1/stats/|' - r'/api/v1/fbanalytics/', - ).hasMatch(url); - if (seenBlocked) { - return WebResourceResponse( - data: Uint8List.fromList( - utf8.encode('{"status":"ok"}'), - ), - statusCode: 200, - contentType: 'application/json', - ); - } - - // โ€” Block /api/graphql + gateway on homepage & - // ANY /direct/* page (not just /direct/t/). - // Allow on /direct/inbox/ so inbox loads. - // Broader scope catches seen indicators sent - // during SPA transitions on re-entry. - final currentPath = - Uri.tryParse(_currentUrl)?.path ?? - _currentUrl; - final isHomepage = - currentPath == '/' || currentPath == ''; - final isOnDirect = currentPath.startsWith( - '/direct/', - ); - if (!currentPath.startsWith( - '/direct/inbox/', - ) && - (isHomepage || isOnDirect) && - (url.contains('/api/graphql') || - url.contains( - 'gateway.instagram.com', - ))) { - return WebResourceResponse( - data: Uint8List.fromList( - utf8.encode('{"status":"ok"}'), - ), - statusCode: 200, - contentType: 'application/json', - ); - } - } - - // Legacy homepage graphql + gateway block - // (kept for safety โ€” the ghost mode block above now covers it) - if (_blockHomepageGraphql && - (url.contains('/api/graphql') || - url.contains( - 'gateway.instagram.com', - ))) { - return WebResourceResponse( - data: Uint8List.fromList( - utf8.encode('{"status":"ok"}'), - ), - statusCode: 200, - contentType: 'application/json', - ); - } - - /* Strip ads from feed (JS handles it) - if (settings.noAds && - url.contains( - 'instagram.com/graphql/query', - )) {/ - try { - final res = await http.post( - Uri.parse(url), - headers: Map.from( - request.headers ?? {}, - ), - ); - - final json = jsonDecode(res.body); - final connection = - json['data']?['xdt_api__v1__feed__timeline__connection']; - - if (connection != null && - connection['edges'] is List) { - final edges = connection['edges'] as List; - edges.removeWhere((e) { - final node = e['node']; - if (node == null) return false; - // Strip ads from feed - if (settings.noAds && - url.contains( - 'instagram.com/graphql', - )) { - try { - final res = await http.post( - Uri.parse(url), - headers: Map.from( - request.headers ?? {}, - ), - ); - - final json = jsonDecode(res.body); - - void filterEdges(dynamic obj) { - if (obj == null) return; - if (obj is Map) { - if (obj['edges'] is List) { - (obj['edges'] as List).removeWhere(( - e, - ) { - final node = e is Map - ? e['node'] - : null; - if (node == null || - node is! Map) - return false; - return node['is_ad'] == - true || - node['ad_id'] != null || - node['ad_action_links'] != - null || - node['is_paid_partnership'] == - true || - node['sponsor_tags'] != - null || - node['commerciality_status'] == - 'ad' || - node['commerciality_status'] == - 'shoppable_feed_ad' || - (node['__typename'] - ?.toString() - .toLowerCase() - .contains( - 'ad', - ) ?? - false); - }); - } - obj.values.forEach(filterEdges); - } else if (obj is List) { - obj.forEach(filterEdges); - } - } - - filterEdges(json); - - return WebResourceResponse( - data: Uint8List.fromList( - utf8.encode(jsonEncode(json)), - ), - headers: res.headers, - statusCode: 200, - contentType: 'application/json', - ); - } catch (_) { - return null; - } - } - }); - } - - return WebResourceResponse( - data: Uint8List.fromList( - utf8.encode(jsonEncode(json)), - ), - headers: res.headers, - statusCode: 200, - contentType: 'application/json', - ); - } catch (e) { - // if anything fails, pass through original request unmodified - return null; - } - }*/ - - return null; - }, - 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, - ); - - // Set ghost mode flags (scripts already injected by preloader) - _setGhostModeFlags(controller, settingsService); - - // Navigate to startup page if not Home - if (settingsService.startupPage != 'home') { - await controller.loadUrl( - urlRequest: URLRequest( - url: WebUri(settingsService.startupUrl), - ), - ); - } - - // Force-inject grayscale on initial WebView creation, - // because the preloader's keepAlive causes the main - // WebView to skip onLoadStop on cold start. - if (settingsService.isGrayscaleActiveNow) { - try { - await controller.evaluateJavascript( - source: grayscale.kGrayscaleJS, - ); - } catch (_) {} - } - - _registerJavaScriptHandlers(controller); - - // โ”€โ”€ FocusGram v2 overlay initial sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // ScriptEngineV2Overlay reads enabled state from prefs keys: - // fg_v2_{scriptName}_enabled - // Set them BEFORE DOCUMENT_START scripts are injected. - // V2 overlay toggles: - // - ghost_mode: user FocusGram "ghostMode" controls it - // - others: keep using existing v2 toggles - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.ghostMode.name}_enabled', - settingsService.ghostMode, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.adBlockerDom.name}_enabled', - settingsService.v2AdBlockerDomEnabled, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.contentHider.name}_enabled', - settingsService.v2ContentHiderEnabled, - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.fetchInterceptor.name}_enabled', - _shouldEnableFetchInterceptor( - settingsService, - ), - ); - await prefs.setBool( - 'fg_v2_${V2OverlayScriptId.autoplayBlocker.name}_enabled', - settingsService.blockAutoplay, - ); - - // Content hider flags consumed by v2/content_hider.js - await prefs.setBool( - 'content_stories', - settingsService.contentStories, - ); - await prefs.setBool( - 'content_posts', - settingsService.contentPosts, - ); - await prefs.setBool( - 'content_reels', - settingsService.contentReels, - ); - await prefs.setBool( - 'content_suggested', - settingsService.contentSuggested, - ); - - // Phase 1 V2 overlay engine (theme + best-effort ad DOM cleanup) - _v2Engine = ScriptEngineV2Overlay( - controller: controller, - prefs: prefs, - ); - await _v2Engine!.initDocumentStartScripts(); - - // 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() ?? ''; - _syncDirectThreadState(u); - // Update homepage graphql block flag SYNCHRONOUSLY - // (before setState, so shouldInterceptRequest sees it) - final path = Uri.tryParse(u)?.path ?? u; - _blockHomepageGraphql = - settings.ghostMode && - (path == '/' || - path == '' || - path == '/explore/'); - 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() ?? ''; - _syncDirectThreadState(current); - - // Re-set ghost mode flags on every page load. - // evaluateJavascript-set flags are destroyed when - // the JS context resets on navigation. The flags - // are also prepended to initialUserScripts, but - // this covers the toggle-off โ†’ reload case. - final s = context.read(); - _setGhostModeFlags(controller, s); - - setState(() { - _isLoading = false; - _currentUrl = current; - _hasError = false; - }); - - await _injectionManager - ?.runAllPostLoadInjections(current); - - // Phase 1 V2 overlay DOM scripts - await _v2Engine?.injectDocumentEndScripts(); - - // Re-inject grayscale on every page load - if (s.isGrayscaleActiveNow) { - await controller.evaluateJavascript( - source: grayscale.kGrayscaleJS, - ); - } else { - await controller.evaluateJavascript( - source: grayscale.kGrayscaleOffJS, - ); - } - - 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(); - _syncDirectThreadState(url); - - final disableReels = - appSettings.disableReelsEntirely; - final disableExplore = - appSettings.disableExploreEntirely; - - 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)) { - // Show overlay immediately without navigating away - setState( - () => _exploreBlockedOverlay = true, - ); - // Don't go back - just block the navigation - 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. - ], - ); - }, - ), - ), - ], - ), - ), - if (_hasError) - _NoInternetScreen( - onRetry: () { - setState(() => _hasError = false); - _controller?.reload(); - }, - ), - if (_isLoading) - Positioned( - top: - (_isOnOnboardingPage ? 0 : 60) + - MediaQuery.of(context).padding.top, - left: 0, - right: 0, - child: const _InstagramGradientProgressBar(), - ), - _EdgePanel(key: _edgePanelKey, currentUrl: _currentUrl), - - if (_exploreBlockedOverlay) - Positioned.fill( - child: Consumer( - builder: (ctx, settings, _) { - final isDark = settings.isDarkMode; - final bg = isDark ? Colors.black : Colors.white; - final textMain = isDark ? Colors.white : Colors.black; - final textDim = isDark ? Colors.white70 : Colors.black87; - final textSub = isDark ? Colors.white38 : Colors.black45; - - return Container( - color: bg.withValues(alpha: 0.95), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.explore_off_rounded, - color: Colors.orangeAccent, - size: 80, - ), - const SizedBox(height: 24), - Text( - 'Explore is Disabled', - style: GoogleFonts.grandHotel( - color: textMain, - fontSize: 42, - ), - ), - const SizedBox(height: 12), - Text( - 'Explore is disabled in your FocusGram settings.', - textAlign: TextAlign.center, - style: TextStyle( - color: textDim, - fontSize: 15, - height: 1.5, - ), - ), - const SizedBox(height: 48), - Text( - 'You can re-enable Explore in Settings > Focus.', - textAlign: TextAlign.center, - style: TextStyle(color: textSub, fontSize: 12), - ), - const SizedBox(height: 20), - TextButton( - onPressed: () { - setState(() => _exploreBlockedOverlay = false); - _controller?.goBack(); - }, - child: Text( - 'Go Back', - style: TextStyle(color: textSub), - ), - ), - ], - ), - ); - }, - ), - ), - - if (_reelsBlockedOverlay) - Positioned.fill( - child: Consumer( - builder: (ctx, settings, _) { - final isDark = settings.isDarkMode; - final bg = isDark ? Colors.black : Colors.white; - final textMain = isDark ? Colors.white : Colors.black; - final textDim = isDark ? Colors.white70 : Colors.black87; - final textSub = isDark ? Colors.white38 : Colors.black45; - - return Container( - color: bg.withValues(alpha: 0.95), - padding: const EdgeInsets.all(32), - child: Consumer( - builder: (ctx, sm, _) { - final onCooldown = sm.isCooldownActive; - final quotaFinished = sm.dailyRemainingSeconds <= 0; - final reelsHardDisabled = - settings.disableReelsEntirely; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - quotaFinished - ? Icons.timer_off_rounded - : Icons.lock_clock_rounded, - color: quotaFinished - ? Colors.redAccent - : Colors.blueAccent, - size: 80, - ), - const SizedBox(height: 24), - Text( - quotaFinished - ? 'Daily Quota Finished' - : (reelsHardDisabled - ? 'Reels are Disabled' - : 'Reels are Blocked'), - style: GoogleFonts.grandHotel( - color: textMain, - fontSize: 42, - ), - ), - const SizedBox(height: 12), - Text( - quotaFinished - ? 'You have reached your planned limit for today. Step away and focus on what matters most.' - : (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, - fontSize: 15, - height: 1.5, - ), - ), - const SizedBox(height: 48), - if (quotaFinished) ...[ - Text( - 'Your discipline is your strength.', - style: TextStyle( - color: Colors.greenAccent.withValues( - alpha: 0.8, - ), - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: 24), - Text( - 'To adjust your daily limit, go to Settings > Guardrails.', - textAlign: TextAlign.center, - style: TextStyle( - color: textSub, - 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( - horizontal: 28, - vertical: 14, - ), - decoration: BoxDecoration( - color: Colors.orange.withValues( - alpha: 0.12, - ), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.orange.withValues( - alpha: 0.4, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.hourglass_bottom_rounded, - color: Colors.orangeAccent, - size: 18, - ), - const SizedBox(width: 8), - Text( - 'Cooldown: ${_fmtSeconds(sm.cooldownRemainingSeconds)}', - style: const TextStyle( - color: Colors.orangeAccent, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - const SizedBox(height: 8), - Text( - 'Wait for the cooldown to expire before starting a new session.', - textAlign: TextAlign.center, - style: TextStyle( - color: textSub, - fontSize: 12, - ), - ), - ] else - ElevatedButton( - onPressed: _showReelSessionPicker, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 40, - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - ), - child: const Text( - 'Start Session', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 20), - TextButton( - onPressed: () { - setState(() => _reelsBlockedOverlay = false); - _controller?.goBack(); - }, - child: Text( - 'Go Back', - style: TextStyle(color: textSub), - ), - ), - ], - ); - }, - ), - ); - }, - ), - ), - ], - ), - ), - ); - } - - 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( - // Use hash of message for unique ID - prevents random duplicate notifications - id: (msg.hashCode.abs() % 100000) + 2000, - 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: 'FocusGramMediaDownload', - callback: (args) async { - if (!mounted) return null; - - final raw = (args.isNotEmpty ? args[0] : '') as String; - - // We still want to show a tailored snackbar message, but the heavy - // JSON + security validation is delegated to the pure helper. - String type = 'video'; - try { - final payload = jsonDecode(raw) as Map; - type = (payload['type'] as String? ?? 'video').toString(); - } catch (_) { - // If payload isn't parseable, helper will reject anyway. - } - - final ok = await handleFocusGramMediaDownload( - raw: raw, - launch: (uri) => launchUrl(uri, mode: LaunchMode.externalApplication), - ); - - if (!mounted) return null; - - if (!ok) return null; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - type == 'photo' - ? 'Opening photo downloadโ€ฆ' - : 'Opening video downloadโ€ฆ', - ), - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.fromLTRB(16, 0, 16, 20), - ), - ); - return null; - }, // closes callback - ); // closes addJavaScriptHandler - - 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; - }, - ); - - // ReelMetadata handler removed โ€” reel history feature deleted - - controller.addJavaScriptHandler( - handlerName: 'UrlChange', - callback: (args) async { - final url = (args.isNotEmpty ? args[0] : '') as String? ?? ''; - - // Update _currentUrl SYNCHRONOUSLY before any async operations, - // so shouldInterceptRequest sees the correct path immediately. - _currentUrl = url; - - _syncDirectThreadState(url); - - final s = context.read(); - - // Update homepage graphql block for SPA navigation - final path = Uri.tryParse(url)?.path ?? url; - _blockHomepageGraphql = - s.ghostMode && (path == '/' || path == '' || path == '/explore/'); - - // Re-set ghost mode flags on SPA navigation (no page reload). - _setGhostModeFlags(controller, s); - - await _injectionManager?.runAllPostLoadInjections(url); - - // Re-inject grayscale on SPA nav (no page reload) - if (s.isGrayscaleActiveNow) { - await controller.evaluateJavascript(source: grayscale.kGrayscaleJS); - } else { - await controller.evaluateJavascript( - source: grayscale.kGrayscaleOffJS, - ); - } - - // Phase 1 V2 overlay re-inject on SPA route changes - await _v2Engine?.injectDocumentEndScripts(); - - 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 disableReels = context - .read() - .disableReelsEntirely; - final disableExplore = context - .read() - .disableExploreEntirely; - - 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 String currentUrl; - const _EdgePanel({super.key, this.currentUrl = ''}); - @override - State<_EdgePanel> createState() => _EdgePanelState(); -} - -class _EdgePanelState extends State<_EdgePanel> { - bool _isExpanded = false; - void _toggleExpansion() => setState(() => _isExpanded = !_isExpanded); - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final settings = context.watch(); - final isDark = settings.isDarkMode; - final reelsHardDisabled = settings.disableReelsEntirely; - final panelBg = isDark ? const Color(0xFF111214) : Colors.white; - final textMain = isDark ? Colors.white : Colors.black87; - final textSub = isDark ? Colors.white60 : Colors.black54; - final border = isDark ? Colors.white12 : Colors.black12; - final canStart = - !reelsHardDisabled && - !sm.isSessionActive && - !sm.isCooldownActive && - sm.dailyRemainingSeconds > 0; - final statusColor = reelsHardDisabled - ? Colors.redAccent - : sm.isSessionActive - ? Colors.greenAccent - : sm.isCooldownActive - ? Colors.orangeAccent - : Colors.blueAccent; - final statusText = reelsHardDisabled - ? 'Reels blocked' - : sm.isSessionActive - ? 'Session active' - : sm.isCooldownActive - ? 'Cooldown' - : sm.dailyRemainingSeconds <= 0 - ? 'Daily limit reached' - : 'Ready'; - final sessionProgress = sm.isSessionActive && sm.perSessionSeconds > 0 - ? (sm.remainingSessionSeconds / sm.perSessionSeconds).clamp(0.0, 1.0) - : 0.0; - - return Stack( - children: [ - if (_isExpanded) - Positioned.fill( - child: GestureDetector( - onTap: _toggleExpansion, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05), - ), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - right: _isExpanded ? 12 : -328, - top: MediaQuery.of(context).padding.top + 72, - child: Container( - width: 316, - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height - - MediaQuery.of(context).padding.top - - 96, - ), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: panelBg.withValues(alpha: 0.98), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: border, width: 0.5), - boxShadow: [ - BoxShadow( - color: (isDark ? Colors.black : Colors.black12).withValues( - alpha: 0.3, - ), - blurRadius: 24, - offset: const Offset(0, 12), - ), - ], - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.timer_outlined, color: statusColor), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Focus Control', - style: TextStyle( - color: textMain, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - statusText, - style: TextStyle( - color: statusColor, - fontSize: 12, - ), - ), - ], - ), - ), - // Level badge - Consumer( - builder: (context, lv, _) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: lv.level >= 3 - ? Colors.purple.withValues(alpha: 0.2) - : Colors.grey.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: lv.level >= 3 - ? Colors.purpleAccent.withValues(alpha: 0.4) - : Colors.grey.withValues(alpha: 0.2), - ), - ), - child: Text( - 'Lv ${lv.level}', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: lv.level >= 3 - ? Colors.purpleAccent - : Colors.grey, - ), - ), - ), - ), - // Save current page โ€” REMOVED - const SizedBox(width: 4), - IconButton( - tooltip: 'Close', - icon: Icon( - Icons.close_rounded, - color: textSub, - size: 22, - ), - onPressed: _toggleExpansion, - ), - ], - ), - // Bait Me button row - _BaitMeButtonRow(), - const SizedBox(height: 18), - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black).withValues( - alpha: 0.05, - ), - borderRadius: BorderRadius.circular(14), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Reel session', - style: TextStyle(color: textSub, fontSize: 12), - ), - const SizedBox(height: 6), - Text( - sm.isSessionActive - ? _formatTime(sm.remainingSessionSeconds) - : 'Not running', - style: TextStyle( - color: sm.isSessionActive ? statusColor : textMain, - fontSize: sm.isSessionActive ? 38 : 24, - fontWeight: FontWeight.w700, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(999), - child: LinearProgressIndicator( - value: sm.isSessionActive ? sessionProgress : 0, - minHeight: 6, - backgroundColor: isDark - ? Colors.white10 - : Colors.black12, - valueColor: AlwaysStoppedAnimation( - statusColor, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _buildStatCard( - 'Quota', - '${sm.dailyRemainingSeconds ~/ 60}m', - Icons.hourglass_bottom_rounded, - isDark: isDark, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _buildStatCard( - 'Auto-close app', - sm.appSessionRemainingSeconds > 0 - ? _formatTime(sm.appSessionRemainingSeconds) - : 'Off', - Icons.lock_clock_rounded, - isDark: isDark, - ), - ), - ], - ), - const SizedBox(height: 10), - _buildStatusRow( - icon: Icons.local_cafe_outlined, - label: 'Cooldown', - value: sm.isCooldownActive - ? _formatTime(sm.cooldownRemainingSeconds) - : 'Inactive', - color: sm.isCooldownActive ? Colors.orangeAccent : textSub, - isDark: isDark, - ), - _buildStatusRow( - icon: Icons.block_rounded, - label: 'Hard block', - value: reelsHardDisabled ? 'On' : 'Off', - color: reelsHardDisabled ? Colors.redAccent : textSub, - isDark: isDark, - ), - - const SizedBox(height: 16), - if (sm.isSessionActive) - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - context.read().endSession(); - HapticFeedback.mediumImpact(); - }, - icon: const Icon(Icons.stop_circle_outlined, size: 18), - label: const Text('End Session'), - style: FilledButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - ), - ), - ) - else - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: canStart - ? () { - _toggleExpansion(); - context - .findAncestorStateOfType< - _MainWebViewPageState - >() - ?._showReelSessionPicker(); - } - : null, - icon: const Icon(Icons.play_arrow_rounded, size: 20), - label: const Text('Start Session'), - ), - ), - const SizedBox(height: 8), - if (!canStart && !sm.isSessionActive) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - reelsHardDisabled - ? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.' - : sm.isCooldownActive - ? 'A cooldown is active before the next Reel session.' - : 'Your daily Reel quota is used up.', - style: TextStyle( - color: textSub, - fontSize: 12, - height: 1.35, - ), - ), - if (sm.dailyRemainingSeconds <= 0 && - !reelsHardDisabled && - !sm.isCooldownActive) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Consumer( - builder: (ctx, credits, _) { - if (!credits.canWatchAdToday) { - return Text( - 'Ad limit reached (3/day)', - style: TextStyle( - color: textSub, - fontSize: 11, - ), - ); - } - return SizedBox( - width: double.infinity, - height: 40, - child: OutlinedButton.icon( - onPressed: () async { - final adResult = - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const AdsterraAdScreen( - sessionType: 'reels', - requiredSeconds: 20, - ), - ), - ); - if (adResult == true && context.mounted) { - context - .read() - .addReelsMinutes(amount: 2); - context - .read() - .addBonusDailyMinutes(2); - HapticFeedback.heavyImpact(); - } - }, - icon: const Icon(Icons.videocam, size: 16), - label: Text( - 'Watch Ad (+2 min) ' - '(${CreditStore.maxDailyAds - credits.adsWatchedToday}/3 today)', - style: const TextStyle(fontSize: 12), - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.orangeAccent, - side: BorderSide( - color: Colors.orangeAccent.withValues( - alpha: 0.4, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ); - }, - ), - ), - ], - ), - const SizedBox(height: 10), - Divider(color: border), - ListTile( - onTap: () { - _toggleExpansion(); - context - .findAncestorStateOfType<_MainWebViewPageState>() - ?._signOut(); - }, - leading: const Icon( - Icons.logout_rounded, - color: Colors.redAccent, - size: 20, - ), - title: const Text( - 'Switch Account', - style: TextStyle( - color: Colors.redAccent, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - dense: true, - contentPadding: EdgeInsets.zero, - ), - ], - ), - ), - ), - ), - ], - ); - } - - Widget _buildStatCard( - String label, - String value, - IconData icon, { - bool isDark = true, - }) { - final textMain = isDark ? Colors.white : Colors.black; - final textSub = isDark ? Colors.white54 : Colors.black54; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: textSub, size: 18), - const SizedBox(height: 8), - Text(label, style: TextStyle(color: textSub, fontSize: 11)), - const SizedBox(height: 2), - Text( - value, - style: TextStyle( - color: textMain, - fontSize: 18, - fontWeight: FontWeight.w700, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ); - } - - Widget _buildStatusRow({ - required IconData icon, - required String label, - required String value, - required Color color, - bool isDark = true, - }) { - final textMain = isDark ? Colors.white : Colors.black87; - final textSub = isDark ? Colors.white54 : Colors.black54; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 10), - Expanded( - child: Text(label, style: TextStyle(color: textSub, fontSize: 13)), - ), - Text( - value, - style: TextStyle( - color: color == textSub ? textMain : color, - fontWeight: FontWeight.w600, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ); - } - - String _formatTime(int seconds) { - final m = seconds ~/ 60; - final s = seconds % 60; - return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; - } -} - -/// Small row showing the Bait Me button and daily XP for the edge panel. -class _BaitMeButtonRow extends StatelessWidget { - const _BaitMeButtonRow(); - - @override - Widget build(BuildContext context) { - final levelService = context.watch(); - final baitEngine = context.watch(); - final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe); - - if (!isUnlocked) return const SizedBox.shrink(); - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton.icon( - onPressed: baitEngine.isOnCooldown - ? null - : () => _openBaitMe(context), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.purpleAccent.withValues(alpha: 0.2), - foregroundColor: Colors.purpleAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.casino_rounded, size: 20), - label: Text( - baitEngine.isOnCooldown - ? 'Bait Me (${baitEngine.cooldownRemainingMinutes}m cooldown)' - : '๐ŸŽฒ Bait Me โ€” Feel Lucky?', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - ), - ); - } - - void _openBaitMe(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const BaitMeFullScreen()), - ); - } -} - -class _BrandedTopBar extends StatelessWidget { - final VoidCallback? onFocusControlTap; - final VoidCallback? onDmGhostToggle; - final VoidCallback? onReload; - final String currentUrl; - final bool dmGhostActive; - const _BrandedTopBar({ - this.onFocusControlTap, - this.onDmGhostToggle, - this.onReload, - this.currentUrl = '', - this.dmGhostActive = false, - }); - - static bool _isDirectInbox(String url) { - final path = Uri.tryParse(url)?.path ?? url; - return path == '/direct/inbox/' || path == '/direct/inbox'; - } - - static bool _isDirectThread(String url) { - final path = Uri.tryParse(url)?.path ?? url; - return RegExp(r'^/direct/t/').hasMatch(path); - } - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - final barBg = isDark ? Colors.black : Colors.white; - final textMain = isDark ? Colors.white : Colors.black; - final iconColor = isDark ? Colors.white70 : Colors.black54; - final border = isDark ? Colors.white12 : Colors.black12; - final showDmGhostBtn = _isDirectThread(currentUrl) && dmGhostActive; - final showReloadBtn = _isDirectInbox(currentUrl); - - return Container( - height: 60, - decoration: BoxDecoration( - color: barBg, - border: Border(bottom: BorderSide(color: border, width: 0.5)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Left: settings icon - IconButton( - icon: Icon(Icons.settings_outlined, color: iconColor, size: 22), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), - ), - - // Center: FocusGram logo (or DM ghost badge) - if (showDmGhostBtn) - GestureDetector( - onTap: onDmGhostToggle, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.redAccent.withValues(alpha: 0.4), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.visibility_off, - color: Colors.redAccent, - size: 16, - ), - const SizedBox(width: 4), - Text( - 'DM Ghost ON', - style: TextStyle( - color: Colors.redAccent, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.close, - color: Colors.redAccent.withValues(alpha: 0.6), - size: 14, - ), - ], - ), - ), - ) - else - Text( - 'FocusGram', - style: GoogleFonts.grandHotel( - color: textMain, - fontSize: 32, - letterSpacing: 0.5, - ), - ), - - // Right: reload button + timer icon - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (showReloadBtn) - IconButton( - icon: Icon( - Icons.refresh_rounded, - color: iconColor, - size: 22, - ), - onPressed: onReload, - tooltip: 'Reload page', - ), - IconButton( - icon: const Icon( - Icons.timer_outlined, - color: Colors.blueAccent, - size: 22, - ), - onPressed: onFocusControlTap, - ), - ], - ), - ], - ), - ), - ); - } -} - -class _InstagramGradientProgressBar extends StatelessWidget { - const _InstagramGradientProgressBar(); - @override - Widget build(BuildContext context) { - return SizedBox( - height: 2.5, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFFEDA75), - Color(0xFFFA7E1E), - Color(0xFFD62976), - Color(0xFF962FBF), - Color(0xFF4F5BD5), - ], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - ), - ), - ); - } -} - -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}); - @override - Widget build(BuildContext context) { - final isDark = context.watch().isDarkMode; - return Container( - color: isDark ? Colors.black : Colors.white, - width: double.infinity, - height: double.infinity, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.wifi_off_rounded, - color: isDark ? Colors.white24 : Colors.black12, - size: 80, - ), - const SizedBox(height: 24), - Text( - 'No Connection', - style: TextStyle( - color: isDark ? Colors.white : Colors.black, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Please check your internet settings.', - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black38, - fontSize: 14, - ), - ), - const SizedBox(height: 40), - ElevatedButton( - onPressed: onRetry, - style: ElevatedButton.styleFrom( - backgroundColor: isDark ? Colors.white : Colors.black, - foregroundColor: isDark ? Colors.black : Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: const Text('Retry'), - ), - ], - ), - ); - } -} diff --git a/lib/screens/offline_feed_viewer.dart b/lib/screens/offline_feed_viewer.dart deleted file mode 100644 index 0e7c92f..0000000 --- a/lib/screens/offline_feed_viewer.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:provider/provider.dart'; -import '../services/snapshot_service.dart'; - -/// Opens a saved page offline. Uses saved HTML content when available, -/// falls back to WebView cache. -class OfflineFeedViewer extends StatelessWidget { - final String url; - final String? pageId; - - const OfflineFeedViewer({super.key, required this.url, this.pageId}); - - @override - Widget build(BuildContext context) { - // Find the saved page with HTML content - SavedPage? page; - if (pageId != null) { - final ss = context.read(); - final matches = ss.savedPages.where((p) => p.id == pageId); - if (matches.isNotEmpty) page = matches.first; - } - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Offline View', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - color: Colors.blue.withValues(alpha: 0.1), - child: const Row( - children: [ - Icon( - Icons.wifi_off_rounded, - size: 14, - color: Colors.blueAccent, - ), - SizedBox(width: 6), - Text( - 'Offline โ€” saved content shown', - style: TextStyle(fontSize: 11, color: Colors.blueAccent), - ), - ], - ), - ), - Expanded( - child: page?.htmlContent != null - ? InAppWebView( - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - domStorageEnabled: true, - transparentBackground: false, - useHybridComposition: true, - ), - onWebViewCreated: (c) async { - await c.loadData( - data: page!.htmlContent!, - mimeType: 'text/html', - encoding: 'utf-8', - baseUrl: WebUri(url), - ); - }, - ) - : InAppWebView( - initialUrlRequest: URLRequest(url: WebUri(url)), - initialSettings: InAppWebViewSettings( - cacheEnabled: true, - cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK, - domStorageEnabled: true, - javaScriptEnabled: true, - transparentBackground: false, - useHybridComposition: true, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/onboarding_page.dart b/lib/screens/onboarding_page.dart deleted file mode 100644 index 1896724..0000000 --- a/lib/screens/onboarding_page.dart +++ /dev/null @@ -1,436 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:app_settings/app_settings.dart'; -import '../services/settings_service.dart'; -import '../services/notification_service.dart'; - -class OnboardingPage extends StatefulWidget { - final VoidCallback onFinish; - - const OnboardingPage({super.key, required this.onFinish}); - - @override - State createState() => _OnboardingPageState(); -} - -class _OnboardingPageState extends State { - final PageController _pageController = PageController(); - int _currentPage = 0; - - // Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications - static const int _kTotalPages = 5; - - static const int _kBlurPage = 3; - static const int _kLinkPage = 2; - static const int _kNotifPage = 4; - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - - final List slides = [ - // โ”€โ”€ Page 0: Welcome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _StaticSlide( - icon: Icons.auto_awesome_rounded, - color: const Color(0xFF4F8DFF), - title: 'Welcome to FocusGram', - description: - 'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.', - ), - - // โ”€โ”€ Page 1: Focus controls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _StaticSlide( - icon: Icons.timer_outlined, - color: const Color(0xFFFFB74D), - title: 'Time With Intent', - description: - 'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.', - ), - - // โ”€โ”€ Page 2: Open links โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _StaticSlide( - icon: Icons.link_rounded, - color: const Color(0xFF35C2D6), - title: 'Open Links in FocusGram', - description: - 'To open Instagram links directly here: Tap "Configure", then "Open by default" โ†’ "Add link" and select all.', - isAppSettingsPage: true, - ), - - // โ”€โ”€ Page 3: Blur Settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _BlurSettingsSlide(settings: settings), - - // โ”€โ”€ Page 4: Notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _StaticSlide( - icon: Icons.notifications_active_outlined, - color: const Color(0xFF5DD18A), - title: 'Useful Alerts Only', - description: - 'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.', - isPermissionPage: true, - permission: Permission.notification, - ), - ]; - - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - PageView.builder( - controller: _pageController, - onPageChanged: (index) => setState(() => _currentPage = index), - itemCount: _kTotalPages, - itemBuilder: (context, index) => slides[index], - ), - Positioned( - bottom: 50, - left: 0, - right: 0, - child: Column( - children: [ - // Dot indicators - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - _kTotalPages, - (index) => AnimatedContainer( - duration: const Duration(milliseconds: 250), - margin: const EdgeInsets.symmetric(horizontal: 4), - width: _currentPage == index ? 12 : 8, - height: 8, - decoration: BoxDecoration( - color: _currentPage == index - ? Colors.blue - : Colors.white24, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - const SizedBox(height: 28), - // CTA button - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: SizedBox( - width: double.infinity, - height: 56, - child: Builder( - builder: (context) { - final isLast = _currentPage == _kTotalPages - 1; - final isLink = _currentPage == _kLinkPage; - final isNotif = _currentPage == _kNotifPage; - final isBlur = _currentPage == _kBlurPage; - - String label; - if (isNotif) { - label = 'Allow & Start'; - } else if (isLink) { - label = 'Configure'; - } else if (isBlur) { - label = 'Save & Continue'; - } else if (isLast) { - label = 'Get Started'; - } else { - label = 'Next'; - } - - return ElevatedButton( - onPressed: () async { - if (isLink) { - await AppSettings.openAppSettings( - type: AppSettingsType.settings, - ); - } else if (isNotif) { - await Permission.notification.request(); - await NotificationService() - .requestPermissionsNow(); - } - - if (!context.mounted) return; - if (isLast) { - _finish(context); - } else { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - label, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ); - }, - ), - ), - ), - // Skip button (available on all pages except last) - if (_currentPage < _kTotalPages - 1) - TextButton( - onPressed: () { - if (_currentPage == _kNotifPage) { - _finish(context); - } else { - _pageController.animateToPage( - _kTotalPages - 1, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - child: const Text( - 'Skip setup', - style: TextStyle(color: Colors.white38, fontSize: 14), - ), - ), - ], - ), - ), - ], - ), - ); - } - - void _finish(BuildContext context) { - context.read().setFirstRunCompleted(); - widget.onFinish(); - } -} - -// โ”€โ”€ 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; - - const _StaticSlide({ - required this.icon, - required this.color, - required this.title, - required this.description, - this.isPermissionPage = false, - this.isAppSettingsPage = false, - this.permission, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(28, 40, 28, 160), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 112, - height: 112, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(28), - border: Border.all(color: color.withValues(alpha: 0.28)), - ), - child: Icon(icon, size: 54, color: color), - ), - const SizedBox(height: 36), - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Text( - description, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white70, - fontSize: 16, - height: 1.5, - ), - ), - if (isPermissionPage || isAppSettingsPage) ...[ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white10), - ), - child: Text( - isPermissionPage - ? 'Permission is optional and can be changed later.' - : 'This opens Android settings; return here when done.', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white54, fontSize: 12), - ), - ), - ], - ], - ), - ); - } -} - -// โ”€โ”€ Blur settings slide โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class _BlurSettingsSlide extends StatelessWidget { - final SettingsService settings; - - const _BlurSettingsSlide({required this.settings}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(32, 40, 32, 160), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Center( - child: Icon( - Icons.blur_on_rounded, - size: 90, - color: Colors.purpleAccent, - ), - ), - const SizedBox(height: 36), - const Center( - child: Text( - 'Distraction Shield', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 30, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 12), - const Center( - child: Text( - 'Blur feeds you don\'t want to be tempted by. You can change these anytime in Settings.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white60, - fontSize: 16, - height: 1.5, - ), - ), - ), - const SizedBox(height: 40), - - // Blur Home Feed toggle - _BlurToggleTile( - icon: Icons.home_rounded, - label: 'Blur Home Feed', - subtitle: 'Posts in your feed will be blurred until tapped', - value: settings.blurReels, - onChanged: (v) => settings.setBlurReels(v), - ), - const SizedBox(height: 16), - - // Blur Explore toggle - _BlurToggleTile( - icon: Icons.explore_rounded, - label: 'Blur Explore Feed', - subtitle: 'Explore thumbnails stay blurred until you tap', - value: settings.blurExplore, - onChanged: (v) => settings.setBlurExplore(v), - ), - ], - ), - ); - } -} - -class _BlurToggleTile extends StatelessWidget { - final IconData icon; - final String label; - final String subtitle; - final bool value; - final ValueChanged 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 deleted file mode 100644 index a95c8c9..0000000 --- a/lib/screens/reel_player_overlay.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import '../services/injection_controller.dart'; -import '../services/session_manager.dart'; -import 'package:provider/provider.dart'; - -/// An isolated player for a single Reel opened from a DM. -/// Uses JS history interception to lock the user to the initial reel URL. -class ReelPlayerOverlay extends StatefulWidget { - final String url; - const ReelPlayerOverlay({super.key, required this.url}); - - @override - State createState() => _ReelPlayerOverlayState(); -} - -class _ReelPlayerOverlayState extends State { - DateTime? _startTime; - - @override - void initState() { - super.initState(); - _startTime = DateTime.now(); - } - - @override - void dispose() { - // Record viewing time toward daily count - if (_startTime != null) { - final durationSeconds = DateTime.now().difference(_startTime!).inSeconds; - if (mounted) { - context.read().accrueSeconds(durationSeconds); - } - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - backgroundColor: Colors.black, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - title: const Text( - 'Reel', - style: TextStyle(color: Colors.white, fontSize: 16), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.orangeAccent, width: 0.5), - ), - child: const Text( - 'Locked', - style: TextStyle(color: Colors.orangeAccent, fontSize: 11), - ), - ), - ), - ], - ), - body: InAppWebView( - initialUrlRequest: URLRequest(url: WebUri(widget.url)), - initialSettings: InAppWebViewSettings( - userAgent: InjectionController.iOSUserAgent, - mediaPlaybackRequiresUserGesture: true, - useHybridComposition: true, - cacheEnabled: true, - cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK, - domStorageEnabled: true, - databaseEnabled: true, - hardwareAcceleration: true, - transparentBackground: true, - safeBrowsingEnabled: false, - supportZoom: false, - allowsInlineMediaPlayback: true, - verticalScrollBarEnabled: false, - horizontalScrollBarEnabled: false, - ), - onWebViewCreated: (controller) { - // Controller is not stored; this overlay is self-contained. - }, - onLoadStop: (controller, url) async { - // Set isolated player flag to ensure scroll-lock applies even if a session is active globally - await controller.evaluateJavascript( - source: 'window.__focusgramIsolatedPlayer = true;', - ); - // Apply scroll-lock via MutationObserver: prevents swiping to next reel - await controller.evaluateJavascript( - source: InjectionController.reelsMutationObserverJS, - ); - // Also apply FocusGram baseline CSS (hides bottom nav etc.) - await controller.evaluateJavascript( - source: InjectionController.buildInjectionJS( - sessionActive: true, - blurExplore: false, - blurReels: false, - tapToUnblur: false, - enableTextSelection: true, - hideSuggestedPosts: false, - hideSponsoredPosts: false, - hideLikeCounts: false, - hideFollowerCounts: false, - // hideStoriesBar removed per user request - hideExploreTab: false, - hideReelsTab: false, - hideShopTab: false, - disableReelsEntirely: false, - blockHomeFeedScroll: false, - ), - ); - }, - shouldOverrideUrlLoading: (controller, action) async { - // Keep this overlay locked to instagram.com pages only - final uri = action.request.url; - if (uri == null) return NavigationActionPolicy.CANCEL; - if (!uri.host.contains('instagram.com')) { - return NavigationActionPolicy.CANCEL; - } - return NavigationActionPolicy.ALLOW; - }, - ), - ); - } -} diff --git a/lib/screens/session_modal.dart b/lib/screens/session_modal.dart deleted file mode 100644 index b7382d1..0000000 --- a/lib/screens/session_modal.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/session_manager.dart'; -import '../services/settings_service.dart'; -import '../utils/discipline_challenge.dart'; - -class SessionModal extends StatefulWidget { - const SessionModal({super.key}); - - @override - State createState() => _SessionModalState(); -} - -class _SessionModalState extends State { - double _customMinutes = 5.0; - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - - return Container( - padding: const EdgeInsets.all(24), - decoration: const BoxDecoration( - color: Color(0xFF121212), - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Start Reel Session', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, color: Colors.white54), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Remaining Daily: ${sm.dailyRemainingSeconds ~/ 60}m', - style: const TextStyle(color: Colors.white70), - ), - if (sm.isCooldownActive) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - 'Cooldown active: ${sm.cooldownRemainingSeconds ~/ 60}m ${sm.cooldownRemainingSeconds % 60}s left', - style: const TextStyle(color: Colors.orangeAccent), - ), - ), - const SizedBox(height: 24), - const Text( - 'Presets', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [1, 3, 5, 10, 15, 20, 30].map((m) { - return SizedBox( - width: 72, - child: ElevatedButton( - onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted) - ? null - : () => _start(m), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white12, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text('${m}m'), - ), - ); - }).toList(), - ), - const SizedBox(height: 32), - const Text( - 'Custom Duration', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), - ), - Slider( - value: _customMinutes, - min: 1, - max: 60, - divisions: 59, - label: '${_customMinutes.toInt()}m', - onChanged: (v) => setState(() => _customMinutes = v), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted) - ? null - : () => _start(_customMinutes.toInt()), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - 'Start Session', - style: TextStyle(fontSize: 16), - ), - ), - ), - const SizedBox(height: 16), - ], - ), - ); - } - - void _start(int minutes) async { - final sm = context.read(); - final settings = context.read(); - - if (settings.requireWordChallenge) { - final success = await DisciplineChallenge.show( - context, - count: settings.resolvedWordChallengeCount(), - ); - if (!success) return; - } - - if (sm.startSession(minutes)) { - if (mounted) Navigator.pop(context); - } - } -} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart deleted file mode 100644 index 2cb2412..0000000 --- a/lib/screens/settings_page.dart +++ /dev/null @@ -1,1424 +0,0 @@ -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/level_service.dart'; -import '../services/credit_store.dart'; -import '../services/app_lock_service.dart'; -// snapshot_service import removed โ€” offline feature deleted -import '../services/focusgram_router.dart'; -import 'app_lock_settings_page.dart'; -// snapshot_manager_screen import removed โ€” offline feature deleted -import 'level_panel_screen.dart'; -//import 'debug_menu_screen.dart'; -import '../widgets/native_ad_banner.dart'; -import '../features/screen_time/screen_time_screen.dart'; -// reels_history_screen import removed โ€” feature deleted -import 'guardrails_page.dart'; -import 'extras_settings_page.dart'; - -// โ”€โ”€โ”€ Main Settings Page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class SettingsPage extends StatelessWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final settings = context.watch(); - final isDark = settings.isDarkMode; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Settings', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: ListView( - children: [ - const _DonateTile(), - _buildStatsRow(sm, context), - - const _SectionHeader(title: 'FOCUS & BLOCKING'), - _SubmoduleTile( - icon: Icons.block_rounded, - iconColor: Colors.redAccent, - title: 'Focus Mode', - subtitle: settings.minimalModeEnabled - ? 'Minimal mode on' - : 'Blocking, Content Hider, Feed Blur and more', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const FocusSettingsPage()), - ), - ), - _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()), - ), - ), - - const _SectionHeader(title: 'EXTRAS'), - _SubmoduleTile( - icon: Icons.download_rounded, - iconColor: Colors.orangeAccent, - title: 'Extras', - subtitle: 'Startup Page, Download media, Ghost Mode', - enabled: true, - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()), - ), - ), - - const _SectionHeader(title: 'APPEARANCE'), - _SubmoduleTile( - icon: Icons.palette_outlined, - iconColor: Colors.purpleAccent, - title: 'Appearance', - subtitle: settings.grayscaleEnabled - ? 'Grayscale on' - : settings.grayscaleSchedules.isNotEmpty - ? 'Grayscale scheduled (${settings.grayscaleSchedules.length} schedules)' - : 'Grayscale and schedules', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AppearancePage()), - ), - ), - - const _SectionHeader(title: 'SECURITY'), - _SubmoduleTile( - icon: Icons.lock_rounded, - iconColor: Colors.blueAccent, - title: 'App Lock', - subtitle: _appLockSubtitle(context.watch()), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AppLockSettingsPage()), - ), - ), - - const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'), - _SubmoduleTile( - icon: Icons.lock_outline, - iconColor: Colors.tealAccent, - title: 'Privacy & Notifications', - subtitle: 'Manage Your Notifications', - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const PrivacyNotificationsPage(), - ), - ), - ), - - 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()), - ), - ), - _SubmoduleTile( - icon: Icons.trending_up_rounded, - iconColor: Colors.amber, - title: 'Your Journey', - subtitle: - 'Level ${context.watch().level} ยท ${context.watch().xp} XP', - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LevelPanelScreen()), - ), - ), - - // Quick XP debug grant (visible in settings for testing) - // _XpDebugTile(), - // Reels History removed - const _SectionHeader(title: 'ABOUT'), - _VersionTile(), - 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, - color: Colors.purpleAccent, - ), - title: const Text('Instagram Settings'), - subtitle: const Text( - 'Open native Instagram account settings', - style: TextStyle(fontSize: 12), - ), - trailing: const Icon( - Icons.open_in_new, - color: Colors.white24, - size: 14, - ), - onTap: () { - Navigator.pop(context); - FocusGramRouter.pendingUrl.value = - 'https://www.instagram.com/accounts/settings/?entrypoint=profile'; - }, - ), - const SizedBox(height: 20), - const NativeAdBanner(height: 60), - const SizedBox(height: 40), - Center( - child: Text( - 'FocusGram ยท Built with ๐Ÿ’– by Ujwal Chapagain', - style: TextStyle( - color: isDark ? Colors.white12 : Colors.black12, - fontSize: 12, - ), - ), - ), - const SizedBox(height: 24), - ], - ), - ); - } - - Widget _buildStatsRow(SessionManager sm, BuildContext context) { - final creditStore = context.watch(); - final cells = [ - _statCell('Opens Today', '${sm.dailyOpenCount}ร—', Colors.blue), - _dividerCell(), - _statCell( - 'Reels Used', - '${sm.dailyUsedSeconds ~/ 60}m', - Colors.orangeAccent, - ), - _dividerCell(), - _statCell( - 'Remaining', - '${sm.dailyRemainingSeconds ~/ 60}m', - Colors.greenAccent, - ), - ]; - - if (true) { - // ad counter always shown - cells.addAll([ - _dividerCell(), - _statCell( - 'XP Ads Watched', - '${creditStore.adsWatchedToday}', - Colors.purpleAccent, - ), - ]); - } - - return Container( - margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.blue.withValues(alpha: 0.1)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: cells, - ), - ); - } - - 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)), - ], - ); - - Widget _dividerCell() => Container( - width: 1, - height: 36, - color: Colors.blue.withValues(alpha: 0.1), - ); - - String _appLockSubtitle(AppLockService a) { - if (!a.anyLockEnabled) return 'Protect FocusGram with a PIN'; - final parts = []; - if (a.lockAppWide) parts.add('App-wide'); - if (a.lockMessages) parts.add('Messages'); - return '${parts.join(' + ')} lock active'; - } - - 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), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - } -} - -// โ”€โ”€โ”€ 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), - ), - ), - ], - ), - ), - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.shield_rounded, - color: Colors.redAccent, - size: 20, - ), - ), - title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)), - subtitle: Text( - settings.minimalModeEnabled - ? 'Enabled - tap to customize' - : 'Disabled - tap to configure', - style: TextStyle( - fontSize: 12, - color: settings.minimalModeEnabled - ? Colors.greenAccent - : Colors.grey, - ), - ), - trailing: Switch( - value: settings.minimalModeEnabled, - onChanged: (v) async { - await settings.setMinimalModeEnabled(v); - HapticFeedback.selectionClick(); - }, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()), - ), - ), - - const SizedBox(height: 8), - - const _SectionHeader(title: 'FRICTION'), - _SwitchTile( - title: 'Mindfulness Gate', - subtitle: '${settings.breathGateSeconds}s before opening Instagram', - value: settings.showBreathGate, - onChanged: (v) => settings.setShowBreathGate(v), - ), - if (settings.showBreathGate) - _NumberEditTile( - title: 'Gate Duration', - label: '${settings.breathGateSeconds} seconds', - initialValue: settings.breathGateSeconds, - min: 3, - max: 60, - suffix: 'seconds', - onSubmitted: (v) => settings.setBreathGateSeconds(v), - ), - _SwitchTile( - title: 'Typing Challenge', - subtitle: settings.wordChallengeCount == 0 - ? 'Random: 10-35 words' - : '${settings.wordChallengeCount} words', - value: settings.requireWordChallenge, - onChanged: (v) => settings.setRequireWordChallenge(v), - ), - if (settings.requireWordChallenge) - _ChoiceTile( - title: 'Typing Words', - value: settings.wordChallengeCount, - label: settings.wordChallengeCount == 0 - ? 'Random (10-35)' - : '${settings.wordChallengeCount} words', - options: const [20, 25, 30, 35, 0], - optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words', - onSelected: (v) => settings.setWordChallengeCount(v), - ), - - _SwitchTile( - title: 'Effort Friction Mode', - subtitle: 'Earn credits by watching ads โ€” enabled by default', - value: settings.effortFrictionEnabled, - onChanged: (v) async { - if (v && - !context.read().isFeatureUnlocked( - AppFeature.effortFriction, - )) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unlocks at Level 3')), - ); - return; - } - await settings.setEffortFrictionEnabled(v); - HapticFeedback.selectionClick(); - }, - ), - - const _SectionHeader(title: 'MEDIA'), - // Block Autoplay removed โ€” was unreliable - _SwitchTile( - title: 'Blur Feed & Explore', - subtitle: 'Blurs post thumbnails until tapped', - value: settings.blurExplore, - onChanged: (v) => settings.setBlurExplore(v), - ), - // Tap to Unblur as child toggle (shown directly under Blur Feed when enabled) - if (settings.blurExplore) - Padding( - padding: const EdgeInsets.only(left: 32), - child: _SwitchTile( - title: 'Tap to Unblur', - subtitle: 'First tap reveals the post (doesn\'t open it)', - value: settings.tapToUnblur, - onChanged: (v) => settings.setTapToUnblur(v), - ), - ), - - const _SectionHeader(title: 'CONTENT HIDER'), - - _SwitchTile( - title: 'Hide Feed Posts', - subtitle: 'Hides home feed posts', - value: settings.contentPosts, - onChanged: (v) => settings.setContentPostsEnabled(v), - ), - - const SizedBox(height: 40), - ], - ), - ); - } -} - -class _DonateTile extends StatelessWidget { - const _DonateTile(); - - static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal'); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 4), - decoration: BoxDecoration( - color: Colors.pinkAccent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)), - ), - child: ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.pinkAccent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.favorite_rounded, - color: Colors.pinkAccent, - size: 20, - ), - ), - title: const Text( - 'Please donate to support the development of this project.', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - subtitle: const Text( - 'Your support keeps FocusGram free and maintained.', - style: TextStyle(fontSize: 12), - ), - trailing: const Icon(Icons.open_in_new, size: 14), - onTap: () => - launchUrl(_donateUri, mode: LaunchMode.externalApplication), - ), - ); - } -} - -// โ”€โ”€โ”€ Minimal Mode Submenu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class MinimalModeSubmenuPage extends StatefulWidget { - const MinimalModeSubmenuPage({super.key}); - - @override - State createState() => _MinimalModeSubmenuPageState(); -} - -class _MinimalModeSubmenuPageState extends State { - late bool _blurExplore; - late bool _disableReelsEntirely; - late bool _disableExploreEntirely; - late bool _blockHomeFeedScroll; - - @override - void initState() { - super.initState(); - final settings = context.read(); - _blurExplore = settings.blurExplore; - _disableReelsEntirely = settings.disableReelsEntirely; - _disableExploreEntirely = settings.disableExploreEntirely; - _blockHomeFeedScroll = settings.blockHomeFeedScroll; - } - - Future _updateSetting(String key, bool value) async { - final settings = context.read(); - setState(() { - switch (key) { - case 'blurExplore': - _blurExplore = value; - break; - case 'disableReelsEntirely': - _disableReelsEntirely = value; - break; - case 'disableExploreEntirely': - _disableExploreEntirely = value; - break; - case 'blockHomeFeedScroll': - _blockHomeFeedScroll = value; - break; - } - }); - - switch (key) { - case 'blurExplore': - await settings.setBlurExplore(value); - break; - case 'disableReelsEntirely': - await settings.setDisableReelsEntirelyInternal(value); - break; - case 'disableExploreEntirely': - await settings.setDisableExploreEntirelyInternal(value); - break; - case 'blockHomeFeedScroll': - await settings.setBlockHomeFeedScrollInternal(value); - break; - } - - if (!mounted) return; - final latest = context.read(); - setState(() { - _blurExplore = latest.blurExplore; - _disableReelsEntirely = latest.disableReelsEntirely; - _disableExploreEntirely = latest.disableExploreEntirely; - _blockHomeFeedScroll = latest.blockHomeFeedScroll; - }); - HapticFeedback.selectionClick(); - } - - Future _turnOnMinimalMode() async { - final settings = context.read(); - await settings.setMinimalModeEnabled(true); - setState(() { - _blurExplore = true; - _disableReelsEntirely = true; - _disableExploreEntirely = true; - _blockHomeFeedScroll = true; - }); - HapticFeedback.mediumImpact(); - } - - Future _turnOffMinimalMode() async { - final settings = context.read(); - await settings.setMinimalModeEnabled(false); - // Refresh local state after turning off - setState(() { - _blurExplore = settings.blurExplore; - _disableReelsEntirely = settings.disableReelsEntirely; - _disableExploreEntirely = settings.disableExploreEntirely; - _blockHomeFeedScroll = settings.blockHomeFeedScroll; - }); - HapticFeedback.mediumImpact(); - } - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - final isMinimalModeEnabled = settings.minimalModeEnabled; - - return Scaffold( - appBar: _subAppBar(context, 'Minimal Mode'), - body: ListView( - children: [ - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: isMinimalModeEnabled - ? [ - Colors.redAccent.withValues(alpha: 0.2), - Colors.red.withValues(alpha: 0.1), - ] - : [ - Colors.grey.withValues(alpha: 0.1), - Colors.grey.withValues(alpha: 0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isMinimalModeEnabled - ? Colors.redAccent.withValues(alpha: 0.3) - : Colors.grey.withValues(alpha: 0.2), - ), - ), - child: Column( - children: [ - Icon( - isMinimalModeEnabled - ? Icons.shield_rounded - : Icons.shield_outlined, - color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey, - size: 48, - ), - const SizedBox(height: 12), - Text( - isMinimalModeEnabled - ? 'Minimal Mode Active' - : 'Minimal Mode Disabled', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: isMinimalModeEnabled - ? Colors.redAccent - : Colors.grey, - ), - ), - const SizedBox(height: 8), - Text( - isMinimalModeEnabled - ? 'Distractions are blocked. Customize which features stay enabled below.' - : 'Turn on to block all distractions at once, or customize individual settings below.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: isMinimalModeEnabled - ? _turnOffMinimalMode - : _turnOnMinimalMode, - style: ElevatedButton.styleFrom( - backgroundColor: isMinimalModeEnabled - ? Colors.grey - : Colors.redAccent, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - isMinimalModeEnabled - ? 'Turn Off Minimal Mode' - : 'Turn On Minimal Mode', - ), - ), - ), - ], - ), - ), - - const _SectionHeader(title: 'CUSTOMIZE SETTINGS'), - - 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.touch_app_rounded, - size: 14, - color: Colors.blueAccent, - ), - SizedBox(width: 8), - Expanded( - child: Text( - 'Toggle settings below to customize what gets enabled when Minimal Mode is turned ON.', - style: TextStyle(fontSize: 11, color: Colors.blueAccent), - ), - ), - ], - ), - ), - - _SwitchTile( - title: 'Blur Feed & Explore', - subtitle: 'Blurs post thumbnails until tapped', - value: _blurExplore, - onChanged: (v) => _updateSetting('blurExplore', v), - ), - _SwitchTile( - title: 'Block Home Feed Scroll', - subtitle: 'Freeze vertical scrolling on the home feed only', - value: _blockHomeFeedScroll, - onChanged: (v) => _updateSetting('blockHomeFeedScroll', v), - ), - _SwitchTile( - title: 'Disable Reels Entirely', - subtitle: 'Block all Reels with no session option', - value: _disableReelsEntirely, - onChanged: (v) => _updateSetting('disableReelsEntirely', v), - ), - _SwitchTile( - title: 'Disable Explore Entirely', - subtitle: 'Block Explore completely (not just blur)', - value: _disableExploreEntirely, - onChanged: (v) => _updateSetting('disableExploreEntirely', v), - ), - - const SizedBox(height: 40), - ], - ), - ); - } -} - -// โ”€โ”€โ”€ Appearance โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -class AppearancePage extends StatefulWidget { - const AppearancePage({super.key}); - - @override - State createState() => _AppearancePageState(); -} - -class _AppearancePageState extends State { - Future _addSchedule( - BuildContext context, - SettingsService settings, - ) async { - TimeOfDay? startTime = await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 21, minute: 0), - helpText: 'Select start time', - ); - - if (startTime == null || !context.mounted) return; - - TimeOfDay? endTime = await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 6, minute: 0), - helpText: 'Select end time', - ); - - if (endTime == null || !context.mounted) return; - - final newSchedule = { - 'enabled': true, - 'startTime': - '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', - 'endTime': - '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', - }; - - await settings.addGrayscaleSchedule(newSchedule); - } - - Future _editSchedule( - BuildContext context, - SettingsService settings, - int index, - ) async { - final schedules = settings.grayscaleSchedules; - if (index >= schedules.length) return; - - final current = schedules[index]; - final startParts = (current['startTime'] as String).split(':'); - final endParts = (current['endTime'] as String).split(':'); - - // Capture context before async gap - final capturedContext = context; - - TimeOfDay? startTime = await showTimePicker( - context: capturedContext, - initialTime: TimeOfDay( - hour: int.parse(startParts[0]), - minute: int.parse(startParts[1]), - ), - helpText: 'Select start time', - ); - - if (startTime == null || !capturedContext.mounted) return; - - TimeOfDay? endTime = await showTimePicker( - context: capturedContext, - initialTime: TimeOfDay( - hour: int.parse(endParts[0]), - minute: int.parse(endParts[1]), - ), - helpText: 'Select end time', - ); - - if (endTime == null || !capturedContext.mounted) return; - - final updatedSchedule = { - ...current, - 'startTime': - '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', - 'endTime': - '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', - }; - - await settings.updateGrayscaleSchedule(index, updatedSchedule); - } - - Future _toggleSchedule(SettingsService settings, int index) async { - final schedules = List>.from( - settings.grayscaleSchedules, - ); - if (index >= schedules.length) return; - - schedules[index] = { - ...schedules[index], - 'enabled': !(schedules[index]['enabled'] as bool), - }; - - await settings.setGrayscaleSchedules(schedules); - } - - Future _deleteSchedule(SettingsService settings, int index) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Schedule'), - content: const Text('Are you sure you want to delete this schedule?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed == true) { - await settings.removeGrayscaleSchedule(index); - } - } - - String _formatTimeRange(Map schedule) { - return '${schedule['startTime']} - ${schedule['endTime']}'; - } - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final isDark = settings.isDarkMode; - 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 SCHEDULES'), - 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( - 'Auto-enable grayscale during specific hours. Similar to Scheduled Blocking in Guardrails. You can add multiple schedules.', - style: TextStyle(fontSize: 12, height: 1.5), - ), - ), - - // Status indicator - if (settings.grayscaleSchedules.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: settings.isGrayscaleActiveNow - ? Colors.green.withValues(alpha: 0.1) - : Colors.orange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: settings.isGrayscaleActiveNow - ? Colors.green.withValues(alpha: 0.3) - : Colors.orange.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - settings.isGrayscaleActiveNow - ? Icons.check_circle - : Icons.schedule, - color: settings.isGrayscaleActiveNow - ? Colors.greenAccent - : Colors.orangeAccent, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - settings.isGrayscaleActiveNow - ? 'Grayscale is active now' - : 'Grayscale is currently inactive', - style: TextStyle( - fontSize: 13, - color: settings.isGrayscaleActiveNow - ? Colors.greenAccent - : Colors.orangeAccent, - ), - ), - ), - ], - ), - ), - ), - - // Schedule list - ...List.generate(settings.grayscaleSchedules.length, (index) { - final schedule = settings.grayscaleSchedules[index]; - final isEnabled = schedule['enabled'] as bool; - return ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: (isEnabled ? Colors.purpleAccent : Colors.grey) - .withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - isEnabled - ? Icons.play_circle_outline - : Icons.pause_circle_outline, - color: isEnabled ? Colors.purpleAccent : Colors.grey, - size: 20, - ), - ), - title: Text( - _formatTimeRange(schedule), - style: TextStyle( - color: isEnabled ? Colors.purpleAccent : Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - subtitle: Text( - isEnabled ? 'Active' : 'Disabled', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.black45, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Switch( - value: isEnabled, - onChanged: (v) => _toggleSchedule(settings, index), - ), - PopupMenuButton( - onSelected: (value) { - if (value == 'edit') { - _editSchedule(context, settings, index); - } else if (value == 'delete') { - _deleteSchedule(settings, index); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'edit', - child: Row( - children: [ - Icon(Icons.edit, size: 18), - SizedBox(width: 8), - Text('Edit'), - ], - ), - ), - const PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon(Icons.delete, size: 18, color: Colors.red), - SizedBox(width: 8), - Text('Delete', style: TextStyle(color: Colors.red)), - ], - ), - ), - ], - ), - ], - ), - onTap: () => _editSchedule(context, settings, index), - ); - }), - - // Add schedule button - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.add_circle_outline, - color: Colors.green, - size: 20, - ), - ), - title: const Text( - 'Add Schedule', - style: TextStyle(color: Colors.green), - ), - subtitle: Text( - 'Add a new grayscale schedule', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.black45, - ), - ), - onTap: () => _addSchedule(context, settings), - ), - - 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), - ), - _SwitchTile( - title: 'Persistent Notification', - subtitle: 'Show ongoing notification while using Instagram', - value: settings.notifyPersistent, - onChanged: (v) => settings.setNotifyPersistent(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 String? disabledSubtitle; - final bool enabled; - final VoidCallback onTap; - - const _SubmoduleTile({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - this.disabledSubtitle, - this.enabled = true, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: (enabled ? iconColor : Colors.grey).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), - ), - 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 _SwitchTile extends StatelessWidget { - final String title; - final String? subtitle; - final bool value; - final ValueChanged onChanged; - - const _SwitchTile({ - required this.title, - this.subtitle, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return SwitchListTile( - title: Text(title, style: const TextStyle(fontSize: 15)), - subtitle: subtitle != null - ? Text(subtitle ?? '', style: const TextStyle(fontSize: 12)) - : null, - value: value, - onChanged: onChanged, - ); - } -} - -class _ChoiceTile extends StatelessWidget { - final String title; - final T value; - final String label; - final List options; - final String Function(T value) optionLabel; - final ValueChanged onSelected; - - const _ChoiceTile({ - required this.title, - required this.value, - required this.label, - required this.options, - required this.optionLabel, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(title, style: const TextStyle(fontSize: 15)), - subtitle: Text(label, style: const TextStyle(fontSize: 12)), - trailing: PopupMenuButton( - initialValue: value, - onSelected: onSelected, - itemBuilder: (context) => options - .map( - (option) => PopupMenuItem( - value: option, - child: Text(optionLabel(option)), - ), - ) - .toList(), - child: const Icon(Icons.expand_more_rounded, size: 22), - ), - onTap: () async { - final selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: ListView( - shrinkWrap: true, - children: options - .map( - (option) => ListTile( - title: Text(optionLabel(option)), - trailing: option == value - ? const Icon(Icons.check_rounded) - : null, - onTap: () => Navigator.pop(context, option), - ), - ) - .toList(), - ), - ), - ); - if (selected != null) onSelected(selected); - }, - ); - } -} - -class _NumberEditTile extends StatelessWidget { - final String title; - final String label; - final int initialValue; - final int min; - final int max; - final String suffix; - final ValueChanged onSubmitted; - - const _NumberEditTile({ - required this.title, - required this.label, - required this.initialValue, - required this.min, - required this.max, - required this.suffix, - required this.onSubmitted, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(title, style: const TextStyle(fontSize: 15)), - subtitle: Text(label, style: const TextStyle(fontSize: 12)), - trailing: const Icon(Icons.edit_outlined, size: 20), - onTap: () async { - final controller = TextEditingController(text: '$initialValue'); - final result = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - suffixText: suffix, - helperText: '$min-$max $suffix', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - final parsed = int.tryParse(controller.text.trim()); - if (parsed == null) return; - Navigator.pop(dialogContext, parsed.clamp(min, max).toInt()); - }, - child: const Text('Save'), - ), - ], - ), - ); - controller.dispose(); - if (result != null) onSubmitted(result); - }, - ); - } -} - -class _VersionTile extends StatelessWidget { - const _VersionTile(); - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) => ListTile( - title: const Text('Version'), - trailing: Text( - snapshot.data?.version ?? 'โ€ฆ', - style: const TextStyle(color: Colors.grey), - ), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - final String title; - const _SectionHeader({required this.title}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Text( - title, - style: const TextStyle( - color: Colors.grey, - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ), - ); - } -} diff --git a/lib/screens/snapshot_manager_screen.dart b/lib/screens/snapshot_manager_screen.dart deleted file mode 100644 index 4169fea..0000000 --- a/lib/screens/snapshot_manager_screen.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../services/snapshot_service.dart'; -import '../services/level_service.dart'; -import 'offline_feed_viewer.dart'; - -/// Manages saved pages for offline viewing via WebView cache. -/// Gated behind Level 5. -class SnapshotManagerScreen extends StatelessWidget { - const SnapshotManagerScreen({super.key}); - - @override - Widget build(BuildContext context) { - final levelService = context.watch(); - final isUnlocked = levelService.level >= 5; // offline pages at L5 - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Offline Pages', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), - ), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 18), - onPressed: () => Navigator.pop(context), - ), - ), - body: isUnlocked - ? const _SavedPageList() - : Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline, - size: 64, - color: Colors.grey.withValues(alpha: 0.4), - ), - const SizedBox(height: 16), - const Text( - 'Unlocks at Level 5', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Earn XP to unlock offline browsing.\n' - 'Watch ads and reduce reel time to level up.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: isDark ? Colors.white54 : Colors.black54, - height: 1.5, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SavedPageList extends StatelessWidget { - const _SavedPageList(); - - @override - Widget build(BuildContext context) { - final snapshotService = context.watch(); - final isDark = Theme.of(context).brightness == Brightness.dark; - - return Column( - children: [ - // Info card - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.07), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.12)), - ), - child: Row( - children: [ - const Icon( - Icons.info_outline, - size: 16, - color: Colors.blueAccent, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'The WebView already caches pages you visit. ' - 'Save bookmarks here to easily reopen them when offline.\n' - 'No API needed โ€” the cache handles everything.', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white60 : Colors.black54, - height: 1.4, - ), - ), - ), - ], - ), - ), - - // Header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Row( - children: [ - Text( - '${snapshotService.totalSaved} saved page${snapshotService.totalSaved == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white54 : Colors.black54, - ), - ), - const Spacer(), - if (snapshotService.totalSaved > 0) - GestureDetector( - onTap: () => _confirmClearAll(context, snapshotService), - child: Text( - 'Clear all', - style: TextStyle( - fontSize: 12, - color: Colors.redAccent.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ), - - // Page list - Expanded( - child: snapshotService.savedPages.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.bookmark_border_rounded, - size: 48, - color: Colors.grey.withValues(alpha: 0.3), - ), - const SizedBox(height: 12), - Text( - 'No saved pages yet', - style: TextStyle( - color: isDark ? Colors.white38 : Colors.black38, - ), - ), - const SizedBox(height: 4), - Text( - 'Visit Instagram pages online, then save them here\nto browse offline later.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white24 : Colors.black26, - height: 1.4, - ), - ), - ], - ), - ) - : ListView.builder( - itemCount: snapshotService.savedPages.length, - itemBuilder: (context, index) { - final page = snapshotService.savedPages[index]; - return ListTile( - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.web_rounded, - color: Colors.blueAccent, - size: 22, - ), - ), - title: Text( - page.title, - style: const TextStyle(fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - _formatDate(page.savedAt), - style: const TextStyle(fontSize: 12), - ), - trailing: PopupMenuButton( - onSelected: (value) { - if (value == 'delete') { - _confirmDelete(context, snapshotService, page.id); - } else if (value == 'open') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => OfflineFeedViewer( - url: page.url, - pageId: page.id, - ), - ), - ); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'open', - child: Row( - children: [ - Icon(Icons.open_in_browser, size: 18), - SizedBox(width: 8), - Text('Open Offline'), - ], - ), - ), - const PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Icon( - Icons.delete_outline, - color: Colors.redAccent, - size: 18, - ), - SizedBox(width: 8), - Text( - 'Remove', - style: TextStyle(color: Colors.redAccent), - ), - ], - ), - ), - ], - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => OfflineFeedViewer(url: page.url), - ), - ); - }, - ); - }, - ), - ), - ], - ); - } - - void _confirmDelete( - BuildContext context, - SnapshotService service, - String id, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Remove page?'), - content: const Text( - 'Removes the bookmark. Cache is preserved automatically.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - service.deletePage(id); - Navigator.pop(ctx); - }, - child: const Text( - 'Remove', - style: TextStyle(color: Colors.redAccent), - ), - ), - ], - ), - ); - } - - void _confirmClearAll(BuildContext context, SnapshotService service) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Clear all saved pages?'), - content: const Text('This removes all bookmarks.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - service.deleteAll(); - Navigator.pop(ctx); - }, - child: const Text( - 'Clear', - style: TextStyle(color: Colors.redAccent), - ), - ), - ], - ), - ); - } - - String _formatDate(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - if (diff.inDays < 7) return '${diff.inDays}d ago'; - return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/screens/timer_fallback_screen.dart b/lib/screens/timer_fallback_screen.dart deleted file mode 100644 index 550148c..0000000 --- a/lib/screens/timer_fallback_screen.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// A 15-second timer that acts as the last-resort fallback -/// when both AdMob and Adsterra fail to serve an ad. -/// -/// Shows a digital wellness quote while the user waits. -/// After the timer, they earn the same reward. -class TimerFallbackScreen extends StatefulWidget { - final String sessionType; - final int requiredSeconds; - - const TimerFallbackScreen({ - super.key, - required this.sessionType, - this.requiredSeconds = 15, - }); - - @override - State createState() => _TimerFallbackScreenState(); -} - -class _TimerFallbackScreenState extends State { - int _remaining = 0; - Timer? _timer; - int _quoteIndex = 0; - - static const _quotes = [ - '"The secret of getting ahead is getting started." โ€” Mark Twain', - '"Focus on being productive instead of busy." โ€” Tim Ferriss', - '"Almost everything will work if you unplug it for a few minutes." โ€” Ann Lamott', - '"The key is not to prioritize what\'s on your schedule, but to schedule your priorities." โ€” Stephen Covey', - '"Your mind is for having ideas, not holding them." โ€” David Allen', - '"Simplicity is the ultimate sophistication." โ€” Leonardo da Vinci', - '"The ability to simplify means to eliminate the unnecessary." โ€” Hans Hofmann', - '"In the midst of chaos, there is also opportunity." โ€” Sun Tzu', - ]; - - @override - void initState() { - super.initState(); - _remaining = widget.requiredSeconds; - _quoteIndex = DateTime.now().millisecondsSinceEpoch % _quotes.length; - _startTimer(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - void _startTimer() { - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) return; - setState(() { - if (_remaining > 0) { - _remaining--; - } else { - _timer?.cancel(); - HapticFeedback.heavyImpact(); - } - }); - }); - } - - @override - Widget build(BuildContext context) { - final done = _remaining <= 0; - - return Scaffold( - backgroundColor: Colors.black, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Spacer(flex: 2), - - // Icon - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.green.withValues(alpha: 0.1), - border: Border.all( - color: Colors.green.withValues(alpha: 0.3), - width: 2, - ), - ), - child: Icon( - done ? Icons.check_circle : Icons.timer_outlined, - color: done ? Colors.greenAccent : Colors.green, - size: 36, - ), - ), - - const SizedBox(height: 28), - - // Timer - Text( - done ? 'Done!' : '$_remaining', - style: TextStyle( - color: done ? Colors.greenAccent : Colors.white, - fontSize: 56, - fontWeight: FontWeight.bold, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - - const SizedBox(height: 8), - Text( - done - ? 'You earned ${widget.sessionType == 'reels' ? 'reel' : 'Instagram'} time' - : 'Please wait while we prepare your reward', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 14, - ), - ), - - const SizedBox(height: 40), - - // Quote - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.white.withValues(alpha: 0.08), - ), - ), - child: Text( - _quotes[_quoteIndex], - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 15, - height: 1.5, - fontStyle: FontStyle.italic, - ), - ), - ), - - const Spacer(flex: 1), - - // Continue button - SizedBox( - width: double.infinity, - height: 54, - child: ElevatedButton.icon( - onPressed: done ? () => Navigator.pop(context, true) : null, - style: ElevatedButton.styleFrom( - backgroundColor: done ? Colors.greenAccent : Colors.grey, - foregroundColor: done ? Colors.black : Colors.white38, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 0, - ), - icon: Icon( - done ? Icons.check_circle : Icons.hourglass_empty, - size: 22, - ), - label: Text( - done - ? 'Continue & Earn Reward' - : 'Wait $_remaining seconds', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - ), - ), - - const SizedBox(height: 16), - Text( - 'No ad available โ€” timer reward instead', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.2), - fontSize: 11, - ), - ), - - const Spacer(flex: 1), - ], - ), - ), - ), - ); - } -} diff --git a/lib/scripts/autoplay_blocker.dart b/lib/scripts/autoplay_blocker.dart deleted file mode 100644 index 0a6c133..0000000 --- a/lib/scripts/autoplay_blocker.dart +++ /dev/null @@ -1,258 +0,0 @@ -/// JavaScript to block autoplaying videos on Instagram feed/explore while: -/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF -/// - Allowing user-initiated playback on click when blocking is ON -/// - NEVER blocking reels (they should always play normally per user request) -/// -/// This script: -/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises. -/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore). -/// - Returns Promise.resolve() for blocked autoplay calls (never throws). -/// - Uses a per-element flag set by user clicks to permanently allow that video to play. -/// - Strips the autoplay attribute from dynamically added