RELEASE: moved from beta to First stable release.

Check CHANGELOG.md for full changelog
This commit is contained in:
Ujwal
2026-02-27 04:14:40 +05:45
parent eecb823e62
commit 7992d65bc8
64 changed files with 6208 additions and 2752 deletions

View File

@@ -0,0 +1,532 @@
import 'package:flutter/material.dart';
enum SkeletonType { feed, reels, explore, messages, profile, generic }
class SkeletonScreen extends StatefulWidget {
final SkeletonType skeletonType;
const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic});
@override
State<SkeletonScreen> createState() => _SkeletonScreenState();
}
class _SkeletonScreenState extends State<SkeletonScreen>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(_shimmerController);
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = theme.colorScheme.surfaceContainerHighest.withValues(
alpha: theme.brightness == Brightness.dark ? 0.25 : 0.4,
);
final highlightColor = theme.colorScheme.onSurface.withValues(alpha: 0.08);
return Container(
color: theme.scaffoldBackgroundColor,
width: double.infinity,
height: double.infinity,
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: const [0.1, 0.3, 0.6],
transform: _SlidingGradientTransform(
slidePercent: _shimmerAnimation.value,
),
).createShader(rect);
},
blendMode: BlendMode.srcATop,
child: _buildSkeletonContent(context),
);
},
),
);
}
Widget _buildSkeletonContent(BuildContext context) {
switch (widget.skeletonType) {
case SkeletonType.feed:
return _buildFeedSkeleton(context);
case SkeletonType.reels:
return _buildReelsSkeleton(context);
case SkeletonType.explore:
return _buildExploreSkeleton(context);
case SkeletonType.messages:
return _buildMessagesSkeleton(context);
case SkeletonType.profile:
return _buildProfileSkeleton(context);
case SkeletonType.generic:
return _buildGenericSkeleton(context);
}
}
Widget _buildFeedSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 6,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(height: 4),
Container(
width: 32,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 0),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(width: 8),
Container(
width: 80,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
Container(
width: double.infinity,
height: width,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: List.generate(
3,
(i) => Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
),
),
],
);
}
Widget _buildReelsSkeleton(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 24),
Container(
width: 150,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 12),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
}
Widget _buildExploreSkeleton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(2),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 15,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
);
}
Widget _buildMessagesSkeleton(BuildContext context) {
return ListView.builder(
itemCount: 8,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: 150,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildProfileSkeleton(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 24),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(48),
),
),
const SizedBox(height: 16),
Container(
width: 120,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
3,
(index) => Column(
children: [
Container(
width: 40,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
const SizedBox(height: 24),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 9,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
],
),
);
}
Widget _buildGenericSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
),
const SizedBox(height: 8),
Container(
width: 40,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: width * 0.4,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.25,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: width * 1.1,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 12),
Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.5,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
},
),
),
],
);
}
}
class _SlidingGradientTransform extends GradientTransform {
final double slidePercent;
const _SlidingGradientTransform({required this.slidePercent});
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}
SkeletonType getSkeletonTypeFromUrl(String url) {
final parsed = Uri.tryParse(url);
if (parsed == null) return SkeletonType.generic;
final path = parsed.path.toLowerCase();
if (path.startsWith('/reels') || path.startsWith('/reel/')) {
return SkeletonType.reels;
} else if (path.startsWith('/explore')) {
return SkeletonType.explore;
} else if (path.startsWith('/messages') || path.startsWith('/inbox')) {
return SkeletonType.messages;
} else if (path.startsWith('/') && !path.startsWith('/accounts')) {
if (path.split('/').length <= 2) {
return SkeletonType.feed;
}
return SkeletonType.profile;
}
return SkeletonType.generic;
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
class NativeBottomNav extends StatelessWidget {
final String currentUrl;
final bool reelsEnabled;
final bool exploreEnabled;
final bool minimalMode;
final Function(String path) onNavigate;
const NativeBottomNav({
super.key,
required this.currentUrl,
required this.reelsEnabled,
required this.exploreEnabled,
required this.minimalMode,
required this.onNavigate,
});
String get _path {
final parsed = Uri.tryParse(currentUrl);
if (parsed != null && parsed.path.isNotEmpty) return parsed.path;
return currentUrl; // may already be a path from SPA callbacks
}
bool get _onHome => _path == '/' || _path.isEmpty;
bool get _onExplore => _path.startsWith('/explore');
bool get _onReels => _path.startsWith('/reels') || _path.startsWith('/reel/');
bool get _onProfile =>
_path.startsWith('/accounts') ||
_path.contains('/profile') ||
_path.split('/').where((p) => p.isNotEmpty).length == 1;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bgColor =
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
final iconColorInactive =
isDark ? Colors.white70 : Colors.black54;
final iconColorActive =
theme.colorScheme.primary;
final tabs = <_NavItem>[
_NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: 'Home',
path: '/',
active: _onHome,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.search_outlined,
activeIcon: Icons.search,
label: 'Search',
path: '/explore/',
active: _onExplore,
enabled: exploreEnabled,
),
_NavItem(
icon: Icons.add_box_outlined,
activeIcon: Icons.add_box,
label: 'New',
path: '/create/select/',
active: false,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.play_circle_outline,
activeIcon: Icons.play_circle,
label: 'Reels',
path: '/reels/',
active: _onReels,
enabled: reelsEnabled,
),
_NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: 'Profile',
path: '/accounts/edit/',
active: _onProfile,
enabled: true,
),
];
return SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
width: 0.5,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) {
final color =
item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35;
return Expanded(
child: Opacity(
opacity: opacity,
child: InkWell(
onTap: item.enabled ? () => onNavigate(item.path) : null,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.active ? item.activeIcon : item.icon,
size: 24,
color: color,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
color: color,
),
),
],
),
),
),
),
);
}).toList(),
),
),
);
}
}
class _NavItem {
final IconData icon;
final IconData activeIcon;
final String label;
final String path;
final bool active;
final bool enabled;
_NavItem({
required this.icon,
required this.activeIcon,
required this.label,
required this.path,
required this.active,
required this.enabled,
});
}

View File

@@ -0,0 +1,72 @@
import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart';
class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView;
static InAppWebViewController? controller;
static final InAppWebViewKeepAlive keepAlive = InAppWebViewKeepAlive();
static bool isReady = false;
static Future<void> start(String userAgent) async {
if (_headlessWebView != null) return; // don't start twice
_headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive,
initialUrlRequest: URLRequest(
url: WebUri('https://www.instagram.com/'),
),
initialSettings: InAppWebViewSettings(
userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
),
initialUserScripts: UnmodifiableListView([
UserScript(
source: 'window.__fgBlockAutoplay = true;',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kSpaNavigationMonitorScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
]),
onWebViewCreated: (c) {
controller = c;
},
onLoadStop: (c, url) async {
isReady = true;
await c.evaluateJavascript(source: kNativeFeelingPostLoadScript);
},
);
await _headlessWebView!.run();
}
static void dispose() {
_headlessWebView?.dispose();
_headlessWebView = null;
controller = null;
isReady = false;
}
}

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'reels_history_service.dart';
class ReelsHistoryScreen extends StatefulWidget {
const ReelsHistoryScreen({super.key});
@override
State<ReelsHistoryScreen> createState() => _ReelsHistoryScreenState();
}
class _ReelsHistoryScreenState extends State<ReelsHistoryScreen> {
final _service = ReelsHistoryService();
late Future<List<ReelsHistoryEntry>> _future;
@override
void initState() {
super.initState();
_future = _service.getEntries();
}
Future<void> _refresh() async {
setState(() => _future = _service.getEntries());
}
String _formatTimestamp(DateTime dt) =>
DateFormat('EEE, MMM d • h:mm a').format(dt.toLocal());
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt.toLocal());
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatTimestamp(dt);
}
Future<void> _confirmClearAll() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear Reels History?'),
content: const Text(
'This removes all history entries stored locally on this device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Clear All'),
),
],
),
);
if (ok != true || !mounted) return;
await _service.clearAll();
await _refresh();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Reels History',
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),
),
actions: [
IconButton(
tooltip: 'Clear All',
onPressed: _confirmClearAll,
icon: const Icon(Icons.delete_outline),
),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<List<ReelsHistoryEntry>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final entries = snapshot.data ?? const <ReelsHistoryEntry>[];
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(
Icons.lock_outline,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 6),
Text(
'${entries.length} reels stored locally on device only',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
const SizedBox(height: 4),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.all(48),
child: Center(
child: Column(
children: [
Icon(
Icons.play_circle_outline,
size: 48,
color: Colors.grey,
),
SizedBox(height: 12),
Text(
'No Reels history yet.\nWatch a Reel and it will appear here.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
)
else
...entries.map((entry) {
return Dismissible(
key: ValueKey(entry.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.redAccent.withValues(alpha: 0.15),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
),
),
onDismissed: (_) async {
await _service.deleteEntry(entry.id);
// Don't call _refresh() on dismiss — removes the entry from
// the live list already via Dismissible, avoids double setState
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
leading: _ReelThumbnail(url: entry.thumbnailUrl),
title: Text(
entry.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
_relativeTime(entry.visitedAt),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
trailing: const Icon(
Icons.play_circle_outline,
color: Colors.blue,
size: 20,
),
onTap: () => Navigator.pop(context, entry.url),
),
);
}),
const SizedBox(height: 40),
],
);
},
),
),
);
}
}
/// Thumbnail widget that correctly sends Referer + User-Agent headers
/// required by Instagram's CDN. Without these the CDN returns 403.
class _ReelThumbnail extends StatelessWidget {
final String url;
const _ReelThumbnail({required this.url});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: url.isEmpty
? _placeholder()
: Image.network(
url,
width: 60,
height: 60,
fit: BoxFit.cover,
headers: const {
// Instagram CDN requires a valid Referer header
'Referer': 'https://www.instagram.com/',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86',
},
errorBuilder: (_, _, _) => _placeholder(),
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return Container(
color: Colors.white10,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
);
},
),
),
);
}
Widget _placeholder() => Container(
color: Colors.white10,
child: const Icon(
Icons.play_circle_outline,
color: Colors.white30,
size: 28,
),
);
}

View File

@@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ReelsHistoryEntry {
final String id;
final String url;
final String title;
final String thumbnailUrl;
final DateTime visitedAt;
const ReelsHistoryEntry({
required this.id,
required this.url,
required this.title,
required this.thumbnailUrl,
required this.visitedAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
};
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
return ReelsHistoryEntry(
id: (json['id'] as String?) ?? '',
url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
);
}
}
class ReelsHistoryService {
static const String _prefsKey = 'reels_history';
static const int _maxEntries = 200;
SharedPreferences? _prefs;
Future<SharedPreferences> _getPrefs() async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
}
Future<List<ReelsHistoryEntry>> getEntries() async {
final prefs = await _getPrefs();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw) as List<dynamic>;
final entries = decoded
.whereType<Map>()
.map((e) => ReelsHistoryEntry.fromJson(e.cast<String, dynamic>()))
.where((e) => e.url.isNotEmpty)
.toList();
entries.sort((a, b) => b.visitedAt.compareTo(a.visitedAt));
return entries;
} catch (_) {
return [];
}
}
Future<void> addEntry({
required String url,
required String title,
required String thumbnailUrl,
}) async {
if (url.isEmpty) return;
final now = DateTime.now().toUtc();
final entries = await getEntries();
final recentDuplicate = entries.any((e) {
if (e.url != url) return false;
final diff = now.difference(e.visitedAt).inSeconds.abs();
return diff <= 60;
});
if (recentDuplicate) return;
final entry = ReelsHistoryEntry(
id: DateTime.now().microsecondsSinceEpoch.toString(),
url: url,
title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl,
visitedAt: now,
);
final updated = [entry, ...entries];
if (updated.length > _maxEntries) {
updated.removeRange(_maxEntries, updated.length);
}
await _save(updated);
}
Future<void> deleteEntry(String id) async {
final entries = await getEntries();
entries.removeWhere((e) => e.id == id);
await _save(entries);
}
Future<void> clearAll() async {
final prefs = await _getPrefs();
await prefs.remove(_prefsKey);
}
Future<void> _save(List<ReelsHistoryEntry> entries) async {
final prefs = await _getPrefs();
final jsonList = entries.map((e) => e.toJson()).toList();
await prefs.setString(_prefsKey, jsonEncode(jsonList));
}
}

