mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-02 01:25:33 +02:00
Feature Pack with bug fixes for V2
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
**Use social media on your terms.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://flutter.dev)
|
||||
[](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 (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?**<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.
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -339,4 +339,4 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
*/
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ?? '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
Reference in New Issue
Block a user