mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-04-05 02:42:28 +02:00
RELEASE: moved from beta to First stable release.
Check CHANGELOG.md for full changelog
This commit is contained in:
532
lib/features/loading/skeleton_screen.dart
Normal file
532
lib/features/loading/skeleton_screen.dart
Normal 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;
|
||||
}
|
||||
167
lib/features/native_nav/native_bottom_nav.dart
Normal file
167
lib/features/native_nav/native_bottom_nav.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
72
lib/features/preloader/instagram_preloader.dart
Normal file
72
lib/features/preloader/instagram_preloader.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
252
lib/features/reels_history/reels_history_screen.dart
Normal file
252
lib/features/reels_history/reels_history_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
117
lib/features/reels_history/reels_history_service.dart
Normal file
117
lib/features/reels_history/reels_history_service.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
307
lib/features/screen_time/screen_time_screen.dart
Normal file
307
lib/features/screen_time/screen_time_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lib/features/update_checker/update_banner.dart
Normal file
130
lib/features/update_checker/update_banner.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
105
lib/features/update_checker/update_checker_service.dart
Normal file
105
lib/features/update_checker/update_checker_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
113
lib/scripts/autoplay_blocker.dart
Normal file
113
lib/scripts/autoplay_blocker.dart
Normal 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
473
lib/scripts/content_disabling.dart
Normal file
473
lib/scripts/content_disabling.dart
Normal 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 });
|
||||
})();
|
||||
''';
|
||||
472
lib/scripts/core_injection.dart
Normal file
472
lib/scripts/core_injection.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
16
lib/scripts/dm_keyboard_fix.dart
Normal file
16
lib/scripts/dm_keyboard_fix.dart
Normal 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 (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
48
lib/scripts/grayscale.dart
Normal file
48
lib/scripts/grayscale.dart
Normal 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
12
lib/scripts/haptic_bridge.dart
Normal file
12
lib/scripts/haptic_bridge.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
|
||||
68
lib/scripts/native_feel.dart
Normal file
68
lib/scripts/native_feel.dart
Normal 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';
|
||||
})();
|
||||
''';
|
||||
118
lib/scripts/reel_metadata_extractor.dart
Normal file
118
lib/scripts/reel_metadata_extractor.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
13
lib/scripts/scroll_smoothing.dart
Normal file
13
lib/scripts/scroll_smoothing.dart
Normal 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
32
lib/scripts/spa_navigation_monitor.dart
Normal file
32
lib/scripts/spa_navigation_monitor.dart
Normal 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
263
lib/scripts/ui_hider.dart
Normal 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 }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
@@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
505
lib/services/injection_manager.dart
Normal file
505
lib/services/injection_manager.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
107
lib/services/screen_time_service.dart
Normal file
107
lib/services/screen_time_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user