View File

@@ -0,0 +1,307 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/screen_time_service.dart';
class ScreenTimeScreen extends StatelessWidget {
const ScreenTimeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Screen Time',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Consumer<ScreenTimeService>(
builder: (context, service, _) {
final data = service.secondsByDate;
final todayKey = _todayKey();
final todaySeconds = data[todayKey] ?? 0;
final last7 = _lastNDays(7);
final barSpots = <BarChartGroupData>[];
int totalSeconds = 0;
for (var i = 0; i < last7.length; i++) {
final key = last7[i];
final sec = data[key] ?? 0;
totalSeconds += sec;
barSpots.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: sec / 60.0,
width: 10,
borderRadius: BorderRadius.circular(4),
color: Colors.blueAccent,
),
],
),
);
}
final daysWithData = data.values.isEmpty ? 0 : data.length;
final weeklyAvgMinutes = last7.isEmpty
? 0.0
: totalSeconds / 60.0 / last7.length;
final allTimeMinutes = totalSeconds / 60.0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildStatCard(
title: 'Today',
value: _formatDuration(todaySeconds),
),
const SizedBox(height: 16),
_buildChartCard(barSpots, last7),
const SizedBox(height: 16),
_buildInlineStats(
weeklyAvgMinutes: weeklyAvgMinutes,
allTimeMinutes: allTimeMinutes,
daysWithData: daysWithData,
),
const SizedBox(height: 24),
const Text(
'All data stored locally on your device only',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 24),
Center(
child: OutlinedButton.icon(
onPressed: () => _confirmReset(context, service),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Reset all data'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
),
),
],
);
},
),
);
}
static String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
static List<String> _lastNDays(int n) {
final now = DateTime.now();
return List.generate(n, (i) {
final d = now.subtract(Duration(days: n - 1 - i));
return '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
});
}
Widget _buildStatCard({required String title, required String value}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildChartCard(List<BarChartGroupData> bars, List<String> last7Keys) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white12),
),
height: 220,
child: BarChart(
BarChartData(
barGroups: bars,
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= last7Keys.length) {
return const SizedBox.shrink();
}
final label = last7Keys[index].substring(
last7Keys[index].length - 2,
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
label,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
},
),
),
),
),
),
);
}
Widget _buildInlineStats({
required double weeklyAvgMinutes,
required double allTimeMinutes,
required int daysWithData,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.02),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_inlineStat(
label: '7-day avg',
value: '${weeklyAvgMinutes.toStringAsFixed(1)} min',
),
_inlineDivider(),
_inlineStat(
label: 'All-time total',
value: '${allTimeMinutes.toStringAsFixed(0)} min',
),
_inlineDivider(),
_inlineStat(label: 'Tracked days', value: '$daysWithData'),
],
),
);
}
Widget _inlineStat({required String label, required String value}) {
return Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
}
Widget _inlineDivider() {
return Container(
width: 1,
height: 40,
color: Colors.white.withValues(alpha: 0.08),
);
}
static String _formatDuration(int seconds) {
if (seconds < 60) {
return '0:${seconds.toString().padLeft(2, '0')}';
}
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h > 0) {
return '${h}h ${m.toString().padLeft(2, '0')}m';
}
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _confirmReset(
BuildContext context,
ScreenTimeService service,
) async {
final first = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset screen time?'),
content: const Text(
'This will clear all locally stored screen time data for the last 30 days.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Continue',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (first != true) return;
if (!context.mounted) return;
final second = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirm reset'),
content: const Text(
'Are you sure you want to permanently delete all screen time data?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Yes, delete',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (!context.mounted) return;
if (second == true) {
await service.resetAll();
}
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'update_checker_service.dart';
class UpdateBanner extends StatefulWidget {
final UpdateInfo updateInfo;
final VoidCallback onDismiss;
const UpdateBanner({
super.key,
required this.updateInfo,
required this.onDismiss,
});
@override
State<UpdateBanner> createState() => _UpdateBannerState();
}
class _UpdateBannerState extends State<UpdateBanner> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
const Text('🎉', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
'FocusGram ${widget.updateInfo.latestVersion} available',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
HapticFeedback.lightImpact();
setState(() => _isExpanded = !_isExpanded);
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
HapticFeedback.lightImpact();
widget.onDismiss();
},
),
],
),
),
if (_isExpanded) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"What's new",
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_formatReleaseNotes(widget.updateInfo.whatsNew),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () async {
final uri = Uri.parse(widget.updateInfo.releaseUrl);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
},
child: const Text('Download on GitHub'),
),
),
],
),
),
],
],
),
);
}
String _formatReleaseNotes(String raw) {
var text = raw;
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
text =
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
return text.trim();
}
}

View File

@@ -0,0 +1,105 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class UpdateInfo {
final String latestVersion; // e.g. "1.0.0"
final String releaseUrl; // html_url
final String whatsNew; // trimmed body
final bool isUpdateAvailable;
const UpdateInfo({
required this.latestVersion,
required this.releaseUrl,
required this.whatsNew,
required this.isUpdateAvailable,
});
}
class UpdateCheckerService extends ChangeNotifier {
static const String _lastDismissedKey = 'last_dismissed_update_version';
static const String _githubUrl =
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest';
UpdateInfo? _updateInfo;
bool _isDismissed = false;
bool get hasUpdate => _updateInfo != null && !_isDismissed;
UpdateInfo? get updateInfo => hasUpdate ? _updateInfo : null;
Future<void> checkForUpdates() async {
try {
final response = await http
.get(Uri.parse(_githubUrl))
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return;
final data = json.decode(response.body);
final String gitVersionTag =
data['tag_name'] ?? ''; // e.g. "v0.9.8-beta.2"
final String htmlUrl = data['html_url'] ?? '';
final String body = (data['body'] as String?) ?? '';
if (gitVersionTag.isEmpty || htmlUrl.isEmpty) return;
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version; // e.g. "0.9.8-beta.2"
if (!_isNewerVersion(gitVersionTag, currentVersion)) return;
final prefs = await SharedPreferences.getInstance();
final dismissedVersion = prefs.getString(_lastDismissedKey);
if (dismissedVersion == gitVersionTag) {
_isDismissed = true;
return;
}
final cleanVersion =
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
var trimmed = body.trim();
if (trimmed.length > 1500) {
trimmed = trimmed.substring(0, 1500).trim();
}
_updateInfo = UpdateInfo(
latestVersion: cleanVersion,
releaseUrl: htmlUrl,
whatsNew: trimmed,
isUpdateAvailable: true,
);
_isDismissed = false;
notifyListeners();
} catch (e) {
debugPrint('Update check failed: $e');
}
}
Future<void> dismissUpdate() async {
if (_updateInfo == null) return;
_isDismissed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastDismissedKey, _updateInfo!.latestVersion);
notifyListeners();
}
bool _isNewerVersion(String gitTag, String current) {
// Clean versions: strip 'v' and everything after '-' (beta/rc)
String cleanGit = gitTag.startsWith('v') ? gitTag.substring(1) : gitTag;
String cleanCurrent = current;
List<String> gitParts = cleanGit.split('-')[0].split('.');
List<String> currentParts = cleanCurrent.split('-')[0].split('.');
for (int i = 0; i < gitParts.length && i < currentParts.length; i++) {
int gitNum = int.tryParse(gitParts[i]) ?? 0;
int curNum = int.tryParse(currentParts[i]) ?? 0;
if (gitNum > curNum) return true;
if (gitNum < curNum) return false;
}
return false;
}
}

View File

