Feature Pack with bug fixes for V2

This commit is contained in:
Ujwal223
2026-06-13 13:06:25 +05:45
parent 39b6545e4a
commit b7c8120496
41 changed files with 453 additions and 414 deletions
+1 -1
View File
@@ -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))
+3 -4
View File
@@ -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.
+33 -35
View File
@@ -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)
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
@@ -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 (115 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 (130 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?**<br>
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?**<br>
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?**<br>
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.
+1 -1
View File
@@ -45,7 +45,7 @@ android {
minSdk = 24
targetSdk = 35
versionCode = 4
versionName = "2.0.0"
versionName = "2.1.0"
}
buildTypes {
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

@@ -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;
@@ -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<String, int> 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;
+8 -4
View File
@@ -128,6 +128,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler>
bool _breathCompleted = false;
bool _appSessionStarted = false;
bool _onboardingCompleted = false;
bool _lockScreenDismissed = false;
late AppLinks _appLinks;
@override
@@ -162,11 +163,14 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler>
}
}
void _showLockScreen() {
Navigator.push(
Future<void> _showLockScreen() async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)),
);
if (result == true && mounted) {
setState(() => _lockScreenDismissed = true);
}
}
Future<void> _initDeepLinks() async {
@@ -190,8 +194,8 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler>
final settings = context.watch<SettingsService>();
final appLock = context.watch<AppLockService>();
// 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();
+38 -20
View File
@@ -152,14 +152,19 @@ class _AdsterraAdScreenState extends State<AdsterraAdScreen> {
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<AdsterraAdScreen> {
minHeight: 3,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
done ? Colors.greenAccent : Colors.blueAccent),
done ? Colors.greenAccent : Colors.blueAccent,
),
),
),
// Hint text
@@ -212,8 +218,10 @@ class _AdsterraAdScreenState extends State<AdsterraAdScreen> {
!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<AdsterraAdScreen> {
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<AdsterraAdScreen> {
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<AdsterraAdScreen> {
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,
),
),
),
],
+11 -48
View File
@@ -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<AppLockScreen> {
// 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<AppLockScreen> {
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<AppLockScreen> {
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<AppLockScreen> {
void _onDelete() {
if (_enteredPin.isEmpty) return;
setState(() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1));
setState(
() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1),
);
}
Future<void> _verifyPin() async {
setState(() => _isVerifying = true);
final appLock = context.read<AppLockService>();
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<AppLockScreen> {
}
}
Future<void> _authenticateBiometric() async {
final appLock = context.read<AppLockService>();
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 {
+34 -19
View File
@@ -32,8 +32,10 @@ class _AppLockSettingsPageState extends State<AppLockSettingsPage> {
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<AppLockSettingsPage> {
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<AppLockSettingsPage> {
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<AppLockSettingsPage> {
// ── 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<AppLockSettingsPage> {
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<AppLockSettingsPage> {
),
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,
),
),
);
}
}
+1 -4
View File
@@ -29,10 +29,7 @@ class _AppLockSetupScreenState extends State<AppLockSetupScreen> {
@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(
+1 -3
View File
@@ -95,9 +95,7 @@ class _BaitMeButtonState extends State<BaitMeButton>
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,
+25 -13
View File
@@ -68,7 +68,9 @@ class _BaitMeFullScreenState extends State<BaitMeFullScreen>
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<BaitMeFullScreen>
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<BaitMeFullScreen>
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<BaitMeFullScreen>
_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<BaitMeFullScreen>
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<BaitMeFullScreen>
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();
+1 -1
View File
@@ -339,4 +339,4 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
}
}
}
*/
*/
+5 -9
View File
@@ -35,8 +35,9 @@ class _EffortFrictionGateState extends State<EffortFrictionGate> {
Widget build(BuildContext context) {
final creditStore = context.watch<CreditStore>();
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<EffortFrictionGate> {
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<EffortFrictionGate> {
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)),
),
),
+2 -2
View File
@@ -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<String>(
value: settings.startupPage,
initialValue: settings.startupPage,
decoration: const InputDecoration(
labelText: 'Launch Page',
border: OutlineInputBorder(),
+2 -3
View File
@@ -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,
),
],
+58 -37
View File
@@ -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';
}
}
}
+12 -4
View File
@@ -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<SettingsService>();
+13 -6
View File
@@ -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),
),
],
),
),
+15 -16
View File
@@ -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<LevelService>().level} · ${context.watch<LevelService>().xp} XP',
subtitle:
'Level ${context.watch<LevelService>().level} · ${context.watch<LevelService>().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<LevelService>().isFeatureUnlocked(AppFeature.effortFriction)) {
if (v &&
!context.read<LevelService>().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});
+29 -12
View File
@@ -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),
),
),
],
),
+3 -7
View File
@@ -154,14 +154,10 @@ class _TimerFallbackScreenState extends State<TimerFallbackScreen> {
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),
),
+13 -5
View File
@@ -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<UserScript> 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
+10 -9
View File
@@ -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<void> setPin(String pin, {required bool forAppWide}) async {
+5 -3
View File
@@ -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) {
+2 -9
View File
@@ -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<Map<String, dynamic>> get recentXpLog {
return _xpHistory.reversed
+1 -3
View File
@@ -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;
+2 -2
View File
@@ -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) ?? '';
+12 -7
View File
@@ -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<String, dynamic>))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
_savedPages =
decoded
.map((e) => SavedPage.fromJson(e as Map<String, dynamic>))
.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<void> savePage(String url, {String title = 'Instagram', String? htmlContent}) async {
Future<void> savePage(
String url, {
String title = 'Instagram',
String? htmlContent,
}) async {
if (url.isEmpty) return;
// Avoid duplicates
if (_savedPages.any((p) => p.url == url)) return;
+2 -1
View File
@@ -21,7 +21,8 @@ const String _kMediumRectCode = '''
class MediumRectBanner extends StatelessWidget {
const MediumRectBanner({super.key});
String get _html => '''
String get _html =>
'''
<!DOCTYPE html>
<html>
<head>
+1 -1
View File
@@ -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
+4 -8
View File
@@ -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);
});
});
+35 -34
View File
@@ -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"]'),
);
},
);
});
}
@@ -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'));
+8 -6
View File
@@ -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);
},
);
});
}
+16 -24
View File
@@ -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,
);
});
+6 -21
View File
@@ -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));
});
});
}
+12 -10
View File
@@ -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', () {
+4 -1
View File
@@ -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') ||