first commit

This commit is contained in:
Ujwal
2026-02-22 22:00:52 +05:45
commit a848b9222d
40 changed files with 3730 additions and 0 deletions
+45
View File
@@ -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
+30
View File
@@ -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'
+220
View File
@@ -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, endlessscroll 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 DMReel Exception (oneoff, 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., 0120 minutes)
* PerSession Reel Time Limit (configurable, e.g., 130 minutes)
* Session Cooldown Time (configurable, e.g., 5180 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 DMopened 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 longpress unlock: user must longpress the Reel Session button for 2 seconds to start a session (reduces impulsive enabling).
---
## 3. NonGoals
* 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 singleReel open | High |
| F4 | Allow singleReel open from DM in isolated player (no swipe to other reels) | High |
| F5 | Provide UI to start a Reel session limited by persession 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** — fullscreen WebView + top status bar + FAB for Reel Session
* **InjectionController** — handles JS/CSS injection, MutationObserver lifecycle, and reapply 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 persession 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 5minute 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
* DMopened 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.*
+16
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+49
View File
@@ -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>
+43
View File
@@ -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>
+24
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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();
}
}
+210
View File
@@ -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();
}
}
+143
View File
@@ -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'),
),
),
],
),
),
),
);
}
}
+169
View File
@@ -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),
],
),
),
),
);
}
}
+513
View File
@@ -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;
}
}
+110
View File
@@ -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),
);
}
}
+132
View File
@@ -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);
}
}
}
+405
View File
@@ -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);
}
},
),
),
],
);
}
}
+341
View File
@@ -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
''';
}
}
+89
View File
@@ -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});
}
+328
View File
@@ -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] (160).
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();
}
}
+55
View File
@@ -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
View File
@@ -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"
+35
View File
@@ -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
+10
View File
@@ -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);
});
}