@@ -1,16 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'services/session_manager.dart';
import 'services/settings_service.dart';
import 'services/screen_time_service.dart';
import 'services/focusgram_router.dart';
import 'services/injection_controller.dart';
import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart';
import 'screens/app_session_picker.dart';
import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart';
import 'features/update_checker/update_checker_service.dart';
import 'features/preloader/instagram_preloader.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -23,9 +29,13 @@ void main() async {
final sessionManager = SessionManager();
final settingsService = SettingsService();
final screenTimeService = ScreenTimeService();
final updateChecker = UpdateCheckerService();
await sessionManager.init();
await settingsService.init();
await screenTimeService.init();
await NotificationService().init();
runApp(
@@ -33,10 +43,15 @@ void main() async {
providers: [
ChangeNotifierProvider.value(value: sessionManager),
ChangeNotifierProvider.value(value: settingsService),
ChangeNotifierProvider.value(value: screenTimeService),
ChangeNotifierProvider.value(value: updateChecker),
],
child: const FocusGramApp(),
),
);
// Fire and forget — preloads Instagram while app UI initialises.
unawaited(InstagramPreloader.start(InjectionController.iOSUserAgent));
}
class FocusGramApp extends StatelessWidget {
@@ -72,7 +87,8 @@ class FocusGramApp extends StatelessWidget {
/// 1. Onboarding (if first run)
/// 2. Cooldown Gate (if app-open cooldown active)
/// 3. Breath Gate (if enabled in settings)
/// 4. App Session Picker (always)
/// 4. If an app session is already active, resume it
/// otherwise show App Session Picker
/// 5. Main WebView
class InitialRouteHandler extends StatefulWidget {
const InitialRouteHandler({super.key});
@@ -133,11 +149,16 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
);
}
// Step 4: App session picker
// Step 4: App session picker / resume existing session
if (!_appSessionStarted) {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
if (sm.isAppSessionActive) {
// User already has an active app session — don't ask intention again.
_appSessionStarted = true;
} else {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
}
}
// Step 5: Main app

View File

@@ -1,211 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:http/http.dart' as http;
class AboutPage extends StatefulWidget {
const AboutPage({super.key});
@override
State<AboutPage> createState() => _AboutPageState();
}
class _AboutPageState extends State<AboutPage> {
final String _currentVersion = '0.9.8-beta.2';
bool _isChecking = false;
Future<void> _checkUpdate() async {
setState(() => _isChecking = true);
try {
final response = await http
.get(
Uri.parse(
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest',
),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final latestVersion = data['tag_name'].toString().replaceAll('v', '');
final downloadUrl = data['html_url'];
if (latestVersion != _currentVersion) {
_showUpdateDialog(latestVersion, downloadUrl);
} else {
_showSnackBar('You are up to date! 🎉');
}
} else {
_showSnackBar('Could not check for updates.');
}
} catch (_) {
_showSnackBar('Connectivity issue. Try again later.');
} finally {
if (mounted) setState(() => _isChecking = false);
}
}
void _showUpdateDialog(String version, String url) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
title: const Text(
'Update Available!',
style: TextStyle(color: Colors.white),
),
content: Text(
'A new version ($version) is available on GitHub.',
style: const TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later', style: TextStyle(color: Colors.white38)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_launchURL(url);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
child: const Text('Download'),
),
],
),
);
}
void _showSnackBar(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'About FocusGram',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: ClipOval(
child: Image.asset(
'assets/images/focusgram.png',
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
const Text(
'FocusGram',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Version $_currentVersion',
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
const SizedBox(height: 40),
const Text(
'Developed with passion for digital discipline by',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 4),
const Text(
'Ujwal Chapagain',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 40),
ElevatedButton.icon(
onPressed: _isChecking ? null : _checkUpdate,
icon: _isChecking
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.update),
label: Text(_isChecking ? 'Checking...' : 'Check for Update'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent.withValues(alpha: 0.2),
foregroundColor: Colors.white,
minimumSize: const Size(200, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () =>
_launchURL('https://github.com/Ujwal223/FocusGram'),
icon: const Icon(Icons.code),
label: const Text('View on GitHub'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white10,
foregroundColor: Colors.white,
minimumSize: const Size(200, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 20),
const Text(
'FocusGram is not affiliated with Instagram.',
style: TextStyle(
color: Color.fromARGB(48, 255, 255, 255),
fontSize: 10,
),
),
],
),
),
),
);
}
Future<void> _launchURL(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,58 +18,61 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<OnboardingData> _pages = [
OnboardingData(
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
icon: Icons.auto_awesome,
color: Colors.blue,
),
OnboardingData(
title: 'Ghost Mode',
description:
'Browse with total privacy. We block typing indicators and read receipts automatically.',
icon: Icons.visibility_off,
color: Colors.purple,
),
OnboardingData(
title: 'Session Management',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
icon: Icons.timer,
color: Colors.orange,
),
OnboardingData(
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" -> "Add link" and select all.',
icon: Icons.link,
color: Colors.cyan,
isAppSettingsPage: true,
),
OnboardingData(
title: 'Upload Content',
description:
'We need access to your gallery if you want to upload stories or posts directly from FocusGram.',
icon: Icons.photo_library,
color: Colors.orange,
isPermissionPage: true,
permission: Permission.photos,
),
OnboardingData(
title: 'Stay Notified',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
icon: Icons.notifications_active,
color: Colors.green,
isPermissionPage: true,
permission: Permission.notification,
),
];
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5;
static const int _kBlurPage = 3;
static const int _kLinkPage = 2;
static const int _kNotifPage = 4;
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final List<Widget> slides = [
// ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide(
icon: Icons.auto_awesome,
color: Colors.blue,
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
),
// ── Page 1: Session Management ───────────────────────────────────────
_StaticSlide(
icon: Icons.timer,
color: Colors.orange,
title: 'Session Management',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
),
// ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide(
icon: Icons.link,
color: Colors.cyan,
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
isAppSettingsPage: true,
),
// ── Page 3: Blur Settings ────────────────────────────────────────────
_BlurSettingsSlide(settings: settings),
// ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide(
icon: Icons.notifications_active,
color: Colors.green,
title: 'Stay Notified',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
isPermissionPage: true,
permission: Permission.notification,
),
];
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
@@ -77,9 +80,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _pages.length,
itemBuilder: (context, index) =>
_OnboardingSlide(data: _pages[index]),
itemCount: _kTotalPages,
itemBuilder: (context, index) => slides[index],
),
Positioned(
bottom: 50,
@@ -87,11 +89,13 @@ class _OnboardingPageState extends State<OnboardingPage> {
right: 0,
child: Column(
children: [
// Dot indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_pages.length,
(index) => Container(
_kTotalPages,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 12 : 8,
height: 8,
@@ -105,6 +109,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
const SizedBox(height: 32),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(
@@ -112,24 +117,38 @@ class _OnboardingPageState extends State<OnboardingPage> {
height: 56,
child: Builder(
builder: (context) {
final data = _pages[_currentPage];
final isLast = _currentPage == _kTotalPages - 1;
final isLink = _currentPage == _kLinkPage;
final isNotif = _currentPage == _kNotifPage;
final isBlur = _currentPage == _kBlurPage;
String label;
if (isLast) {
label = 'Get Started';
} else if (isLink) {
label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) {
label = 'Save & Continue';
} else {
label = 'Next';
}
return ElevatedButton(
onPressed: () async {
if (data.isAppSettingsPage) {
if (isLink) {
await AppSettings.openAppSettings(
type: AppSettingsType.settings,
);
} else if (data.isPermissionPage) {
if (data.permission != null) {
await data.permission!.request();
}
if (data.title == 'Stay Notified') {
await NotificationService().init();
}
} else if (isNotif) {
await Permission.notification.request();
await NotificationService().init();
}
if (_currentPage == _pages.length - 1) {
_finish();
if (!context.mounted) return;
if (isLast) {
_finish(context);
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
@@ -145,11 +164,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
child: Text(
_currentPage == _pages.length - 1
? 'Get Started'
: (data.isAppSettingsPage
? 'Configure'
: 'Next'),
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -160,6 +175,15 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
),
// Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1)
TextButton(
onPressed: () => _finish(context),
child: const Text(
'Skip',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
),
],
),
),
@@ -168,48 +192,44 @@ class _OnboardingPageState extends State<OnboardingPage> {
);
}
void _finish() {
void _finish(BuildContext context) {
context.read<SettingsService>().setFirstRunCompleted();
widget.onFinish();
}
}
class OnboardingData {
final String title;
final String description;
// ── Static info slide ──────────────────────────────────────────────────────────
class _StaticSlide extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String description;
final bool isPermissionPage;
final bool isAppSettingsPage;
final Permission? permission;
OnboardingData({
required this.title,
required this.description,
const _StaticSlide({
required this.icon,
required this.color,
required this.title,
required this.description,
this.isPermissionPage = false,
this.isAppSettingsPage = false,
this.permission,
});
}
class _OnboardingSlide extends StatelessWidget {
final OnboardingData data;
const _OnboardingSlide({required this.data});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(40),
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(data.icon, size: 120, color: data.color),
Icon(icon, size: 120, color: color),
const SizedBox(height: 48),
Text(
data.title,
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
@@ -219,7 +239,7 @@ class _OnboardingSlide extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
data.description,
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
@@ -232,3 +252,147 @@ class _OnboardingSlide extends StatelessWidget {
);
}
}
// ── Blur settings slide ────────────────────────────────────────────────────────
class _BlurSettingsSlide extends StatelessWidget {
final SettingsService settings;
const _BlurSettingsSlide({required this.settings});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(32, 40, 32, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Center(
child: Icon(
Icons.blur_on_rounded,
size: 90,
color: Colors.purpleAccent,
),
),
const SizedBox(height: 36),
const Center(
child: Text(
'Distraction Shield',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
const Center(
child: Text(
'Blur feeds you don\'t want to be tempted by. You can change these anytime in Settings.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white60,
fontSize: 16,
height: 1.5,
),
),
),
const SizedBox(height: 40),
// Blur Home Feed toggle
_BlurToggleTile(
icon: Icons.home_rounded,
label: 'Blur Home Feed',
subtitle: 'Posts in your feed will be blurred until tapped',
value: settings.blurReels,
onChanged: (v) => settings.setBlurReels(v),
),
const SizedBox(height: 16),
// Blur Explore toggle
_BlurToggleTile(
icon: Icons.explore_rounded,
label: 'Blur Explore Feed',
subtitle: 'Explore thumbnails stay blurred until you tap',
value: settings.blurExplore,
onChanged: (v) => settings.setBlurExplore(v),
),
],
),
);
}
}
class _BlurToggleTile extends StatelessWidget {
final IconData icon;
final String label;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _BlurToggleTile({
required this.icon,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: value
? Colors.purpleAccent.withValues(alpha: 0.12)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: value
? Colors.purpleAccent.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
Icon(
icon,
color: value ? Colors.purpleAccent : Colors.white38,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: value ? Colors.white : Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeThumbColor: Colors.purpleAccent,
),
],
),
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../services/injection_controller.dart';
import '../services/session_manager.dart';
import 'package:provider/provider.dart';
@@ -15,58 +15,12 @@ class ReelPlayerOverlay extends StatefulWidget {
}
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
late final WebViewController _controller;
DateTime? _startTime;
@override
void initState() {
super.initState();
_startTime = DateTime.now();
_initWebView();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(InjectionController.iOSUserAgent)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
_controller.runJavaScript(
'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
_controller.runJavaScript(
InjectionController.reelsMutationObserverJS,
);
// Also hide Instagram's bottom nav inside this overlay
_controller.runJavaScript(
InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: true,
),
);
},
onNavigationRequest: (request) {
// Allow only the initial reel URL and instagram.com generally
final uri = Uri.tryParse(request.url);
if (uri == null) return NavigationDecision.prevent;
final host = uri.host;
if (!host.contains('instagram.com')) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.url));
}
@override
@@ -114,7 +68,65 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
),
],
),
body: WebViewWidget(controller: _controller),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
userAgent: InjectionController.iOSUserAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
supportZoom: false,
allowsInlineMediaPlayback: true,
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
),
onWebViewCreated: (controller) {
// Controller is not stored; this overlay is self-contained.
},
onLoadStop: (controller, url) async {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
await controller.evaluateJavascript(
source: 'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
await controller.evaluateJavascript(
source: InjectionController.reelsMutationObserverJS,
);
// Also apply FocusGram baseline CSS (hides bottom nav etc.)
await controller.evaluateJavascript(
source: InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
enableTextSelection: true,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
hideStoriesBar: false,
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
),
);
},
shouldOverrideUrlLoading: (controller, action) async {
// Keep this overlay locked to instagram.com pages only
final uri = action.request.url;
if (uri == null) return NavigationActionPolicy.CANCEL;
if (!uri.host.contains('instagram.com')) {
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
/// JavaScript to block autoplaying videos on Instagram while still allowing
/// explicit user-initiated playback.
///
/// This script:
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
/// - Uses a short-lived per-element flag set by user clicks to allow play().
/// - Strips the autoplay attribute from dynamically added <video> elements.
const String kAutoplayBlockerJS = r'''
(function fgAutoplayBlocker() {
if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true;
// Toggleable at runtime from Flutter:
// window.__fgBlockAutoplay = true/false
if (typeof window.__fgBlockAutoplay === 'undefined') {
window.__fgBlockAutoplay = true;
}
const ALLOW_KEY = '__fgAllowPlayUntil';
const ALLOW_WINDOW_MS = 1000;
function markAllow(video) {
try {
video[ALLOW_KEY] = Date.now() + ALLOW_WINDOW_MS;
} catch (_) {}
}
function shouldAllow(video) {
try {
const until = video[ALLOW_KEY] || 0;
return Date.now() <= until;
} catch (_) {
return false;
}
}
function stripAutoplay(root) {
try {
if (window.__fgBlockAutoplay !== true) return;
const all = root.querySelectorAll
? root.querySelectorAll('video')
: (root.tagName === 'VIDEO' ? [root] : []);
all.forEach(v => {
v.removeAttribute('autoplay');
try { v.autoplay = false; } catch (_) {}
});
} catch (_) {}
}
// Initial pass
try {
document.querySelectorAll('video').forEach(v => stripAutoplay(v));
} catch (_) {}
// MutationObserver for dynamically added videos
try {
const mo = new MutationObserver(ms => {
if (window.__fgBlockAutoplay !== true) return;
ms.forEach(m => {
m.addedNodes.forEach(node => {
if (!node || node.nodeType !== 1) return;
if (node.tagName === 'VIDEO') {
stripAutoplay(node);
} else {
stripAutoplay(node);
}
});
});
});
mo.observe(document.documentElement, { childList: true, subtree: true });
} catch (_) {}
// Allow play() shortly after a direct user click on a video.
document.addEventListener('click', function(e) {
try {
const video = e.target && e.target.closest && e.target.closest('video');
if (!video) return;
markAllow(video);
try { video.play(); } catch (_) {}
} catch (_) {}
}, true);
// Prototype override
try {
const origPlay = HTMLVideoElement.prototype.play;
if (!origPlay) return;
if (!window.__fgOrigVideoPlay) window.__fgOrigVideoPlay = origPlay;
HTMLVideoElement.prototype.play = function() {
try {
if (window.__fgBlockAutoplay !== true) {
return origPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return origPlay.apply(this, arguments);
}
// Block autoplay: resolve without actually starting playback.
return Promise.resolve();
} catch (_) {
// If anything goes wrong, fall back to original behaviour to avoid
// breaking Instagram's player.
try {
return origPlay.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
}
};
} catch (_) {}
})();
''';

View File

@@ -0,0 +1,473 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// The JS hiders below use requestIdleCallback + a 300ms debounce so they run
// only during idle time and never on every single mutation.
// ─── CSS-based (reliable, zero perf cost) ────────────────────────────────────
const String kHideLikeCountsCSS =
"""
[role="button"][aria-label${r"$"}=" like"],
[role="button"][aria-label${r"$"}=" likes"],
[role="button"][aria-label${r"$"}=" view"],
[role="button"][aria-label${r"$"}=" views"],
a[href*="/liked_by/"] {
display: none !important;
}
""";
const String kHideFollowerCountsCSS = """
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
""";
// Stories bar — broad selector covering multiple Instagram DOM layouts
const String kHideStoriesBarCSS = """
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"][aria-label*="tories"],
[role="listbox"][aria-label*="tories"],
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
""";
// Also do a JS sweep for stories — CSS alone isn't reliable across Instagram versions
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Target the horizontal scrollable stories container
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Fallback: find story bubbles (circular avatar containers at top of feed)
document.querySelectorAll('section > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if (style.overflowX === 'scroll' || style.overflowX === 'auto') {
const circles = el.querySelectorAll('canvas, [style*="border-radius: 50%"]');
if (circles.length > 2) {
el.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _storiesTimer = null;
window.__fgStoriesObserver = new MutationObserver(() => {
// Debounce — only run after mutations settle, not on every single one
clearTimeout(_storiesTimer);
_storiesTimer = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
const String kHideExploreTabCSS = """
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
""";
const String kHideReelsTabCSS = """
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
""";
const String kHideShopTabCSS = """
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
""";
// ─── Complete Section Disabling (CSS-based) ─────────────────────────────────
// Minimal mode - disables Reels and Explore entirely
const String kMinimalModeCssScript = r'''
(function() {
const css = `
/* Hide Reels tab */
a[href="/reels/"], a[href="/reels"] { display: none !important; }
/* Hide Explore tab */
a[href="/explore/"], a[href="/explore"] { display: none !important; }
/* Hide Create tab */
a[href="/create/"], a[href="/create"] { display: none !important; }
/* Hide Reels in feed */
article a[href*="/reel/"] { display: none !important; }
/* Hide Explore entry points */
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-minimal-mode';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Reels entirely
const String kDisableReelsEntirelyCssScript = r'''
(function() {
const css = `
a[href="/reels/"], a[href="/reels"] { display: none !important; }
article a[href*="/reel/"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-reels';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Explore entirely
const String kDisableExploreEntirelyCssScript = r'''
(function() {
const css = `
a[href="/explore/"], a[href="/explore"] { display: none !important; }
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-explore';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// ─── DM-embedded Reels Scroll Control ────────────────────────────────────────
// Disables vertical scroll on reels opened from DM unless comment box or share modal is open
const String kDmReelScrollLockScript = r'''
(function() {
// Track scroll lock state
window.__fgDmReelScrollLocked = true;
window.__fgDmReelCommentOpen = false;
window.__fgDmReelShareOpen = false;
function lockScroll() {
if (window.__fgDmReelScrollLocked) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
}
}
function unlockScroll() {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
function updateScrollState() {
// Only unlock if comment or share modal is open
if (window.__fgDmReelCommentOpen || window.__fgDmReelShareOpen) {
unlockScroll();
} else if (window.__fgDmReelScrollLocked) {
lockScroll();
}
}
// Listen for comment box opening/closing
function setupCommentObserver() {
const commentBox = document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
if (commentBox) {
window.__fgDmReelCommentOpen = true;
updateScrollState();
}
}
// Listen for share modal
function setupShareObserver() {
const shareModal = document.querySelector('div[role="dialog"][aria-label*="Share"], section[aria-label*="Share"]');
if (shareModal) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
// Set up MutationObserver to detect comment/share modals
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
const role = node.getAttribute('role') || '';
// Check for comment box
if (ariaLabel.toLowerCase().includes('comment') ||
(role === 'dialog' && ariaLabel === '')) {
// Check if it's a comment dialog
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
// Check for share modal
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
if (ariaLabel.toLowerCase().includes('comment')) {
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = false;
updateScrollState();
}
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
// Initial lock
lockScroll();
// Expose functions for external control
window.__fgSetDmReelScrollLock = function(locked) {
window.__fgDmReelScrollLocked = locked;
updateScrollState();
};
})();
''';
// ─── JS-based (text-content detection, debounced) ─────────────────────────────
// Sponsored posts — scans for "Sponsored" text, debounced so it doesn't
// cause scroll jank on Instagram's constantly-mutating feed DOM.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return; // skip already-processed elements
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true; // mark as checked (non-sponsored)
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _timer = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — debounced same way.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _timer = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// ─── DM Reel Blocker ─────────────────────────────────────────────────────────
/// Overlays a "Reels are disabled" card on reel preview cards inside DMs.
///
/// DM reel previews use pushState (SPA) not <a href> navigation, so the CSS
/// display:none in kDisableReelsEntirelyCssScript doesn't remove the preview
/// card from the thread. This script finds them structurally and covers them
/// with a blocking overlay that also swallows all touch/click events.
///
/// Inject when disableReelsEntirely OR minimalMode is on.
const String kDmReelBlockerJS = r'''
(function() {
if (window.__fgDmReelBlockerRunning) return;
window.__fgDmReelBlockerRunning = true;
const BLOCKED_ATTR = 'data-fg-blocked';
function buildOverlay() {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position:absolute',
'inset:0',
'z-index:99999',
'display:flex',
'flex-direction:column',
'align-items:center',
'justify-content:center',
'background:rgba(0,0,0,0.85)',
'border-radius:inherit',
'pointer-events:all',
'gap:8px',
'cursor:default',
].join(';');
const icon = document.createElement('span');
icon.textContent = '🚫';
icon.style.cssText = 'font-size:28px;line-height:1';
const label = document.createElement('span');
label.textContent = 'Reels are disabled';
label.style.cssText = [
'color:#fff',
'font-size:13px',
'font-weight:600',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 12px',
].join(';');
const sub = document.createElement('span');
sub.textContent = 'Disable "Block Reels" in FocusGram settings';
sub.style.cssText = [
'color:rgba(255,255,255,0.5)',
'font-size:11px',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 16px',
].join(';');
div.appendChild(icon);
div.appendChild(label);
div.appendChild(sub);
// Swallow all interaction so the reel beneath cannot be triggered
['click','touchstart','touchend','touchmove','pointerdown'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return; // already overlaid
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay());
}
function blockDmReels() {
try {
// Strategy 1: <a href*="/reel/"> links inside the DM thread
document.querySelectorAll('a[href*="/reel/"]').forEach(function(link) {
try {
link.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(link.closest('div') || link.parentElement);
} catch(_) {}
});
// Strategy 2: <video> inside DMs (reel cards without <a> wrapper)
// Only targets videos inside the Direct thread or on /direct/ path
document.querySelectorAll('video').forEach(function(video) {
try {
const inDm = !!video.closest('[aria-label="Direct"], [aria-label*="Direct"]');
const isDmPath = window.location.pathname.includes('/direct/');
if (!inDm && !isDmPath) return;
const container = video.closest('div[class]') || video.parentElement;
if (!container) return;
video.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(container);
} catch(_) {}
});
} catch(_) {}
}
blockDmReels();
let _t = null;
new MutationObserver(function() {
clearTimeout(_t);
_t = setTimeout(blockDmReels, 200);
}).observe(document.documentElement, { childList: true, subtree: true });
})();
''';

View File

@@ -0,0 +1,472 @@
// Core JS and CSS payloads injected into the Instagram WebView.
//
// WARNING: Do not add any network interception logic ("ghost mode") here.
// All scripts in this file must be limited to UI behaviour, navigation helpers,
// and local-only features that do not modify data sent to Meta's servers.
// ── CSS payloads ──────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
/// because Instagram's comment input sheet also uses that role and the
/// CSS would paint a grey overlay on top of the typing area.
const String kGlobalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
/// Activated via the body[path] attribute written by the path tracker script.
const String kBlurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !important;
transition: filter 0.15s ease !important;
}
body[path="/"] article img:hover,
body[path="/"] article video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native.
const String kDisableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
const String kHideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
/// Blurs reel thumbnails in the feed AND reel preview cards sent in DMs.
///
/// Feed reels are wrapped in a[href*="/reel/"] — straightforward.
/// DM reel previews are inline media cards NOT wrapped in a[href*="/reel/"],
/// so they need separate selectors targeting img/video inside [aria-label="Direct"].
/// Profile photos are excluded via :not([alt*="rofile"]) — covers both
/// "profile" and "Profile" without case-sensitivity workarounds.
const String kBlurReelsCSS = '''
a[href*="/reel/"] img {
filter: blur(12px) !important;
}
[aria-label="Direct"] img:not([alt*="rofile"]):not([alt=""]),
[aria-label="Direct"] video {
filter: blur(12px) !important;
}
''';
// ── JavaScript helpers ────────────────────────────────────────────────────────
/// Removes the "Open in App" nag banner.
const String kDismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
/// Intercepts clicks on /reels/ links when no session is active and redirects
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
///
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
const String kStrictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
/// via the `UrlChange` handler so reels can be blocked on SPA navigation.
const String kTrackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('UrlChange', p);
}
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
const String kThemeDetectorJS = r'''
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
// 1. Check Instagram's specific classes
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
// 2. Check body background color
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}
} catch(_) {}
return 'dark'; // Fallback
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current);
}
}
}
setInterval(check, 1500);
check();
})();
''';
/// Prevents swipe-to-next-reel in the isolated DM reel player and when Reels
/// are blocked by FocusGram's session controls.
const String kReelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
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
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
if (window.__fgDisableReelsEntirely === true) return 'disabled';
return null;
}
function isLocked() {
return lockMode() !== null;
}
function allowInteractionTarget(t) {
if (!t || !t.closest) return false;
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
if (t.closest(MODAL_SEL)) return true;
return false;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
// Mark the first DM reel as loaded on first swipe attempt
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
let __fgOrigHtmlOverflow = null;
let __fgOrigBodyOverflow = null;
function applyOverflowLock() {
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 (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
}
document.documentElement.style.overflow = 'hidden';
if (document.body) document.body.style.overflow = 'hidden';
} else if (__fgOrigHtmlOverflow !== null) {
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
__fgOrigHtmlOverflow = null;
__fgOrigBodyOverflow = null;
}
} catch (_) {}
}
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
applyOverflowLock();
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
// Give the first reel 3.5 s to buffer before activating the DM lock
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) {
window.__fgDmReelAlreadyLoaded = true;
}
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) {
clearTimeout(window.__fgDmReelTimer);
window.__fgDmReelTimer = null;
}
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer =
p.includes('/reel/') && !p.startsWith('/reels');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
applyOverflowLock();
}, 400);
}
})();
''';
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
/// and Notifications icons, as well as the page title. Sends an event to
/// Flutter whenever a new notification is detected.
const String kBadgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
const startedAt = Date.now();
let initialised = false;
let lastDmCount = 0;
let lastNotifCount = 0;
let lastTitleUnread = 0;
function parseBadgeCount(el) {
if (!el) return 0;
try {
const raw = (el.innerText || el.textContent || '').trim();
const n = parseInt(raw, 10);
return isNaN(n) ? 1 : n;
} catch (_) {
return 1;
}
}
function check() {
try {
// 1. Check Title for (N) indicator
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
// 2. Scan for DM unread badge
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div',
'a[href*="/direct/inbox/"] ._a9-v',
].join(','));
const currentDmCount = parseBadgeCount(dmBadge);
// 3. Scan for Notifications unread badge
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [style*="255, 48, 64"]',
'a[href*="/notifications"] [aria-label*="unread"]'
].join(','));
const currentNotifCount = parseBadgeCount(notifBadge);
// Establish baseline on first run and suppress false positives right after reload.
if (!initialised) {
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
initialised = true;
return;
}
if (Date.now() - startedAt < 6000) {
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
return;
}
if (currentDmCount > lastDmCount) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
}
} else if (currentNotifCount > lastNotifCount) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
}
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
// Initial check after some delay to let page settle
setTimeout(check, 2000);
setInterval(check, 1000);
})();
''';
/// Forwards Web Notification events to the native Flutter channel.
const String kNotificationBridgeJS = '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const startedAt = Date.now();
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
// Avoid false positives on reload / initial bootstrap.
if (Date.now() - startedAt < 6000) {
return new _N(title, opts);
}
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramNotificationChannel',
title + (opts && opts.body ? ': ' + opts.body : ''),
);
}
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
/// channel instead.
const String kLinkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.flutter_inappwebview && u) {
window.flutter_inappwebview.callHandler(
'FocusGramShareChannel',
JSON.stringify({ url: u, title: (d && d.title) || '' }),
);
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';

View File

@@ -0,0 +1,16 @@
/// JS to help Instagram's layout detect viewport changes when the Android
/// soft keyboard appears in a WebView container.
///
/// It listens for resize events and re-dispatches an `orientationchange`
/// event, which nudges Instagram's layout system out of the DM loading
/// spinner state.
const String kDmKeyboardFixJS = r'''
// Fix: tell Instagram's layout system the viewport has changed after keyboard events
// This resolves the loading state that appears on DM screens in WebView
window.addEventListener('resize', function() {
try {
window.dispatchEvent(new Event('orientationchange'));
} catch (_) {}
});
''';

View File

@@ -0,0 +1,48 @@
/// Grayscale style injector.
/// Uses a <style> tag with !important so Instagram's CSS cannot override it.
const String kGrayscaleJS = r'''
(function fgGrayscale() {
try {
const ID = 'fg-grayscale';
function inject() {
let el = document.getElementById(ID);
if (!el) {
el = document.createElement('style');
el.id = ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = 'html { filter: grayscale(100%) !important; }';
}
inject();
if (!window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver = new MutationObserver(() => {
if (!document.getElementById('fg-grayscale')) inject();
});
window.__fgGrayscaleObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
} catch (_) {}
})();
''';
/// Removes grayscale AND disconnects the observer so it cannot re-add it.
/// Previously kGrayscaleOffJS only removed the style tag — the observer
/// immediately re-injected it, requiring an app restart to actually go off.
const String kGrayscaleOffJS = r'''
(function() {
try {
// 1. Disconnect the observer FIRST so it cannot react to the removal
if (window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver.disconnect();
window.__fgGrayscaleObserver = null;
}
// 2. Remove the style tag
const el = document.getElementById('fg-grayscale');
if (el) el.remove();
// 3. Clear any inline filter that may have been set by older code
document.documentElement.style.filter = '';
} catch (_) {}
})();
''';

View File

@@ -0,0 +1,12 @@
const String kHapticBridgeScript = '''
(function() {
// Trigger native haptic feedback on double-tap (like gesture on posts)
// Uses flutter_inappwebview's callHandler instead of postMessage
document.addEventListener('dblclick', function(e) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('Haptic', 'light');
}
}, true);
})();
''';

View File

@@ -0,0 +1,68 @@
// Document-start script — injected before Instagram's JS loads.
const String kNativeFeelingScript = '''
(function() {
const style = document.createElement('style');
style.id = 'fg-native-feel';
style.textContent = `
/* Hide all scrollbars */
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
*::-webkit-scrollbar {
display: none !important;
}
/* Remove blue tap highlight */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Disable text selection globally except inputs */
* {
-webkit-user-select: none !important;
user-select: none !important;
}
input, textarea, [contenteditable="true"] {
-webkit-user-select: text !important;
user-select: text !important;
}
/* Momentum scrolling */
* {
-webkit-overflow-scrolling: touch !important;
}
/* Remove focus outlines */
*:focus, *:focus-visible {
outline: none !important;
}
/* Fade images in */
img {
animation: igFadeIn 0.15s ease-in-out;
}
@keyframes igFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`;
if (document.head) {
document.head.appendChild(style);
} else {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(style);
});
}
})();
''';
// Post-load script — call in onLoadStop only.
// IMPORTANT: Do NOT add overscroll-behavior rules here — they lock the feed scroll.
const String kNativeFeelingPostLoadScript = '''
(function() {
// Smooth anchor scrolling only — do NOT apply to all containers.
document.documentElement.style.scrollBehavior = 'auto';
})();
''';

View File

@@ -0,0 +1,118 @@
// Reel metadata extraction for history feature.
// Extracts title and thumbnail URL from the page and sends to Flutter.
const String kReelMetadataExtractorScript = r'''
(function() {
// Track if we've already extracted for this URL to avoid duplicates
window.__fgReelExtracted = window.__fgReelExtracted || false;
window.__fgLastExtractedUrl = window.__fgLastExtractedUrl || '';
function extractAndSend() {
const currentUrl = window.location.href;
// Skip if already extracted for this URL
if (window.__fgReelExtracted && window.__fgLastExtractedUrl === currentUrl) {
return;
}
// Check if this is a reel page
if (!currentUrl.includes('/reel/')) {
return;
}
// Try multiple sources for metadata
let title = '';
let thumbnailUrl = '';
// 1. Try Open Graph tags
const ogTitle = document.querySelector('meta[property="og:title"]');
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogTitle) title = ogTitle.content;
if (ogImage) thumbnailUrl = ogImage.content;
// 2. Fallback to document title if no OG title
if (!title && document.title) {
title = document.title.replace(' on Instagram', '').trim();
if (!title) title = 'Instagram Reel';
}
// 3. Try JSON-LD structured data
if (!thumbnailUrl) {
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
jsonLdScripts.forEach(function(script) {
try {
const data = JSON.parse(script.textContent);
if (data.image) {
if (Array.isArray(data.image)) {
thumbnailUrl = data.image[0];
} else if (typeof data.image === 'string') {
thumbnailUrl = data.image;
} else if (data.image.url) {
thumbnailUrl = data.image.url;
}
}
} catch(e) {}
});
}
// 4. Try Twitter card as fallback
if (!thumbnailUrl) {
const twitterImage = document.querySelector('meta[name="twitter:image"]');
if (twitterImage) thumbnailUrl = twitterImage.content;
}
// Skip if no thumbnail found
if (!thumbnailUrl) {
return;
}
// Mark as extracted
window.__fgReelExtracted = true;
window.__fgLastExtractedUrl = currentUrl;
// Send to Flutter
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'ReelMetadata',
JSON.stringify({
url: currentUrl,
title: title || 'Instagram Reel',
thumbnailUrl: thumbnailUrl
})
);
}
}
// Run immediately in case metadata is already loaded
extractAndSend();
// Set up MutationObserver to detect page changes and metadata loading
if (!window.__fgReelObserver) {
let debounceTimer = null;
window.__fgReelObserver = new MutationObserver(function(mutations) {
// Debounce to avoid excessive calls
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
extractAndSend();
}, 500);
});
window.__fgReelObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
}
// Also listen for URL changes (SPA navigation)
let lastUrl = location.href;
setInterval(function() {
if (location.href !== lastUrl) {
lastUrl = location.href;
window.__fgReelExtracted = false;
window.__fgLastExtractedUrl = '';
extractAndSend();
}
}, 1000);
})();
''';

View File

@@ -0,0 +1,13 @@
/// JS to improve momentum scrolling behaviour inside the WebView, especially
/// for content-heavy feeds like Reels.
///
/// Applies touch-style overflow scrolling hints to the root element.
const String kScrollSmoothingJS = r'''
(function fgScrollSmoothing() {
try {
document.documentElement.style.setProperty('-webkit-overflow-scrolling', 'touch');
document.documentElement.style.setProperty('overflow-scrolling', 'touch');
} catch (_) {}
})();
''';

View File

@@ -0,0 +1,32 @@
const String kSpaNavigationMonitorScript = '''
(function() {
// Monitor Instagram's SPA navigation and notify Flutter on every URL change.
// Instagram uses history.pushState — onLoadStop won't fire for these transitions.
// This is injected at document start so it wraps pushState before Instagram does.
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
function notifyUrlChange(url) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'UrlChange',
url || window.location.href
);
}
}
history.pushState = function() {
originalPushState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
window.addEventListener('popstate', () => notifyUrlChange());
})();
''';

263
lib/scripts/ui_hider.dart Normal file
View File

@@ -0,0 +1,263 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// All JS hiders below use a 300ms debounce so they run only after mutations settle.
// ─── CSS-based ────────────────────────────────────────────────────────────────
// FIX: Like count CSS.
// Instagram's like BUTTON has aria-label="Like" (the verb) — NOT the count.
// [role="button"][aria-label$=" likes"] never matches anything.
// The COUNT lives in a[href*="/liked_by/"] (e.g. "1,234 likes" link).
// We hide that link. The JS hider below catches React-rendered span variants.
const String kHideLikeCountsCSS = '''
a[href*="/liked_by/"],
section a[href*="/liked_by/"] {
display: none !important;
}
''';
const String kHideFollowerCountsCSS = '''
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
''';
// Stories bar CSS — multiple selectors for different Instagram DOM versions.
// :has() is supported in WebKit (Instagram's engine). Targets the container,
// not individual story items which is what [aria-label*="Stories"] matches.
const String kHideStoriesBarCSS = '''
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"]:has([aria-label*="tory"]),
[role="listbox"]:has([aria-label*="tory"]),
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
''';
const String kHideExploreTabCSS = '''
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
''';
const String kHideReelsTabCSS = '''
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
''';
const String kHideShopTabCSS = '''
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
''';
// ─── JS-based ─────────────────────────────────────────────────────────────────
// Like counts — JS fallback for React-rendered count spans not caught by CSS.
// Scans for text matching "1,234 likes" / "12.3K views" patterns.
const String kHideLikeCountsJS = r'''
(function() {
function hideLikeCounts() {
try {
// Hide liked_by links and their immediate parent wrapper
document.querySelectorAll('a[href*="/liked_by/"]').forEach(function(el) {
try {
el.style.setProperty('display', 'none', 'important');
// Also hide the parent span/div that wraps the count text
if (el.parentElement) {
el.parentElement.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Scan spans for numeric like/view count text patterns
document.querySelectorAll('span').forEach(function(el) {
try {
const text = el.textContent.trim();
// Matches: "1,234 likes", "12.3K views", "1 like", "45 views", etc.
if (/^[\d,.]+[KkMm]?\s+(like|likes|view|views)$/.test(text)) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideLikeCounts();
if (!window.__fgLikeCountObserver) {
let _t = null;
window.__fgLikeCountObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideLikeCounts, 300);
});
window.__fgLikeCountObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Stories bar JS — structural detection when CSS selectors don't match.
// Two strategies:
// 1. aria-label scan on role=list/listbox elements
// 2. BoundingClientRect check: story circles are square, narrow (<120px), appear in a row
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Strategy 1: aria-label on list containers
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stor')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Strategy 2: BoundingClientRect — story circles are narrow square items in a row.
// Look for a <ul> or <div role=list> whose first child is roughly square and < 120px wide.
document.querySelectorAll('ul, [role="list"]').forEach(function(el) {
try {
const items = el.children;
if (items.length < 3) return;
const first = items[0].getBoundingClientRect();
// Story item: small, roughly square (width ≈ height), near top of viewport
if (
first.width > 0 &&
first.width < 120 &&
Math.abs(first.width - first.height) < 20 &&
first.top < 300
) {
el.style.setProperty('display', 'none', 'important');
// Also hide the section wrapping this if it has no article (pure stories row)
const section = el.closest('section, div[class]');
if (section && !section.querySelector('article')) {
section.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
// Strategy 3: horizontal overflow container before any article in the feed
document.querySelectorAll('main > div > div > div').forEach(function(container) {
try {
if (container.querySelector('article')) return;
const inner = container.querySelector('div, ul');
if (!inner) return;
const s = window.getComputedStyle(inner);
if (s.overflowX === 'scroll' || s.overflowX === 'auto') {
container.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _t = null;
window.__fgStoriesObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Sponsored posts — scans article elements for "Sponsored" text child.
// CSS cannot traverse from child text up to parent — JS only.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return;
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true;
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _t = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — scans for heading text, walks up to parent article/section.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _t = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';

View File

@@ -1,245 +1,21 @@
// ============================================================================
// FocusGram — InjectionController
// ============================================================================
//
// Builds all JavaScript and CSS payloads injected into the Instagram WebView.
//
// ── Ghost Mode Design ────────────────────────────────────────────────────────
//
// Instead of blocking exact URLs (brittle — Instagram renames paths constantly),
// we block by SEMANTIC KEYWORD GROUPS. A request is silenced if its URL contains
// ANY keyword from the relevant group.
//
// Ghost Mode Semantic Groups (last verified: 2025-02)
// ────────────────────────────────────────────────────
// seenKeywords — story/DM seen receipts (any endpoint Instagram uses to
// tell others you read/watched something)
// typingKeywords — typing indicator REST calls + WS text frames
// liveKeywords — live viewer heartbeat / join_request (presence on streams)
// photoKeywords — disappearing / view-once DM photo seen receipts
//
// Adding new endpoints in the future: just append a keyword to the right group
// in _ghostGroups below — no other code needs to change.
//
// ── Confirmed endpoint map ───────────────────────────────────────────────────
// /api/v1/media/seen/ — story seen v1 (covered by "media/seen")
// /api/v2/media/seen/ — story seen v2 (covered by "media/seen")
// /stories/reel/seen — web story seen (covered by "reel/seen")
// /api/v1/stories/reel/mark_seen/ — story mark (covered by "mark_seen")
// /direct_v2/threads/…/seen/ — DM message read (covered by "/seen")
// /api/v1/direct_v2/set_reel_seen/ — DM story (covered by "reel_seen")
// /api/v1/direct_v2/mark_visual_item_seen/ — disappearing photos
// /api/v1/live/…/heartbeat_and_get_viewer_count/ — live presence
// /api/v1/live/…/join_request/ — live join
// WS text frames with "typing", "direct_v2/typing", "activity_status"
//
// ============================================================================
/// Central hub for all JavaScript and CSS injected into the Instagram WebView.
import '../scripts/core_injection.dart' as scripts;
import '../scripts/ui_hider.dart' as ui_hider;
class InjectionController {
// ── User Agent ──────────────────────────────────────────────────────────────
/// iOS UA ensures Instagram serves the full mobile UI (Reels, Stories, DMs).
/// Without spoofing, instagram.com returns a stripped desktop-lite shell.
static const String iOSUserAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
'Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;'
'FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;'
'FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]';
'Version/26.0 Mobile/15E148 Safari/604.1';
// ── Ghost Mode keyword groups ────────────────────────────────────────────────
static const String reelsMutationObserverJS =
scripts.kReelsMutationObserverJS;
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
/// Semantic groups used by [buildGhostModeJS].
///
/// Each group is a list of URL substrings. A network request is suppressed
/// if its URL contains ANY substring in the enabled groups.
///
/// To add future endpoints: append keywords here — nothing else changes.
static const Map<String, List<String>> _ghostGroups = {
// Any URL that records you having seen/read something
'seen': ['/seen', '/mark_seen', 'reel_seen', 'reel/seen', 'media/seen'],
// Typing indicator (REST + WebSocket text frames)
'typing': ['set_typing_status', '/typing', 'activity_status'],
// Live stream viewer join / heartbeat (you appear in viewer list)
'live': ['/live/'],
// Disappearing / view-once DM photos
'dmPhotos': ['visual_item_seen'],
};
// ── CSS ─────────────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
/// because Instagram's comment input sheet also uses that role and the
/// CSS would paint a grey overlay on top of the typing area.
static const String _globalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
/// Activated via the body[path] attribute written by [_trackPathJS].
static const String _blurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !important;
transition: filter 0.15s ease !important;
}
body[path="/"] article img:hover,
body[path="/"] article video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native.
static const String _disableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
static const String _hideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
// _blurExploreCSS removed — replaced by _blurHomeFeedAndExploreCSS above.
/// Blurs reel thumbnail images shown in the feed.
static const String _blurReelsCSS = '''
a[href*="/reel/"] img { filter: blur(12px) !important; }
''';
// ── JavaScript helpers ───────────────────────────────────────────────────────
/// Removes the "Open in App" nag banner.
static const String _dismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
/// Replaces ONLY the Instagram wordmark SVG with "FocusGram" brand text.
/// Specifically targets the top-bar logo SVG (aria-label="Instagram") while
/// explicitly excluding SVG icons inside nav/tablist (home, notifications,
/// create, reels, profile icons).
static const String _brandingJS = r'''
(function fgBranding() {
// Only the wordmark: SVG with aria-label="Instagram" that is NOT inside
// a [role="tablist"] (bottom nav) or a [role="navigation"] (nav bar).
// Also targets the ._ac83 class which Instagram uses for its top wordmark.
const WORDMARK_SEL = [
'svg[aria-label="Instagram"]',
'._ac83 svg[aria-label="Instagram"]',
'h1[role="presentation"] svg',
];
const STYLE =
'font-family:"Grand Hotel",cursive;font-size:26px;color:#fff;' +
'vertical-align:middle;cursor:default;letter-spacing:.5px;display:inline-block;';
function isNavIcon(el) {
// Exclude any SVG that lives inside a tablist, nav, or link with
// non-home/non-root href (these are functional icons, not the wordmark).
if (el.closest('[role="tablist"]')) return true;
if (el.closest('[role="navigation"]')) return true;
// The wordmark is always at the TOP of the page in a header/banner
const header = el.closest('header, [role="banner"], [role="main"]');
if (!header && el.closest('[role="button"]')) return true;
// If the SVG has a meaningful role (img presenting an action icon), skip it
const role = el.getAttribute('role');
if (role && role !== 'img') return true;
// If the parent <a> goes somewhere other than "/" it is a nav link
const anchor = el.closest('a');
if (anchor) {
const href = anchor.getAttribute('href') || '';
if (href && href !== '/' && !href.startsWith('/?')) return true;
}
return false;
}
function apply() {
WORDMARK_SEL.forEach(sel => document.querySelectorAll(sel).forEach(logo => {
if (logo.dataset.fgBranded) return;
if (isNavIcon(logo)) return;
logo.dataset.fgBranded = 'true';
const span = Object.assign(document.createElement('span'),
{ textContent: 'FocusGram' });
span.style.cssText = STYLE;
logo.style.display = 'none';
logo.parentNode.insertBefore(span, logo.nextSibling);
}));
}
apply();
new MutationObserver(apply)
.observe(document.documentElement, { childList: true, subtree: true });
})();
''';
/// Intercepts clicks on /reels/ links when no session is active and redirects
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
///
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
static const String _strictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
/// via `FocusGramPathChannel` so reels can be blocked on SPA navigation.
static const String _trackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.FocusGramPathChannel) window.FocusGramPathChannel.postMessage(p);
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
/// Injects a persistent `style` element and keeps it alive across SPA route
/// changes by watching for it being removed from `head`.
static String _buildMutationObserver(String cssContent) =>
'''
(function fgApplyStyles() {
@@ -264,9 +40,6 @@ class InjectionController {
return '`$escaped`';
}
// ── Navigation helpers ───────────────────────────────────────────────────────
/// Returns JS that navigates to [path] only when not already on it.
static String softNavigateJS(String path) =>
'''
(function() {
@@ -275,526 +48,59 @@ class InjectionController {
})();
''';
// ── Session state ────────────────────────────────────────────────────────────
/// Writes the current session-active flag into the WebView global scope.
/// All injected scripts (Ghost Mode, scroll lock) read this flag.
static String buildSessionStateJS(bool active) =>
'window.__focusgramSessionActive = $active;';
// ── Ghost Mode ───────────────────────────────────────────────────────────────
/// Returns all URL keywords that should be blocked for the given feature flags.
///
/// Exposed as a separate method so unit tests can verify keyword selection
/// independently of the full JS string.
static List<String> resolveBlockedKeywords({
required bool typingIndicator,
required bool seenStatus,
required bool stories,
required bool dmPhotos,
}) {
final out = <String>[];
if (seenStatus) out.addAll(_ghostGroups['seen']!);
if (typingIndicator) out.addAll(_ghostGroups['typing']!);
if (stories) out.addAll(_ghostGroups['live']!);
if (dmPhotos) out.addAll(_ghostGroups['dmPhotos']!);
return out;
}
/// Returns all WebSocket text-frame keywords to drop for the given flags.
static List<String> resolveWsBlockedKeywords({
required bool typingIndicator,
}) {
if (!typingIndicator) return const [];
return List.unmodifiable(_ghostGroups['typing']!);
}
/// Builds JavaScript that intercepts fetch, XHR, WebSocket, and sendBeacon
/// traffic to suppress ALL activity receipts (seen, typing, live, DM photos).
///
/// All blocked requests return `{"status":"ok"}` with HTTP 200 so Instagram
/// does not retry or display an error.
///
/// See [resolveBlockedKeywords] for the URL-keyword logic.
static String buildGhostModeJS({
required bool typingIndicator,
required bool seenStatus,
required bool stories,
required bool dmPhotos,
}) {
if (!typingIndicator && !seenStatus && !stories && !dmPhotos) return '';
final blocked = resolveBlockedKeywords(
typingIndicator: typingIndicator,
seenStatus: seenStatus,
stories: stories,
dmPhotos: dmPhotos,
);
final wsBlocked = resolveWsBlockedKeywords(
typingIndicator: typingIndicator,
);
final urlsJson = blocked.map((u) => '"$u"').join(', ');
final wsJson = wsBlocked.map((u) => '"$u"').join(', ');
return '''
(function fgGhostMode() {
if (window.__fgGhostModeDone) return;
window.__fgGhostModeDone = true;
// URL substrings — any request whose URL contains one of these is silenced.
const BLOCKED = [$urlsJson];
// WebSocket text-frame keywords to drop (MQTT typing/presence).
const WS_KEYS = [$wsJson];
function shouldBlock(url) {
return typeof url === 'string' && BLOCKED.some(k => url.includes(k));
}
function isDmVideoLocked(url) {
if (typeof url !== 'string') return false;
if (!url.includes('.mp4') && !url.includes('/v/t') && !url.includes('cdninstagram') && !url.includes('.dash')) return false;
return window.__fgDmReelAlreadyLoaded === true;
}
// ── fetch ──────────────────────────────────────────────────────────────
const _oFetch = window.__fgOrigFetch || window.fetch;
window.__fgOrigFetch = _oFetch;
window.__fgGhostFetch = function(resource, init) {
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
// Ghost mode: block seen/typing receipts
if (shouldBlock(url))
return Promise.resolve(new Response('{"status":"ok"}',
{ status: 200, headers: { 'Content-Type': 'application/json' } }));
// DM isolation: block additional video segments after first reel loaded
if (isDmVideoLocked(url))
return Promise.resolve(new Response('', { status: 200 }));
return _oFetch.apply(this, arguments);
};
window.fetch = window.__fgGhostFetch;
// ── sendBeacon ─────────────────────────────────────────────────────────
if (navigator.sendBeacon && !window.__fgBeaconPatched) {
window.__fgBeaconPatched = true;
const _oBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url, data) {
if (shouldBlock(url)) return true;
return _oBeacon(url, data);
};
}
// ── XHR ────────────────────────────────────────────────────────────────
const _oOpen = window.__fgOrigXhrOpen || XMLHttpRequest.prototype.open;
const _oSend = window.__fgOrigXhrSend || XMLHttpRequest.prototype.send;
window.__fgOrigXhrOpen = _oOpen;
window.__fgOrigXhrSend = _oSend;
XMLHttpRequest.prototype.open = function(m, url) {
this._fgUrl = url;
this._fgBlock = shouldBlock(url);
return _oOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() {
if (this._fgBlock) {
Object.defineProperty(this, 'readyState', { get: () => 4, configurable: true });
Object.defineProperty(this, 'status', { get: () => 200, configurable: true });
Object.defineProperty(this, 'responseText', { get: () => '{"status":"ok"}', configurable: true });
Object.defineProperty(this, 'response', { get: () => '{"status":"ok"}', configurable: true });
setTimeout(() => {
try { if (this.onreadystatechange) this.onreadystatechange(); } catch(_) {}
try { if (this.onload) this.onload(); } catch(_) {}
}, 0);
return;
}
// DM isolation: block additional video XHR fetches after first reel loaded
if (this._fgUrl && isDmVideoLocked(this._fgUrl)) {
setTimeout(() => { try { this.onload?.(); } catch(_) {} }, 0);
return;
}
return _oSend.apply(this, arguments);
};
// ── WebSocket — block text AND binary frames ───────────────────────────
if (!window.__fgWsGhostDone) {
window.__fgWsGhostDone = true;
const _OWS = window.WebSocket;
const ALL_SEEN = [$urlsJson];
function containsKeyword(data) {
if (typeof data === 'string') return ALL_SEEN.some(k => data.includes(k));
try {
let bytes;
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
else if (data instanceof Uint8Array) bytes = data;
else return false;
const text = String.fromCharCode.apply(null, bytes);
return ALL_SEEN.some(k => text.includes(k));
} catch(_) { return false; }
}
function FgWS(url, proto) {
const ws = proto != null ? new _OWS(url, proto) : new _OWS(url);
const _send = ws.send.bind(ws);
ws.send = function(data) {
if (containsKeyword(data)) return;
return _send(data);
};
return ws;
}
FgWS.prototype = _OWS.prototype;
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k => FgWS[k] = _OWS[k]);
window.WebSocket = FgWS;
}
// Reapply every 3 s in case Instagram replaces window.fetch
if (!window.__fgGhostReapplyInterval) {
window.__fgGhostReapplyInterval = setInterval(() => {
if (window.fetch !== window.__fgGhostFetch && window.__fgOrigFetch)
window.fetch = window.__fgGhostFetch;
}, 3000);
}
})();
''';
}
// ── Theme Detector ───────────────────────────────────────────────────────────
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
static const String _themeDetectorJS = r'''
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
// 1. Check Instagram's specific classes
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
// 2. Check body background color
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}
} catch(_) {}
return 'dark'; // Fallback
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.FocusGramThemeChannel) {
window.FocusGramThemeChannel.postMessage(current);
}
}
}
setInterval(check, 1500);
check();
})();
''';
// ── Reel scroll lock ─────────────────────────────────────────────────────────
/// Prevents swipe-to-next-reel in the isolated DM reel player.
///
/// Lock is active when:
/// `window.__focusgramIsolatedPlayer === true` (DM overlay)
/// OR `window.__focusgramSessionActive === false` (no session)
///
/// Allow-list (these are never blocked):
/// • buttons, anchors, [role=button], aria elements
/// • dialogs, menus, modals, sheets (comment box, emoji picker, share sheet)
/// • keyboard input inside comment / text fields
/// Prevents swipe-to-next-reel in the isolated DM reel player.
///
/// Uses a document-level capture-phase touchmove listener so it fires BEFORE
/// Instagram's scroll container can steal the gesture. The lock is active when
/// `window.__focusgramIsolatedPlayer === true` (single reel from DM),
/// OR `window.__focusgramSessionActive === false` (reels feed, no session).
///
/// The isolated player flag is also maintained here from the path tracker
/// so it works for SPA navigations that don't trigger onPageFinished.
static const String reelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
const ALLOW_SEL = 'button,a,[role="button"],[aria-label],[aria-haspopup],input,textarea,span,h1,h2,h3';
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function isLocked() {
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
return window.__focusgramIsolatedPlayer === true ||
window.__focusgramSessionActive === false ||
isDmReel;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
// Allow vertical swipe if in a session and not on a DM/isolated path
if (window.__focusgramSessionActive === true && !window.location.pathname.includes('/direct/')) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
// Mark the first DM reel as loaded on first swipe attempt
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
// Give the first reel 3.5 s to buffer before activating the DM lock
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) {
window.__fgDmReelAlreadyLoaded = true;
}
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) {
clearTimeout(window.__fgDmReelTimer);
window.__fgDmReelTimer = null;
}
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer =
p.includes('/reel/') && !p.startsWith('/reels/');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
}, 400);
}
})();
''';
// ── Badge Monitor ────────────────────────────────────────────────────────────
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
/// and Notifications icons, as well as the page title. Sends an event to
/// Flutter whenever a new notification is detected.
static const String _badgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
let lastDmCount = 0;
let lastNotifCount = 0;
let lastTitleUnread = 0;
function check() {
try {
// 1. Check Title for (N) indicator
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
// 2. Scan for DM unread badge
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', // New red dot sibling
'a[href*="/direct/inbox/"] ._a9-v', // Modern common red badge class
].join(','));
const currentDmCount = dmBadge ? (parseInt(dmBadge.innerText) || 1) : 0;
// 3. Scan for Notifications unread badge
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [style*="255, 48, 64"]',
'a[href*="/notifications"] [aria-label*="unread"]'
].join(','));
const currentNotifCount = notifBadge ? (parseInt(notifBadge.innerText) || 1) : 0;
if (currentDmCount > lastDmCount) {
window.FocusGramNotificationChannel?.postMessage('DM');
} else if (currentNotifCount > lastNotifCount) {
window.FocusGramNotificationChannel?.postMessage('Activity');
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
window.FocusGramNotificationChannel?.postMessage('Activity');
}
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
// Initial check after some delay to let page settle
setTimeout(check, 2000);
setInterval(check, 3000);
})();
''';
// ── Notification bridge ──────────────────────────────────────────────────────
/// Forwards Web Notification events to the native Flutter channel.
static String get notificationBridgeJS => '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
if (window.FocusGramNotificationChannel)
window.FocusGramNotificationChannel
.postMessage(title + (opts && opts.body ? ': ' + opts.body : ''));
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
// ── Link sanitization ────────────────────────────────────────────────────────
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
/// channel instead.
static const String linkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.FocusGramShareChannel && u) {
window.FocusGramShareChannel.postMessage(
JSON.stringify({ url: u, title: (d && d.title) || '' }));
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';
// ── Main injection builder ───────────────────────────────────────────────────
/// Builds the complete JS payload for a page load or session-state change.
///
/// Injection order matters (later scripts can depend on earlier ones):
/// 1. Session flag — other scripts read `__focusgramSessionActive`
/// 2. Path tracker — writes `body[path]` for CSS page targeting
/// 3. CSS observer — keeps `<style>` alive across SPA navigations
/// 4. Banner dismiss — removes "Open in App" nag
/// 5. Branding — replaces Instagram logo with FocusGram
/// 6. Reels JS blocker — click-interceptor (only when no session)
/// 7. Ghost Mode — network interceptors (fetch / XHR / WS)
/// 8. Link sanitizer — tracking param stripping
static String buildInjectionJS({
required bool sessionActive,
required bool blurExplore,
required bool blurReels,
required bool ghostTyping,
required bool ghostSeen,
required bool ghostStories,
required bool ghostDmPhotos,
required bool enableTextSelection,
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
required bool hideLikeCounts,
required bool hideFollowerCounts,
required bool hideStoriesBar,
required bool hideExploreTab,
required bool hideReelsTab,
required bool hideShopTab,
required bool disableReelsEntirely,
}) {
final css = StringBuffer()..writeln(_globalUIFixesCSS);
if (!enableTextSelection) css.writeln(_disableSelectionCSS);
if (!sessionActive) {
css.writeln(_hideReelsFeedContentCSS);
if (blurReels) css.writeln(_blurReelsCSS);
}
// blurExplore now also blurs home-feed posts ("Blur Posts and Explore")
if (blurExplore) css.writeln(_blurHomeFeedAndExploreCSS);
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
final ghost = buildGhostModeJS(
typingIndicator: ghostTyping,
seenStatus: ghostSeen,
stories: ghostStories,
dmPhotos: ghostDmPhotos,
);
if (!sessionActive) {
// Hide reel feed content when no session active
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);
if (hideStoriesBar) css.writeln(ui_hider.kHideStoriesBarCSS);
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
return '''
${buildSessionStateJS(sessionActive)}
$_trackPathJS
window.__fgDisableReelsEntirely = $disableReelsEntirely;
${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())}
$_dismissAppBannerJS
$_brandingJS
${!sessionActive ? _strictReelsBlockJS : ''}
$reelsMutationObserverJS
$ghost
$linkSanitizationJS
$_themeDetectorJS
$_badgeMonitorJS
${scripts.kDismissAppBannerJS}
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
${scripts.kReelsMutationObserverJS}
${scripts.kLinkSanitizationJS}
${scripts.kThemeDetectorJS}
${scripts.kBadgeMonitorJS}
''';
}
}

