mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-17 21:34:42 +02:00
first commit
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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'
|
||||
@@ -0,0 +1,220 @@
|
||||
# FocusGram — Product Requirements Document (Canvas)
|
||||
|
||||
> Working title: **FocusGram**
|
||||
|
||||
---
|
||||
|
||||
## Product Type
|
||||
|
||||
Personal-use Flutter mobile application (Android). WebView wrapper around Instagram for private, distraction-free use.
|
||||
|
||||
## Primary Goal
|
||||
|
||||
Allow full use of Instagram (feed, stories, notes, DMs, profile) **without Reels or Explore distractions**, while preserving the ability to open a Reel **only when it is sent directly in a message**. Reels must not be discoverable anywhere else in the app.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Instagram's Reels and Explore experiences create compulsive, endless‑scroll behaviours. The user wants full functionality of Instagram *except* persistent exposure to Reels and similar autoplay distractions. Reels may be accessed intentionally and in a controlled way (session/time/cooldown), and Reels opened from DMs must not allow the user to scroll into other Reels.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Features (MVP + Integrated Phase 2)
|
||||
|
||||
These include all Phase 2 items integrated into the main product.
|
||||
|
||||
### 2.1 Embedded Instagram
|
||||
|
||||
* WebView loads `https://www.instagram.com`
|
||||
* JavaScript enabled
|
||||
* Custom user-agent to reduce login friction
|
||||
* Cookie/session persistence stored locally
|
||||
|
||||
### 2.2 Global Reel & Explore Blocking (always-on)
|
||||
|
||||
* Remove/hide the Reels tab, Explore tab, and any UI element that reveals Reels elsewhere (profile grid toggles that surface Reels, Explore cards, thumbnails linking to `/reel/`).
|
||||
* Block navigation to any URL containing `/reel/` or `/reels` unless in an active Reel session or when specifically opening a Reel message item.
|
||||
* Inject a persistent CSS style (`hide-reels-style`) + MutationObserver to remove dynamic elements injected by Instagram's SPA.
|
||||
|
||||
### 2.3 DM‑Reel Exception (one‑off, isolated playback)
|
||||
|
||||
* If a Reel URL is received via Direct Message (DM) and the user taps it in the message thread, the app will allow opening that single Reel in an isolated player overlay.
|
||||
* The isolated player must:
|
||||
|
||||
* Load only the single Reel content (not the Reels feed).
|
||||
* Disable gestures/controls that would navigate to other Reels (no left/right swipe to next Reel).
|
||||
* Provide explicit controls: Play/Pause, Close, Share (if desired).
|
||||
* Respect session/time/cooldown and count viewing duration toward limits.
|
||||
|
||||
### 2.4 Session & Daily Controls (customizable)
|
||||
|
||||
Provide settings and enforcement for controlled Reel consumption:
|
||||
|
||||
**Settings**
|
||||
|
||||
* Daily Total Reel Time (configurable, e.g., 0–120 minutes)
|
||||
* Per‑Session Reel Time Limit (configurable, e.g., 1–30 minutes)
|
||||
* Session Cooldown Time (configurable, e.g., 5–180 minutes) — the minimum wait between sessions
|
||||
* Session Shortcuts (preset buttons: 1, 5, 10, 15 minutes)
|
||||
|
||||
**Behavior/Enforcement**
|
||||
|
||||
* A session may be started by user explicitly (via FAB or DM Reel tap when allowed).
|
||||
* When a session starts, a countdown runs; when it reaches zero, the session ends and Reels are blocked again.
|
||||
* All viewing time (including DM‑opened Reel play) counts toward the daily total.
|
||||
* If daily total is exhausted, Reel sessions are blocked until midnight local device time, or until user increases limit in settings.
|
||||
* Cooldown prevents immediately starting a new session until cooldown expires. The cooldown may be overridden only by changing settings (confirmed by an intentional action) — optional: require PIN to override.
|
||||
|
||||
### 2.5 Additional Controls & UX
|
||||
|
||||
* Quick status indicator in app chrome showing: `Reels: blocked` / `Reels: session active (mm:ss left)` / `Daily left: XX min`.
|
||||
* Modal Reel Session UI: when enabling a session, present a small modal confirming session length, remaining daily minutes, and cooldown on completion.
|
||||
* Option to blur Reels instead of hide (toggle in settings) — still blocks navigation but visually indicates presence.
|
||||
* Option for long‑press unlock: user must long‑press the Reel Session button for 2 seconds to start a session (reduces impulsive enabling).
|
||||
|
||||
---
|
||||
|
||||
## 3. Non‑Goals
|
||||
|
||||
* No public distribution via Play Store (personal use only)
|
||||
* No scraping or automated interactions with Instagram
|
||||
* No use of private Instagram APIs
|
||||
* Not attempting to permanently alter Instagram servers or content
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements (detailed)
|
||||
|
||||
| ID | Requirement | Priority |
|
||||
| -- | ----------------------------------------------------------------------------------------- | -------- |
|
||||
| F1 | Load Instagram in WebView with persistent session | High |
|
||||
| F2 | Inject/maintain CSS to hide Reels/Explore everywhere | High |
|
||||
| F3 | Block navigation to `/reel` URLs globally unless ephemeral session or DM single‑Reel open | High |
|
||||
| F4 | Allow single‑Reel open from DM in isolated player (no swipe to other reels) | High |
|
||||
| F5 | Provide UI to start a Reel session limited by per‑session and daily settings | High |
|
||||
| F6 | Enforce session cooldowns between sessions | High |
|
||||
| F7 | Track and persist daily usage and session history locally | High |
|
||||
| F8 | Provide override/change settings with explicit confirm (optional PIN) | Medium |
|
||||
| F9 | Provide visual feedback and counters on main UI | High |
|
||||
|
||||
---
|
||||
|
||||
## 5. Technical Architecture
|
||||
|
||||
### Framework & Libraries
|
||||
|
||||
* Flutter (stable)
|
||||
* `webview_flutter` for WebView
|
||||
* `shared_preferences` for local persistence
|
||||
* `intl` for date handling and resets
|
||||
* Optional: `flutter_local_notifications` for session reminders/cooldown completion
|
||||
|
||||
### High-level Components
|
||||
|
||||
* **MainWebViewPage** — full‑screen WebView + top status bar + FAB for Reel Session
|
||||
* **InjectionController** — handles JS/CSS injection, MutationObserver lifecycle, and re‑apply logic
|
||||
* **NavigationGuard** — intercepts navigation requests and blocks `/reel` URLs when necessary
|
||||
* **ReelPlayerOverlay** — isolated player used only for opening Reel from DM (no swiping)
|
||||
* **SessionManager** — enforces per‑session timer, daily totals, cooldowns, and persistence
|
||||
* **Settings** — UI for user to configure daily limit, session length, cooldown, blur/hide toggle
|
||||
|
||||
### JS/CSS Injection Patterns (examples)
|
||||
|
||||
* Insert a single `style` element with id `hide-reels-style` containing selectors for `href*="/reel"`, `href*="/reels"`, Reels tab anchors and Explore cards.
|
||||
* MutationObserver that removes or hides any dynamically added nodes matching those selectors.
|
||||
* Example-safe selectors: `a[href*="/reel"], a[href*="/reels"], nav a[href*="/reels"], [role="button"] [aria-label*="Reels"]`.
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / Wireframes (textual)
|
||||
|
||||
**Main screen**
|
||||
|
||||
* WebView occupying most of the screen
|
||||
* Top compact status bar: `Reels: Blocked • Daily left: 45m` (tappable to open Session modal)
|
||||
* Floating Action Button (FAB) bottom-right: play icon — opens Reel Session modal
|
||||
|
||||
**Session modal**
|
||||
|
||||
* Presets: 1 / 5 / 10 / 15 minutes
|
||||
* Input to set custom minutes
|
||||
* Show `Daily left: X min` and `Cooldown: Y min remaining` if applicable
|
||||
* Confirm button: `Start Session`
|
||||
|
||||
**DM Reel tap flow**
|
||||
|
||||
* User taps Reel link in DM
|
||||
* If session active and daily left > 0 → open in ReelPlayerOverlay
|
||||
* If session inactive → show small prompt: `Open this Reel? This will start a 5‑minute session (or choose length).` Confirm to open; counts toward session & daily totals.
|
||||
|
||||
**End of session**
|
||||
|
||||
* Overlay message: `Session ended. Reels are blocked.` with cooldown timer
|
||||
* Option to extend session (only if daily minutes available and cooldown rules allow)
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model & Persistence
|
||||
|
||||
Stored locally via `shared_preferences` (or a small local DB if desired):
|
||||
|
||||
* `dailyDate` (YYYY-MM-DD) — date of last reset
|
||||
* `dailyUsedMinutes` (int)
|
||||
* `sessionActive` (bool) + `sessionExpiryTimestamp` (ms)
|
||||
* `lastSessionEndTimestamp` (ms)
|
||||
* `settings`: { dailyLimitMinutes, defaultSessionMinutes, cooldownMinutes, blurInsteadOfHide, requireLongPress }
|
||||
* `sessionHistory[]` (timestamp, duration) — optional, capped locally
|
||||
|
||||
Reset logic: check `dailyDate` on app start / resume; if different from local device date, reset `dailyUsedMinutes` to 0 and update `dailyDate`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases & Rules
|
||||
|
||||
* If a Reel message contains a playlist or multiple reels link, block additional navigation — only allow the primary Reel to load.
|
||||
* If Instagram tries to redirect from a DM Reel link into the Reels feed, intercept and force load of the single Reel content in `ReelPlayerOverlay`.
|
||||
* If login prompts or security interstitials appear in WebView (2FA / suspicious login), surface them to the user; do not attempt to automate.
|
||||
* If DOM selectors fail (Instagram update), fall back to broader `href*` checks and reapply; show a small banner to the user: `Reel blocker needs update` with troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Criteria
|
||||
|
||||
* Reels are not visible anywhere by default (tabs, explore, profile toggles)
|
||||
* Tapping a Reel sent in DM opens only that Reel and does not allow navigating to others
|
||||
* Session limits and daily totals are enforced reliably — user cannot bypass session/cooldown without changing settings and confirming
|
||||
* UX is intuitive: starting/stopping sessions, seeing remaining time, and cooldowns are clear
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of Done
|
||||
|
||||
* App loads Instagram and preserves login across restarts
|
||||
* Injected CSS/JS reliably hides Reels and blocks `/reel` navigation
|
||||
* DM‑opened Reel flow works as isolated playback with no swipe navigation to other reels
|
||||
* Session start/stop, daily enforcement, and cooldown behavior function and persist
|
||||
* Settings screen implemented and persisting user preferences
|
||||
|
||||
---
|
||||
|
||||
## 11. Next Steps (recommended)
|
||||
|
||||
1. Create minimal Flutter skeleton with `webview_flutter` and SessionManager stub
|
||||
2. Implement CSS/JS injection and test with local device Instagram login
|
||||
3. Implement NavigationGuard and ReelPlayerOverlay
|
||||
4. Add Settings and persistence
|
||||
5. Test DM Reel flows thoroughly (multiple DM formats, external links)
|
||||
6. Iterate selectors if Instagram DOM changes
|
||||
|
||||
---
|
||||
|
||||
## 12. Notes & Considerations
|
||||
|
||||
* This is for personal use only. Avoid publishing or distributing a wrapper app.
|
||||
* Instagram may change behaviours; expect occasional maintenance.
|
||||
* Consider adding a simple debug UI (visible only in dev builds) to reapply selectors and show blocked navigation attempts.
|
||||
|
||||
---
|
||||
|
||||
*End of PRD.*
|
||||
@@ -0,0 +1,16 @@
|
||||
# focusgram
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
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.focusgram.focusgram"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.focusgram.focusgram"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,43 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:label="FocusGram"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Flutter tool meta-data -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.focusgram.focusgram
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Only allow HTTPS connections -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<!-- Allow Instagram domains explicitly -->
|
||||
<domain-config cleartextTrafficPermitted="false">
|
||||
<domain includeSubdomains="true">instagram.com</domain>
|
||||
<domain includeSubdomains="true">cdninstagram.com</domain>
|
||||
<domain includeSubdomains="true">fbcdn.net</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
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")
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'services/session_manager.dart';
|
||||
import 'services/settings_service.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';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Lock to portrait
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
final sessionManager = SessionManager();
|
||||
final settingsService = SettingsService();
|
||||
|
||||
await sessionManager.init();
|
||||
await settingsService.init();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: sessionManager),
|
||||
ChangeNotifierProvider.value(value: settingsService),
|
||||
],
|
||||
child: const FocusGramApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class FocusGramApp extends StatelessWidget {
|
||||
const FocusGramApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'FocusGram',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: Colors.blue.shade400,
|
||||
surface: Colors.black,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
useMaterial3: true,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
home: const InitialRouteHandler(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flow on every cold open:
|
||||
/// 1. Cooldown Gate (if app-open cooldown active)
|
||||
/// 2. Breath Gate (if enabled in settings)
|
||||
/// 3. App Session Picker (always)
|
||||
/// 4. Main WebView
|
||||
class InitialRouteHandler extends StatefulWidget {
|
||||
const InitialRouteHandler({super.key});
|
||||
|
||||
@override
|
||||
State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
|
||||
}
|
||||
|
||||
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
bool _breathCompleted = false;
|
||||
bool _appSessionStarted = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
final settings = context.watch<SettingsService>();
|
||||
|
||||
// Step 1: Cooldown gate — if too soon since last session
|
||||
if (sm.isAppOpenCooldownActive) {
|
||||
return const CooldownGateScreen();
|
||||
}
|
||||
|
||||
// Step 2: Breath gate
|
||||
if (settings.showBreathGate && !_breathCompleted) {
|
||||
return BreathGateScreen(
|
||||
onFinish: () => setState(() => _breathCompleted = true),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: App session picker
|
||||
if (!_appSessionStarted) {
|
||||
return AppSessionPickerScreen(
|
||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Main app
|
||||
return const MainWebViewPage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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<AppSessionPickerScreen> createState() => _AppSessionPickerScreenState();
|
||||
}
|
||||
|
||||
class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
static final List<int> _minuteOptions = [
|
||||
5,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
45,
|
||||
50,
|
||||
55,
|
||||
60,
|
||||
];
|
||||
int _selectedIndex = 2; // default: 15 min
|
||||
|
||||
@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,
|
||||
onSelectedItemChanged: (i) {
|
||||
setState(() => _selectedIndex = i);
|
||||
},
|
||||
controller: FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
),
|
||||
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<SessionManager>().startAppSession(minutes);
|
||||
widget.onSessionStarted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 8-second breath.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
}
|
||||
|
||||
class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
int _secondsRemaining = 8;
|
||||
Timer? _timer;
|
||||
bool _canContinue = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 8-second breathing animation: 4s in, 4s out
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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<CooldownGateScreen> createState() => _CooldownGateScreenState();
|
||||
}
|
||||
|
||||
class _CooldownGateScreenState extends State<CooldownGateScreen> {
|
||||
Timer? _timer;
|
||||
static const List<String> _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<SessionManager>();
|
||||
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: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// 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',
|
||||
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 Spacer(flex: 1),
|
||||
|
||||
// Quote
|
||||
Text(
|
||||
_quote,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white30,
|
||||
fontSize: 13,
|
||||
height: 1.7,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/injection_controller.dart';
|
||||
import '../services/navigation_guard.dart';
|
||||
import 'session_modal.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'reel_player_overlay.dart';
|
||||
|
||||
class MainWebViewPage extends StatefulWidget {
|
||||
const MainWebViewPage({super.key});
|
||||
|
||||
@override
|
||||
State<MainWebViewPage> createState() => _MainWebViewPageState();
|
||||
}
|
||||
|
||||
class _MainWebViewPageState extends State<MainWebViewPage> {
|
||||
late final WebViewController _controller;
|
||||
int _currentIndex = 0;
|
||||
bool _isLoading = true;
|
||||
|
||||
// Cached username for profile navigation
|
||||
String? _cachedUsername;
|
||||
|
||||
// Watchdog for app-session expiry
|
||||
Timer? _watchdog;
|
||||
bool _extensionDialogShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initWebView();
|
||||
_startWatchdog();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_watchdog?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startWatchdog() {
|
||||
_watchdog = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
if (!mounted) return;
|
||||
final sm = context.read<SessionManager>();
|
||||
if (sm.isAppSessionExpired && !_extensionDialogShown) {
|
||||
_extensionDialogShown = true;
|
||||
_showSessionExpiredDialog(sm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showSessionExpiredDialog(SessionManager sm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => 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: () {
|
||||
Navigator.pop(context);
|
||||
sm.endAppSession();
|
||||
SystemNavigator.pop(); // Force close
|
||||
},
|
||||
child: const Text(
|
||||
'Close App',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
if (sm.canExtendAppSession)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
sm.extendAppSession();
|
||||
_extensionDialogShown =
|
||||
false; // Reset so watchdog can fire again at next expiry
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('+10 minutes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
final sessionManager = context.read<SessionManager>();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(InjectionController.iOSUserAgent)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
// Only show loading if it's a real page load (not SPA nav)
|
||||
if (!url.contains('#')) {
|
||||
if (mounted) setState(() => _isLoading = true);
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
_applyInjections();
|
||||
_updateCurrentTab(url);
|
||||
// Cache username whenever we finish loading any page
|
||||
_cacheUsername();
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
final isDmReel = NavigationGuard.isDmReelLink(request.url);
|
||||
|
||||
final decision = NavigationGuard.evaluate(
|
||||
url: request.url,
|
||||
sessionActive: sessionManager.isSessionActive,
|
||||
isDmReelException: isDmReel,
|
||||
);
|
||||
|
||||
if (decision.blocked) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(decision.reason ?? 'Blocked'),
|
||||
backgroundColor: Colors.red.shade900,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
// Open DM reel in isolated player
|
||||
if (isDmReel && !sessionManager.isSessionActive) {
|
||||
final canonicalUrl = NavigationGuard.canonicalizeDmReelUrl(
|
||||
request.url,
|
||||
);
|
||||
if (canonicalUrl != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReelPlayerOverlay(url: canonicalUrl),
|
||||
),
|
||||
);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse('https://www.instagram.com/'));
|
||||
}
|
||||
|
||||
void _applyInjections() {
|
||||
final sessionManager = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
final js = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionManager.isSessionActive,
|
||||
blurExplore: settings.blurExplore,
|
||||
);
|
||||
_controller.runJavaScript(js);
|
||||
}
|
||||
|
||||
Future<void> _cacheUsername() async {
|
||||
if (_cachedUsername != null) return; // Already known
|
||||
try {
|
||||
final result = await _controller.runJavaScriptReturningResult(
|
||||
InjectionController.getLoggedInUsernameJS,
|
||||
);
|
||||
final raw = result.toString().replaceAll('"', '').replaceAll("'", '');
|
||||
if (raw.isNotEmpty && raw != 'null' && raw != 'undefined') {
|
||||
_cachedUsername = raw;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _updateCurrentTab(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
final path = uri.path;
|
||||
|
||||
int newIndex = _currentIndex;
|
||||
if (path == '/' || path.isEmpty) {
|
||||
newIndex = 0;
|
||||
} else if (path.startsWith('/explore') || path.startsWith('/search')) {
|
||||
newIndex = 1;
|
||||
} else if (path.startsWith('/direct')) {
|
||||
newIndex = 3;
|
||||
} else if (_cachedUsername != null &&
|
||||
path.startsWith('/$_cachedUsername')) {
|
||||
newIndex = 4;
|
||||
}
|
||||
|
||||
if (newIndex != _currentIndex) {
|
||||
setState(() => _currentIndex = newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate using JS when already on Instagram (avoids full page reload).
|
||||
/// Falls back to loadRequest if not on instagram.com.
|
||||
Future<void> _navigateTo(String path) async {
|
||||
try {
|
||||
final currentUrl = await _controller.currentUrl();
|
||||
if (currentUrl != null && currentUrl.contains('instagram.com')) {
|
||||
// SPA soft nav — instant, no full reload
|
||||
await _controller.runJavaScript(
|
||||
InjectionController.softNavigateJS(path),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: full load
|
||||
await _controller.loadRequest(Uri.parse('https://www.instagram.com$path'));
|
||||
}
|
||||
|
||||
Future<void> _onTabTapped(int index) async {
|
||||
// Don't re-navigate if already on this tab
|
||||
if (index == _currentIndex) return;
|
||||
setState(() => _currentIndex = index);
|
||||
|
||||
switch (index) {
|
||||
case 0:
|
||||
await _navigateTo('/');
|
||||
break;
|
||||
case 1:
|
||||
await _navigateTo('/explore/search/');
|
||||
break;
|
||||
case 2:
|
||||
// Try to click Instagram's create button via JS
|
||||
try {
|
||||
await _controller.runJavaScript(
|
||||
InjectionController.clickCreateButtonJS,
|
||||
);
|
||||
} catch (_) {
|
||||
await _navigateTo('/');
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
await _navigateTo('/direct/inbox/');
|
||||
break;
|
||||
case 4:
|
||||
if (_cachedUsername != null) {
|
||||
await _navigateTo('/$_cachedUsername/');
|
||||
} else {
|
||||
// Try to get username first then navigate
|
||||
await _cacheUsername();
|
||||
if (_cachedUsername != null) {
|
||||
await _navigateTo('/$_cachedUsername/');
|
||||
} else {
|
||||
// Last fallback: navigate to accounts/edit — usually has username
|
||||
await _navigateTo('/accounts/edit/');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// Status Bar — always on top
|
||||
_StatusBar(),
|
||||
|
||||
// WebView
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
// Thin loading bar (not full-screen spinner)
|
||||
if (_isLoading)
|
||||
const LinearProgressIndicator(
|
||||
backgroundColor: Colors.transparent,
|
||||
color: Colors.blue,
|
||||
minHeight: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _FocusGramNavBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: _onTabTapped,
|
||||
),
|
||||
floatingActionButton: _SessionFAB(onTap: _openSessionModal),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
}
|
||||
|
||||
void _openSessionModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const SessionModal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Status Bar Widget — only rebuilds when session state changes
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
String label;
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
|
||||
if (sm.isSessionActive) {
|
||||
final m = sm.remainingSessionSeconds ~/ 60;
|
||||
final s = sm.remainingSessionSeconds % 60;
|
||||
label =
|
||||
'Reels: ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
dotColor = Colors.greenAccent;
|
||||
dotIcon = Icons.play_circle_outline;
|
||||
} else if (sm.isCooldownActive) {
|
||||
final m = sm.cooldownRemainingSeconds ~/ 60;
|
||||
label = 'Cooldown: ${m}m left';
|
||||
dotColor = Colors.orangeAccent;
|
||||
dotIcon = Icons.timer_outlined;
|
||||
} else {
|
||||
label = 'Reels Blocked';
|
||||
dotColor = Colors.redAccent;
|
||||
dotIcon = Icons.block;
|
||||
}
|
||||
|
||||
// App session indicator
|
||||
final appM = sm.appSessionRemainingSeconds ~/ 60;
|
||||
final appS = sm.appSessionRemainingSeconds % 60;
|
||||
final appLabel = sm.isAppSessionActive
|
||||
? 'App: ${appM.toString().padLeft(2, '0')}:${appS.toString().padLeft(2, '0')}'
|
||||
: '';
|
||||
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: Colors.black,
|
||||
child: Row(
|
||||
children: [
|
||||
// Status dot
|
||||
Icon(dotIcon, color: dotColor, size: 13),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: dotColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// App session timer
|
||||
if (appLabel.isNotEmpty)
|
||||
Text(
|
||||
appLabel,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
if (appLabel.isNotEmpty) const SizedBox(width: 10),
|
||||
// Daily reel usage
|
||||
Text(
|
||||
'Daily: ${sm.dailyRemainingSeconds ~/ 60}m',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Settings icon
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
),
|
||||
child: const Icon(Icons.tune, color: Colors.white38, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Custom Bottom Nav Bar — minimal, Instagram-like
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FocusGramNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final Future<void> Function(int) onTap;
|
||||
|
||||
const _FocusGramNavBar({required this.currentIndex, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
(Icons.home_outlined, Icons.home_rounded, 'Home'),
|
||||
(Icons.search, Icons.search, 'Search'),
|
||||
(Icons.add_box_outlined, Icons.add_box_rounded, 'Create'),
|
||||
(Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'),
|
||||
(Icons.person_outline, Icons.person, 'Profile'),
|
||||
];
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final (outlinedIcon, filledIcon, label) = entry.value;
|
||||
final isSelected = i == currentIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => onTap(i),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isSelected ? filledIcon : outlinedIcon,
|
||||
color: isSelected ? Colors.white : Colors.white54,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Session FAB
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SessionFAB extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _SessionFAB({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
final settings = context.watch<SettingsService>();
|
||||
|
||||
if (sm.isSessionActive) {
|
||||
// Show "end session" button when session is active
|
||||
return FloatingActionButton.small(
|
||||
backgroundColor: Colors.green.shade700,
|
||||
onPressed: () => sm.endSession(),
|
||||
child: const Icon(Icons.stop, color: Colors.white, size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
final fab = FloatingActionButton.small(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
onPressed: settings.requireLongPress ? null : onTap,
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
);
|
||||
|
||||
if (settings.requireLongPress) {
|
||||
return GestureDetector(onLongPress: onTap, child: fab);
|
||||
}
|
||||
return fab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.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<ReelPlayerOverlay> createState() => _ReelPlayerOverlayState();
|
||||
}
|
||||
|
||||
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
late final WebViewController _controller;
|
||||
DateTime? _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTime = DateTime.now();
|
||||
_initWebView();
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(InjectionController.iOSUserAgent)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (url) {
|
||||
// Apply scroll-lock: prevents swiping to next reel in the feed
|
||||
_controller.runJavaScript(
|
||||
InjectionController.reelScrollLockJS(widget.url),
|
||||
);
|
||||
// Also hide Instagram's bottom nav inside this overlay
|
||||
_controller.runJavaScript(
|
||||
InjectionController.buildInjectionJS(
|
||||
sessionActive: true,
|
||||
blurExplore: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
// Allow only the initial reel URL and instagram.com generally
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.prevent;
|
||||
final host = uri.host;
|
||||
if (!host.contains('instagram.com')) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.url));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Record viewing time toward daily count
|
||||
if (_startTime != null) {
|
||||
final durationSeconds = DateTime.now().difference(_startTime!).inSeconds;
|
||||
if (mounted) {
|
||||
context.read<SessionManager>().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: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
|
||||
class SessionModal extends StatefulWidget {
|
||||
const SessionModal({super.key});
|
||||
|
||||
@override
|
||||
State<SessionModal> createState() => _SessionModalState();
|
||||
}
|
||||
|
||||
class _SessionModalState extends State<SessionModal> {
|
||||
double _customMinutes = 5.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
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),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [1, 5, 10, 15].map((m) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
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: 30,
|
||||
divisions: 29,
|
||||
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) {
|
||||
final sm = context.read<SessionManager>();
|
||||
if (sm.startSession(minutes)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsService>();
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text(
|
||||
'FocusGram',
|
||||
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: [
|
||||
// ── Stats row ───────────────────────────────────────────
|
||||
_buildStatsRow(sm),
|
||||
|
||||
// ── Consumption Limits ──────────────────────────────────
|
||||
_buildSectionHeader('Reel Consumption Limits'),
|
||||
_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,
|
||||
warningText:
|
||||
'Increasing your daily limit may make it easier to mindlessly scroll. Are you sure?',
|
||||
onConfirmed: (v) => 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,
|
||||
warningText:
|
||||
'Reducing the cooldown makes it easier to start new reel sessions. Are you sure?',
|
||||
onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()),
|
||||
),
|
||||
|
||||
// ── Distraction Management ──────────────────────────────
|
||||
_buildSectionHeader('Distraction Management'),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Blur Explore feed',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Blurs posts and reels in Explore by default',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.blurExplore,
|
||||
onChanged: (v) => settings.setBlurExplore(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
|
||||
// ── Friction & Discipline ───────────────────────────────
|
||||
_buildSectionHeader('Friction & Discipline'),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Mindfulness Gate',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Show breathing exercise before opening Instagram',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.showBreathGate,
|
||||
onChanged: (v) => settings.setShowBreathGate(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Long-press to start Reel session',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Requires 2s hold on the play button',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.requireLongPress,
|
||||
onChanged: (v) => settings.setRequireLongPress(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white10, height: 40),
|
||||
|
||||
// ── Danger zone ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _confirmReset(context, sm),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red.withAlpha(
|
||||
(255 * 0.08).round(),
|
||||
), // Changed from withOpacity
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent, width: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('Reset Daily Usage Counter'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const Center(
|
||||
child: Text(
|
||||
'FocusGram · Built for discipline',
|
||||
style: TextStyle(color: Colors.white12, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsRow(SessionManager sm) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF111111),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statCell(String label, String value, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dividerCell() =>
|
||||
Container(width: 1, height: 36, color: Colors.white10);
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A slider tile that shows a friction dialog before accepting a larger value.
|
||||
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 String warningText,
|
||||
required Future<void> Function(double) onConfirmed,
|
||||
}) {
|
||||
return _FrictionSliderTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
divisor: divisor,
|
||||
warningText: warningText,
|
||||
onConfirmed: onConfirmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmReset(BuildContext context, SessionManager sm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text(
|
||||
'Reset Counter?',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const Text(
|
||||
'This will reset your daily reel usage to zero minutes.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
sm.resetDailyCounter();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text(
|
||||
'Reset',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful slider tile that shows a friction dialog when the user moves the
|
||||
/// slider to a value greater than the current persisted value.
|
||||
class _FrictionSliderTile extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int divisor;
|
||||
final String warningText;
|
||||
final Future<void> 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.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);
|
||||
// Keep draft in sync if external value changed (e.g. after reset)
|
||||
if (!_pendingConfirm) _draftValue = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final divisions = ((widget.max - widget.min) / widget.divisor).round();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${_draftValue.toInt()} min',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
trailing: _pendingConfirm
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_draftValue = widget.value;
|
||||
_pendingConfirm = false;
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
setState(() => _pendingConfirm = false);
|
||||
await widget.onConfirmed(_draftValue);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'Apply',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (_pendingConfirm)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
widget.warningText,
|
||||
style: TextStyle(
|
||||
color: Colors.orangeAccent.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Slider(
|
||||
value: _draftValue,
|
||||
min: widget.min,
|
||||
max: widget.max,
|
||||
divisions: divisions,
|
||||
activeColor: _pendingConfirm ? Colors.orange : Colors.blue,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_draftValue = v;
|
||||
// Show friction warning when moving to a larger (more permissive) value
|
||||
_pendingConfirm = v > widget.value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (v) {
|
||||
// If decreasing (more strict), apply immediately without dialog
|
||||
if (v <= widget.value) {
|
||||
widget.onConfirmed(v);
|
||||
setState(() => _pendingConfirm = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/// Generates all CSS and JavaScript injection strings for the WebView.
|
||||
///
|
||||
/// Strategy:
|
||||
/// - Instagram's own bottom nav bar is hidden via both CSS and a periodic JS
|
||||
/// removal loop, since SPA re-renders can outpace MutationObserver.
|
||||
/// - Reel elements are hidden/blurred based on settings/session state.
|
||||
/// - A MutationObserver keeps re-applying the rules after SPA re-renders.
|
||||
/// - App-install banners are auto-dismissed.
|
||||
class InjectionController {
|
||||
/// iOS Safari user-agent — reduces login friction with Instagram.
|
||||
static const String iOSUserAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Version/17.5 Mobile/15E148 Safari/604.1';
|
||||
|
||||
// ── CSS injection ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Robust CSS that hides Instagram's native bottom nav bar.
|
||||
/// Covers all known selector patterns including dynamic class names.
|
||||
static const String _hideInstagramNavCSS = '''
|
||||
/* ── Instagram bottom navigation bar — hide completely ── */
|
||||
/* Role-based selectors */
|
||||
div[role="tablist"],
|
||||
nav[role="navigation"],
|
||||
/* Fixed-position bottom bar */
|
||||
div[style*="position: fixed"][style*="bottom"],
|
||||
div[style*="position:fixed"][style*="bottom"],
|
||||
/* Instagram legacy class names */
|
||||
._acbl, ._aa4b, ._aahi, ._ab8s,
|
||||
/* Section nav elements */
|
||||
section nav,
|
||||
/* Any nav inside the main app shell */
|
||||
#react-root nav,
|
||||
/* The outer wrapper of the bottom bar (PWA/mobile web) */
|
||||
[class*="x1n2onr6"][class*="x1vjfegm"] > nav,
|
||||
/* Catch-all: any fixed bottom element containing nav links */
|
||||
footer nav,
|
||||
div[class*="bottom"] nav {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* Ensure the body doesn't add bottom padding for the nav */
|
||||
body, #react-root, main {
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// CSS to hide Reel-related elements everywhere (feed, profile, search).
|
||||
/// Used when session is NOT active.
|
||||
static const String _hideReelsCSS = '''
|
||||
/* Hide reel thumbnails and links */
|
||||
a[href*="/reel/"],
|
||||
a[href*="/reels"],
|
||||
[aria-label*="Reel"],
|
||||
[aria-label*="Reels"],
|
||||
div[data-media-type="2"],
|
||||
/* Profile grid reel filter tabs */
|
||||
[aria-label="Reels"],
|
||||
/* Reel indicators on feed thumbnails */
|
||||
svg[aria-label="Reels"],
|
||||
/* Video/reel chips in feed */
|
||||
[class*="reel"],
|
||||
[class*="Reel"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// CSS to blur Explore feed posts/reels (keeps stories visible).
|
||||
static const String _blurExploreCSS = '''
|
||||
/* Blur Explore grid posts and reel cards (not stories row) */
|
||||
main[role="main"] section > div > div:not(:first-child) a img,
|
||||
main[role="main"] section > div > div:not(:first-child) video,
|
||||
main[role="main"] section > div > div:not(:first-child) [class*="x6s0dn4"],
|
||||
main[role="main"] article img,
|
||||
main[role="main"] article video,
|
||||
/* Explore page grid */
|
||||
._aagv img,
|
||||
._aagv video {
|
||||
filter: blur(12px) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
/* Overlay to block tapping blurred content */
|
||||
._aagv::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
._aagv {
|
||||
position: relative !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Auto-dismiss "Open in App" banner that Instagram shows in mobile browsers.
|
||||
static const String _dismissAppBannerJS = '''
|
||||
(function dismissBanners() {
|
||||
const selectors = [
|
||||
'[id*="app-banner"]',
|
||||
'[class*="app-banner"]',
|
||||
'[data-testid*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]',
|
||||
'div[role="dialog"][aria-label*="App"]',
|
||||
];
|
||||
selectors.forEach(sel => {
|
||||
document.querySelectorAll(sel).forEach(el => el.remove());
|
||||
});
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Periodic remover: every 500ms force-removes the bottom nav.
|
||||
/// Complements the MutationObserver for sites that rebuild DOM faster.
|
||||
static const String _periodicNavRemoverJS = '''
|
||||
(function periodicNavRemove() {
|
||||
function removeNav() {
|
||||
// Target all fixed-bottom elements that could be the nav bar
|
||||
document.querySelectorAll([
|
||||
'div[role="tablist"]',
|
||||
'nav[role="navigation"]',
|
||||
'._acbl', '._aa4b', '._aahi', '._ab8s',
|
||||
'section nav',
|
||||
'footer nav'
|
||||
].join(',')).forEach(function(el) {
|
||||
el.style.cssText += ';display:none!important;height:0!important;overflow:hidden!important;';
|
||||
});
|
||||
// Also hide any element that is fixed at the bottom and contains nav links
|
||||
document.querySelectorAll('div[style]').forEach(function(el) {
|
||||
const s = el.style;
|
||||
if ((s.position === 'fixed' || s.position === 'sticky') &&
|
||||
(s.bottom === '0px' || s.bottom === '0') &&
|
||||
el.querySelector('a,button')) {
|
||||
el.style.cssText += ';display:none!important;';
|
||||
}
|
||||
});
|
||||
}
|
||||
removeNav();
|
||||
setInterval(removeNav, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// MutationObserver that continuously re-applies CSS after SPA re-renders.
|
||||
static String _buildMutationObserver(String cssContent) =>
|
||||
'''
|
||||
(function applyFocusGramStyles() {
|
||||
const STYLE_ID = 'focusgram-injected-style';
|
||||
|
||||
function injectCSS() {
|
||||
let el = document.getElementById(STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = ${_escapeJsString(cssContent)};
|
||||
}
|
||||
|
||||
injectCSS();
|
||||
|
||||
const observer = new MutationObserver(function() {
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
injectCSS();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
})();
|
||||
''';
|
||||
|
||||
static String _escapeJsString(String s) {
|
||||
// Wrap in JS template literal backticks; escape any internal backticks.
|
||||
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
// ── Navigation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// JS that soft-navigates Instagram's SPA without a full page reload.
|
||||
/// [path] should start with / e.g. '/direct/inbox/'.
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
const target = ${_escapeJsString(path)};
|
||||
// Try React Router / Instagram SPA navigation first (pushState trick)
|
||||
if (window.location.pathname !== target) {
|
||||
window.location.href = target;
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// JS to click Instagram's native "create post" button.
|
||||
static const String clickCreateButtonJS = '''
|
||||
(function() {
|
||||
const btn = document.querySelector(
|
||||
'[aria-label="New post"], [aria-label="Create"], svg[aria-label="New post"]'
|
||||
);
|
||||
if (btn) {
|
||||
btn.closest('a, button') ? btn.closest('a, button').click() : btn.click();
|
||||
} else {
|
||||
// Fallback: navigate to home first, create will open as modal
|
||||
window.location.href = '/';
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
/// JS to get the currently logged-in user's username.
|
||||
static const String getLoggedInUsernameJS = '''
|
||||
(function() {
|
||||
try {
|
||||
// Try shared data approach
|
||||
const scripts = Array.from(document.querySelectorAll('script[type="application/json"]'));
|
||||
for (const s of scripts) {
|
||||
try {
|
||||
const d = JSON.parse(s.textContent);
|
||||
if (d && d.config && d.config.viewer && d.config.viewer.username) {
|
||||
return d.config.viewer.username;
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
// Try window additionalDataLoaded
|
||||
if (window.__additionalDataLoaded) {
|
||||
const keys = Object.keys(window.__additionalDataLoaded || {});
|
||||
for (const k of keys) {
|
||||
const v = window.__additionalDataLoaded[k];
|
||||
if (v && v.data && v.data.user && v.data.user.username) {
|
||||
return v.data.user.username;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: try profile anchor in nav
|
||||
const profileLink = document.querySelector('a[href][aria-label*="rofile"]');
|
||||
if (profileLink) {
|
||||
const href = profileLink.getAttribute('href');
|
||||
if (href) {
|
||||
const parts = href.replace(/^[/]/, "").split("/");
|
||||
if (parts[0] && parts[0].length > 0) return parts[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch(e) { return null; }
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Reel scroll-lock ────────────────────────────────────────────────────────
|
||||
|
||||
/// JS that prevents the user from scrolling to a different reel.
|
||||
/// Intercepts history changes — if a /reel/ URL changes, navigate back.
|
||||
static String reelScrollLockJS(String canonicalUrl) {
|
||||
final escapedUrl = _escapeJsString(canonicalUrl);
|
||||
return '''
|
||||
(function lockReel() {
|
||||
const LOCKED_URL = $escapedUrl;
|
||||
function extractReelId(url) {
|
||||
const m = url.match(/\\/reel\\/([^\\/\\?#]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
const lockedId = extractReelId(LOCKED_URL);
|
||||
if (!lockedId) return;
|
||||
|
||||
// Override pushState and replaceState
|
||||
const _pushState = history.pushState.bind(history);
|
||||
const _replaceState = history.replaceState.bind(history);
|
||||
|
||||
function checkAndRevert(newUrl) {
|
||||
const newId = extractReelId(newUrl || window.location.href);
|
||||
if (newId && newId !== lockedId) {
|
||||
// Different reel — go back to ours
|
||||
setTimeout(function() {
|
||||
window.location.replace(LOCKED_URL);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
history.pushState = function(state, title, url) {
|
||||
_pushState(state, title, url);
|
||||
checkAndRevert(url);
|
||||
};
|
||||
history.replaceState = function(state, title, url) {
|
||||
_replaceState(state, title, url);
|
||||
checkAndRevert(url);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function() {
|
||||
checkAndRevert(window.location.href);
|
||||
});
|
||||
|
||||
// Also disable vertical swipe gestures that drive reel-to-reel
|
||||
let startY = 0;
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
startY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (Math.abs(dy) > 20) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
/// JS to disable swipe-to-next behavior inside the isolated Reel player.
|
||||
static const String disableReelSwipeJS = '''
|
||||
(function disableSwipeNavigation() {
|
||||
let startX = 0;
|
||||
document.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, {passive: true});
|
||||
document.addEventListener('touchmove', e => {
|
||||
const dx = Math.abs(e.touches[0].clientX - startX);
|
||||
if (dx > 30) e.preventDefault();
|
||||
}, {passive: false});
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full injection JS to run on every page load.
|
||||
static String buildInjectionJS({
|
||||
required bool sessionActive,
|
||||
required bool blurExplore,
|
||||
}) {
|
||||
final StringBuffer css = StringBuffer();
|
||||
css.write(_hideInstagramNavCSS);
|
||||
if (!sessionActive) css.write(_hideReelsCSS);
|
||||
if (blurExplore) css.write(_blurExploreCSS);
|
||||
|
||||
return '''
|
||||
${_buildMutationObserver(css.toString())}
|
||||
$_periodicNavRemoverJS
|
||||
$_dismissAppBannerJS
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/// Determines whether a navigation request should be blocked.
|
||||
///
|
||||
/// Rules:
|
||||
/// - /reels/* and /reel/* are blocked unless [sessionActive] is true OR
|
||||
/// [isDmReelException] is true (single DM reel open).
|
||||
/// - /explore/ is allowed (but explore content is blurred via CSS).
|
||||
/// - Only instagram.com domains are allowed (blocks external redirects).
|
||||
class NavigationGuard {
|
||||
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
|
||||
|
||||
static const _blockedPathPrefixes = ['/reels', '/reel/'];
|
||||
|
||||
/// Returns a [BlockDecision] for the given [url].
|
||||
static BlockDecision evaluate({
|
||||
required String url,
|
||||
required bool sessionActive,
|
||||
required bool isDmReelException,
|
||||
}) {
|
||||
Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(url);
|
||||
} catch (_) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Allow non-HTTP schemes (about:blank, data:, etc.)
|
||||
if (!uri.scheme.startsWith('http')) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
// Block non-Instagram domains (prevents phishing redirects)
|
||||
final host = uri.host.toLowerCase();
|
||||
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
|
||||
return BlockDecision(
|
||||
blocked: true,
|
||||
reason: 'External domain blocked: $host',
|
||||
);
|
||||
}
|
||||
|
||||
// Check reel/reels path
|
||||
final path = uri.path.toLowerCase();
|
||||
final isReelUrl = _blockedPathPrefixes.any((p) => path.startsWith(p));
|
||||
|
||||
if (isReelUrl) {
|
||||
if (sessionActive || isDmReelException) {
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
return BlockDecision(
|
||||
blocked: true,
|
||||
reason: 'Reel navigation blocked — no active session',
|
||||
);
|
||||
}
|
||||
|
||||
return BlockDecision(blocked: false, reason: null);
|
||||
}
|
||||
|
||||
/// Returns true if the URL looks like a Reel link from a DM.
|
||||
static bool isDmReelLink(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final path = uri.path.toLowerCase();
|
||||
return path.startsWith('/reel/') || path.startsWith('/reels/');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a canonical single-reel URL from a DM reel link.
|
||||
/// Strips query params that might trigger Reels feed.
|
||||
static String? canonicalizeDmReelUrl(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
// Keep only the reel path, strip all query parameters
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'www.instagram.com',
|
||||
path: uri.path,
|
||||
).toString();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BlockDecision {
|
||||
final bool blocked;
|
||||
final String? reason;
|
||||
const BlockDecision({required this.blocked, required this.reason});
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Manages all session logic for FocusGram:
|
||||
///
|
||||
/// **App Session** — how long the user plans to use Instagram today.
|
||||
/// Started by the AppSessionPicker on every cold open.
|
||||
/// Enforced with a watchdog timer; one 10-min extension allowed.
|
||||
/// Cooldown enforced between app-opens.
|
||||
///
|
||||
/// **Reel Session** — a period during which reels are unblocked.
|
||||
/// Started manually by the user via the FAB.
|
||||
/// Deducted from the daily reel quota.
|
||||
class SessionManager extends ChangeNotifier {
|
||||
// ── Reel-session keys ──────────────────────────────────────
|
||||
static const _keyDailyDate = 'sessn_daily_date';
|
||||
static const _keyDailyUsedSeconds = 'sessn_daily_used_sec';
|
||||
static const _keySessionExpiry = 'sessn_expiry_ts';
|
||||
static const _keyLastSessionEnd = 'sessn_last_end_ts';
|
||||
static const _keyDailyLimitSec = 'sessn_daily_limit_sec';
|
||||
static const _keyPerSessionSec = 'sessn_per_session_sec';
|
||||
static const _keyCooldownSec = 'sessn_cooldown_sec';
|
||||
|
||||
// ── App-session keys ───────────────────────────────────────
|
||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||
static const _keyDailyOpenCount = 'app_open_count';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
// ── Reel-session runtime ───────────────────────────────────
|
||||
bool _isSessionActive = false;
|
||||
DateTime? _sessionExpiry;
|
||||
int _dailyUsedSeconds = 0;
|
||||
DateTime? _lastSessionEnd;
|
||||
Timer? _ticker;
|
||||
|
||||
// ── App-session runtime ────────────────────────────────────
|
||||
DateTime? _appSessionEnd;
|
||||
bool _appExtensionUsed = false;
|
||||
DateTime? _lastAppSessionEnd;
|
||||
bool _appSessionExpiredFlag =
|
||||
false; // set when time runs out, waiting for user action
|
||||
int _dailyOpenCount = 0;
|
||||
|
||||
// ── Settings defaults ──────────────────────────────────────
|
||||
int _dailyLimitSeconds = 30 * 60; // 30 min
|
||||
int _perSessionSeconds = 5 * 60; // 5 min
|
||||
int _cooldownSeconds = 15 * 60; // 15 min cooldown between reel sessions
|
||||
|
||||
// ── Public getters — Reel session ─────────────────────────
|
||||
bool get isSessionActive => _isSessionActive;
|
||||
|
||||
int get remainingSessionSeconds {
|
||||
if (!_isSessionActive || _sessionExpiry == null) return 0;
|
||||
final diff = _sessionExpiry!.difference(DateTime.now()).inSeconds;
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
|
||||
int get dailyUsedSeconds => _dailyUsedSeconds;
|
||||
int get dailyLimitSeconds => _dailyLimitSeconds;
|
||||
int get dailyRemainingSeconds {
|
||||
final rem = _dailyLimitSeconds - _dailyUsedSeconds;
|
||||
return rem > 0 ? rem : 0;
|
||||
}
|
||||
|
||||
bool get isDailyLimitExhausted => dailyRemainingSeconds <= 0;
|
||||
|
||||
bool get isCooldownActive {
|
||||
if (_lastSessionEnd == null) return false;
|
||||
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
|
||||
return elapsed < _cooldownSeconds;
|
||||
}
|
||||
|
||||
int get cooldownRemainingSeconds {
|
||||
if (!isCooldownActive || _lastSessionEnd == null) return 0;
|
||||
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
|
||||
final rem = _cooldownSeconds - elapsed;
|
||||
return rem > 0 ? rem : 0;
|
||||
}
|
||||
|
||||
int get perSessionSeconds => _perSessionSeconds;
|
||||
int get cooldownSeconds => _cooldownSeconds;
|
||||
|
||||
// ── Public getters — App session ──────────────────────────
|
||||
|
||||
/// Whether the user has an active app session right now.
|
||||
bool get isAppSessionActive {
|
||||
if (_appSessionEnd == null) return false;
|
||||
return DateTime.now().isBefore(_appSessionEnd!);
|
||||
}
|
||||
|
||||
/// Seconds left in the current app session.
|
||||
int get appSessionRemainingSeconds {
|
||||
if (_appSessionEnd == null) return 0;
|
||||
final diff = _appSessionEnd!.difference(DateTime.now()).inSeconds;
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
|
||||
/// True when the app session has expired and user has not yet acted.
|
||||
bool get isAppSessionExpired => _appSessionExpiredFlag;
|
||||
|
||||
/// Whether the 10-min extension has been used.
|
||||
bool get canExtendAppSession => !_appExtensionUsed;
|
||||
|
||||
/// Seconds remaining in the app-open cooldown.
|
||||
int get appOpenCooldownRemainingSeconds {
|
||||
if (_lastAppSessionEnd == null) return 0;
|
||||
final elapsed = DateTime.now().difference(_lastAppSessionEnd!).inSeconds;
|
||||
final rem = _cooldownSeconds - elapsed;
|
||||
return rem > 0 ? rem : 0;
|
||||
}
|
||||
|
||||
/// True if the app-open cooldown is still active.
|
||||
bool get isAppOpenCooldownActive {
|
||||
if (_lastAppSessionEnd == null) return false;
|
||||
return appOpenCooldownRemainingSeconds > 0;
|
||||
}
|
||||
|
||||
/// How many times the user has opened the app today.
|
||||
int get dailyOpenCount => _dailyOpenCount;
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _resetDailyIfNeeded();
|
||||
_loadPersisted();
|
||||
_startTicker();
|
||||
_incrementOpenCount();
|
||||
}
|
||||
|
||||
Future<void> _resetDailyIfNeeded() async {
|
||||
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
final stored = _prefs!.getString(_keyDailyDate) ?? '';
|
||||
if (stored != today) {
|
||||
await _prefs!.setString(_keyDailyDate, today);
|
||||
await _prefs!.setInt(_keyDailyUsedSeconds, 0);
|
||||
await _prefs!.setInt(_keyDailyOpenCount, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void _loadPersisted() {
|
||||
_dailyUsedSeconds = _prefs!.getInt(_keyDailyUsedSeconds) ?? 0;
|
||||
_dailyLimitSeconds = _prefs!.getInt(_keyDailyLimitSec) ?? 30 * 60;
|
||||
_perSessionSeconds = _prefs!.getInt(_keyPerSessionSec) ?? 5 * 60;
|
||||
_cooldownSeconds = _prefs!.getInt(_keyCooldownSec) ?? 15 * 60;
|
||||
_dailyOpenCount = _prefs!.getInt(_keyDailyOpenCount) ?? 0;
|
||||
|
||||
// Reel session
|
||||
final expiryMs = _prefs!.getInt(_keySessionExpiry) ?? 0;
|
||||
if (expiryMs > 0) {
|
||||
final expiry = DateTime.fromMillisecondsSinceEpoch(expiryMs);
|
||||
if (expiry.isAfter(DateTime.now())) {
|
||||
_sessionExpiry = expiry;
|
||||
_isSessionActive = true;
|
||||
} else {
|
||||
_cleanupExpiredReelSession();
|
||||
}
|
||||
}
|
||||
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
|
||||
if (lastEndMs > 0) {
|
||||
_lastSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastEndMs);
|
||||
}
|
||||
|
||||
// App session
|
||||
final appEndMs = _prefs!.getInt(_keyAppSessionEnd) ?? 0;
|
||||
if (appEndMs > 0) {
|
||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||
}
|
||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||
|
||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
||||
if (lastAppEndMs > 0) {
|
||||
_lastAppSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastAppEndMs);
|
||||
}
|
||||
}
|
||||
|
||||
void _incrementOpenCount() {
|
||||
_dailyOpenCount++;
|
||||
_prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount);
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
bool changed = false;
|
||||
|
||||
// Reel session countdown
|
||||
if (_isSessionActive) {
|
||||
if (remainingSessionSeconds <= 0) {
|
||||
_cleanupExpiredReelSession();
|
||||
changed = true;
|
||||
} else {
|
||||
_dailyUsedSeconds++;
|
||||
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
||||
if (isDailyLimitExhausted) _cleanupExpiredReelSession();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// App session expiry check
|
||||
if (_appSessionEnd != null &&
|
||||
!_appSessionExpiredFlag &&
|
||||
DateTime.now().isAfter(_appSessionEnd!)) {
|
||||
_appSessionExpiredFlag = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isCooldownActive) changed = true;
|
||||
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
void _cleanupExpiredReelSession() {
|
||||
_isSessionActive = false;
|
||||
_sessionExpiry = null;
|
||||
_lastSessionEnd = DateTime.now();
|
||||
_prefs?.setInt(_keySessionExpiry, 0);
|
||||
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// ── Reel session API ───────────────────────────────────────
|
||||
|
||||
bool startSession(int minutes) {
|
||||
if (isDailyLimitExhausted) return false;
|
||||
if (isCooldownActive) return false;
|
||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||
_isSessionActive = true;
|
||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void endSession() {
|
||||
if (!_isSessionActive) return;
|
||||
_cleanupExpiredReelSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void accrueSeconds(int seconds) {
|
||||
_dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp(
|
||||
0,
|
||||
_dailyLimitSeconds,
|
||||
);
|
||||
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
||||
if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── App session API ────────────────────────────────────────
|
||||
|
||||
/// Start an app session of [minutes] (1–60).
|
||||
void startAppSession(int minutes) {
|
||||
final end = DateTime.now().add(Duration(minutes: minutes));
|
||||
_appSessionEnd = end;
|
||||
_appSessionExpiredFlag = false;
|
||||
_appExtensionUsed = false;
|
||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Extend the app session by 10 minutes. Only works once.
|
||||
bool extendAppSession() {
|
||||
if (_appExtensionUsed) return false;
|
||||
final base = _appSessionEnd ?? DateTime.now();
|
||||
_appSessionEnd = base.add(const Duration(minutes: 10));
|
||||
_appExtensionUsed = true;
|
||||
_appSessionExpiredFlag = false;
|
||||
_prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch);
|
||||
_prefs?.setBool(_keyAppSessionExtUsed, true);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Called when the user closes the app voluntarily or after extension denial.
|
||||
void endAppSession() {
|
||||
_lastAppSessionEnd = DateTime.now();
|
||||
_appSessionEnd = null;
|
||||
_appSessionExpiredFlag = false;
|
||||
_prefs?.setInt(
|
||||
_keyLastAppSessEnd,
|
||||
_lastAppSessionEnd!.millisecondsSinceEpoch,
|
||||
);
|
||||
_prefs?.setInt(_keyAppSessionEnd, 0);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Settings mutations ─────────────────────────────────────
|
||||
|
||||
Future<void> setDailyLimitMinutes(int minutes) async {
|
||||
_dailyLimitSeconds = minutes * 60;
|
||||
await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setPerSessionMinutes(int minutes) async {
|
||||
_perSessionSeconds = minutes * 60;
|
||||
await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCooldownMinutes(int minutes) async {
|
||||
_cooldownSeconds = minutes * 60;
|
||||
await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resetDailyCounter() async {
|
||||
_dailyUsedSeconds = 0;
|
||||
await _prefs?.setInt(_keyDailyUsedSeconds, 0);
|
||||
if (_isSessionActive) endSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores and retrieves all user-configurable app settings.
|
||||
class SettingsService extends ChangeNotifier {
|
||||
static const _keyBlurExplore = 'set_blur_explore';
|
||||
static const _keyBlurReels = 'set_blur_reels';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool _blurExplore = true; // Default: blur explore feed posts/reels
|
||||
bool _blurReels = false; // If false: hide reels in feed (after session ends)
|
||||
bool _requireLongPress = true; // Long-press FAB to start session
|
||||
bool _showBreathGate = true; // Show breathing gate on every open
|
||||
|
||||
bool get blurExplore => _blurExplore;
|
||||
bool get blurReels => _blurReels;
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
|
||||
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_blurExplore = v;
|
||||
await _prefs?.setBool(_keyBlurExplore, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlurReels(bool v) async {
|
||||
_blurReels = v;
|
||||
await _prefs?.setBool(_keyBlurReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setRequireLongPress(bool v) async {
|
||||
_requireLongPress = v;
|
||||
await _prefs?.setBool(_keyRequireLongPress, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setShowBreathGate(bool v) async {
|
||||
_showBreathGate = v;
|
||||
await _prefs?.setBool(_keyShowBreathGate, v);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "20.1.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.13.1"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.10.11"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "108bd85d0ff20bff1e8b52a040f5c19b6b9fc4a78fdf3160534ff5a11a82e267"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.23.7"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.10.7 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: focusgram
|
||||
description: "FocusGram — Distraction-free Instagram WebView wrapper."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView engine — latest stable
|
||||
webview_flutter: ^4.13.1
|
||||
|
||||
# Local key-value persistence — latest stable
|
||||
shared_preferences: ^2.5.4
|
||||
|
||||
# Date/time formatting for daily resets — latest stable
|
||||
intl: ^0.20.2
|
||||
|
||||
# Reactive state management — latest stable
|
||||
provider: ^6.1.5
|
||||
|
||||
# Local notifications for session reminders — latest stable
|
||||
flutter_local_notifications: ^20.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focusgram/main.dart';
|
||||
|
||||
void main() {
|
||||
// Widget tests for FocusGram are not yet implemented.
|
||||
// The app requires SharedPreferences and WebView which need mocking.
|
||||
test('placeholder', () {
|
||||
expect(FocusGramApp, isNotNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user