Progress SAve- downloader,blur,ghost mode(Partially) works

This commit is contained in:
Ujwal223
2026-05-25 18:00:57 +05:45
parent 4f63e784ac
commit 2d33dcb889
66 changed files with 6373 additions and 909 deletions
+17
View File
@@ -0,0 +1,17 @@
class FocusSettings {
final bool ghostMode; // hide read receipts
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
final bool noAutoplay; // stop videos autoplaying
final bool noDMs; // block direct messages
const FocusSettings({
this.ghostMode = false,
this.noAds = false,
this.noStories = false,
this.noReels = false,
this.noAutoplay = false,
this.noDMs = false,
});
}
+1
View File
@@ -145,6 +145,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
// Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true),
);
}
+20 -4
View File
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
55,
60,
];
int _selectedIndex = 2; // default: 15 min
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
late final FixedExtentScrollController _scrollController;
@override
void initState() {
super.initState();
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
final lastIndex = _minuteOptions.indexOf(lastMinutes);
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
_scrollController = FixedExtentScrollController(
initialItem: _selectedIndex,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
controller: _scrollController,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
+11 -7
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 10-second breath.
/// A mindfulness screen shown before Instagram opens.
class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish;
final int durationSeconds;
const BreathGateScreen({super.key, required this.onFinish});
const BreathGateScreen({
super.key,
required this.onFinish,
this.durationSeconds = 10,
});
@override
State<BreathGateScreen> createState() => _BreathGateScreenState();
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _secondsRemaining = 10;
late int _secondsRemaining;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Are you sure you want to open FocusGram?',
'Are you sure you want to open Instagram?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
borderRadius: BorderRadius.circular(25),
),
),
child: const Text('Continue to FocusGram'),
child: const Text('Continue to Instagram'),
),
),
],
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
appBar: AppBar(
title: const Text(
'Extras',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Download Media (Feed + Reels)',
subtitle: 'Adds a download icon on posts and reels',
value: settings.videoDownloadEnabled,
onChanged: (v) async {
await settings.setVideoDownloadEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'FOCUS'),
_SwitchTile(
title: 'GHOST MODE',
subtitle: 'Hide seen indicator / read receipts',
value: settings.ghostMode,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
const SizedBox(height: 40),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
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,
),
),
);
}
}
+7 -2
View File
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> Function() action,
) async {
if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35);
final settings = context.read<SettingsService>();
final ok = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!context.mounted || !ok) return;
}
await action();
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton(
onPressed: () async {
final sm = context.read<SessionManager>();
int wordCount = 15;
final settings = context.read<SettingsService>();
int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) {
File diff suppressed because it is too large Load Diff
+66 -28
View File
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5;
static const int _kBlurPage = 3;
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
final List<Widget> slides = [
// ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide(
icon: Icons.auto_awesome,
color: Colors.blue,
icon: Icons.auto_awesome_rounded,
color: const Color(0xFF4F8DFF),
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
),
// ── Page 1: Session Management ───────────────────────────────────────
// ── Page 1: Focus controls ───────────────────────────────────────────
_StaticSlide(
icon: Icons.timer,
color: Colors.orange,
title: 'Session Management',
icon: Icons.timer_outlined,
color: const Color(0xFFFFB74D),
title: 'Time With Intent',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
),
// ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide(
icon: Icons.link,
color: Colors.cyan,
icon: Icons.link_rounded,
color: const Color(0xFF35C2D6),
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
// ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide(
icon: Icons.notifications_active,
color: Colors.green,
title: 'Stay Notified',
icon: Icons.notifications_active_outlined,
color: const Color(0xFF5DD18A),
title: 'Useful Alerts Only',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
isPermissionPage: true,
permission: Permission.notification,
),
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
),
const SizedBox(height: 32),
const SizedBox(height: 28),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
final isBlur = _currentPage == _kBlurPage;
String label;
if (isLast) {
label = 'Get Started';
if (isNotif) {
label = 'Allow & Start';
} else if (isLink) {
label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) {
label = 'Save & Continue';
} else if (isLast) {
label = 'Get Started';
} else {
label = 'Next';
}
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
);
} else if (isNotif) {
await Permission.notification.request();
await NotificationService().init();
await NotificationService()
.requestPermissionsNow();
}
if (!context.mounted) return;
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
// Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1)
TextButton(
onPressed: () => _finish(context),
onPressed: () {
if (_currentPage == _kNotifPage) {
_finish(context);
} else {
_pageController.animateToPage(
_kTotalPages - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text(
'Skip',
'Skip setup',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
),
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 120, color: color),
const SizedBox(height: 48),
Container(
width: 112,
height: 112,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Icon(icon, size: 54, color: color),
),
const SizedBox(height: 36),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 18,
fontSize: 16,
height: 1.5,
),
),
if (isPermissionPage || isAppSettingsPage) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Text(
isPermissionPage
? 'Permission is optional and can be changed later.'
: 'This opens Android settings; return here when done.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
],
),
);
+1
View File
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
),
);
},
+26 -21
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget {
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [1, 5, 10, 15].map((m) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(m),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
return SizedBox(
width: 72,
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(m),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
),
);
}).toList(),
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
Slider(
value: _customMinutes,
min: 1,
max: 30,
divisions: 29,
max: 60,
divisions: 59,
label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v),
),
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
void _start(int minutes) async {
final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>();
// Always require word challenge for reel sessions (User request)
final success = await DisciplineChallenge.show(context);
if (!success) return;
if (settings.requireWordChallenge) {
final success = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!success) return;
}
if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context);
+447 -80
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -8,6 +9,7 @@ import '../services/settings_service.dart';
import '../services/focusgram_router.dart';
import '../features/screen_time/screen_time_screen.dart';
import 'guardrails_page.dart';
import 'extras_settings_page.dart';
// ─── Main Settings Page ───────────────────────────────────────────────────────
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
),
body: ListView(
children: [
const _DonateTile(),
_buildStatsRow(sm),
const _SectionHeader(title: 'FOCUS & BLOCKING'),
@@ -63,6 +66,19 @@ class SettingsPage extends StatelessWidget {
),
),
const _SectionHeader(title: 'EXTRAS'),
_SubmoduleTile(
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Download media, Ghost Mode, Ad Blocker',
enabled: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
),
),
const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile(
icon: Icons.palette_outlined,
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
body: ListView(
children: [
const _SectionHeader(title: 'BLOCKING'),
Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
color: Colors.redAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.shield_rounded, color: Colors.redAccent, size: 20),
child: const Icon(
Icons.shield_rounded,
color: Colors.redAccent,
size: 20,
),
),
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
subtitle: Text(
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure',
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey),
settings.minimalModeEnabled
? 'Enabled - tap to customize'
: 'Disabled - tap to configure',
style: TextStyle(
fontSize: 12,
color: settings.minimalModeEnabled
? Colors.greenAccent
: Colors.grey,
),
),
trailing: Switch(
value: settings.minimalModeEnabled,
@@ -307,22 +335,49 @@ class FocusSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick();
},
),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
),
),
const _SectionHeader(title: 'FRICTION'),
_SwitchTile(
title: 'Mindfulness Gate',
subtitle: 'Breath screen before opening Instagram',
subtitle: '${settings.breathGateSeconds}s before opening Instagram',
value: settings.showBreathGate,
onChanged: (v) => settings.setShowBreathGate(v),
),
if (settings.showBreathGate)
_NumberEditTile(
title: 'Gate Duration',
label: '${settings.breathGateSeconds} seconds',
initialValue: settings.breathGateSeconds,
min: 3,
max: 60,
suffix: 'seconds',
onSubmitted: (v) => settings.setBreathGateSeconds(v),
),
_SwitchTile(
title: 'Strict Mode (Word Challenge)',
subtitle: 'Must type a phrase before starting a Reel session',
title: 'Typing Challenge',
subtitle: settings.wordChallengeCount == 0
? 'Random: 10-35 words'
: '${settings.wordChallengeCount} words',
value: settings.requireWordChallenge,
onChanged: (v) => settings.setRequireWordChallenge(v),
),
if (settings.requireWordChallenge)
_ChoiceTile<int>(
title: 'Typing Words',
value: settings.wordChallengeCount,
label: settings.wordChallengeCount == 0
? 'Random (10-35)'
: '${settings.wordChallengeCount} words',
options: const [20, 25, 30, 35, 0],
optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words',
onSelected: (v) => settings.setWordChallengeCount(v),
),
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Block Autoplay Videos',
@@ -348,6 +403,48 @@ class FocusSettingsPage extends StatelessWidget {
),
),
const _SectionHeader(title: 'FOCUSGRAM V2 OVERLAY'),
_SwitchTile(
title: 'Content Hider',
subtitle: 'Hide stories tray, feed posts, reels, suggested content',
value: settings.v2ContentHiderEnabled,
onChanged: (v) => settings.setV2ContentHiderEnabled(v),
),
if (settings.v2ContentHiderEnabled)
Padding(
padding: const EdgeInsets.only(left: 32),
child: Column(
children: [
_SwitchTile(
title: 'Hide Stories Tray',
subtitle: 'Story bubbles row',
value: settings.contentStories,
onChanged: (v) => settings.setContentStoriesEnabled(v),
),
_SwitchTile(
title: 'Hide Feed Posts',
subtitle: 'Home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
_SwitchTile(
title: 'Hide Reels (Feed)',
subtitle: 'Reels shown in the feed',
value: settings.contentReels,
onChanged: (v) => settings.setContentReelsEnabled(v),
),
_SwitchTile(
title: 'Hide Suggested Content',
subtitle: 'Suggested posts and recommendation units',
value: settings.contentSuggested,
onChanged: (v) => settings.setContentSuggestedEnabled(v),
),
],
),
),
const SizedBox(height: 40),
],
),
@@ -355,6 +452,50 @@ class FocusSettingsPage extends StatelessWidget {
}
}
class _DonateTile extends StatelessWidget {
const _DonateTile();
static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
decoration: BoxDecoration(
color: Colors.pinkAccent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)),
),
child: ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.pinkAccent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.pinkAccent,
size: 20,
),
),
title: const Text(
'Please donate to support the development of this project.',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: const Text(
'Your support keeps FocusGram free and maintained.',
style: TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.open_in_new, size: 14),
onTap: () =>
launchUrl(_donateUri, mode: LaunchMode.externalApplication),
),
);
}
}
// ─── Minimal Mode Submenu ─────────────────────────────────────────────────────
class MinimalModeSubmenuPage extends StatefulWidget {
@@ -368,6 +509,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
late bool _blurExplore;
late bool _disableReelsEntirely;
late bool _disableExploreEntirely;
late bool _blockHomeFeedScroll;
@override
void initState() {
@@ -376,26 +518,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
}
void _updateSetting(String key, bool value) {
Future<void> _updateSetting(String key, bool value) async {
final settings = context.read<SettingsService>();
setState(() {
switch (key) {
case 'blurExplore':
_blurExplore = value;
settings.setBlurExplore(value);
break;
case 'disableReelsEntirely':
_disableReelsEntirely = value;
settings.setDisableReelsEntirelyInternal(value);
break;
case 'disableExploreEntirely':
_disableExploreEntirely = value;
settings.setDisableExploreEntirelyInternal(value);
break;
case 'blockHomeFeedScroll':
_blockHomeFeedScroll = value;
break;
}
});
switch (key) {
case 'blurExplore':
await settings.setBlurExplore(value);
break;
case 'disableReelsEntirely':
await settings.setDisableReelsEntirelyInternal(value);
break;
case 'disableExploreEntirely':
await settings.setDisableExploreEntirelyInternal(value);
break;
case 'blockHomeFeedScroll':
await settings.setBlockHomeFeedScrollInternal(value);
break;
}
if (!mounted) return;
final latest = context.read<SettingsService>();
setState(() {
_blurExplore = latest.blurExplore;
_disableReelsEntirely = latest.disableReelsEntirely;
_disableExploreEntirely = latest.disableExploreEntirely;
_blockHomeFeedScroll = latest.blockHomeFeedScroll;
});
HapticFeedback.selectionClick();
}
@@ -406,6 +573,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = true;
_disableReelsEntirely = true;
_disableExploreEntirely = true;
_blockHomeFeedScroll = true;
});
HapticFeedback.mediumImpact();
}
@@ -418,6 +586,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
});
HapticFeedback.mediumImpact();
}
@@ -437,61 +606,88 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isMinimalModeEnabled
? [Colors.redAccent.withValues(alpha: 0.2), Colors.red.withValues(alpha: 0.1)]
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
colors: isMinimalModeEnabled
? [
Colors.redAccent.withValues(alpha: 0.2),
Colors.red.withValues(alpha: 0.1),
]
: [
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(
color: isMinimalModeEnabled ? Colors.redAccent.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.2),
color: isMinimalModeEnabled
? Colors.redAccent.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Column(
children: [
Icon(
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined,
isMinimalModeEnabled
? Icons.shield_rounded
: Icons.shield_outlined,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
size: 48,
),
const SizedBox(height: 12),
Text(
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled',
isMinimalModeEnabled
? 'Minimal Mode Active'
: 'Minimal Mode Disabled',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
color: isMinimalModeEnabled
? Colors.redAccent
: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
isMinimalModeEnabled
isMinimalModeEnabled
? 'Distractions are blocked. Customize which features stay enabled below.'
: 'Turn on to block all distractions at once, or customize individual settings below.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.black54),
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode,
onPressed: isMinimalModeEnabled
? _turnOffMinimalMode
: _turnOnMinimalMode,
style: ElevatedButton.styleFrom(
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent,
backgroundColor: isMinimalModeEnabled
? Colors.grey
: Colors.redAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
isMinimalModeEnabled
? 'Turn Off Minimal Mode'
: 'Turn On Minimal Mode',
),
child: Text(isMinimalModeEnabled ? 'Turn Off Minimal Mode' : 'Turn On Minimal Mode'),
),
),
],
),
),
const _SectionHeader(title: 'CUSTOMIZE SETTINGS'),
Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -502,7 +698,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
),
child: const Row(
children: [
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent),
Icon(
Icons.touch_app_rounded,
size: 14,
color: Colors.blueAccent,
),
SizedBox(width: 8),
Expanded(
child: Text(
@@ -513,13 +713,19 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
],
),
),
_SwitchTile(
title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped',
value: _blurExplore,
onChanged: (v) => _updateSetting('blurExplore', v),
),
_SwitchTile(
title: 'Block Home Feed Scroll',
subtitle: 'Freeze vertical scrolling on the home feed only',
value: _blockHomeFeedScroll,
onChanged: (v) => _updateSetting('blockHomeFeedScroll', v),
),
_SwitchTile(
title: 'Disable Reels Entirely',
subtitle: 'Block all Reels with no session option',
@@ -532,7 +738,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
value: _disableExploreEntirely,
onChanged: (v) => _updateSetting('disableExploreEntirely', v),
),
const SizedBox(height: 40),
],
),
@@ -550,43 +756,52 @@ class AppearancePage extends StatefulWidget {
}
class _AppearancePageState extends State<AppearancePage> {
Future<void> _addSchedule(BuildContext context, SettingsService settings) async {
Future<void> _addSchedule(
BuildContext context,
SettingsService settings,
) async {
TimeOfDay? startTime = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 21, minute: 0),
helpText: 'Select start time',
);
if (startTime == null || !context.mounted) return;
TimeOfDay? endTime = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 6, minute: 0),
helpText: 'Select end time',
);
if (endTime == null || !context.mounted) return;
final newSchedule = {
'enabled': true,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
'startTime':
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime':
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
};
await settings.addGrayscaleSchedule(newSchedule);
}
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async {
Future<void> _editSchedule(
BuildContext context,
SettingsService settings,
int index,
) async {
final schedules = settings.grayscaleSchedules;
if (index >= schedules.length) return;
final current = schedules[index];
final startParts = (current['startTime'] as String).split(':');
final endParts = (current['endTime'] as String).split(':');
// Capture context before async gap
final capturedContext = context;
TimeOfDay? startTime = await showTimePicker(
context: capturedContext,
initialTime: TimeOfDay(
@@ -595,9 +810,9 @@ class _AppearancePageState extends State<AppearancePage> {
),
helpText: 'Select start time',
);
if (startTime == null || !capturedContext.mounted) return;
TimeOfDay? endTime = await showTimePicker(
context: capturedContext,
initialTime: TimeOfDay(
@@ -606,27 +821,31 @@ class _AppearancePageState extends State<AppearancePage> {
),
helpText: 'Select end time',
);
if (endTime == null || !capturedContext.mounted) return;
final updatedSchedule = {
...current,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
'startTime':
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime':
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
};
await settings.updateGrayscaleSchedule(index, updatedSchedule);
}
Future<void> _toggleSchedule(SettingsService settings, int index) async {
final schedules = List<Map<String, dynamic>>.from(settings.grayscaleSchedules);
final schedules = List<Map<String, dynamic>>.from(
settings.grayscaleSchedules,
);
if (index >= schedules.length) return;
schedules[index] = {
...schedules[index],
'enabled': !(schedules[index]['enabled'] as bool),
};
await settings.setGrayscaleSchedules(schedules);
}
@@ -648,7 +867,7 @@ class _AppearancePageState extends State<AppearancePage> {
],
),
);
if (confirmed == true) {
await settings.removeGrayscaleSchedule(index);
}
@@ -669,7 +888,8 @@ class _AppearancePageState extends State<AppearancePage> {
const _SectionHeader(title: 'DISPLAY'),
_SwitchTile(
title: 'Grayscale Mode',
subtitle: 'Makes Instagram black & white — reduces dopamine response',
subtitle:
'Makes Instagram black & white — reduces dopamine response',
value: settings.grayscaleEnabled,
onChanged: (v) => settings.setGrayscaleEnabled(v),
),
@@ -687,7 +907,7 @@ class _AppearancePageState extends State<AppearancePage> {
style: TextStyle(fontSize: 12, height: 1.5),
),
),
// Status indicator
if (settings.grayscaleSchedules.isNotEmpty)
Padding(
@@ -695,26 +915,38 @@ class _AppearancePageState extends State<AppearancePage> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1),
color: settings.isGrayscaleActiveNow
? Colors.green.withValues(alpha: 0.1)
: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.3) : Colors.orange.withValues(alpha: 0.3)),
border: Border.all(
color: settings.isGrayscaleActiveNow
? Colors.green.withValues(alpha: 0.3)
: Colors.orange.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule,
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent,
size: 20
settings.isGrayscaleActiveNow
? Icons.check_circle
: Icons.schedule,
color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
settings.isGrayscaleActiveNow
? 'Grayscale is active now'
settings.isGrayscaleActiveNow
? 'Grayscale is active now'
: 'Grayscale is currently inactive',
style: TextStyle(
fontSize: 13,
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent
fontSize: 13,
color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
),
),
),
@@ -722,7 +954,7 @@ class _AppearancePageState extends State<AppearancePage> {
),
),
),
// Schedule list
...List.generate(settings.grayscaleSchedules.length, (index) {
final schedule = settings.grayscaleSchedules[index];
@@ -732,11 +964,14 @@ class _AppearancePageState extends State<AppearancePage> {
width: 36,
height: 36,
decoration: BoxDecoration(
color: (isEnabled ? Colors.purpleAccent : Colors.grey).withValues(alpha: 0.12),
color: (isEnabled ? Colors.purpleAccent : Colors.grey)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isEnabled ? Icons.play_circle_outline : Icons.pause_circle_outline,
isEnabled
? Icons.play_circle_outline
: Icons.pause_circle_outline,
color: isEnabled ? Colors.purpleAccent : Colors.grey,
size: 20,
),
@@ -750,7 +985,10 @@ class _AppearancePageState extends State<AppearancePage> {
),
subtitle: Text(
isEnabled ? 'Active' : 'Disabled',
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black45,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -795,7 +1033,7 @@ class _AppearancePageState extends State<AppearancePage> {
onTap: () => _editSchedule(context, settings, index),
);
}),
// Add schedule button
ListTile(
leading: Container(
@@ -805,16 +1043,26 @@ class _AppearancePageState extends State<AppearancePage> {
color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.add_circle_outline, color: Colors.green, size: 20),
child: const Icon(
Icons.add_circle_outline,
color: Colors.green,
size: 20,
),
),
title: const Text(
'Add Schedule',
style: TextStyle(color: Colors.green),
),
title: const Text('Add Schedule', style: TextStyle(color: Colors.green)),
subtitle: Text(
'Add a new grayscale schedule',
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black45,
),
),
onTap: () => _addSchedule(context, settings),
),
const SizedBox(height: 40),
],
),
@@ -966,15 +1214,9 @@ class _SwitchTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(
title,
style: const TextStyle(fontSize: 15),
),
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(
subtitle ?? '',
style: const TextStyle(fontSize: 12),
)
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
@@ -982,6 +1224,131 @@ class _SwitchTile extends StatelessWidget {
}
}
class _ChoiceTile<T> extends StatelessWidget {
final String title;
final T value;
final String label;
final List<T> options;
final String Function(T value) optionLabel;
final ValueChanged<T> onSelected;
const _ChoiceTile({
required this.title,
required this.value,
required this.label,
required this.options,
required this.optionLabel,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
trailing: PopupMenuButton<T>(
initialValue: value,
onSelected: onSelected,
itemBuilder: (context) => options
.map(
(option) => PopupMenuItem<T>(
value: option,
child: Text(optionLabel(option)),
),
)
.toList(),
child: const Icon(Icons.expand_more_rounded, size: 22),
),
onTap: () async {
final selected = await showModalBottomSheet<T>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: options
.map(
(option) => ListTile(
title: Text(optionLabel(option)),
trailing: option == value
? const Icon(Icons.check_rounded)
: null,
onTap: () => Navigator.pop(context, option),
),
)
.toList(),
),
),
);
if (selected != null) onSelected(selected);
},
);
}
}
class _NumberEditTile extends StatelessWidget {
final String title;
final String label;
final int initialValue;
final int min;
final int max;
final String suffix;
final ValueChanged<int> onSubmitted;
const _NumberEditTile({
required this.title,
required this.label,
required this.initialValue,
required this.min,
required this.max,
required this.suffix,
required this.onSubmitted,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.edit_outlined, size: 20),
onTap: () async {
final controller = TextEditingController(text: '$initialValue');
final result = await showDialog<int>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
suffixText: suffix,
helperText: '$min-$max $suffix',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(dialogContext, parsed.clamp(min, max).toInt());
},
child: const Text('Save'),
),
],
),
);
controller.dispose();
if (result != null) onSubmitted(result);
},
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
+7 -6
View File
@@ -277,13 +277,15 @@ const String kReelsMutationObserverJS = r'''
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() {
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
// feed as a separate Minimal Mode control.
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
// Only lock scroll when reel element is actually present on the page
if (window.__fgDisableReelsEntirely === true &&
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
if (window.__fgBlockHomeFeedScroll === true &&
(window.location.pathname === '/' || window.location.pathname === '')) {
return 'home_feed';
}
return null;
}
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
try {
const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL);
// Apply lock for dm_reel or disabled modes when reel is present
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart';
// Ghost Mode
const String ghostModeJS = '''
const _WS = window.WebSocket;
window.WebSocket = function(url, protocols) {
if (url.includes('edge-chat.instagram.com') ||
url.includes('gateway.instagram.com')) {
return {
send: ()=>{}, close: ()=>{},
readyState: 1,
addEventListener: ()=>{},
removeEventListener: ()=>{},
};
}
return new _WS(url, protocols);
};
window.WebSocket.prototype = _WS.prototype;
''';
// No Story Tray
const String hideStoryTrayJS = '''
const style = document.createElement('style');
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
document.head.appendChild(style);
''';
// No Autoplay
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') {
e.target.pause();
}
}, true);
''';
// No Reels / Explore
const String hideReelsJS = '''
const hideReels = () => {
// nav bar reels icon
document.querySelectorAll('a[href="/reels/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
// explore page
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
};
new MutationObserver(hideReels).observe(document.body, {
childList: true,
subtree: true
});
hideReels();
''';
// No DMs
const String hideDMsJS = '''
const style = document.createElement('style');
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
document.head.appendChild(style);
''';
List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[];
final endScripts = <String>[];
// AT_DOCUMENT_START scripts
if (settings.ghostMode) startScripts.add(ghostModeJS);
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END scripts
if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS);
final scripts = <UserScript>[];
if (startScripts.isNotEmpty) {
scripts.add(UserScript(
source: startScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
));
}
if (endScripts.isNotEmpty) {
scripts.add(UserScript(
source: endScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
forMainFrameOnly: true,
));
}
return scripts;
}
+355
View File
@@ -0,0 +1,355 @@
/// Best-effort Instagram media downloader UI.
///
/// The script only exposes URLs already rendered in the WebView. It cannot
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
/// feed posts, reels, profile avatars, and DM visual/video messages.
const String kVideoDownloadJS = r'''
(function() {
'use strict';
if (window.__fgMediaDownloadRunning) return;
window.__fgMediaDownloadRunning = true;
const BTN_ATTR = 'data-fg-download-btn';
const URL_ATTR = 'data-fg-download-url';
const TYPE_ATTR = 'data-fg-download-type';
const MAX_PER_PASS = 60;
function text(value) {
try { return (value || '').toString(); } catch (_) { return ''; }
}
function isHttp(value) {
const s = text(value);
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
}
function cleanUrl(value) {
const s = text(value).trim();
if (!isHttp(s)) return null;
return s.replace(/&amp;/g, '&');
}
function bestFromSrcset(srcset) {
const raw = text(srcset);
if (!raw) return null;
let best = null;
let bestScore = -1;
raw.split(',').forEach(function(part) {
const bits = part.trim().split(/\s+/);
const url = cleanUrl(bits[0]);
if (!url) return;
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
if (score >= bestScore) {
bestScore = score;
best = url;
}
});
return best;
}
function backgroundUrl(el) {
try {
const bg = window.getComputedStyle(el).backgroundImage || '';
const match = bg.match(/url\(["']?(.*?)["']?\)/);
return match ? cleanUrl(match[1]) : null;
} catch (_) {
return null;
}
}
function urlFromJsonishAttribute(el) {
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
for (let i = 0; i < attrs.length; i++) {
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
}
return null;
}
function mediaUrl(el) {
if (!el) return null;
const tag = text(el.tagName).toLowerCase();
if (tag === 'video') {
return cleanUrl(el.currentSrc || el.src) ||
cleanUrl(el.getAttribute('src')) ||
cleanUrl(el.getAttribute('poster')) ||
firstSource(el);
}
if (tag === 'img') {
return cleanUrl(el.currentSrc || el.src) ||
bestFromSrcset(el.getAttribute('srcset')) ||
cleanUrl(el.getAttribute('src'));
}
return backgroundUrl(el) || urlFromJsonishAttribute(el);
}
function firstSource(video) {
try {
const sources = video.querySelectorAll('source');
for (let i = 0; i < sources.length; i++) {
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
if (url) return url;
}
} catch (_) {}
return null;
}
function typeFrom(el, url) {
const tag = text(el && el.tagName).toLowerCase();
const u = text(url).toLowerCase();
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
return 'video';
}
return 'photo';
}
function looksLikeAvatar(el) {
try {
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
if (!img) return false;
const alt = text(img.getAttribute('alt')).toLowerCase();
const r = img.getBoundingClientRect();
const rounded =
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
} catch (_) {
return false;
}
}
function mediaScore(item) {
try {
const r = item.el.getBoundingClientRect();
let score = Math.max(0, r.width) * Math.max(0, r.height);
if (item.type === 'video') score += 10000000;
if (looksLikeAvatar(item.el)) score -= 10000000;
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
return score;
} catch (_) {
return 0;
}
}
function filename(type) {
const ext = type === 'video' ? 'mp4' : 'jpg';
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
}
function inView(el) {
try {
const r = el.getBoundingClientRect();
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
} catch (_) {
return false;
}
}
function icon() {
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
}
function sendDownload(url, type) {
try {
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
type: type,
url: url,
filename: filename(type),
}));
} catch (_) {}
}
function makeButton(url, type, mode) {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute(BTN_ATTR, '1');
btn.setAttribute(URL_ATTR, url);
btn.setAttribute(TYPE_ATTR, type);
btn.setAttribute('aria-label', 'Download media');
btn.innerHTML = icon();
btn.style.cssText = [
'position:absolute',
'z-index:2147483647',
'width:34px',
'height:34px',
'border-radius:10px',
'border:1px solid rgba(255,255,255,.18)',
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
'color:rgba(255,255,255,.94)',
'display:flex',
'align-items:center',
'justify-content:center',
'padding:0',
'cursor:pointer',
'pointer-events:auto',
'backdrop-filter:blur(8px)',
'-webkit-backdrop-filter:blur(8px)',
].join(';');
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
}, true);
return btn;
}
function ensureRelative(container) {
try {
const pos = window.getComputedStyle(container).position;
if (!pos || pos === 'static') container.style.position = 'relative';
} catch (_) {}
}
function placeNearSave(article, url, type) {
const ref = article.querySelector([
'button[aria-label*="Save" i]',
'button[aria-label*="Bookmark" i]',
'svg[aria-label*="Save" i]',
'svg[aria-label*="Bookmark" i]',
'a[href*="/save"]',
].join(','));
if (!ref) return false;
const target = ref.closest('button,a,div') || ref;
const bar = target.parentElement || article;
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
const btn = makeButton(url, type, 'inline');
btn.style.position = 'relative';
btn.style.inset = 'auto';
btn.style.marginLeft = '8px';
btn.style.color = 'currentColor';
btn.style.border = '0';
btn.style.backdropFilter = 'none';
btn.style.webkitBackdropFilter = 'none';
try {
target.insertAdjacentElement('afterend', btn);
return true;
} catch (_) {
return false;
}
}
function placeOverlay(container, url, type, where) {
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
ensureRelative(container);
const btn = makeButton(url, type, 'overlay');
if (where === 'reel') {
btn.style.top = '12px';
btn.style.right = '12px';
} else if (where === 'profile') {
btn.style.top = '8px';
btn.style.right = '8px';
} else {
btn.style.right = '10px';
btn.style.bottom = '10px';
}
container.appendChild(btn);
return true;
}
function visibleMedia(root) {
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
.filter(inView)
.map(function(el) {
const url = mediaUrl(el);
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
})
.filter(Boolean);
}
function handleFeed() {
let added = 0;
document.querySelectorAll('article').forEach(function(article) {
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
const media = visibleMedia(article)
.filter(function(item) { return !looksLikeAvatar(item.el); })
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
if (!media) return;
if (placeNearSave(article, media.url, media.type) ||
placeOverlay(article, media.url, media.type, 'feed')) {
added++;
}
});
return added;
}
function handleReels() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const container =
media.el.closest('[class*="ReelsVideoPlayer"]') ||
media.el.closest('article') ||
media.el.closest('[role="presentation"]') ||
media.el.parentElement;
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
});
return added;
}
function handleDirect() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const bubble =
media.el.closest('[role="button"]') ||
media.el.closest('div[style*="max-width"]') ||
media.el.closest('article') ||
media.el.parentElement;
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
});
return added;
}
function handleProfile() {
let added = 0;
const path = window.location.pathname || '/';
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
if (added >= 4 || !inView(img)) return;
const url = mediaUrl(img);
if (!url) return;
const r = img.getBoundingClientRect();
if (r.width < 56 && r.height < 56) return;
const container = img.closest('div') || img.parentElement;
if (placeOverlay(container, url, 'photo', 'profile')) added++;
});
return added;
}
function pass() {
try {
const path = window.location.pathname || '/';
if (path.indexOf('/direct') === 0) {
handleDirect();
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
handleReels();
} else {
handleFeed();
handleProfile();
}
} catch (_) {}
}
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(pass, 220);
}
new MutationObserver(schedule).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
});
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule, { passive: true });
window.addEventListener('focus', schedule, { passive: true });
pass();
})();
''';
@@ -0,0 +1,430 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class AdblockContentBlockerData {
final List<ContentBlocker> contentBlockers;
final Set<String> blockedHosts;
final String sourceTag;
const AdblockContentBlockerData({
required this.contentBlockers,
required this.blockedHosts,
required this.sourceTag,
});
Map<String, dynamic> toJson() => {
'sourceTag': sourceTag,
'hosts': blockedHosts.toList(),
// We cant safely serialize ContentBlocker objects; rebuild from hosts.
// contentBlockers will always be regenerated from hosts when restoring.
};
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
final hosts =
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
return AdblockContentBlockerData(
contentBlockers: hosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: hosts,
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
);
}
}
class AdblockContentBlockerLoader {
// Cache keys
static const _keyCache = 'adblock_cb_cache_v2';
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
static const _keySourceCache = 'adblock_source_cache_v1';
static const _maxContentBlockerRules = 5000;
// Raw GitHub sources, intentionally split by repository sections so the app
// follows upstream changes without depending on third-party packaged mirrors.
static const _sources = <_SourceSpec>[
// uBlock Origin built-in Annoyances family:
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
_SourceSpec(
tag: 'ublock_annoyances',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_cookies',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_others',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
),
// EasyList network-blocking sections:
// https://github.com/easylist/easylist/tree/master/easylist
_SourceSpec(
tag: 'easylist_adservers',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
),
_SourceSpec(
tag: 'easylist_general_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
),
_SourceSpec(
tag: 'easylist_specific_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
),
_SourceSpec(
tag: 'easylist_thirdparty',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
),
// AdGuard BaseFilter network-blocking sections:
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
_SourceSpec(
tag: 'adguard_base_adservers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
),
_SourceSpec(
tag: 'adguard_base_adservers_firstparty',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
),
_SourceSpec(
tag: 'adguard_base_antiadblock',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
),
_SourceSpec(
tag: 'adguard_base_cryptominers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
),
_SourceSpec(
tag: 'adguard_base_general_url',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
),
_SourceSpec(
tag: 'adguard_base_specific',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
),
];
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
required bool enabled,
required SharedPreferences prefs,
int timeoutMs = 8000,
}) async {
if (!enabled) {
return const AdblockContentBlockerData(
contentBlockers: [],
blockedHosts: {},
sourceTag: 'disabled',
);
}
final cachedData = _readCachedData(prefs);
final sourceCache = _readSourceCache(prefs);
final fetchResults = await _fetchAllSources(
cache: sourceCache,
timeoutMs: timeoutMs,
);
if (fetchResults.isEmpty && cachedData != null) {
return cachedData;
}
final sourceEntries = <String, _CachedSource>{...sourceCache};
for (final result in fetchResults) {
sourceEntries[result.tag] = result.source;
}
final hosts = sourceEntries.values
.expand((source) => source.hosts)
.where(_isValidHostname)
.toSet();
if (hosts.isEmpty && cachedData != null) {
return cachedData;
}
final data = _buildData(
hosts: hosts,
sourceTag: fetchResults.any((r) => r.changed)
? 'updated-github'
: 'validated-github-cache',
);
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
await prefs.setString(
_keySourceCache,
jsonEncode({
for (final entry in sourceEntries.entries) entry.key: entry.value,
}),
);
await prefs.setInt(
_keyCacheUpdatedAt,
DateTime.now().millisecondsSinceEpoch,
);
return data;
}
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
final cached = prefs.getString(_keyCache);
if (cached == null) return null;
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return AdblockContentBlockerData.fromJson(decoded);
} catch (_) {
return null;
}
}
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
final cached = prefs.getString(_keySourceCache);
if (cached == null) return {};
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return decoded.map((tag, value) {
return MapEntry(
tag,
_CachedSource.fromJson(value as Map<String, dynamic>),
);
});
} catch (_) {
return {};
}
}
AdblockContentBlockerData _buildData({
required Set<String> hosts,
required String sourceTag,
}) {
final sortedHosts = hosts.toList(growable: false)..sort();
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
return AdblockContentBlockerData(
contentBlockers: cappedHosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: cappedHosts,
sourceTag: sourceTag,
);
}
Future<List<_FetchedSource>> _fetchAllSources({
required Map<String, _CachedSource> cache,
required int timeoutMs,
}) async {
final client = http.Client();
try {
final timeout = Duration(milliseconds: timeoutMs);
return Future.wait(
_sources.map(
(source) => _fetchSource(
client: client,
source: source,
cached: cache[source.tag],
timeout: timeout,
),
),
).then((results) => results.whereType<_FetchedSource>().toList());
} finally {
client.close();
}
}
Future<_FetchedSource?> _fetchSource({
required http.Client client,
required _SourceSpec source,
required _CachedSource? cached,
required Duration timeout,
}) async {
try {
final headers = <String, String>{
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
if (cached?.lastModified != null)
'If-Modified-Since': cached!.lastModified!,
'User-Agent': 'FocusGram-AdblockListUpdater',
};
final res = await client
.get(Uri.parse(source.url), headers: headers)
.timeout(timeout);
if (res.statusCode == 304 && cached != null) {
return _FetchedSource(tag: source.tag, source: cached, changed: false);
}
if (res.statusCode != 200 || res.body.isEmpty) return null;
return _FetchedSource(
tag: source.tag,
source: _CachedSource(
url: source.url,
etag: res.headers['etag'],
lastModified: res.headers['last-modified'],
hosts: parseHostsFromFilterText(res.body),
),
changed: true,
);
} catch (_) {
return null;
}
}
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
/// syntax forms:
/// - ||example.com^
/// - ||example.com/
/// - ||example.com
///
/// We ignore all element-hiding/cosmetic rules and $ options.
@visibleForTesting
static Set<String> parseHostsFromFilterText(String raw) {
final hosts = <String>{};
for (final line in raw.split('\n')) {
final l = line.trim();
if (l.isEmpty) continue;
if (l.startsWith('!')) continue;
if (l.startsWith('@@')) continue;
// Skip comments / metadata
if (l.startsWith('[')) continue;
// Skip cosmetic element-hiding rules
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
continue;
}
// uBlock-style host anchors
if (l.startsWith('||')) {
final body = l.substring(2);
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
// e.g. "example.com^" -> "example.com"
// e.g. "example.com/" -> "example.com"
// e.g. "example.com^$third-party" -> "example.com"
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
String host = body;
for (final sc in stopChars) {
final idx = host.indexOf(sc);
if (idx >= 0) host = host.substring(0, idx);
}
host = host.trim();
// Remove leading/trailing dots
host = host
.replaceAll(RegExp(r'^\.+'), '')
.replaceAll(RegExp(r'\.+$'), '');
if (host.isEmpty) continue;
if (host.contains('*') || host.contains(',')) continue;
final normalized = host.toLowerCase();
if (!_isValidHostname(normalized)) continue;
hosts.add(normalized);
}
}
return hosts;
}
static String _urlFilterForHost(String host) {
final escaped = RegExp.escape(host);
return r'^https?://([^/?#]+\.)?'
'$escaped'
r'([/?#:].*)?$';
}
static bool _isValidHostname(String host) {
if (!host.contains('.')) return false;
if (host.length > 255) return false;
if (host.startsWith('.') || host.endsWith('.')) return false;
if (host.contains('..')) return false;
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
}
}
class _SourceSpec {
final String tag;
final String url;
const _SourceSpec({required this.tag, required this.url});
}
class _FetchedSource {
final String tag;
final _CachedSource source;
final bool changed;
_FetchedSource({
required this.tag,
required this.source,
required this.changed,
});
}
class _CachedSource {
final String url;
final String? etag;
final String? lastModified;
final Set<String> hosts;
const _CachedSource({
required this.url,
required this.etag,
required this.lastModified,
required this.hosts,
});
factory _CachedSource.fromJson(Map<String, dynamic> json) {
return _CachedSource(
url: (json['url'] as String?) ?? '',
etag: json['etag'] as String?,
lastModified: json['lastModified'] as String?,
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
);
}
Map<String, dynamic> toJson() => {
'url': url,
'etag': etag,
'lastModified': lastModified,
'hosts': hosts.toList(growable: false)..sort(),
};
}
+4 -9
View File
@@ -57,15 +57,15 @@ class InjectionController {
required bool blurReels,
required bool tapToUnblur,
required bool enableTextSelection,
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
required bool hideSuggestedPosts,
required bool hideSponsoredPosts,
required bool hideLikeCounts,
required bool hideFollowerCounts,
// hideStoriesBar parameter removed per user request
required bool hideExploreTab,
required bool hideReelsTab,
required bool hideShopTab,
required bool disableReelsEntirely,
required bool blockHomeFeedScroll,
}) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
@@ -75,18 +75,12 @@ class InjectionController {
css.writeln(scripts.kHideReelsFeedContentCSS);
}
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
// Previously it was inside that block alongside display:none on the parent —
// you cannot blur children of a display:none element, making it dead code.
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
// when sessionActive=false, reels are hidden anyway (blur harmless).
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
// Stories hiding removed per user request
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
@@ -94,6 +88,7 @@ class InjectionController {
return '''
${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())}
+46 -19
View File
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
import '../scripts/grayscale.dart' as grayscale;
import '../scripts/ui_hider.dart' as ui_hider;
import '../scripts/content_disabling.dart' as content_disabling;
import '../scripts/video_downloader.dart' as video_downloader;
// Core JS and CSS payloads injected into the Instagram WebView.
//
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
// ── InjectionManager class ─────────────────────────────────────────────────
class InjectionManager {
abstract class JsEvaluator {
Future<void> evaluateJavascript({required String source});
}
class _WebViewJsEvaluator implements JsEvaluator {
final InAppWebViewController controller;
_WebViewJsEvaluator(this.controller);
@override
Future<void> evaluateJavascript({required String source}) {
return controller.evaluateJavascript(source: source);
}
}
class InjectionManager {
final JsEvaluator _jsEvaluator;
final SharedPreferences prefs;
final SessionManager sessionManager;
SettingsService? _settingsService;
InjectionManager({
required this.controller,
required InAppWebViewController controller,
required this.prefs,
required this.sessionManager,
});
JsEvaluator? jsEvaluator,
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
InjectionManager.forTest({
required JsEvaluator jsEvaluator,
required this.prefs,
required this.sessionManager,
}) : _jsEvaluator = jsEvaluator;
void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService;
@@ -415,18 +437,19 @@ class InjectionManager {
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection;
final hideSponsoredPosts = settings.hideSponsoredPosts;
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
final hideSuggestedPosts = false;
final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts;
// Stories hiding functionality removed per user request
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
// These are now only controllable via minimal mode submenu
final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely;
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive,
@@ -434,33 +457,35 @@ class InjectionManager {
blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection,
hideSuggestedPosts: false, // Feature removed
hideSponsoredPosts: hideSponsoredPosts,
hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts,
// hideStoriesBar removed per user request
hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely,
blockHomeFeedScroll: blockHomeFeedScroll,
);
try {
await controller.evaluateJavascript(source: injectionJS);
await _jsEvaluator.evaluateJavascript(source: injectionJS);
} catch (e) {
// Silently handle injection errors
}
// Inject grayscale when active, remove when not active
if (isGrayscaleActive) {
if (settings.isGrayscaleActiveNow) {
try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) {
// Silently handle injection errors
}
} else {
try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
await _jsEvaluator.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
} catch (e) {
// Silently handle injection errors
}
@@ -469,7 +494,9 @@ class InjectionManager {
// Inject hide like counts JS when enabled
if (hideLikeCounts) {
try {
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideLikeCountsJS,
);
} catch (e) {
// Silently handle injection errors
}
@@ -478,11 +505,11 @@ class InjectionManager {
// Stories hiding functionality removed per user request
// No stories overlay injection needed
// Inject hide sponsored posts JS when enabled
if (hideSponsoredPosts) {
// Inject video downloader UI when enabled
if (settings.videoDownloadEnabled) {
try {
await controller.evaluateJavascript(
source: ui_hider.kHideSponsoredPostsJS,
await _jsEvaluator.evaluateJavascript(
source: video_downloader.kVideoDownloadJS,
);
} catch (e) {
// Silently handle injection errors
@@ -492,7 +519,7 @@ class InjectionManager {
// Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) {
try {
await controller.evaluateJavascript(
await _jsEvaluator.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS,
);
} catch (e) {
+10 -5
View File
@@ -9,16 +9,16 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> init() async {
Future<void> init({bool requestPermissions = false}) async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: requestPermissions,
requestBadgePermission: requestPermissions,
requestSoundPermission: requestPermissions,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
@@ -37,7 +37,12 @@ class NotificationService {
},
);
// Request permissions after initialization
if (requestPermissions) {
await requestPermissionsNow();
}
}
Future<void> requestPermissionsNow() async {
await _requestIOSPermissions();
await _requestAndroidPermissions();
}
+5 -6
View File
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
///
/// Storage format (in SharedPreferences, key `screen_time_data`):
/// {
/// "2026-02-26": 3420, // seconds
/// "2026-02-25": 1800
/// "2026-05-26": 3420, // seconds
/// "2026-05-25": 1800
/// }
///
/// All data stays on-device only.
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
int get totalSeconds =>
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
}
} catch (_) {
_secondsByDate = {};
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
super.dispose();
}
}
+20 -10
View File
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h';
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0;
int _lastAppSessionMinutes = 5;
// ── Scheduled Blocking runtime ─────────────────────────────
bool _scheduleEnabled = false;
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0;
List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false;
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _scheduleNotificationShown =
false; // Track if schedule notification was shown
bool _sessionEndNotificationShown =
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0;
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount;
int get lastAppSessionMinutes => _lastAppSessionMinutes;
// ── Scheduled Blocking Getters ─────────────────────────────
bool get scheduleEnabled => _scheduleEnabled;
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
}
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) {
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
}
}
// App session expiry check
// App session countdown / expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
changed = true;
}
if (isCooldownActive) {
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
changed = true;
// Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true;
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
// (i.e., when loading an expired session from a previous app session)
if (showNotification && !_sessionEndNotificationShown) {
_sessionEndNotificationShown = true;
// Check if user wants session end notifications
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
final notifySessionEnd =
_prefs?.getBool('set_notify_session_end') ?? false;
if (notifySessionEnd) {
NotificationService().showNotification(
id: 999,
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
);
}
}
_isSessionActive = false;
_sessionExpiry = null;
_lastSessionEnd = DateTime.now();
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true;
_sessionEndNotificationShown = false; // Reset notification flag for new session
_sessionEndNotificationShown =
false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners();
}
+409 -66
View File
@@ -2,14 +2,18 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels';
static const _keyTapToUnblur = 'set_tap_to_unblur';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyBreathGateSeconds = 'breath_gate_seconds';
static const _keyWordChallengeCount = 'word_challenge_count';
static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings';
@@ -18,23 +22,42 @@ class SettingsService extends ChangeNotifier {
// Focus / playback
static const _keyBlockAutoplay = 'block_autoplay';
// Extras (Phase 2)
static const _keyVideoDownloadEnabled = 'video_download_enabled';
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
static const _keyContentStories = 'content_stories';
static const _keyContentPosts = 'content_posts';
static const _keyContentReels = 'content_reels';
static const _keyContentSuggested = 'content_suggested';
// Grayscale mode - now supports multiple schedules
static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules';
// Content filtering / UI hiding
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideShopTab = 'hide_shop_tab';
// Minimal mode
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Minimal mode state tracking for smart restore
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
static const _keyMinimalModePrevDisableReels =
'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevDisableExplore =
'minimal_mode_prev_disable_explore';
static const _keyMinimalModePrevBlurExplore =
'minimal_mode_prev_blur_explore';
static const _keyMinimalModePrevBlockHomeFeedScroll =
'minimal_mode_prev_block_home_feed_scroll';
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
@@ -46,6 +69,14 @@ class SettingsService extends ChangeNotifier {
static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent';
// Focus mode settings
static const _keyGhostMode = 'ghost_mode';
static const _keyNoAds = 'no_ads';
static const _keyNoStories = 'no_stories';
static const _keyNoReels = 'no_reels';
static const _keyNoAutoplay = 'no_autoplay';
static const _keyNoDMs = 'no_dms';
SharedPreferences? _prefs;
bool _blurExplore = true;
@@ -54,19 +85,33 @@ class SettingsService extends ChangeNotifier {
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
int _breathGateSeconds = 10;
int _wordChallengeCount = 30;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true;
bool _videoDownloadEnabled = false;
bool _hideSuggestedPosts = false;
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
bool _v2GhostModeEnabled = false;
bool _v2AdBlockerDomEnabled = false;
bool _v2ContentHiderEnabled = false;
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
bool _contentStories = false;
bool _contentPosts = false;
bool _contentReels = false;
bool _contentSuggested = false;
// Grayscale mode - now supports multiple schedules
bool _grayscaleEnabled = false;
// Grayscale schedules - list of {enabled, startTime, endTime}
// startTime and endTime are in format "HH:MM"
List<Map<String, dynamic>> _grayscaleSchedules = [];
bool _hideSponsoredPosts = false;
// Content filtering / UI hiding
bool _hideLikeCounts = false;
bool _hideFollowerCounts = false;
bool _hideShopTab = false;
@@ -74,12 +119,14 @@ class SettingsService extends ChangeNotifier {
// These are now controlled internally by minimal mode
bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false;
bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false;
// Tracking for smart restore
bool _prevDisableReels = false;
bool _prevDisableExplore = false;
bool _prevBlurExplore = false;
bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true;
@@ -90,6 +137,14 @@ class SettingsService extends ChangeNotifier {
bool _notifySessionEnd = false;
bool _notifyPersistent = false;
// Focus mode settings
bool _ghostMode = false;
bool _noAds = false;
bool _noStories = false;
bool _noReels = false;
bool _noAutoplay = false;
bool _noDMs = false;
List<String> _enabledTabs = [
'Home',
'Search',
@@ -105,12 +160,28 @@ class SettingsService extends ChangeNotifier {
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
int get breathGateSeconds => _breathGateSeconds;
int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay;
// Extras (Phase 2)
bool get videoDownloadEnabled => _videoDownloadEnabled;
bool get hideSuggestedPosts => _hideSuggestedPosts;
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
bool get contentStories => _contentStories;
bool get contentPosts => _contentPosts;
bool get contentReels => _contentReels;
bool get contentSuggested => _contentSuggested;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd;
@@ -119,14 +190,22 @@ class SettingsService extends ChangeNotifier {
bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
bool get noReels => _noReels;
bool get noAutoplay => _noAutoplay;
bool get noDMs => _noDMs;
// These are now controlled by minimal mode only
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _disableExploreEntirely;
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
@@ -136,22 +215,23 @@ class SettingsService extends ChangeNotifier {
bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true;
if (_grayscaleSchedules.isEmpty) return false;
final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute;
for (final schedule in _grayscaleSchedules) {
if (schedule['enabled'] != true) continue;
try {
final startParts = (schedule['startTime'] as String).split(':');
final endParts = (schedule['endTime'] as String).split(':');
if (startParts.length != 2 || endParts.length != 2) continue;
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
final startMinutes =
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
// Handle overnight schedules (e.g., 21:00 to 06:00)
if (endMinutes < startMinutes) {
// Overnight: active if current time is >= start OR < end
@@ -182,43 +262,80 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
.clamp(3, 60)
.toInt();
_wordChallengeCount = _normaliseWordChallengeCount(
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
);
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
_v2AdBlockerDomEnabled =
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
_v2ContentHiderEnabled =
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
_grayscaleSchedules = List<Map<String, dynamic>>.from(
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
(jsonDecode(schedulesJson) as List).map(
(e) => Map<String, dynamic>.from(e),
),
);
} catch (_) {
_grayscaleSchedules = [];
}
}
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
// Load minimal mode
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevDisableReels =
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore =
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
_prevBlockHomeFeedScroll =
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
// These are now internal states, not user-facing settings
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
_disableReelsEntirely =
_prefs!.getBool('internal_disable_reels_entirely') ?? false;
_disableExploreEntirely =
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
_blockHomeFeedScroll =
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
@@ -245,12 +362,12 @@ class SettingsService extends ChangeNotifier {
Future<void> setBlurExplore(bool v) async {
_blurExplore = v;
// Sync blur explore with blur reels - enabling one enables the other
if (v && !_blurReels) {
_blurReels = true;
await _prefs?.setBool(_keyBlurReels, true);
}
await _prefs?.setBool(_keyBlurExplore, v);
if (_minimalModeEnabled) {
await _checkAndAutoDisableMinimalMode();
}
notifyListeners();
}
@@ -289,6 +406,30 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setBreathGateSeconds(int seconds) async {
_breathGateSeconds = seconds.clamp(3, 60).toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
notifyListeners();
}
Future<void> setWordChallengeCount(int count) async {
_wordChallengeCount = _normaliseWordChallengeCount(count);
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
notifyListeners();
}
int resolvedWordChallengeCount() {
if (_wordChallengeCount != 0) return _wordChallengeCount;
final now = DateTime.now().microsecondsSinceEpoch;
return 10 + (now % 26);
}
static int _normaliseWordChallengeCount(int count) {
if (count == 0) return 0;
const allowed = [20, 25, 30, 35];
return allowed.contains(count) ? count : 30;
}
Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -307,13 +448,29 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
Future<void> setVideoDownloadEnabled(bool v) async {
_videoDownloadEnabled = v;
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
notifyListeners();
}
Future<void> setHideSuggestedPosts(bool v) async {
_hideSuggestedPosts = v;
await _prefs?.setBool(_keyHideSuggestedPosts, v);
notifyListeners();
}
Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners();
}
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
Future<void> setGrayscaleSchedules(
List<Map<String, dynamic>> schedules,
) async {
_grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners();
@@ -321,14 +478,23 @@ class SettingsService extends ChangeNotifier {
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
_grayscaleSchedules.add(schedule);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners();
}
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
Future<void> updateGrayscaleSchedule(
int index,
Map<String, dynamic> schedule,
) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules[index] = schedule;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners();
}
}
@@ -336,20 +502,76 @@ class SettingsService extends ChangeNotifier {
Future<void> removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners();
}
}
Future<void> setHideSponsoredPosts(bool v) async {
_hideSponsoredPosts = v;
await _prefs?.setBool(_keyHideSponsoredPosts, v);
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
Future<void> setHideLikeCounts(bool v) async {
_hideLikeCounts = v;
await _prefs?.setBool(_keyHideLikeCounts, v);
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
Future<void> setV2GhostModeEnabled(bool v) async {
_v2GhostModeEnabled = v;
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
notifyListeners();
}
Future<void> setV2AdBlockerDomEnabled(bool v) async {
_v2AdBlockerDomEnabled = v;
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
notifyListeners();
}
Future<void> setV2ContentHiderEnabled(bool v) async {
_v2ContentHiderEnabled = v;
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
notifyListeners();
}
Future<void> setContentStoriesEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentStories = v;
await _prefs?.setBool(_keyContentStories, v);
notifyListeners();
}
Future<void> setContentPostsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentPosts = v;
await _prefs?.setBool(_keyContentPosts, v);
notifyListeners();
}
Future<void> setContentReelsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentReels = v;
await _prefs?.setBool(_keyContentReels, v);
notifyListeners();
}
Future<void> setContentSuggestedEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentSuggested = v;
await _prefs?.setBool(_keyContentSuggested, v);
notifyListeners();
}
@@ -359,62 +581,138 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
/// Setter for internal disable reels state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Setter for internal disable explore state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
_blockHomeFeedScroll = v;
await _prefs?.setBool('internal_block_home_feed_scroll', v);
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Helper: Auto-disable minimal mode if all its features are disabled
/// This ensures minimal mode auto-turns-off when user disables all sub-features
///
/// NOTE: We must check the RAW state variables here, NOT the public getters
/// (disableReelsEntirely/disableExploreEntirely), because those getters
/// unconditionally return true when _minimalModeEnabled is true, which would
/// make the "all disabled" condition impossible to reach.
Future<void> _checkAndAutoDisableMinimalMode() async {
if (!_minimalModeEnabled) return;
// Check the RAW saved state, not the getters
final rawReels =
_prefs?.getBool('internal_disable_reels_entirely') ??
_disableReelsEntirely;
final rawExplore =
_prefs?.getBool('internal_disable_explore_entirely') ??
_disableExploreEntirely;
final rawHomeFeedScroll =
_prefs?.getBool('internal_block_home_feed_scroll') ??
_blockHomeFeedScroll;
final allDisabled =
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
if (allDisabled) {
_minimalModeEnabled = false;
await _prefs?.setBool(_keyMinimalModeEnabled, false);
}
}
/// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async {
if (v) {
// Turning ON - save current states BEFORE enabling minimal mode
// ── Turning ON ──────────────────────────────────────────────────────────
// Save current pre-minimal-mode states so we can restore them later
_prevDisableReels = _disableReelsEntirely;
_prevDisableExplore = _disableExploreEntirely;
_prevBlurExplore = _blurExplore;
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
await _prefs?.setBool(
_keyMinimalModePrevDisableExplore,
_prevDisableExplore,
);
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
// Enable all minimal mode settings
await _prefs?.setBool(
_keyMinimalModePrevBlockHomeFeedScroll,
_prevBlockHomeFeedScroll,
);
_minimalModeEnabled = true;
_disableReelsEntirely = true;
_disableExploreEntirely = true;
_blurExplore = true;
_blockHomeFeedScroll = true;
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
await _prefs?.setBool(_keyMinimalModeEnabled, true);
await _prefs?.setBool('internal_disable_reels_entirely', true);
await _prefs?.setBool('internal_disable_explore_entirely', true);
await _prefs?.setBool('internal_block_home_feed_scroll', true);
await _prefs?.setBool(_keyBlurExplore, true);
} else {
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
// ── Turning OFF ─────────────────────────────────────────────────────────
// Restore states that were saved BEFORE minimal mode was enabled.
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
_minimalModeEnabled = false;
// Simply restore to the states that were saved BEFORE minimal mode was enabled
_disableReelsEntirely = _prevDisableReels;
_disableExploreEntirely = _prevDisableExplore;
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
// to the saved prefs value (covers the case where no prev was saved).
_blurExplore = _prevBlurExplore;
// Save the restored states
await _prefs?.setBool(_keyMinimalModeEnabled, false);
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
await _prefs?.setBool(
'internal_disable_reels_entirely',
_disableReelsEntirely,
);
await _prefs?.setBool(
'internal_disable_explore_entirely',
_disableExploreEntirely,
);
await _prefs?.setBool(
'internal_block_home_feed_scroll',
_blockHomeFeedScroll,
);
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
// After restoring, check whether the user had ALL minimal features OFF
// already — if so, minimal mode should stay off (no-op).
if (!_disableReelsEntirely &&
!_disableExploreEntirely &&
!_blockHomeFeedScroll &&
!_blurExplore) {
// All features are off — minimal mode correctly stays off. No action needed.
}
}
notifyListeners();
}
@@ -441,24 +739,69 @@ class SettingsService extends ChangeNotifier {
Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifyPersistent(bool v) async {
_notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v);
if (v) {
await NotificationService().requestPermissionsNow();
} else {
await NotificationService().cancelPersistentNotification(id: 5001);
}
notifyListeners();
}
// ── Focus mode settings ──────────────────────────────────────────────────────
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setNoAds(bool v) async {
_noAds = v;
await _prefs?.setBool(_keyNoAds, v);
notifyListeners();
}
Future<void> setNoStories(bool v) async {
_noStories = v;
await _prefs?.setBool(_keyNoStories, v);
notifyListeners();
}
Future<void> setNoReels(bool v) async {
_noReels = v;
await _prefs?.setBool(_keyNoReels, v);
notifyListeners();
}
Future<void> setNoAutoplay(bool v) async {
_noAutoplay = v;
await _prefs?.setBool(_keyNoAutoplay, v);
notifyListeners();
}
Future<void> setNoDMs(bool v) async {
_noDMs = v;
await _prefs?.setBool(_keyNoDMs, v);
notifyListeners();
}
+1 -1
View File
@@ -517,7 +517,7 @@ class DisciplineChallenge {
];
/// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 15}) async {
static Future<bool> show(BuildContext context, {int count = 30}) async {
final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' ');
final controller = TextEditingController();
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'script_registry_v2_overlay.dart';
class ScriptEngineV2Overlay {
final InAppWebViewController controller;
final SharedPreferences prefs;
final Map<String, String> _cache = {};
ScriptEngineV2Overlay({required this.controller, required this.prefs});
Future<void> initDocumentStartScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
final code = await _load(s.assetPath);
if (code == null) continue;
await controller.addUserScript(
userScript: UserScript(
source: code,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
allowedOriginRules: {'https://www.instagram.com'},
),
);
}
}
}
Future<void> injectDocumentEndScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
final code = await _load(s.assetPath);
if (code == null) continue;
try {
await controller.evaluateJavascript(source: code);
} catch (_) {
// Best-effort injection; never crash UI.
}
}
}
await _pushContentFlagsIfNeeded();
}
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
await prefs.setBool(_enabledKey(id), enabled);
// For DOCUMENT_START scripts, require reload for clean removal.
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
UserScriptInjectionTime.AT_DOCUMENT_START) {
await controller.reload();
return;
}
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
await controller.reload();
}
bool _getEnabled(V2OverlayScriptId id) {
return prefs.getBool(_enabledKey(id)) ??
(id == V2OverlayScriptId.themeDetector);
}
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
Future<void> _pushContentFlagsIfNeeded() async {
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
final contentFlags = <String, bool>{
'stories': prefs.getBool('content_stories') ?? false,
'posts': prefs.getBool('content_posts') ?? false,
'reels': prefs.getBool('content_reels') ?? false,
'suggested': prefs.getBool('content_suggested') ?? false,
};
// Apply DOM content hider flags
if (contentScriptEnabled) {
await controller.evaluateJavascript(
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
);
}
// Also push network filter flags used by fetch_interceptor.js
// so toggles actually affect request/response behavior.
final noAds =
(prefs.getBool('no_ads') ?? false) ||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
final blockFeedPosts = contentFlags['posts'] ?? false;
final blockSuggested = contentFlags['suggested'] ?? false;
final blockReels = contentFlags['reels'] ?? false;
final blockAutoplay =
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
await controller.evaluateJavascript(
source:
'window.__fgSetFilterConfig?.(${jsonEncode({
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
'blockAds': noAds,
'blockSponsored': noAds,
'blockSuggested': blockSuggested,
// Keep video blocking controlled by existing toggles if desired.
'blockVideos': blockReels,
'blockAutoplay': blockAutoplay,
})});',
);
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
);
}
Future<String?> _load(String assetPath) async {
if (_cache.containsKey(assetPath)) return _cache[assetPath];
try {
final code = await rootBundle.loadString(assetPath);
_cache[assetPath] = code;
return code;
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,77 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum V2OverlayScriptId {
ghostMode,
themeDetector,
adBlockerDom,
contentHider,
fetchInterceptor,
autoplayBlocker,
}
class V2OverlayInstaScript {
final V2OverlayScriptId id;
final String name;
final String assetPath;
final UserScriptInjectionTime injectionTime;
bool enabled;
V2OverlayInstaScript({
required this.id,
required this.name,
required this.assetPath,
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
this.enabled = false,
});
}
class V2OverlayScriptRegistry {
static final List<V2OverlayInstaScript> all = [
V2OverlayInstaScript(
id: V2OverlayScriptId.ghostMode,
name: 'ghost_mode',
assetPath: 'assets/scripts/ghost_mode.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.themeDetector,
name: 'theme_detector',
assetPath: 'assets/scripts/theme_detector.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.adBlockerDom,
name: 'ad_blocker_dom',
assetPath: 'assets/scripts/ad_blocker_dom.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.contentHider,
name: 'content_hider',
assetPath: 'assets/scripts/content_hider.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.fetchInterceptor,
name: 'fetch_interceptor',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.autoplayBlocker,
name: 'autoplay_blocker',
assetPath: 'assets/scripts/autoplay_blocker.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
];
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
return all.firstWhere((s) => s.id == id);
}
}