View File

@@ -0,0 +1,505 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_manager.dart';
import 'settings_service.dart';
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;
// Core JS and CSS payloads injected into the Instagram WebView.
//
// WARNING: Do not add any network interception logic ("ghost mode") here.
// All scripts in this file must be limited to UI behaviour, navigation helpers,
// and local-only features that do not modify data sent to Meta's servers.
// ── CSS payloads ──────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
const String kGlobalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
const String kBlurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !important;
transition: filter 0.15s ease !important;
}
body[path="/"] article img:hover,
body[path="/"] article video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native (only when disabled).
const String kDisableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
const String kHideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
// ── JavaScript helpers ────────────────────────────────────────────────────────
const String kDismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
const String kStrictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
const String kTrackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('UrlChange', p);
}
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
const String kThemeDetectorJS = r'''
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}
} catch(_) {}
return 'dark';
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current);
}
}
}
setInterval(check, 1500);
check();
})();
''';
const String kReelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
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
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
if (window.__fgDisableReelsEntirely === true) return 'disabled';
return null;
}
function isLocked() { return lockMode() !== null; }
function allowInteractionTarget(t) {
if (!t || !t.closest) return false;
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
if (t.closest(MODAL_SEL)) return true;
return false;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
let __fgOrigHtmlOverflow = null;
let __fgOrigBodyOverflow = null;
function applyOverflowLock() {
try {
const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL);
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
}
document.documentElement.style.overflow = 'hidden';
if (document.body) document.body.style.overflow = 'hidden';
} else if (__fgOrigHtmlOverflow !== null) {
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
__fgOrigHtmlOverflow = null;
__fgOrigBodyOverflow = null;
}
} catch (_) {}
}
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
applyOverflowLock();
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) window.__fgDmReelAlreadyLoaded = true;
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; }
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
applyOverflowLock();
}, 400);
}
})();
''';
const String kBadgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
const startedAt = Date.now();
let initialised = false;
let lastDmCount = 0, lastNotifCount = 0, lastTitleUnread = 0;
function parseBadgeCount(el) {
if (!el) return 0;
try {
const raw = (el.innerText || el.textContent || '').trim();
const n = parseInt(raw, 10);
return isNaN(n) ? 1 : n;
} catch (_) { return 1; }
}
function check() {
try {
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'a[href*="/direct/inbox/"] ._a9-v',
].join(','));
const currentDmCount = parseBadgeCount(dmBadge);
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [aria-label*="unread"]',
].join(','));
const currentNotifCount = parseBadgeCount(notifBadge);
if (!initialised) {
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread; initialised = true; return;
}
if (Date.now() - startedAt < 6000) {
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread; return;
}
if (currentDmCount > lastDmCount && window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
} else if (currentNotifCount > lastNotifCount && window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
setTimeout(check, 2000);
setInterval(check, 1000);
})();
''';
const String kNotificationBridgeJS = '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const startedAt = Date.now();
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
if (Date.now() - startedAt < 6000) return new _N(title, opts);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramNotificationChannel',
title + (opts && opts.body ? ': ' + opts.body : ''),
);
}
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
const String kLinkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.flutter_inappwebview && u) {
window.flutter_inappwebview.callHandler(
'FocusGramShareChannel',
JSON.stringify({ url: u, title: (d && d.title) || '' }),
);
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';
// ── InjectionManager class ─────────────────────────────────────────────────
class InjectionManager {
final InAppWebViewController controller;
final SharedPreferences prefs;
final SessionManager sessionManager;
SettingsService? _settingsService;
InjectionManager({
required this.controller,
required this.prefs,
required this.sessionManager,
});
void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService;
}
/// Runs all post-load JavaScript injections based on current settings.
Future<void> runAllPostLoadInjections(String url) async {
if (_settingsService == null) return;
final settings = _settingsService!;
final sessionActive = sessionManager.isSessionActive;
// Get settings values
final blurExplore = settings.blurExplore;
final enableTextSelection = settings.enableTextSelection;
final hideSuggestedPosts = settings.hideSuggestedPosts;
final hideSponsoredPosts = settings.hideSponsoredPosts;
final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts;
final hideExploreTab = settings.hideExploreTab;
final hideReelsTab = settings.hideReelsTab;
final hideShopTab = settings.hideShopTab;
final disableReelsEntirely = settings.disableReelsEntirely;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive,
blurExplore: blurExplore,
blurReels: false, // Blur reels feature removed
enableTextSelection: enableTextSelection,
hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: hideSponsoredPosts,
hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts,
hideStoriesBar: false, // Story blocking removed
hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely,
);
try {
await controller.evaluateJavascript(source: injectionJS);
} catch (e) {
// Silently handle injection errors
}
// Inject grayscale when active, remove when not active
if (isGrayscaleActive) {
try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) {
// Silently handle injection errors
}
} else {
try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
} catch (e) {
// Silently handle injection errors
}
}
// Inject hide like counts JS when enabled
if (hideLikeCounts) {
try {
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
} catch (e) {
// Silently handle injection errors
}
}
// Inject hide suggested posts JS when enabled
if (hideSuggestedPosts) {
try {
await controller.evaluateJavascript(
source: ui_hider.kHideSuggestedPostsJS,
);
} catch (e) {
// Silently handle injection errors
}
}
// Inject hide sponsored posts JS when enabled
if (hideSponsoredPosts) {
try {
await controller.evaluateJavascript(
source: ui_hider.kHideSponsoredPostsJS,
);
} catch (e) {
// Silently handle injection errors
}
}
// Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) {
try {
await controller.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS,
);
} catch (e) {
// Silently handle injection errors
}
}
}
}

