From a848b9222d5784aa79e52e523c073559e646f6c1 Mon Sep 17 00:00:00 2001 From: Ujwal Date: Sun, 22 Feb 2026 22:00:52 +0545 Subject: [PATCH] first commit --- .gitignore | 45 ++ .metadata | 30 + PRD.md | 220 ++++++++ README.md | 16 + analysis_options.yaml | 28 + android/.gitignore | 14 + android/app/build.gradle.kts | 49 ++ android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 43 ++ .../com/focusgram/focusgram/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + .../main/res/xml/network_security_config.xml | 15 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 + lib/main.dart | 104 ++++ lib/screens/app_session_picker.dart | 210 +++++++ lib/screens/breath_gate_screen.dart | 143 +++++ lib/screens/cooldown_gate_screen.dart | 169 ++++++ lib/screens/main_webview_page.dart | 513 ++++++++++++++++++ lib/screens/reel_player_overlay.dart | 110 ++++ lib/screens/session_modal.dart | 132 +++++ lib/screens/settings_page.dart | 405 ++++++++++++++ lib/services/injection_controller.dart | 341 ++++++++++++ lib/services/navigation_guard.dart | 89 +++ lib/services/session_manager.dart | 328 +++++++++++ lib/services/settings_service.dart | 55 ++ pubspec.lock | 490 +++++++++++++++++ pubspec.yaml | 35 ++ test/widget_test.dart | 10 + 40 files changed, 3730 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 PRD.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 lib/main.dart create mode 100644 lib/screens/app_session_picker.dart create mode 100644 lib/screens/breath_gate_screen.dart create mode 100644 lib/screens/cooldown_gate_screen.dart create mode 100644 lib/screens/main_webview_page.dart create mode 100644 lib/screens/reel_player_overlay.dart create mode 100644 lib/screens/session_modal.dart create mode 100644 lib/screens/settings_page.dart create mode 100644 lib/services/injection_controller.dart create mode 100644 lib/services/navigation_guard.dart create mode 100644 lib/services/session_manager.dart create mode 100644 lib/services/settings_service.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..00b74be --- /dev/null +++ b/.metadata @@ -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' diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..af3ea16 --- /dev/null +++ b/PRD.md @@ -0,0 +1,220 @@ +# FocusGram — Product Requirements Document (Canvas) + +> Working title: **FocusGram** + +--- + +## Product Type + +Personal-use Flutter mobile application (Android). WebView wrapper around Instagram for private, distraction-free use. + +## Primary Goal + +Allow full use of Instagram (feed, stories, notes, DMs, profile) **without Reels or Explore distractions**, while preserving the ability to open a Reel **only when it is sent directly in a message**. Reels must not be discoverable anywhere else in the app. + +--- + +## 1. Problem Statement + +Instagram's Reels and Explore experiences create compulsive, endless‑scroll behaviours. The user wants full functionality of Instagram *except* persistent exposure to Reels and similar autoplay distractions. Reels may be accessed intentionally and in a controlled way (session/time/cooldown), and Reels opened from DMs must not allow the user to scroll into other Reels. + +--- + +## 2. Core Features (MVP + Integrated Phase 2) + +These include all Phase 2 items integrated into the main product. + +### 2.1 Embedded Instagram + +* WebView loads `https://www.instagram.com` +* JavaScript enabled +* Custom user-agent to reduce login friction +* Cookie/session persistence stored locally + +### 2.2 Global Reel & Explore Blocking (always-on) + +* Remove/hide the Reels tab, Explore tab, and any UI element that reveals Reels elsewhere (profile grid toggles that surface Reels, Explore cards, thumbnails linking to `/reel/`). +* Block navigation to any URL containing `/reel/` or `/reels` unless in an active Reel session or when specifically opening a Reel message item. +* Inject a persistent CSS style (`hide-reels-style`) + MutationObserver to remove dynamic elements injected by Instagram's SPA. + +### 2.3 DM‑Reel Exception (one‑off, isolated playback) + +* If a Reel URL is received via Direct Message (DM) and the user taps it in the message thread, the app will allow opening that single Reel in an isolated player overlay. +* The isolated player must: + + * Load only the single Reel content (not the Reels feed). + * Disable gestures/controls that would navigate to other Reels (no left/right swipe to next Reel). + * Provide explicit controls: Play/Pause, Close, Share (if desired). + * Respect session/time/cooldown and count viewing duration toward limits. + +### 2.4 Session & Daily Controls (customizable) + +Provide settings and enforcement for controlled Reel consumption: + +**Settings** + +* Daily Total Reel Time (configurable, e.g., 0–120 minutes) +* Per‑Session Reel Time Limit (configurable, e.g., 1–30 minutes) +* Session Cooldown Time (configurable, e.g., 5–180 minutes) — the minimum wait between sessions +* Session Shortcuts (preset buttons: 1, 5, 10, 15 minutes) + +**Behavior/Enforcement** + +* A session may be started by user explicitly (via FAB or DM Reel tap when allowed). +* When a session starts, a countdown runs; when it reaches zero, the session ends and Reels are blocked again. +* All viewing time (including DM‑opened Reel play) counts toward the daily total. +* If daily total is exhausted, Reel sessions are blocked until midnight local device time, or until user increases limit in settings. +* Cooldown prevents immediately starting a new session until cooldown expires. The cooldown may be overridden only by changing settings (confirmed by an intentional action) — optional: require PIN to override. + +### 2.5 Additional Controls & UX + +* Quick status indicator in app chrome showing: `Reels: blocked` / `Reels: session active (mm:ss left)` / `Daily left: XX min`. +* Modal Reel Session UI: when enabling a session, present a small modal confirming session length, remaining daily minutes, and cooldown on completion. +* Option to blur Reels instead of hide (toggle in settings) — still blocks navigation but visually indicates presence. +* Option for long‑press unlock: user must long‑press the Reel Session button for 2 seconds to start a session (reduces impulsive enabling). + +--- + +## 3. Non‑Goals + +* No public distribution via Play Store (personal use only) +* No scraping or automated interactions with Instagram +* No use of private Instagram APIs +* Not attempting to permanently alter Instagram servers or content + +--- + +## 4. Functional Requirements (detailed) + +| ID | Requirement | Priority | +| -- | ----------------------------------------------------------------------------------------- | -------- | +| F1 | Load Instagram in WebView with persistent session | High | +| F2 | Inject/maintain CSS to hide Reels/Explore everywhere | High | +| F3 | Block navigation to `/reel` URLs globally unless ephemeral session or DM single‑Reel open | High | +| F4 | Allow single‑Reel open from DM in isolated player (no swipe to other reels) | High | +| F5 | Provide UI to start a Reel session limited by per‑session and daily settings | High | +| F6 | Enforce session cooldowns between sessions | High | +| F7 | Track and persist daily usage and session history locally | High | +| F8 | Provide override/change settings with explicit confirm (optional PIN) | Medium | +| F9 | Provide visual feedback and counters on main UI | High | + +--- + +## 5. Technical Architecture + +### Framework & Libraries + +* Flutter (stable) +* `webview_flutter` for WebView +* `shared_preferences` for local persistence +* `intl` for date handling and resets +* Optional: `flutter_local_notifications` for session reminders/cooldown completion + +### High-level Components + +* **MainWebViewPage** — full‑screen WebView + top status bar + FAB for Reel Session +* **InjectionController** — handles JS/CSS injection, MutationObserver lifecycle, and re‑apply logic +* **NavigationGuard** — intercepts navigation requests and blocks `/reel` URLs when necessary +* **ReelPlayerOverlay** — isolated player used only for opening Reel from DM (no swiping) +* **SessionManager** — enforces per‑session timer, daily totals, cooldowns, and persistence +* **Settings** — UI for user to configure daily limit, session length, cooldown, blur/hide toggle + +### JS/CSS Injection Patterns (examples) + +* Insert a single `style` element with id `hide-reels-style` containing selectors for `href*="/reel"`, `href*="/reels"`, Reels tab anchors and Explore cards. +* MutationObserver that removes or hides any dynamically added nodes matching those selectors. +* Example-safe selectors: `a[href*="/reel"], a[href*="/reels"], nav a[href*="/reels"], [role="button"] [aria-label*="Reels"]`. + +--- + +## 6. UX / Wireframes (textual) + +**Main screen** + +* WebView occupying most of the screen +* Top compact status bar: `Reels: Blocked • Daily left: 45m` (tappable to open Session modal) +* Floating Action Button (FAB) bottom-right: play icon — opens Reel Session modal + +**Session modal** + +* Presets: 1 / 5 / 10 / 15 minutes +* Input to set custom minutes +* Show `Daily left: X min` and `Cooldown: Y min remaining` if applicable +* Confirm button: `Start Session` + +**DM Reel tap flow** + +* User taps Reel link in DM +* If session active and daily left > 0 → open in ReelPlayerOverlay +* If session inactive → show small prompt: `Open this Reel? This will start a 5‑minute session (or choose length).` Confirm to open; counts toward session & daily totals. + +**End of session** + +* Overlay message: `Session ended. Reels are blocked.` with cooldown timer +* Option to extend session (only if daily minutes available and cooldown rules allow) + +--- + +## 7. Data Model & Persistence + +Stored locally via `shared_preferences` (or a small local DB if desired): + +* `dailyDate` (YYYY-MM-DD) — date of last reset +* `dailyUsedMinutes` (int) +* `sessionActive` (bool) + `sessionExpiryTimestamp` (ms) +* `lastSessionEndTimestamp` (ms) +* `settings`: { dailyLimitMinutes, defaultSessionMinutes, cooldownMinutes, blurInsteadOfHide, requireLongPress } +* `sessionHistory[]` (timestamp, duration) — optional, capped locally + +Reset logic: check `dailyDate` on app start / resume; if different from local device date, reset `dailyUsedMinutes` to 0 and update `dailyDate`. + +--- + +## 8. Edge Cases & Rules + +* If a Reel message contains a playlist or multiple reels link, block additional navigation — only allow the primary Reel to load. +* If Instagram tries to redirect from a DM Reel link into the Reels feed, intercept and force load of the single Reel content in `ReelPlayerOverlay`. +* If login prompts or security interstitials appear in WebView (2FA / suspicious login), surface them to the user; do not attempt to automate. +* If DOM selectors fail (Instagram update), fall back to broader `href*` checks and reapply; show a small banner to the user: `Reel blocker needs update` with troubleshooting. + +--- + +## 9. Success Criteria + +* Reels are not visible anywhere by default (tabs, explore, profile toggles) +* Tapping a Reel sent in DM opens only that Reel and does not allow navigating to others +* Session limits and daily totals are enforced reliably — user cannot bypass session/cooldown without changing settings and confirming +* UX is intuitive: starting/stopping sessions, seeing remaining time, and cooldowns are clear + +--- + +## 10. Definition of Done + +* App loads Instagram and preserves login across restarts +* Injected CSS/JS reliably hides Reels and blocks `/reel` navigation +* DM‑opened Reel flow works as isolated playback with no swipe navigation to other reels +* Session start/stop, daily enforcement, and cooldown behavior function and persist +* Settings screen implemented and persisting user preferences + +--- + +## 11. Next Steps (recommended) + +1. Create minimal Flutter skeleton with `webview_flutter` and SessionManager stub +2. Implement CSS/JS injection and test with local device Instagram login +3. Implement NavigationGuard and ReelPlayerOverlay +4. Add Settings and persistence +5. Test DM Reel flows thoroughly (multiple DM formats, external links) +6. Iterate selectors if Instagram DOM changes + +--- + +## 12. Notes & Considerations + +* This is for personal use only. Avoid publishing or distributing a wrapper app. +* Instagram may change behaviours; expect occasional maintenance. +* Consider adding a simple debug UI (visible only in dev builds) to reapply selectors and show blocked navigation attempts. + +--- + +*End of PRD.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..13cbec0 --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..b1fb261 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..25418d4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt b/android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt new file mode 100644 index 0000000..b9af0c5 --- /dev/null +++ b/android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt @@ -0,0 +1,5 @@ +package com.focusgram.focusgram + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..91fb43d --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + instagram.com + cdninstagram.com + fbcdn.net + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..9d35426 --- /dev/null +++ b/lib/main.dart @@ -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 createState() => _InitialRouteHandlerState(); +} + +class _InitialRouteHandlerState extends State { + bool _breathCompleted = false; + bool _appSessionStarted = false; + + @override + Widget build(BuildContext context) { + final sm = context.watch(); + final settings = context.watch(); + + // 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(); + } +} diff --git a/lib/screens/app_session_picker.dart b/lib/screens/app_session_picker.dart new file mode 100644 index 0000000..2490140 --- /dev/null +++ b/lib/screens/app_session_picker.dart @@ -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 createState() => _AppSessionPickerScreenState(); +} + +class _AppSessionPickerScreenState extends State { + static final List _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().startAppSession(minutes); + widget.onSessionStarted(); + } +} diff --git a/lib/screens/breath_gate_screen.dart b/lib/screens/breath_gate_screen.dart new file mode 100644 index 0000000..9e6e465 --- /dev/null +++ b/lib/screens/breath_gate_screen.dart @@ -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 createState() => _BreathGateScreenState(); +} + +class _BreathGateScreenState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _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( + begin: 1.0, + end: 1.5, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + _controller.repeat(reverse: true); + + _startCountdown(); + } + + void _startCountdown() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsRemaining > 0) { + setState(() => _secondsRemaining--); + } else { + setState(() { + _canContinue = true; + _timer?.cancel(); + }); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Are you sure you want to open Instagram?', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 80), + + // Animated Breath Circle + ScaleTransition( + scale: _scaleAnimation, + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.blue.withValues(alpha: 0.3), + blurRadius: 30, + spreadRadius: 10, + ), + ], + gradient: const RadialGradient( + colors: [Colors.blue, Colors.black], + ), + ), + ), + ), + + const SizedBox(height: 80), + + Text( + _canContinue + ? 'Breathed.' + : 'Take a deep breath for $_secondsRemaining seconds...', + style: const TextStyle( + color: Colors.white70, + fontSize: 16, + fontStyle: FontStyle.italic, + ), + ), + + const SizedBox(height: 40), + + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _canContinue ? widget.onFinish : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + disabledBackgroundColor: Colors.white10, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + child: const Text('Continue to Instagram'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/cooldown_gate_screen.dart b/lib/screens/cooldown_gate_screen.dart new file mode 100644 index 0000000..07ec1f0 --- /dev/null +++ b/lib/screens/cooldown_gate_screen.dart @@ -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 createState() => _CooldownGateScreenState(); +} + +class _CooldownGateScreenState extends State { + Timer? _timer; + static const List _quotes = [ + '"The discipline you show offline\nshapes the clarity you experience online."', + '"Every moment away from the screen\nis a moment given back to yourself."', + '"Boredom is the birthplace of creativity.\nLet it breathe."', + '"Your attention is your most valuable asset.\nSpend it wisely."', + '"Presence is a gift you give yourself first."', + '"Rest is not wasted time.\nIt is the foundation of focused action."', + ]; + late final String _quote; + + @override + void initState() { + super.initState(); + _quote = _quotes[DateTime.now().second % _quotes.length]; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sm = context.watch(); + final remaining = sm.appOpenCooldownRemainingSeconds; + final minutes = remaining ~/ 60; + final seconds = remaining % 60; + + // If cooldown expired, pop this gate + if (!sm.isAppOpenCooldownActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.of(context).maybePop(); + }); + } + + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: 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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart new file mode 100644 index 0000000..a610c4e --- /dev/null +++ b/lib/screens/main_webview_page.dart @@ -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 createState() => _MainWebViewPageState(); +} + +class _MainWebViewPageState extends State { + 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(); + 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(); + + _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(); + final settings = context.read(); + final js = InjectionController.buildInjectionJS( + sessionActive: sessionManager.isSessionActive, + blurExplore: settings.blurExplore, + ); + _controller.runJavaScript(js); + } + + Future _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 _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 _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(); + + 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 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(); + final settings = context.watch(); + + 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; + } +} diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart new file mode 100644 index 0000000..b342b07 --- /dev/null +++ b/lib/screens/reel_player_overlay.dart @@ -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 createState() => _ReelPlayerOverlayState(); +} + +class _ReelPlayerOverlayState extends State { + late final WebViewController _controller; + DateTime? _startTime; + + @override + void initState() { + super.initState(); + _startTime = DateTime.now(); + _initWebView(); + } + + void _initWebView() { + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent(InjectionController.iOSUserAgent) + ..setNavigationDelegate( + NavigationDelegate( + onPageFinished: (url) { + // 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().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), + ); + } +} diff --git a/lib/screens/session_modal.dart b/lib/screens/session_modal.dart new file mode 100644 index 0000000..6a3154e --- /dev/null +++ b/lib/screens/session_modal.dart @@ -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 createState() => _SessionModalState(); +} + +class _SessionModalState extends State { + double _customMinutes = 5.0; + + @override + Widget build(BuildContext context) { + final sm = context.watch(); + + return Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: Color(0xFF121212), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Start Reel Session', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, color: Colors.white54), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Remaining Daily: ${sm.dailyRemainingSeconds ~/ 60}m', + style: const TextStyle(color: Colors.white70), + ), + if (sm.isCooldownActive) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'Cooldown active: ${sm.cooldownRemainingSeconds ~/ 60}m ${sm.cooldownRemainingSeconds % 60}s left', + style: const TextStyle(color: Colors.orangeAccent), + ), + ), + const SizedBox(height: 24), + const Text( + 'Presets', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + 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(); + if (sm.startSession(minutes)) { + Navigator.pop(context); + } + } +} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart new file mode 100644 index 0000000..7a4b8cd --- /dev/null +++ b/lib/screens/settings_page.dart @@ -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(); + final sm = context.watch(); + + 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 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 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); + } + }, + ), + ), + ], + ); + } +} diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart new file mode 100644 index 0000000..bc9320c --- /dev/null +++ b/lib/services/injection_controller.dart @@ -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 + '''; + } +} diff --git a/lib/services/navigation_guard.dart b/lib/services/navigation_guard.dart new file mode 100644 index 0000000..4ea4cac --- /dev/null +++ b/lib/services/navigation_guard.dart @@ -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}); +} diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart new file mode 100644 index 0000000..1bc5ec4 --- /dev/null +++ b/lib/services/session_manager.dart @@ -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 init() async { + _prefs = await SharedPreferences.getInstance(); + await _resetDailyIfNeeded(); + _loadPersisted(); + _startTicker(); + _incrementOpenCount(); + } + + Future _resetDailyIfNeeded() async { + final today = DateFormat('yyyy-MM-dd').format(DateTime.now()); + final stored = _prefs!.getString(_keyDailyDate) ?? ''; + if (stored != today) { + await _prefs!.setString(_keyDailyDate, today); + await _prefs!.setInt(_keyDailyUsedSeconds, 0); + await _prefs!.setInt(_keyDailyOpenCount, 0); + } + } + + void _loadPersisted() { + _dailyUsedSeconds = _prefs!.getInt(_keyDailyUsedSeconds) ?? 0; + _dailyLimitSeconds = _prefs!.getInt(_keyDailyLimitSec) ?? 30 * 60; + _perSessionSeconds = _prefs!.getInt(_keyPerSessionSec) ?? 5 * 60; + _cooldownSeconds = _prefs!.getInt(_keyCooldownSec) ?? 15 * 60; + _dailyOpenCount = _prefs!.getInt(_keyDailyOpenCount) ?? 0; + + // Reel session + final expiryMs = _prefs!.getInt(_keySessionExpiry) ?? 0; + if (expiryMs > 0) { + final expiry = DateTime.fromMillisecondsSinceEpoch(expiryMs); + if (expiry.isAfter(DateTime.now())) { + _sessionExpiry = expiry; + _isSessionActive = true; + } else { + _cleanupExpiredReelSession(); + } + } + final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0; + if (lastEndMs > 0) { + _lastSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastEndMs); + } + + // App session + final appEndMs = _prefs!.getInt(_keyAppSessionEnd) ?? 0; + if (appEndMs > 0) { + _appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs); + } + _appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false; + + final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0; + if (lastAppEndMs > 0) { + _lastAppSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastAppEndMs); + } + } + + void _incrementOpenCount() { + _dailyOpenCount++; + _prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount); + } + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); + } + + void _tick() { + bool changed = false; + + // Reel session countdown + if (_isSessionActive) { + if (remainingSessionSeconds <= 0) { + _cleanupExpiredReelSession(); + changed = true; + } else { + _dailyUsedSeconds++; + _prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds); + if (isDailyLimitExhausted) _cleanupExpiredReelSession(); + changed = true; + } + } + + // App session expiry check + if (_appSessionEnd != null && + !_appSessionExpiredFlag && + DateTime.now().isAfter(_appSessionEnd!)) { + _appSessionExpiredFlag = true; + changed = true; + } + + if (isCooldownActive) changed = true; + + if (changed) notifyListeners(); + } + + void _cleanupExpiredReelSession() { + _isSessionActive = false; + _sessionExpiry = null; + _lastSessionEnd = DateTime.now(); + _prefs?.setInt(_keySessionExpiry, 0); + _prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch); + } + + // ── Reel session API ─────────────────────────────────────── + + bool startSession(int minutes) { + if (isDailyLimitExhausted) return false; + if (isCooldownActive) return false; + final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds); + _sessionExpiry = DateTime.now().add(Duration(seconds: allowed)); + _isSessionActive = true; + _prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch); + notifyListeners(); + return true; + } + + void endSession() { + if (!_isSessionActive) return; + _cleanupExpiredReelSession(); + notifyListeners(); + } + + void accrueSeconds(int seconds) { + _dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp( + 0, + _dailyLimitSeconds, + ); + _prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds); + if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession(); + notifyListeners(); + } + + // ── App session API ──────────────────────────────────────── + + /// Start an app session of [minutes] (1–60). + void startAppSession(int minutes) { + final end = DateTime.now().add(Duration(minutes: minutes)); + _appSessionEnd = end; + _appSessionExpiredFlag = false; + _appExtensionUsed = false; + _prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch); + _prefs?.setBool(_keyAppSessionExtUsed, false); + notifyListeners(); + } + + /// Extend the app session by 10 minutes. Only works once. + bool extendAppSession() { + if (_appExtensionUsed) return false; + final base = _appSessionEnd ?? DateTime.now(); + _appSessionEnd = base.add(const Duration(minutes: 10)); + _appExtensionUsed = true; + _appSessionExpiredFlag = false; + _prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch); + _prefs?.setBool(_keyAppSessionExtUsed, true); + notifyListeners(); + return true; + } + + /// Called when the user closes the app voluntarily or after extension denial. + void endAppSession() { + _lastAppSessionEnd = DateTime.now(); + _appSessionEnd = null; + _appSessionExpiredFlag = false; + _prefs?.setInt( + _keyLastAppSessEnd, + _lastAppSessionEnd!.millisecondsSinceEpoch, + ); + _prefs?.setInt(_keyAppSessionEnd, 0); + notifyListeners(); + } + + // ── Settings mutations ───────────────────────────────────── + + Future setDailyLimitMinutes(int minutes) async { + _dailyLimitSeconds = minutes * 60; + await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds); + notifyListeners(); + } + + Future setPerSessionMinutes(int minutes) async { + _perSessionSeconds = minutes * 60; + await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds); + notifyListeners(); + } + + Future setCooldownMinutes(int minutes) async { + _cooldownSeconds = minutes * 60; + await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds); + notifyListeners(); + } + + Future resetDailyCounter() async { + _dailyUsedSeconds = 0; + await _prefs?.setInt(_keyDailyUsedSeconds, 0); + if (_isSessionActive) endSession(); + notifyListeners(); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..3b18686 --- /dev/null +++ b/lib/services/settings_service.dart @@ -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 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 setBlurExplore(bool v) async { + _blurExplore = v; + await _prefs?.setBool(_keyBlurExplore, v); + notifyListeners(); + } + + Future setBlurReels(bool v) async { + _blurReels = v; + await _prefs?.setBool(_keyBlurReels, v); + notifyListeners(); + } + + Future setRequireLongPress(bool v) async { + _requireLongPress = v; + await _prefs?.setBool(_keyRequireLongPress, v); + notifyListeners(); + } + + Future setShowBreathGate(bool v) async { + _showBreathGate = v; + await _prefs?.setBool(_keyShowBreathGate, v); + notifyListeners(); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..bbe73fa --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d04562c --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..32a6ecb --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}