diff --git a/.github/scripts/get_version.py b/.github/scripts/get_version.py index 09f9cfb..428dc8b 100644 --- a/.github/scripts/get_version.py +++ b/.github/scripts/get_version.py @@ -4,5 +4,5 @@ import re text = Path("CHANGELOG.md").read_text(encoding="utf-8") m = re.search(r"^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", text, re.M) if not m: - raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.0.0") + raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.1.0") print(m.group(1)) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3510bd7..5d47183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,21 +3,20 @@ ### What's new - NEW: Startup Page - choose which page to launch on app launch. -- NEW: App lock and DM's Lock. +- NEW: App lock and DM Lock. - NEW: Bait me button in Focus Control. - NEW: Interactive Level based system for unlocking features. - NEW: Effort Friction Mode. - NEW: Strict and fully working Ghost Mode. -- NEW: REDUCES the amount of ads in your feed (NO Toggles for this, mighn't work on some devices). ### Bug fixes - Fixed: Greyscale mode used to turn off when app was restarted. - Fixed: Images in posts containing multiple images werent getting unblurred when tapped. -- Fixed: Ghost mode didn't work properly. +- Fixed: You could send message as "Ghost" in GHost mode (Ghost's cant talk with real people πŸ€ͺ). - Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs. - Fixed: Download media button (rarely) opened random media rather than desired one. - Fixed: Reel Session could be started despite quota being finished. - Perfomance Optimizations -- A lof of other Minor fixes . +- A lof of other Minor fixes. diff --git a/README.md b/README.md index 2a11f9a..5853c2c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Use social media on your terms.** [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.0.0-white)](https://flutter.dev) +[![Version](https://img.shields.io/badge/version-2.1.0-white)](https://flutter.dev) [![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=30)](https://github.com/ujwal223/focusgram/releases) @@ -20,9 +20,9 @@ --- -Most people don't want to quit Instagram. They want to check their messages, post a story, and leave β€” without losing an hour to Reels they never meant to watch. +Most people don't want to completely quit Instagram but control its usage (i.e They want to check their messages, post a story, and leave) without losing many hours to Reels and distracting content they never meant to watch. -FocusGram is an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use. +FocusGram is an Android-only app that loads the Instagram website with the distracting parts removed and with Extra features. No private APIs. No data collection. Just a cleaner way to use a platform you already use. > FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo πŸ‘‰πŸ‘ˆ. > @@ -36,29 +36,32 @@ FocusGram is an Android app that loads the Instagram website with the distractin **Focus tools** -- Block Reels entirely, or allow them in timed sessions (1–15 min) with daily limits and cooldowns -- Autoplay blocker β€” videos won't play until you tap them -- Minimal Mode β€” strips everything down to Feed and DMs +- Block Reels entirely, or allow them in timed sessions (1–30 min) with daily limits and cooldowns +- Minimal Mode strips everything down to Feed and DMs +- Hide ALL feed posts entirely. **Content filtering** -- Hide the Explore tab, Reels tab, or Shop tab individually -- Disable Explore and blur posts entirely +- Hide the Explore tab or Reels tab individually +- Disable Explore and blur posts, videos on feed entirely +- Click to unblur feed posts - Disable Reels entirely +- Disable scrolling of home feed **Habit tools** -- Screen Time Dashboard β€” daily usage, 7-day chart, weekly average -- Grayscale Mode β€” reduces the visual pull of colour; can be scheduled by time of day -- Session intentions β€” optionally set a reason before opening the app +- Screen Time Dashboard: daily usage, 7-day chart, weekly average +- Grayscale Mode: reduces the visual pull of colour; can be scheduled by time of day +- Session intentions: optionally set a reason before opening the app +- Reel & App Quota: Allocate only certain time for reels and/or instagram -**The app itself** - -- Feels (almost) like a native app, not a browser -- No blank loading screen β€” content loads in the background before you get there -- Instant updates via pull-to-refresh -- Dark mode follows your system +**Other Features** +- Lock the app and/or your private messages. +- See other's message without sending seen indicator* +- Choose which page to launch when app is opened. +- Choose pause time before opening app (mindfulness gate). +- Save media on your local device. --- ## Installation @@ -78,25 +81,27 @@ FocusGram is an Android app that loads the Instagram website with the distractin ## Privacy -FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView β€” your login goes directly to Meta's servers, the same as any mobile browser. +FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView and your login goes directly to Meta's servers, the same as any mobile browser. +Our app has: - No analytics - No crash reporting - No third-party SDKs +- No Logging - No data leaves your device --- ## Frequently asked questions -**Will this get my account banned?** +**Will this get my account banned?**
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. -**Is this a mod of Instagram's app?** +**Is this a mod of Instagram's app?**
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code. -**Why is it free?** -Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0. +**How do i support this project?**
+You can support this project by donating here: [Donate](https://buymemomo.com/ujwal) --- @@ -125,15 +130,6 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe - CSS injection (element hiding, grayscale, scroll behaviour) - URL interception via NavigationDelegate (Reels blocking, Explore blocking) -### Permissions - -| Permission | Reason | -|---|---| -| `INTERNET` | Load instagram.com | -| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart | -| `WAKE_LOCK` | Keep device awake during active Focus sessions | -| `FOREGROUND_SERVICE` | Run background service for session tracking | - ### Stack | | | @@ -151,11 +147,11 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe FocusGram is an independent, free, and open-source productivity tool licensed under AGPL-3.0. It is not affiliated with, endorsed by, or associated with Meta Platforms, Inc. or Instagram in any way. -**How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com` β€” the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers. +**How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com`; the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers. **What we do not do:** -- Use Instagram's or Meta's private APIs -- Intercept, read, log, or store user credentials, session data, or any content +- Use/Alter Instagram's or Meta's private APIs +- Intercept, read, log, or store user credentials, session data, or any sensitive content - Modify any server-side Meta or Instagram services - Scrape, harvest, or collect any user data - Claim ownership of any Meta or Instagram trademarks, logos, or intellectual property β€” any branding visible within the app is served directly from `instagram.com` and remains the property of Meta Platforms, Inc. @@ -168,6 +164,8 @@ For legal concerns, contact `notujwal@proton.me` before taking any other action. ## License -Copyright Β© 2025 Ujwal Chapagain +Copyright Β© 2025-2026 Ujwal Chapagain Licensed under the [GNU Affero General Public License v3.0](LICENSE). You are free to use, modify, and distribute this software under the same terms. + +FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) under AGPL-3.0, Thanks for Reading README. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a24ed93..a447891 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -45,7 +45,7 @@ android { minSdk = 24 targetSdk = 35 versionCode = 4 - versionName = "2.0.0" + versionName = "2.1.0" } buildTypes { diff --git a/assets/images/app-demo.png b/assets/images/app-demo.png new file mode 100644 index 0000000..2a09a35 Binary files /dev/null and b/assets/images/app-demo.png differ diff --git a/lib/features/preloader/instagram_preloader.dart b/lib/features/preloader/instagram_preloader.dart index 13918ff..48d9ce0 100644 --- a/lib/features/preloader/instagram_preloader.dart +++ b/lib/features/preloader/instagram_preloader.dart @@ -5,7 +5,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../scripts/spa_navigation_monitor.dart'; import '../../scripts/native_feel.dart'; import '../../scripts/focus_scripts.dart'; -import '../../scripts/reel_metadata_extractor.dart'; class InstagramPreloader { static HeadlessInAppWebView? _headlessWebView; diff --git a/lib/features/reels_history/reels_history_service.dart b/lib/features/reels_history/reels_history_service.dart index 92ee133..5d76b04 100644 --- a/lib/features/reels_history/reels_history_service.dart +++ b/lib/features/reels_history/reels_history_service.dart @@ -8,7 +8,7 @@ class ReelsHistoryEntry { final String title; final String thumbnailUrl; final DateTime visitedAt; - final int durationSeconds; // How long the session lasted + final int durationSeconds; // How long the session lasted final int adsWatchedInSession; // How many ads watched during this session const ReelsHistoryEntry({ @@ -123,7 +123,9 @@ class ReelsHistoryService { final now = DateTime.now(); final sevenDaysAgo = now.subtract(const Duration(days: 7)); - final recent = entries.where((e) => e.visitedAt.isAfter(sevenDaysAgo)).toList(); + final recent = entries + .where((e) => e.visitedAt.isAfter(sevenDaysAgo)) + .toList(); if (recent.isEmpty) return 0; return recent.length / 7.0; @@ -138,7 +140,8 @@ class ReelsHistoryService { final Map counts = {}; for (final entry in recent) { - final dayKey = '${entry.visitedAt.year}-' + final dayKey = + '${entry.visitedAt.year}-' '${entry.visitedAt.month.toString().padLeft(2, '0')}-' '${entry.visitedAt.day.toString().padLeft(2, '0')}'; counts[dayKey] = (counts[dayKey] ?? 0) + 1; diff --git a/lib/main.dart b/lib/main.dart index 084667b..2511a94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -128,6 +128,7 @@ class _InitialRouteHandlerState extends State bool _breathCompleted = false; bool _appSessionStarted = false; bool _onboardingCompleted = false; + bool _lockScreenDismissed = false; late AppLinks _appLinks; @override @@ -162,11 +163,14 @@ class _InitialRouteHandlerState extends State } } - void _showLockScreen() { - Navigator.push( + Future _showLockScreen() async { + final result = await Navigator.push( context, MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)), ); + if (result == true && mounted) { + setState(() => _lockScreenDismissed = true); + } } Future _initDeepLinks() async { @@ -190,8 +194,8 @@ class _InitialRouteHandlerState extends State final settings = context.watch(); final appLock = context.watch(); - // Step 0: App-wide lock (shows before everything) - if (appLock.needsUnlockOnStart && !_appSessionStarted) { + // Step 0: App-wide lock (shows before everything, once per cold start) + if (appLock.needsUnlockOnStart && !_lockScreenDismissed) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!appLock.isShowingLock) { appLock.onLockScreenShown(); diff --git a/lib/screens/adsterra_ad_screen.dart b/lib/screens/adsterra_ad_screen.dart index f7b9ee4..37caf0a 100644 --- a/lib/screens/adsterra_ad_screen.dart +++ b/lib/screens/adsterra_ad_screen.dart @@ -152,14 +152,19 @@ class _AdsterraAdScreenState extends State { children: [ const Icon(Icons.videocam, color: Colors.white54, size: 18), const SizedBox(width: 8), - const Text('Sponsored', - style: TextStyle(color: Colors.white54, fontSize: 13)), + const Text( + 'Sponsored', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), const Spacer(), - Text('${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s', - style: TextStyle( - color: done ? Colors.greenAccent : Colors.white54, - fontSize: 13, - fontWeight: FontWeight.w600)), + Text( + '${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s', + style: TextStyle( + color: done ? Colors.greenAccent : Colors.white54, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), ], ), ), @@ -171,7 +176,8 @@ class _AdsterraAdScreenState extends State { minHeight: 3, backgroundColor: Colors.white12, valueColor: AlwaysStoppedAnimation( - done ? Colors.greenAccent : Colors.blueAccent), + done ? Colors.greenAccent : Colors.blueAccent, + ), ), ), // Hint text @@ -212,8 +218,10 @@ class _AdsterraAdScreenState extends State { !url.startsWith('about:')) { if (_adsClicked < 2) _adsClicked++; if (mounted) setState(() {}); - await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication); + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); return NavigationActionPolicy.CANCEL; } return NavigationActionPolicy.ALLOW; @@ -232,18 +240,24 @@ class _AdsterraAdScreenState extends State { child: ElevatedButton.icon( onPressed: buttonEnabled ? buttonAction : null, style: ElevatedButton.styleFrom( - backgroundColor: done ? Colors.greenAccent : Colors.grey, + backgroundColor: done + ? Colors.greenAccent + : Colors.grey, foregroundColor: done ? Colors.black : Colors.white38, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), + borderRadius: BorderRadius.circular(14), + ), ), icon: Icon( - done ? Icons.check_circle : Icons.timer_outlined, - size: 22), + done ? Icons.check_circle : Icons.timer_outlined, + size: 22, + ), label: Text( buttonText, style: const TextStyle( - fontWeight: FontWeight.w600, fontSize: 16), + fontWeight: FontWeight.w600, + fontSize: 16, + ), ), ), ), @@ -261,11 +275,14 @@ class _AdsterraAdScreenState extends State { color: Colors.orangeAccent.withValues(alpha: 0.4), ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), + borderRadius: BorderRadius.circular(12), + ), ), icon: const Icon(Icons.refresh, size: 18), - label: const Text('Retry β€” Reload Ads', - style: TextStyle(fontWeight: FontWeight.w600)), + label: const Text( + 'Retry β€” Reload Ads', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), ), const SizedBox(height: 4), @@ -274,8 +291,9 @@ class _AdsterraAdScreenState extends State { child: Text( 'Skip (no reward)', style: TextStyle( - color: Colors.white.withValues(alpha: 0.3), - fontSize: 13), + color: Colors.white.withValues(alpha: 0.3), + fontSize: 13, + ), ), ), ], diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index de82c37..1652120 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -5,7 +5,7 @@ import '../services/app_lock_service.dart'; /// The lock screen shown when FocusGram is locked. /// -/// Supports PIN entry with optional scrambled keypad and biometric fallback. +/// Supports PIN entry with optional scrambled keypad. /// [forAppWide] controls which PIN to verify: true = app-wide, false = messages. /// [title] lets the screen show context (e.g. "Messages Locked"). class AppLockScreen extends StatefulWidget { @@ -73,10 +73,7 @@ class _AppLockScreenState extends State { // Title Text( widget.title ?? 'FocusGram is Locked', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( @@ -126,21 +123,6 @@ class _AppLockScreenState extends State { const Spacer(), - // Biometrics button - if (appLock.biometricsEnabled) - Padding( - padding: const EdgeInsets.only(bottom: 24), - child: IconButton( - icon: Icon( - Icons.fingerprint, - color: Colors.blueAccent.withValues(alpha: 0.8), - size: 40, - ), - onPressed: _authenticateBiometric, - tooltip: 'Use fingerprint', - ), - ), - // Keypad _buildKeypad(appLock), ], @@ -219,11 +201,7 @@ class _AppLockScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _KeypadButton( - label: '⌫', - onTap: _onDelete, - isFunction: true, - ), + _KeypadButton(label: '⌫', onTap: _onDelete, isFunction: true), _KeypadButton( label: digitLabels[0], onTap: () => _onDigit(digitLabels[0]), @@ -257,14 +235,19 @@ class _AppLockScreenState extends State { void _onDelete() { if (_enteredPin.isEmpty) return; - setState(() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1)); + setState( + () => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1), + ); } Future _verifyPin() async { setState(() => _isVerifying = true); final appLock = context.read(); - final valid = await appLock.verifyPin(_enteredPin, forAppWide: widget.forAppWide); + final valid = await appLock.verifyPin( + _enteredPin, + forAppWide: widget.forAppWide, + ); if (!mounted) return; @@ -283,27 +266,7 @@ class _AppLockScreenState extends State { } } - Future _authenticateBiometric() async { - final appLock = context.read(); - final available = await appLock.isBiometricsAvailable(); - if (!available) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Biometrics not available on this device')), - ); - } - return; - } - final success = await appLock.authenticateWithBiometrics(); - if (success && mounted) { - appLock.onUnlocked(); - Navigator.of(context).pop(true); - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Biometric authentication failed')), - ); - } - } + } class _KeypadButton extends StatelessWidget { diff --git a/lib/screens/app_lock_settings_page.dart b/lib/screens/app_lock_settings_page.dart index 77d6575..30167b7 100644 --- a/lib/screens/app_lock_settings_page.dart +++ b/lib/screens/app_lock_settings_page.dart @@ -32,8 +32,10 @@ class _AppLockSettingsPageState extends State { return Scaffold( appBar: AppBar( - title: const Text('App Lock', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + title: const Text( + 'App Lock', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), centerTitle: true, leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new, size: 18), @@ -49,9 +51,16 @@ class _AppLockSettingsPageState extends State { decoration: BoxDecoration( gradient: LinearGradient( colors: anythingOn - ? [Colors.blueAccent.withValues(alpha: 0.15), Colors.blue.withValues(alpha: 0.05)] - : [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)], - begin: Alignment.topLeft, end: Alignment.bottomRight, + ? [ + Colors.blueAccent.withValues(alpha: 0.15), + Colors.blue.withValues(alpha: 0.05), + ] + : [ + Colors.grey.withValues(alpha: 0.1), + Colors.grey.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16), border: Border.all( @@ -71,7 +80,8 @@ class _AppLockSettingsPageState extends State { Text( anythingOn ? 'Lock Active' : 'No Lock', style: TextStyle( - fontSize: 20, fontWeight: FontWeight.bold, + fontSize: 20, + fontWeight: FontWeight.bold, color: anythingOn ? Colors.blueAccent : Colors.grey, ), ), @@ -92,9 +102,7 @@ class _AppLockSettingsPageState extends State { // ── App-wide lock ──────────────────────────────────── SwitchListTile( title: const Text('Lock Entire App'), - subtitle: const Text( - 'Require PIN when opening FocusGram.', - ), + subtitle: const Text('Require PIN when opening FocusGram.'), value: a.lockAppWide, onChanged: (v) async { if (v && !a.hasPin) { @@ -133,9 +141,9 @@ class _AppLockSettingsPageState extends State { MaterialPageRoute(builder: (_) => const AppLockSetupScreen()), ); if (ok == true && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('PIN updated')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('PIN updated'))); } }, ), @@ -163,7 +171,11 @@ class _AppLockSettingsPageState extends State { ), child: const Row( children: [ - Icon(Icons.info_outline, size: 16, color: Colors.blueAccent), + Icon( + Icons.info_outline, + size: 16, + color: Colors.blueAccent, + ), SizedBox(width: 8), Expanded( child: Text( @@ -199,12 +211,15 @@ class _SectionHeader extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Text(title, - style: const TextStyle( - color: Colors.grey, - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2)), + child: Text( + title, + style: const TextStyle( + color: Colors.grey, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), ); } } diff --git a/lib/screens/app_lock_setup_screen.dart b/lib/screens/app_lock_setup_screen.dart index 2eea0e8..b9ebfb0 100644 --- a/lib/screens/app_lock_setup_screen.dart +++ b/lib/screens/app_lock_setup_screen.dart @@ -29,10 +29,7 @@ class _AppLockSetupScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Set App Lock PIN'), - centerTitle: true, - ), + appBar: AppBar(title: const Text('Set App Lock PIN'), centerTitle: true), body: Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/lib/screens/bait_me_button.dart b/lib/screens/bait_me_button.dart index 37982a1..f7d510d 100644 --- a/lib/screens/bait_me_button.dart +++ b/lib/screens/bait_me_button.dart @@ -95,9 +95,7 @@ class _BaitMeButtonState extends State color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(24), - onTap: baitEngine.isOnCooldown - ? null - : _onBaitMe, + onTap: baitEngine.isOnCooldown ? null : _onBaitMe, child: Center( child: Icon( Icons.casino_rounded, diff --git a/lib/screens/bait_me_full_screen.dart b/lib/screens/bait_me_full_screen.dart index d047d3d..62a3797 100644 --- a/lib/screens/bait_me_full_screen.dart +++ b/lib/screens/bait_me_full_screen.dart @@ -68,7 +68,9 @@ class _BaitMeFullScreenState extends State const SizedBox(height: 8), Text( _done - ? BaitEngine.outcomeSubtext(_lastOutcome ?? BaitOutcome.addTenMinutes) + ? BaitEngine.outcomeSubtext( + _lastOutcome ?? BaitOutcome.addTenMinutes, + ) : 'Tap the button to test your luck!', textAlign: TextAlign.center, style: TextStyle( @@ -83,7 +85,9 @@ class _BaitMeFullScreenState extends State animation: _spinAnimation, builder: (context, child) { return Transform.rotate( - angle: _isSpinning ? _spinAnimation.value * 2 * pi * 5 : 0, + angle: _isSpinning + ? _spinAnimation.value * 2 * pi * 5 + : 0, child: child, ); }, @@ -155,8 +159,9 @@ class _BaitMeFullScreenState extends State child: ElevatedButton.icon( onPressed: _isSpinning ? null : _onBaitMe, style: ElevatedButton.styleFrom( - backgroundColor: - _done ? Colors.greenAccent : Colors.purpleAccent, + backgroundColor: _done + ? Colors.greenAccent + : Colors.purpleAccent, foregroundColor: Colors.black, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -167,16 +172,16 @@ class _BaitMeFullScreenState extends State _isSpinning ? Icons.hourglass_top : _done - ? Icons.check_circle - : Icons.casino_rounded, + ? Icons.check_circle + : Icons.casino_rounded, size: 24, ), label: Text( _isSpinning ? 'Rolling…' : _done - ? 'Done β€” Close' - : '🎲 Spin the Wheel!', + ? 'Done β€” Close' + : '🎲 Spin the Wheel!', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 18, @@ -190,9 +195,12 @@ class _BaitMeFullScreenState extends State padding: const EdgeInsets.only(top: 12), child: TextButton( onPressed: () => Navigator.pop(context), - child: Text('Not now', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.3))), + child: Text( + 'Not now', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.3), + ), + ), ), ), @@ -220,13 +228,17 @@ class _BaitMeFullScreenState extends State baitEngine.onAddMinutes = (m) => creditStore.addBonusMinutes(m); baitEngine.onResetSession = () => creditStore.resetBalances(); baitEngine.onReduceSessionTime = (m) { - for (var i = 0; i < m; i++) creditStore.drainReelsMinute(); + for (var i = 0; i < m; i++) { + creditStore.drainReelsMinute(); + } }; baitEngine.onEndReelSession = () => sessionManager.endSession(); baitEngine.onEndAppSession = () => sessionManager.endAppSession(); baitEngine.onOpenUrl = (url) async { final uri = Uri.tryParse(url); - if (uri != null) await launchUrl(uri, mode: LaunchMode.externalApplication); + if (uri != null) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } }; final outcome = await baitEngine.activate(); diff --git a/lib/screens/debug_menu_screen.dart b/lib/screens/debug_menu_screen.dart index e4d1813..78ac4e8 100644 --- a/lib/screens/debug_menu_screen.dart +++ b/lib/screens/debug_menu_screen.dart @@ -339,4 +339,4 @@ class _DebugMenuScreenState extends State { } } } -*/ \ No newline at end of file +*/ diff --git a/lib/screens/effort_friction_gate.dart b/lib/screens/effort_friction_gate.dart index 6a99653..748e08c 100644 --- a/lib/screens/effort_friction_gate.dart +++ b/lib/screens/effort_friction_gate.dart @@ -35,8 +35,9 @@ class _EffortFrictionGateState extends State { Widget build(BuildContext context) { final creditStore = context.watch(); final isReels = widget.sessionType == 'reels'; - final credits = - isReels ? creditStore.reelsMinutes : creditStore.instaMinutes; + final credits = isReels + ? creditStore.reelsMinutes + : creditStore.instaMinutes; return Scaffold( backgroundColor: Colors.black, @@ -55,10 +56,7 @@ class _EffortFrictionGateState extends State { decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( - colors: [ - Colors.orange.shade800, - Colors.orange.shade500, - ], + colors: [Colors.orange.shade800, Colors.orange.shade500], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -231,9 +229,7 @@ class _EffortFrictionGateState extends State { onPressed: widget.onCancel ?? () => Navigator.pop(context), child: Text( credits > 0 ? 'Skip for now' : 'Not now', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.4), - ), + style: TextStyle(color: Colors.white.withValues(alpha: 0.4)), ), ), diff --git a/lib/screens/extras_settings_page.dart b/lib/screens/extras_settings_page.dart index 24cd00e..b134167 100644 --- a/lib/screens/extras_settings_page.dart +++ b/lib/screens/extras_settings_page.dart @@ -77,7 +77,7 @@ class ExtrasSettingsPage extends StatelessWidget { } String _ghostSubtitle(SettingsService s) { - if (s.ghostMode) return 'DM Ghost active'; + if (s.ghostMode) return 'DM Ghost active β€” works inside chat only'; return 'Tap to configure ghost modes'; } @@ -102,7 +102,7 @@ class _LaunchPagePicker extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( - value: settings.startupPage, + initialValue: settings.startupPage, decoration: const InputDecoration( labelText: 'Launch Page', border: OutlineInputBorder(), diff --git a/lib/screens/ghost_mode_submenu_page.dart b/lib/screens/ghost_mode_submenu_page.dart index ea0a39b..98c83ec 100644 --- a/lib/screens/ghost_mode_submenu_page.dart +++ b/lib/screens/ghost_mode_submenu_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../services/settings_service.dart'; @@ -28,7 +27,7 @@ class GhostModeSubmenuPage extends StatelessWidget { _GhostCard( icon: Icons.visibility_off_rounded, title: 'DM Ghost', - subtitle: 'Read messages without the person knowing', + subtitle: 'Read messages without the person knowing (works inside chat interface β€” first entry only)', value: s.ghostMode, warning: 'When DM Ghost is enabled, you can\'t send messages or react to any, you can just receive messages. You can turn ghost mode off anytime from topbar button.', @@ -117,7 +116,7 @@ class _GhostCard extends StatelessWidget { ), Switch( value: value, - activeColor: danger ? Colors.redAccent : null, + activeThumbColor: danger ? Colors.redAccent : null, onChanged: onChanged, ), ], diff --git a/lib/screens/level_panel_screen.dart b/lib/screens/level_panel_screen.dart index 225019c..fb1337b 100644 --- a/lib/screens/level_panel_screen.dart +++ b/lib/screens/level_panel_screen.dart @@ -43,8 +43,10 @@ class LevelPanelScreen extends StatelessWidget { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: _levelColors(levelService.level, isDark)[0] - .withValues(alpha: 0.3), + color: _levelColors( + levelService.level, + isDark, + )[0].withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 8), ), @@ -116,12 +118,14 @@ class LevelPanelScreen extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black) - .withValues(alpha: 0.05), + color: (isDark ? Colors.white : Colors.black).withValues( + alpha: 0.05, + ), borderRadius: BorderRadius.circular(14), border: Border.all( - color: (isDark ? Colors.white : Colors.black) - .withValues(alpha: 0.1), + color: (isDark ? Colors.white : Colors.black).withValues( + alpha: 0.1, + ), ), ), child: Row( @@ -183,19 +187,18 @@ class LevelPanelScreen extends StatelessWidget { final unlocked = levelService.isFeatureUnlocked(feature); return Container( margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black) - .withValues(alpha: unlocked ? 0.04 : 0.02), + color: (isDark ? Colors.white : Colors.black).withValues( + alpha: unlocked ? 0.04 : 0.02, + ), borderRadius: BorderRadius.circular(12), border: Border.all( color: unlocked ? Colors.greenAccent.withValues(alpha: 0.2) - : (isDark ? Colors.white : Colors.black) - .withValues(alpha: 0.08), + : (isDark ? Colors.white : Colors.black).withValues( + alpha: 0.08, + ), ), ), child: Row( @@ -211,12 +214,10 @@ class LevelPanelScreen extends StatelessWidget { feature.name, style: TextStyle( fontSize: 14, - fontWeight: - unlocked ? FontWeight.w600 : FontWeight.normal, - color: - unlocked - ? null - : Colors.grey, + fontWeight: unlocked + ? FontWeight.w600 + : FontWeight.normal, + color: unlocked ? null : Colors.grey, ), ), ), @@ -314,8 +315,9 @@ class LevelPanelScreen extends StatelessWidget { margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: (isDark ? Colors.white : Colors.black) - .withValues(alpha: 0.04), + color: (isDark ? Colors.white : Colors.black).withValues( + alpha: 0.04, + ), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -386,8 +388,11 @@ class LevelPanelScreen extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.warning_amber_rounded, - color: Colors.redAccent, size: 18), + Icon( + Icons.warning_amber_rounded, + color: Colors.redAccent, + size: 18, + ), SizedBox(width: 8), Text( 'XP decays if you backslide', @@ -404,7 +409,11 @@ class LevelPanelScreen extends StatelessWidget { 'β€’ Watching more reels than your weekly average deducts XP\n' 'β€’ Exceeding limits for 3 consecutive days drops a level\n' 'β€’ Levels are preserved on monthly reset, but XP resets', - style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.5), + style: TextStyle( + fontSize: 12, + color: Colors.grey, + height: 1.5, + ), ), ], ), @@ -417,12 +426,18 @@ class LevelPanelScreen extends StatelessWidget { Color _levelColor(int level) { switch (level) { - case 1: return Colors.grey; - case 2: return Colors.blue; - case 3: return Colors.purple; - case 4: return Colors.orange; - case 5: return Colors.amber; - default: return Colors.grey; + case 1: + return Colors.grey; + case 2: + return Colors.blue; + case 3: + return Colors.purple; + case 4: + return Colors.orange; + case 5: + return Colors.amber; + default: + return Colors.grey; } } @@ -461,12 +476,18 @@ class LevelPanelScreen extends StatelessWidget { String _levelTitle(int level) { switch (level) { - case 1: return 'Beginner'; - case 2: return 'Mindful Scroller'; - case 3: return 'Disciplined'; - case 4: return 'Focus Master'; - case 5: return 'Digital Monk'; - default: return 'Level $level'; + case 1: + return 'Beginner'; + case 2: + return 'Mindful Scroller'; + case 3: + return 'Disciplined'; + case 4: + return 'Focus Master'; + case 5: + return 'Digital Monk'; + default: + return 'Level $level'; } } } diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index 8a9f797..5dab374 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -1259,19 +1259,22 @@ window.__fgFullDmGhost = ${s.ghostMode}; } // β€” Block /api/graphql + gateway on homepage & - // DM thread pages. Allow on /direct/inbox/. β€” + // ANY /direct/* page (not just /direct/t/). + // Allow on /direct/inbox/ so inbox loads. + // Broader scope catches seen indicators sent + // during SPA transitions on re-entry. final currentPath = Uri.tryParse(_currentUrl)?.path ?? _currentUrl; final isHomepage = currentPath == '/' || currentPath == ''; - final isDmThread = currentPath.startsWith( - '/direct/t/', + final isOnDirect = currentPath.startsWith( + '/direct/', ); if (!currentPath.startsWith( '/direct/inbox/', ) && - (isHomepage || isDmThread) && + (isHomepage || isOnDirect) && (url.contains('/api/graphql') || url.contains( 'gateway.instagram.com', @@ -2180,6 +2183,11 @@ window.__fgFullDmGhost = ${s.ghostMode}; handlerName: 'UrlChange', callback: (args) async { final url = (args.isNotEmpty ? args[0] : '') as String? ?? ''; + + // Update _currentUrl SYNCHRONOUSLY before any async operations, + // so shouldInterceptRequest sees the correct path immediately. + _currentUrl = url; + _syncDirectThreadState(url); final s = context.read(); diff --git a/lib/screens/offline_feed_viewer.dart b/lib/screens/offline_feed_viewer.dart index d265857..0e7c92f 100644 --- a/lib/screens/offline_feed_viewer.dart +++ b/lib/screens/offline_feed_viewer.dart @@ -23,8 +23,10 @@ class OfflineFeedViewer extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Offline View', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + title: const Text( + 'Offline View', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), centerTitle: true, leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new, size: 18), @@ -39,11 +41,16 @@ class OfflineFeedViewer extends StatelessWidget { color: Colors.blue.withValues(alpha: 0.1), child: const Row( children: [ - Icon(Icons.wifi_off_rounded, size: 14, - color: Colors.blueAccent), + Icon( + Icons.wifi_off_rounded, + size: 14, + color: Colors.blueAccent, + ), SizedBox(width: 6), - Text('Offline β€” saved content shown', - style: TextStyle(fontSize: 11, color: Colors.blueAccent)), + Text( + 'Offline β€” saved content shown', + style: TextStyle(fontSize: 11, color: Colors.blueAccent), + ), ], ), ), diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index 1c6fc91..2cb2412 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -88,7 +88,6 @@ class SettingsPage extends StatelessWidget { MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()), ), ), - const _SectionHeader(title: 'APPEARANCE'), _SubmoduleTile( @@ -147,18 +146,19 @@ class SettingsPage extends StatelessWidget { icon: Icons.trending_up_rounded, iconColor: Colors.amber, title: 'Your Journey', - subtitle: 'Level ${context.watch().level} Β· ${context.watch().xp} XP', + subtitle: + 'Level ${context.watch().level} Β· ${context.watch().xp} XP', onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const LevelPanelScreen()), ), ), - // Quick XP debug grant (visible in settings for testing) - // _XpDebugTile(), - // Reels History removed + // Quick XP debug grant (visible in settings for testing) + // _XpDebugTile(), + // Reels History removed const _SectionHeader(title: 'ABOUT'), - _VersionTile(), + _VersionTile(), ListTile( title: const Text('GitHub'), trailing: const Icon(Icons.open_in_new, size: 14), @@ -236,7 +236,8 @@ class SettingsPage extends StatelessWidget { ), ]; - if (true) { // ad counter always shown + if (true) { + // ad counter always shown cells.addAll([ _dividerCell(), _statCell( @@ -291,8 +292,6 @@ class SettingsPage extends StatelessWidget { return '${parts.join(' + ')} lock active'; } - - void _showLegalDisclaimer(BuildContext context) { showDialog( context: context, @@ -435,12 +434,15 @@ class FocusSettingsPage extends StatelessWidget { _SwitchTile( title: 'Effort Friction Mode', - subtitle: 'Watch ads to earn reel quota minutes', + subtitle: 'Earn credits by watching ads β€” enabled by default', value: settings.effortFrictionEnabled, onChanged: (v) async { - if (v && !context.read().isFeatureUnlocked(AppFeature.effortFriction)) { + if (v && + !context.read().isFeatureUnlocked( + AppFeature.effortFriction, + )) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unlocks at Level 2')), + const SnackBar(content: Text('Unlocks at Level 3')), ); return; } @@ -473,8 +475,7 @@ class FocusSettingsPage extends StatelessWidget { _SwitchTile( title: 'Hide Feed Posts', - subtitle: - 'Hides home feed posts', + subtitle: 'Hides home feed posts', value: settings.contentPosts, onChanged: (v) => settings.setContentPostsEnabled(v), ), @@ -1383,7 +1384,6 @@ class _NumberEditTile extends StatelessWidget { } } - class _VersionTile extends StatelessWidget { const _VersionTile(); @@ -1402,7 +1402,6 @@ class _VersionTile extends StatelessWidget { } } - class _SectionHeader extends StatelessWidget { final String title; const _SectionHeader({required this.title}); diff --git a/lib/screens/snapshot_manager_screen.dart b/lib/screens/snapshot_manager_screen.dart index c836544..4169fea 100644 --- a/lib/screens/snapshot_manager_screen.dart +++ b/lib/screens/snapshot_manager_screen.dart @@ -88,7 +88,11 @@ class _SavedPageList extends StatelessWidget { ), child: Row( children: [ - const Icon(Icons.info_outline, size: 16, color: Colors.blueAccent), + const Icon( + Icons.info_outline, + size: 16, + color: Colors.blueAccent, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -202,7 +206,10 @@ class _SavedPageList extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (_) => OfflineFeedViewer(url: page.url, pageId: page.id), + builder: (_) => OfflineFeedViewer( + url: page.url, + pageId: page.id, + ), ), ); } @@ -222,11 +229,16 @@ class _SavedPageList extends StatelessWidget { value: 'delete', child: Row( children: [ - Icon(Icons.delete_outline, - color: Colors.redAccent, size: 18), + Icon( + Icons.delete_outline, + color: Colors.redAccent, + size: 18, + ), SizedBox(width: 8), - Text('Remove', - style: TextStyle(color: Colors.redAccent)), + Text( + 'Remove', + style: TextStyle(color: Colors.redAccent), + ), ], ), ), @@ -257,8 +269,9 @@ class _SavedPageList extends StatelessWidget { context: context, builder: (ctx) => AlertDialog( title: const Text('Remove page?'), - content: - const Text('Removes the bookmark. Cache is preserved automatically.'), + content: const Text( + 'Removes the bookmark. Cache is preserved automatically.', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), @@ -269,8 +282,10 @@ class _SavedPageList extends StatelessWidget { service.deletePage(id); Navigator.pop(ctx); }, - child: - const Text('Remove', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Remove', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -293,8 +308,10 @@ class _SavedPageList extends StatelessWidget { service.deleteAll(); Navigator.pop(ctx); }, - child: - const Text('Clear', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Clear', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), diff --git a/lib/screens/timer_fallback_screen.dart b/lib/screens/timer_fallback_screen.dart index 38c725d..550148c 100644 --- a/lib/screens/timer_fallback_screen.dart +++ b/lib/screens/timer_fallback_screen.dart @@ -154,14 +154,10 @@ class _TimerFallbackScreenState extends State { width: double.infinity, height: 54, child: ElevatedButton.icon( - onPressed: done - ? () => Navigator.pop(context, true) - : null, + onPressed: done ? () => Navigator.pop(context, true) : null, style: ElevatedButton.styleFrom( - backgroundColor: - done ? Colors.greenAccent : Colors.grey, - foregroundColor: - done ? Colors.black : Colors.white38, + backgroundColor: done ? Colors.greenAccent : Colors.grey, + foregroundColor: done ? Colors.black : Colors.white38, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), diff --git a/lib/scripts/focus_scripts.dart b/lib/scripts/focus_scripts.dart index 7d28c63..436551c 100644 --- a/lib/scripts/focus_scripts.dart +++ b/lib/scripts/focus_scripts.dart @@ -241,7 +241,10 @@ const String kFullDmGhostJS = r''' navigator.sendBeacon = function(url) { return true; }; } - // ── MQTT WS intercept (typing / live viewer) ─────────────── + // ── MQTT WS intercept (typing / live viewer / seen) ──────── + // Instagram uses MQTT over WebSocket for real-time events. + // '/t_fs' = foreground state, '/t_mt' = mark thread seen, + // '/t_s' and '/t_se' = seen receipts, 'activity_indicator' = active status. (function() { var _WS = window.WebSocket; function DmGhostWS(url, protocols) { @@ -254,7 +257,9 @@ const String kFullDmGhostJS = r''' if (packetType === 0x30) { try { var decoded = new TextDecoder('utf-8').decode(bytes); - if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('activity_indicator') !== -1 || + if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('/t_mt') !== -1 || + decoded.indexOf('/t_s') !== -1 || decoded.indexOf('/t_se') !== -1 || + decoded.indexOf('activity_indicator') !== -1 || decoded.indexOf('is_typing') !== -1 || decoded.indexOf('direct_typing') !== -1 || decoded.indexOf('/live/viewer') !== -1 || decoded.indexOf('live_viewer_list') !== -1) { return; @@ -262,7 +267,9 @@ const String kFullDmGhostJS = r''' } catch(e) {} } } else if (typeof data === 'string') { - if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 || data.indexOf('is_typing') !== -1) return; + if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 || + data.indexOf('is_typing') !== -1 || data.indexOf('mark_seen') !== -1 || + data.indexOf('mark_read') !== -1 || data.indexOf('receipt') !== -1) return; } return _send(data); }; @@ -403,8 +410,9 @@ List buildUserScripts(FocusSettings settings) { // (evaluateJavascript-set flags are destroyed when the JS context resets on load.) // DM Ghost uses the comprehensive Full DM approach (URL blocklist, GraphQL ops, SW killer, beacon, WS). // it should have worked, but sadly it didnt - if (settings.ghostMode) - startScripts.add('window.__fgFullDmGhost=true;' + kFullDmGhostJS); + if (settings.ghostMode) { + startScripts.add('window.__fgFullDmGhost=true;$kFullDmGhostJS'); + } if (settings.noAutoplay) startScripts.add(noAutoplayJS); // AT_DOCUMENT_END diff --git a/lib/services/app_lock_service.dart b/lib/services/app_lock_service.dart index c3fa9ed..dc39898 100644 --- a/lib/services/app_lock_service.dart +++ b/lib/services/app_lock_service.dart @@ -27,12 +27,12 @@ class AppLockService extends ChangeNotifier { final _auth = LocalAuthentication(); // ─── Mode toggles ────────────────────────────────────────── - bool _lockAppWide = false; // locks the whole app on start / bg timeout - bool _lockMessages = false; // locks only the DMs tab + bool _lockAppWide = false; // locks the whole app on start / bg timeout + bool _lockMessages = false; // locks only the DMs tab // ─── Settings ────────────────────────────────────────────── bool _scramble = false; - bool _bioEnabled = true; + bool _bioEnabled = false; int _timeoutMs = 120000; // 2 min bool _hasPin = false; @@ -67,15 +67,16 @@ class AppLockService extends ChangeNotifier { // Check if either PIN exists final hashA = await _secure.read(key: _pinAppWideKey); final hashM = await _secure.read(key: _pinMessagesKey); - _hasPin = (hashA != null && hashA.isNotEmpty) || - (hashM != null && hashM.isNotEmpty); + _hasPin = + (hashA != null && hashA.isNotEmpty) || + (hashM != null && hashM.isNotEmpty); } // ─── PIN management ──────────────────────────────────────── - String _hash(String pin) => - utf8.encode('fg_${pin}_salt26') - .map((x) => x.toRadixString(16).padLeft(2, '0')) - .join(); + String _hash(String pin) => utf8 + .encode('fg_${pin}_salt26') + .map((x) => x.toRadixString(16).padLeft(2, '0')) + .join(); /// Set PIN for a specific lock mode. Future setPin(String pin, {required bool forAppWide}) async { diff --git a/lib/services/bait_engine.dart b/lib/services/bait_engine.dart index cbfb28c..225dcfd 100644 --- a/lib/services/bait_engine.dart +++ b/lib/services/bait_engine.dart @@ -35,9 +35,9 @@ class BaitEngine extends ChangeNotifier { final Random _random = Random(); // ── Hardcoded ad URLs ────────────────────────────────────── - String _adWebsiteUrl = + final String _adWebsiteUrl = 'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13'; - String _externalAdUrl = + final String _externalAdUrl = 'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13'; // ── Cooldown ─────────────────────────────────────────────── @@ -99,7 +99,9 @@ class BaitEngine extends ChangeNotifier { final outcome = roll(); _lastActivation = DateTime.now(); await _box.put( - 'last_activation_ms', _lastActivation!.millisecondsSinceEpoch); + 'last_activation_ms', + _lastActivation!.millisecondsSinceEpoch, + ); notifyListeners(); switch (outcome) { diff --git a/lib/services/level_service.dart b/lib/services/level_service.dart index 462c90f..f2b11fe 100644 --- a/lib/services/level_service.dart +++ b/lib/services/level_service.dart @@ -19,7 +19,7 @@ class AppFeature { static const effortFriction = AppFeature._( 'effort_friction', 'Effort Friction Mode', - 2, + 3, ); static const reelsHistory = AppFeature._('reels_history', 'Reels History', 2); static const downloadMedia = AppFeature._( @@ -79,8 +79,6 @@ class LevelService extends ChangeNotifier { int _adsWatchedTotal = 0; // Track today for daily reel logging - int _todayReelCount = 0; - String _todayKey = ''; // ─── Getters ─────────────────────────────────────────────── int get level => _level; @@ -122,10 +120,7 @@ class LevelService extends ChangeNotifier { _cache = await Hive.openBox(_hiveBox); _loadFromCache(); - // 2. Set up today tracking - _todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now()); - - // 3. Check monthly reset + // 2. Check monthly reset await _checkMonthlyReset(); // 4. Check daily degradation @@ -165,8 +160,6 @@ class LevelService extends ChangeNotifier { // ─── XP History ──────────────────────────────────────────── final List<_XpEvent> _xpHistory = []; - List<_XpEvent> get xpHistory => List.unmodifiable(_xpHistory); - /// Human-readable recent XP log for "Your Journey". List> get recentXpLog { return _xpHistory.reversed diff --git a/lib/services/remote_popup_service.dart b/lib/services/remote_popup_service.dart index fcc203f..51822db 100644 --- a/lib/services/remote_popup_service.dart +++ b/lib/services/remote_popup_service.dart @@ -46,9 +46,7 @@ class RemotePopupService { final response = await http.get( uri, - headers: const { - 'Cache-Control': 'no-cache', - }, + headers: const {'Cache-Control': 'no-cache'}, ); if (response.statusCode != 200) return null; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index e73793c..fe6da80 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -149,7 +149,7 @@ class SettingsService extends ChangeNotifier { bool _notifyPersistent = false; // Focus mode settings - bool _effortFrictionEnabled = false; + bool _effortFrictionEnabled = true; String _startupPage = 'home'; // home, following, favorites, direct String _adsterraZoneUrl = ''; String _adsterraAdCode = ''; @@ -363,7 +363,7 @@ class SettingsService extends ChangeNotifier { // Focus mode settings _effortFrictionEnabled = - _prefs!.getBool(_keyEffortFrictionEnabled) ?? false; + _prefs!.getBool(_keyEffortFrictionEnabled) ?? true; _startupPage = _prefs!.getString(_keyStartupPage) ?? 'home'; _adsterraZoneUrl = _prefs!.getString(_keyAdsterraZoneUrl) ?? ''; _adsterraAdCode = _prefs!.getString(_keyAdsterraAdCode) ?? ''; diff --git a/lib/services/snapshot_service.dart b/lib/services/snapshot_service.dart index 5eca741..7a124c1 100644 --- a/lib/services/snapshot_service.dart +++ b/lib/services/snapshot_service.dart @@ -34,8 +34,8 @@ class SavedPage { id: json['id'] as String? ?? '', url: json['url'] as String? ?? '', title: json['title'] as String? ?? 'Instagram', - savedAt: DateTime.tryParse(json['savedAt'] as String? ?? '') ?? - DateTime.now(), + savedAt: + DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(), htmlContent: json['html'] as String?, ); } @@ -68,10 +68,11 @@ class SnapshotService extends ChangeNotifier { final raw = _box.get('page_list') as String?; if (raw != null) { final decoded = jsonDecode(raw) as List; - _savedPages = decoded - .map((e) => SavedPage.fromJson(e as Map)) - .toList() - ..sort((a, b) => b.savedAt.compareTo(a.savedAt)); + _savedPages = + decoded + .map((e) => SavedPage.fromJson(e as Map)) + .toList() + ..sort((a, b) => b.savedAt.compareTo(a.savedAt)); } } catch (_) {} } @@ -82,7 +83,11 @@ class SnapshotService extends ChangeNotifier { } /// Save a page. Optionally pass [htmlContent] captured from the WebView. - Future savePage(String url, {String title = 'Instagram', String? htmlContent}) async { + Future savePage( + String url, { + String title = 'Instagram', + String? htmlContent, + }) async { if (url.isEmpty) return; // Avoid duplicates if (_savedPages.any((p) => p.url == url)) return; diff --git a/lib/widgets/medium_rect_banner.dart b/lib/widgets/medium_rect_banner.dart index 2c1231b..7c6c286 100644 --- a/lib/widgets/medium_rect_banner.dart +++ b/lib/widgets/medium_rect_banner.dart @@ -21,7 +21,8 @@ const String _kMediumRectCode = ''' class MediumRectBanner extends StatelessWidget { const MediumRectBanner({super.key}); - String get _html => ''' + String get _html => + ''' diff --git a/pubspec.yaml b/pubspec.yaml index 4b390e5..bb6c5fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: focusgram description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally." publish_to: 'none' -version: 2.0.0 +version: 2.1.0 environment: sdk: ^3.10.7 diff --git a/test/services/app_lock_service_test.dart b/test/services/app_lock_service_test.dart index 217bf5e..4a568f9 100644 --- a/test/services/app_lock_service_test.dart +++ b/test/services/app_lock_service_test.dart @@ -14,11 +14,9 @@ void main() { final service = AppLockService(); await service.init(); - // Set a PIN first await service.setPin('1234', forAppWide: true); - // Verify it - final valid = await service.verifyPin('1234'); + final valid = await service.verifyPin('1234', forAppWide: true); expect(valid, isTrue); }); @@ -28,7 +26,7 @@ void main() { await service.setPin('1234', forAppWide: true); - final valid = await service.verifyPin('0000'); + final valid = await service.verifyPin('0000', forAppWide: true); expect(valid, isFalse); }); @@ -36,10 +34,8 @@ void main() { final service = AppLockService(); await service.init(); - // Set messages PIN await service.setPin('5678', forAppWide: false); - // Verify with forAppWide: false (messages PIN) final valid = await service.verifyPin('5678', forAppWide: false); expect(valid, isTrue); }); @@ -49,7 +45,7 @@ void main() { await service.init(); await service.setPin('1234', forAppWide: true); - await service.onBackgrounded(); + service.onBackgrounded(); expect(service.shouldLockOnResume, isTrue); service.onUnlocked(); @@ -73,7 +69,7 @@ void main() { final service = AppLockService(); await service.init(); - final valid = await service.verifyPin('1234'); + final valid = await service.verifyPin('1234', forAppWide: true); expect(valid, isFalse); }); }); diff --git a/test/services/dm_ghost_injection_test.dart b/test/services/dm_ghost_injection_test.dart index 0515507..af03912 100644 --- a/test/services/dm_ghost_injection_test.dart +++ b/test/services/dm_ghost_injection_test.dart @@ -5,20 +5,22 @@ import 'package:focusgram/scripts/focus_scripts.dart'; void main() { group('FocusSettings β€” Field cleanup', () { - test('only ghostMode remains (fullDmGhost and storyGhost removed)', - () async { - const settings = FocusSettings(ghostMode: true); + test( + 'only ghostMode remains (fullDmGhost and storyGhost removed)', + () async { + const settings = FocusSettings(ghostMode: true); - expect(settings.ghostMode, isTrue); - expect(settings.noAds, isTrue); - expect(settings.noStories, isFalse); - expect(settings.noReels, isFalse); - expect(settings.noAutoplay, isFalse); - expect(settings.noDMs, isFalse); + expect(settings.ghostMode, isTrue); + expect(settings.noAds, isTrue); + expect(settings.noStories, isFalse); + expect(settings.noReels, isFalse); + expect(settings.noAutoplay, isFalse); + expect(settings.noDMs, isFalse); - // Verify fullDmGhost and storyGhost are NOT fields anymore - // (these would be compile errors if they existed) - }); + // Verify fullDmGhost and storyGhost are NOT fields anymore + // (these would be compile errors if they existed) + }, + ); test('default ghostMode is false', () async { const settings = FocusSettings(); @@ -48,13 +50,12 @@ void main() { }); test('does NOT inject ghost scripts when ghostMode is false', () async { - final scripts = - buildUserScripts(const FocusSettings(ghostMode: false)); + final scripts = buildUserScripts(const FocusSettings(ghostMode: false)); // Should have no DOCUMENT_START scripts - final startScripts = - scripts.where((s) => - s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START); + final startScripts = scripts.where( + (s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START, + ); for (final s in startScripts) { expect(s.source.contains('__fgFullDmGhost'), isFalse); } @@ -65,28 +66,28 @@ void main() { const FocusSettings(ghostMode: true, noAutoplay: true), ); - final startScripts = - scripts.where((s) => - s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START); + final startScripts = scripts.where( + (s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START, + ); expect(startScripts.length, equals(1)); expect(startScripts.first.source, contains('__fgFullDmGhost=true')); expect(startScripts.first.source, contains('document.addEventListener')); }); - test('injects hideStoryTray at DOCUMENT_END when noStories is true', - () async { - final scripts = buildUserScripts( - const FocusSettings(noStories: true), - ); + test( + 'injects hideStoryTray at DOCUMENT_END when noStories is true', + () async { + final scripts = buildUserScripts(const FocusSettings(noStories: true)); - final endScripts = - scripts.where((s) => - s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END); - expect(endScripts.length, equals(1)); - expect( - endScripts.first.source, - contains('[data-pagelet="story_tray"]'), - ); - }); + final endScripts = scripts.where( + (s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END, + ); + expect(endScripts.length, equals(1)); + expect( + endScripts.first.source, + contains('[data-pagelet="story_tray"]'), + ); + }, + ); }); } diff --git a/test/services/ghost_mode_focus_scripts_test.dart b/test/services/ghost_mode_focus_scripts_test.dart index b12cc71..8795483 100644 --- a/test/services/ghost_mode_focus_scripts_test.dart +++ b/test/services/ghost_mode_focus_scripts_test.dart @@ -5,20 +5,22 @@ import 'package:focusgram/scripts/focus_scripts.dart'; void main() { group('FocusSettings β€” Field cleanup', () { - test('only ghostMode remains (fullDmGhost and storyGhost removed)', - () async { - const settings = FocusSettings(ghostMode: true); + test( + 'only ghostMode remains (fullDmGhost and storyGhost removed)', + () async { + const settings = FocusSettings(ghostMode: true); - expect(settings.ghostMode, isTrue); - expect(settings.noAds, isTrue); - expect(settings.noStories, isFalse); - expect(settings.noReels, isFalse); - expect(settings.noAutoplay, isFalse); - expect(settings.noDMs, isFalse); + expect(settings.ghostMode, isTrue); + expect(settings.noAds, isTrue); + expect(settings.noStories, isFalse); + expect(settings.noReels, isFalse); + expect(settings.noAutoplay, isFalse); + expect(settings.noDMs, isFalse); - // Verify fullDmGhost and storyGhost are NOT fields anymore - // (these would be compile errors if they existed) - }); + // Verify fullDmGhost and storyGhost are NOT fields anymore + // (these would be compile errors if they existed) + }, + ); test('default ghostMode is false', () async { const settings = FocusSettings(); @@ -43,8 +45,7 @@ void main() { }); test('does NOT inject ghost scripts when ghostMode is false', () async { - final scripts = - buildUserScripts(const FocusSettings(ghostMode: false)); + final scripts = buildUserScripts(const FocusSettings(ghostMode: false)); // Should have no start scripts (ghostMode is the only start-level script) // unless other features like noAutoplay are also false @@ -62,9 +63,9 @@ void main() { ); // Should have 1 DOCUMENT_START script combining ghost + autoplay - final startScripts = - scripts.where((s) => - s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START); + final startScripts = scripts.where( + (s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START, + ); expect(startScripts.length, equals(1)); expect(startScripts.first.source, contains('__fgFullDmGhost=true')); expect(startScripts.first.source, contains('document.addEventListener')); diff --git a/test/services/ghost_mode_settings_test.dart b/test/services/ghost_mode_settings_test.dart index d924f73..67ef08a 100644 --- a/test/services/ghost_mode_settings_test.dart +++ b/test/services/ghost_mode_settings_test.dart @@ -68,11 +68,13 @@ void main() { expect(s.isGrayscaleActiveNow, isTrue); }); - test('isGrayscaleActiveNow returns false when toggle off and no schedules', - () async { - final s = SettingsService(); - await s.init(); - expect(s.isGrayscaleActiveNow, isFalse); - }); + test( + 'isGrayscaleActiveNow returns false when toggle off and no schedules', + () async { + final s = SettingsService(); + await s.init(); + expect(s.isGrayscaleActiveNow, isFalse); + }, + ); }); } diff --git a/test/services/ghost_mode_url_patterns_test.dart b/test/services/ghost_mode_url_patterns_test.dart index 4c1df45..d72c2a4 100644 --- a/test/services/ghost_mode_url_patterns_test.dart +++ b/test/services/ghost_mode_url_patterns_test.dart @@ -105,14 +105,17 @@ void main() { }); // ── Ephemeral / visual seen ─────────────────────────────── - test('blocks /api/v1/direct_v2/threads/{id}/items/{id}/mark_visual_item_seen/', () { - expect( - seenPattern.hasMatch( - 'https://www.instagram.com/api/v1/direct_v2/threads/abc/items/def/mark_visual_item_seen/', - ), - isTrue, - ); - }); + test( + 'blocks /api/v1/direct_v2/threads/{id}/items/{id}/mark_visual_item_seen/', + () { + expect( + seenPattern.hasMatch( + 'https://www.instagram.com/api/v1/direct_v2/threads/abc/items/def/mark_visual_item_seen/', + ), + isTrue, + ); + }, + ); test('blocks /api/v1/direct_v2/visual_thread/{id}/seen/', () { expect( @@ -171,18 +174,14 @@ void main() { test('blocks /api/v1/launcher/sync/', () { expect( - seenPattern.hasMatch( - 'https://www.instagram.com/api/v1/launcher/sync/', - ), + seenPattern.hasMatch('https://www.instagram.com/api/v1/launcher/sync/'), isTrue, ); }); test('blocks /api/v1/logging/', () { expect( - seenPattern.hasMatch( - 'https://www.instagram.com/api/v1/logging/event', - ), + seenPattern.hasMatch('https://www.instagram.com/api/v1/logging/event'), isTrue, ); }); @@ -204,10 +203,7 @@ void main() { }); test('blocks /ajax/bz', () { - expect( - seenPattern.hasMatch('https://www.instagram.com/ajax/bz'), - isTrue, - ); + expect(seenPattern.hasMatch('https://www.instagram.com/ajax/bz'), isTrue); }); test('blocks /ajax/logging/', () { @@ -220,18 +216,14 @@ void main() { // ── Should NOT block legitimate endpoints ───────────────── test('does NOT block normal feed timeline request', () { expect( - seenPattern.hasMatch( - 'https://www.instagram.com/api/v1/feed/timeline/', - ), + seenPattern.hasMatch('https://www.instagram.com/api/v1/feed/timeline/'), isFalse, ); }); test('does NOT block graphql queries', () { expect( - seenPattern.hasMatch( - 'https://www.instagram.com/api/graphql', - ), + seenPattern.hasMatch('https://www.instagram.com/api/graphql'), isFalse, ); }); diff --git a/test/services/level_service_test.dart b/test/services/level_service_test.dart index 02528b6..4536e54 100644 --- a/test/services/level_service_test.dart +++ b/test/services/level_service_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:hive/hive.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:focusgram/services/level_service.dart'; @@ -9,7 +8,6 @@ void main() { setUp(() async { SharedPreferences.setMockInitialValues({}); - // Ensure Hive is available for LevelService if (!Hive.isAdapterRegistered(0)) { await Hive.initFlutter(); } @@ -17,13 +15,9 @@ void main() { group('AppFeature β€” Your Journey unlock table', () { test('fullDmGhost is NOT in the all list', () async { - // fullDmGhost should still be defined as a constant expect(AppFeature.fullDmGhost, isNotNull); - // But should NOT appear in the unlock table shown to users - final contains = AppFeature.all.any( - (f) => f.id == 'full_dm_ghost', - ); + final contains = AppFeature.all.any((f) => f.id == 'full_dm_ghost'); expect(contains, isFalse); }); @@ -47,20 +41,12 @@ void main() { group('LevelService β€” No Firestore dependency', () { test('init succeeds without Firestore (uses Hive only)', () async { - // This would crash if init tried to reach Firestore - // Since we removed Firebase, it should work with just Hive cache final levelService = LevelService(); - // Should not throw β€” even if no Firestore is available - await expectLater( - () => levelService.init(), - returnsNormally, - ); + await expectLater(() => levelService.init(), returnsNormally); - // Default state expect(levelService.level, equals(1)); expect(levelService.xp, equals(0)); - expect(levelService.synced, isFalse); }); test('addXpForAd awards XP without Firestore', () async { @@ -73,14 +59,13 @@ void main() { expect(levelService.adsWatchedTotal, equals(1)); }); - test('debugSetLevel works with Hive-only storage', () async { + test('level progresses from XP', () async { final levelService = LevelService(); await levelService.init(); - await levelService.debugSetLevel(3, 300); - - expect(levelService.level, equals(3)); - expect(levelService.xp, equals(300)); + expect(levelService.level, equals(1)); + expect(levelService.xp, equals(0)); + expect(levelService.levelProgress, equals(0.0)); }); }); } diff --git a/test/services/session_extension_test.dart b/test/services/session_extension_test.dart index 7590f80..68207f0 100644 --- a/test/services/session_extension_test.dart +++ b/test/services/session_extension_test.dart @@ -37,18 +37,20 @@ void main() { expect(sm.isSessionActive, isTrue); }); - test('canExtendAppSession is false after re-ending an extended session', - () async { - final sm = SessionManager(); - await sm.init(); + test( + 'canExtendAppSession is false after re-ending an extended session', + () async { + final sm = SessionManager(); + await sm.init(); - sm.startAppSession(60); - sm.endAppSession(); - sm.extendAppSession(); - sm.endAppSession(); + sm.startAppSession(60); + sm.endAppSession(); + sm.extendAppSession(); + sm.endAppSession(); - expect(sm.canExtendAppSession, isFalse); - }); + expect(sm.canExtendAppSession, isFalse); + }, + ); }); group('SessionManager β€” App session lifecycle', () { diff --git a/v2/ghost_mode_script.dart b/v2/ghost_mode_script.dart index 3a25da2..897bcb0 100644 --- a/v2/ghost_mode_script.dart +++ b/v2/ghost_mode_script.dart @@ -213,9 +213,12 @@ const String kGhostModeJS = r""" // MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len) try { const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - // Block typing / activity indicator publishes + // Block typing / activity indicator / seen-receipt publishes if ( decoded.includes('/t_fs') || // foreground state (typing) + decoded.includes('/t_mt') || // mark thread seen + decoded.includes('/t_s') || // seen receipt + decoded.includes('/t_se') || // seen receipt (alt) decoded.includes('activity_indicator') || decoded.includes('is_typing') || decoded.includes('direct_typing') ||