View File

@@ -13,14 +13,18 @@ class NotificationService {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
// Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
);
const InitializationSettings initializationSettings =
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
@@ -32,6 +36,34 @@ class NotificationService {
// Handle notification tap
},
);
// Request permissions after initialization
await _requestIOSPermissions();
await _requestAndroidPermissions();
}
Future<void> _requestIOSPermissions() async {
try {
await _notificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) {
debugPrint('iOS permission request error: $e');
}
}
Future<void> _requestAndroidPermissions() async {
try {
await _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission();
} catch (e) {
debugPrint('Android permission request error: $e');
}
}
Future<void> showNotification({

View File

@@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tracks total in-app screen time per day.
///
/// Storage format (in SharedPreferences, key `screen_time_data`):
/// {
/// "2026-02-26": 3420, // seconds
/// "2026-02-25": 1800
/// }
///
/// All data stays on-device only.
class ScreenTimeService extends ChangeNotifier {
static const String prefKey = 'screen_time_data';
SharedPreferences? _prefs;
Map<String, int> _secondsByDate = {};
Timer? _ticker;
bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_load();
}
void _load() {
final raw = _prefs?.getString(prefKey);
if (raw == null || raw.isEmpty) {
_secondsByDate = {};
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
}
} catch (_) {
_secondsByDate = {};
}
}
Future<void> _save() async {
// Prune entries older than 30 days
final now = DateTime.now();
final cutoff = now.subtract(const Duration(days: 30));
_secondsByDate.removeWhere((key, value) {
try {
final d = DateTime.parse(key);
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
} catch (_) {
return true;
}
});
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
notifyListeners();
}
String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
void startTracking() {
if (_tracking) return;
_tracking = true;
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (!_tracking) return;
final key = _todayKey();
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
// Persist every 10 seconds to reduce writes.
if (_secondsByDate[key]! % 10 == 0) {
_save();
} else {
notifyListeners();
}
});
}
void stopTracking() {
if (!_tracking) return;
_tracking = false;
_save();
}
Future<void> resetAll() async {
_secondsByDate.clear();
await _prefs?.remove(prefKey);
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}

