mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-27 17:32:23 +02:00
Progress SAve- downloader,blur,ghost mode(Partially) works
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+848
-252
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: false,
|
||||
blockHomeFeedScroll: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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});
|
||||
|
||||
@@ -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 || '') : '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(/&/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 can’t 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(),
|
||||
};
|
||||
}
|
||||
@@ -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())}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user