View File

@@ -13,19 +13,37 @@ class SettingsService extends ChangeNotifier {
static const _keyShowInstaSettings = 'set_show_insta_settings';
static const _keyIsFirstRun = 'set_is_first_run';
// Granular Ghost Mode keys
static const _keyGhostTyping = 'set_ghost_typing';
static const _keyGhostSeen = 'set_ghost_seen';
static const _keyGhostStories = 'set_ghost_stories';
static const _keyGhostDmPhotos = 'set_ghost_dm_photos';
// Focus / playback
static const _keyBlockAutoplay = 'block_autoplay';
// Grayscale mode
static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleScheduleEnabled = 'grayscale_schedule_enabled';
static const _keyGrayscaleScheduleTime = 'grayscale_schedule_time';
// Content filtering / UI hiding
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideStoriesBar = 'hide_stories_bar';
static const _keyHideExploreTab = 'hide_explore_tab';
static const _keyHideReelsTab = 'hide_reels_tab';
static const _keyHideShopTab = 'hide_shop_tab';
// Complete section disabling / Minimal mode
static const _keyDisableReelsEntirely = 'disable_reels_entirely';
static const _keyDisableExploreEntirely = 'disable_explore_entirely';
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
// Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms';
static const _keyNotifyActivity = 'set_notify_activity';
// Legacy key for migration
static const _keyGhostModeLegacy = 'set_ghost_mode';
static const _keyNotifySessionEnd = 'set_notify_session_end';
SharedPreferences? _prefs;
@@ -38,16 +56,32 @@ class SettingsService extends ChangeNotifier {
bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme
// Granular Ghost Mode defaults (all on)
bool _ghostTyping = true;
bool _ghostSeen = true;
bool _ghostStories = true;
bool _ghostDmPhotos = true;
bool _blockAutoplay = true;
// Privacy defaults
bool _grayscaleEnabled = false;
bool _grayscaleScheduleEnabled = false;
String _grayscaleScheduleTime = '21:00'; // 9:00 PM default
bool _hideSuggestedPosts = false;
bool _hideSponsoredPosts = false;
bool _hideLikeCounts = false;
bool _hideFollowerCounts = false;
bool _hideStoriesBar = false;
bool _hideExploreTab = false;
bool _hideReelsTab = false;
bool _hideShopTab = false;
bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false;
bool _minimalModeEnabled = false;
bool _reelsHistoryEnabled = true;
// Privacy defaults - notifications OFF by default
bool _sanitizeLinks = true;
bool _notifyDMs = true;
bool _notifyActivity = true;
bool _notifyDMs = false;
bool _notifyActivity = false;
bool _notifySessionEnd = false;
List<String> _enabledTabs = [
'Home',
@@ -68,18 +102,49 @@ class SettingsService extends ChangeNotifier {
List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
// Granular Ghost Mode getters
bool get ghostTyping => _ghostTyping;
bool get ghostSeen => _ghostSeen;
bool get ghostStories => _ghostStories;
bool get ghostDmPhotos => _ghostDmPhotos;
bool get blockAutoplay => _blockAutoplay;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd;
/// True if ANY ghost mode setting is enabled (for injection logic).
bool get anyGhostModeEnabled =>
_ghostTyping || _ghostSeen || _ghostStories || _ghostDmPhotos;
bool get grayscaleEnabled => _grayscaleEnabled;
bool get grayscaleScheduleEnabled => _grayscaleScheduleEnabled;
String get grayscaleScheduleTime => _grayscaleScheduleTime;
bool get hideSuggestedPosts => _hideSuggestedPosts;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideStoriesBar => _hideStoriesBar;
bool get hideExploreTab => _hideExploreTab;
bool get hideReelsTab => _hideReelsTab;
bool get hideShopTab => _hideShopTab;
bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _disableExploreEntirely;
bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
/// True if grayscale should currently be applied, considering the manual
/// toggle and the optional schedule.
bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true;
if (!_grayscaleScheduleEnabled) return false;
try {
final parts = _grayscaleScheduleTime.split(':');
if (parts.length != 2) return false;
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute;
final startMinutes = h * 60 + m;
// Active from the configured time until midnight.
return currentMinutes >= startMinutes;
} catch (_) {
return false;
}
}
// Privacy getters
bool get sanitizeLinks => _sanitizeLinks;
@@ -93,31 +158,34 @@ class SettingsService extends ChangeNotifier {
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
// Migrate legacy ghostMode key -> all granular keys
final legacyGhostMode = _prefs!.getBool(_keyGhostModeLegacy);
if (legacyGhostMode != null) {
// Seed all four granular keys with the legacy value
_ghostTyping = legacyGhostMode;
_ghostSeen = legacyGhostMode;
_ghostStories = legacyGhostMode;
_ghostDmPhotos = legacyGhostMode;
// Save granular keys and remove legacy key
await _prefs!.setBool(_keyGhostTyping, legacyGhostMode);
await _prefs!.setBool(_keyGhostSeen, legacyGhostMode);
await _prefs!.setBool(_keyGhostStories, legacyGhostMode);
await _prefs!.setBool(_keyGhostDmPhotos, legacyGhostMode);
await _prefs!.remove(_keyGhostModeLegacy);
} else {
_ghostTyping = _prefs!.getBool(_keyGhostTyping) ?? true;
_ghostSeen = _prefs!.getBool(_keyGhostSeen) ?? true;
_ghostStories = _prefs!.getBool(_keyGhostStories) ?? true;
_ghostDmPhotos = _prefs!.getBool(_keyGhostDmPhotos) ?? true;
}
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
_grayscaleScheduleEnabled =
_prefs!.getBool(_keyGrayscaleScheduleEnabled) ?? false;
_grayscaleScheduleTime =
_prefs!.getString(_keyGrayscaleScheduleTime) ?? '21:00';
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideStoriesBar = _prefs!.getBool(_keyHideStoriesBar) ?? false;
_hideExploreTab = _prefs!.getBool(_keyHideExploreTab) ?? false;
_hideReelsTab = _prefs!.getBool(_keyHideReelsTab) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
_disableReelsEntirely = _prefs!.getBool(_keyDisableReelsEntirely) ?? false;
_disableExploreEntirely =
_prefs!.getBool(_keyDisableExploreEntirely) ?? false;
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? true;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
_enabledTabs =
(_prefs!.getStringList(_keyEnabledTabs) ??
@@ -179,6 +247,102 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setBlockAutoplay(bool v) async {
_blockAutoplay = v;
await _prefs?.setBool(_keyBlockAutoplay, v);
notifyListeners();
}
Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners();
}
Future<void> setGrayscaleScheduleEnabled(bool v) async {
_grayscaleScheduleEnabled = v;
await _prefs?.setBool(_keyGrayscaleScheduleEnabled, v);
notifyListeners();
}
Future<void> setGrayscaleScheduleTime(String hhmm) async {
_grayscaleScheduleTime = hhmm;
await _prefs?.setString(_keyGrayscaleScheduleTime, hhmm);
notifyListeners();
}
Future<void> setHideSuggestedPosts(bool v) async {
_hideSuggestedPosts = v;
await _prefs?.setBool(_keyHideSuggestedPosts, v);
notifyListeners();
}
Future<void> setHideSponsoredPosts(bool v) async {
_hideSponsoredPosts = v;
await _prefs?.setBool(_keyHideSponsoredPosts, v);
notifyListeners();
}
Future<void> setHideLikeCounts(bool v) async {
_hideLikeCounts = v;
await _prefs?.setBool(_keyHideLikeCounts, v);
notifyListeners();
}
Future<void> setHideFollowerCounts(bool v) async {
_hideFollowerCounts = v;
await _prefs?.setBool(_keyHideFollowerCounts, v);
notifyListeners();
}
Future<void> setHideStoriesBar(bool v) async {
_hideStoriesBar = v;
await _prefs?.setBool(_keyHideStoriesBar, v);
notifyListeners();
}
Future<void> setHideExploreTab(bool v) async {
_hideExploreTab = v;
await _prefs?.setBool(_keyHideExploreTab, v);
notifyListeners();
}
Future<void> setHideReelsTab(bool v) async {
_hideReelsTab = v;
await _prefs?.setBool(_keyHideReelsTab, v);
notifyListeners();
}
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
Future<void> setDisableReelsEntirely(bool v) async {
_disableReelsEntirely = v;
await _prefs?.setBool(_keyDisableReelsEntirely, v);
notifyListeners();
}
Future<void> setDisableExploreEntirely(bool v) async {
_disableExploreEntirely = v;
await _prefs?.setBool(_keyDisableExploreEntirely, v);
notifyListeners();
}
Future<void> setMinimalModeEnabled(bool v) async {
_minimalModeEnabled = v;
await _prefs?.setBool(_keyMinimalModeEnabled, v);
notifyListeners();
}
Future<void> setReelsHistoryEnabled(bool v) async {
_reelsHistoryEnabled = v;
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
notifyListeners();
}
void setDarkMode(bool dark) {
if (_isDarkMode != dark) {
_isDarkMode = dark;
@@ -186,31 +350,6 @@ class SettingsService extends ChangeNotifier {
}
}
// Granular Ghost Mode setters
Future<void> setGhostTyping(bool v) async {
_ghostTyping = v;
await _prefs?.setBool(_keyGhostTyping, v);
notifyListeners();
}
Future<void> setGhostSeen(bool v) async {
_ghostSeen = v;
await _prefs?.setBool(_keyGhostSeen, v);
notifyListeners();
}
Future<void> setGhostStories(bool v) async {
_ghostStories = v;
await _prefs?.setBool(_keyGhostStories, v);
notifyListeners();
}
Future<void> setGhostDmPhotos(bool v) async {
_ghostDmPhotos = v;
await _prefs?.setBool(_keyGhostDmPhotos, v);
notifyListeners();
}
Future<void> setSanitizeLinks(bool v) async {
_sanitizeLinks = v;
await _prefs?.setBool(_keySanitizeLinks, v);
@@ -229,6 +368,12 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v);
notifyListeners();
}
Future<void> toggleTab(String tab) async {
if (_enabledTabs.contains(tab)) {
if (_enabledTabs.length > 1) {