What's new

- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
This commit is contained in:
Ujwal
2026-03-04 10:48:14 +05:45
commit 7bb472d212
92 changed files with 14740 additions and 0 deletions
+532
View File
@@ -0,0 +1,532 @@
import 'package:flutter/material.dart';
enum SkeletonType { feed, reels, explore, messages, profile, generic }
class SkeletonScreen extends StatefulWidget {
final SkeletonType skeletonType;
const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic});
@override
State<SkeletonScreen> createState() => _SkeletonScreenState();
}
class _SkeletonScreenState extends State<SkeletonScreen>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(_shimmerController);
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = theme.colorScheme.surfaceContainerHighest.withValues(
alpha: theme.brightness == Brightness.dark ? 0.25 : 0.4,
);
final highlightColor = theme.colorScheme.onSurface.withValues(alpha: 0.08);
return Container(
color: theme.scaffoldBackgroundColor,
width: double.infinity,
height: double.infinity,
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: const [0.1, 0.3, 0.6],
transform: _SlidingGradientTransform(
slidePercent: _shimmerAnimation.value,
),
).createShader(rect);
},
blendMode: BlendMode.srcATop,
child: _buildSkeletonContent(context),
);
},
),
);
}
Widget _buildSkeletonContent(BuildContext context) {
switch (widget.skeletonType) {
case SkeletonType.feed:
return _buildFeedSkeleton(context);
case SkeletonType.reels:
return _buildReelsSkeleton(context);
case SkeletonType.explore:
return _buildExploreSkeleton(context);
case SkeletonType.messages:
return _buildMessagesSkeleton(context);
case SkeletonType.profile:
return _buildProfileSkeleton(context);
case SkeletonType.generic:
return _buildGenericSkeleton(context);
}
}
Widget _buildFeedSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 6,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(height: 4),
Container(
width: 32,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 0),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(width: 8),
Container(
width: 80,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
Container(
width: double.infinity,
height: width,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: List.generate(
3,
(i) => Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
),
),
],
);
}
Widget _buildReelsSkeleton(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 24),
Container(
width: 150,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 12),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
}
Widget _buildExploreSkeleton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(2),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 15,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
);
}
Widget _buildMessagesSkeleton(BuildContext context) {
return ListView.builder(
itemCount: 8,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: 150,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildProfileSkeleton(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 24),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(48),
),
),
const SizedBox(height: 16),
Container(
width: 120,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
3,
(index) => Column(
children: [
Container(
width: 40,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
const SizedBox(height: 24),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 9,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
],
),
);
}
Widget _buildGenericSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
),
const SizedBox(height: 8),
Container(
width: 40,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: width * 0.4,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.25,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: width * 1.1,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 12),
Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.5,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
},
),
),
],
);
}
}
class _SlidingGradientTransform extends GradientTransform {
final double slidePercent;
const _SlidingGradientTransform({required this.slidePercent});
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}
SkeletonType getSkeletonTypeFromUrl(String url) {
final parsed = Uri.tryParse(url);
if (parsed == null) return SkeletonType.generic;
final path = parsed.path.toLowerCase();
if (path.startsWith('/reels') || path.startsWith('/reel/')) {
return SkeletonType.reels;
} else if (path.startsWith('/explore')) {
return SkeletonType.explore;
} else if (path.startsWith('/messages') || path.startsWith('/inbox')) {
return SkeletonType.messages;
} else if (path.startsWith('/') && !path.startsWith('/accounts')) {
if (path.split('/').length <= 2) {
return SkeletonType.feed;
}
return SkeletonType.profile;
}
return SkeletonType.generic;
}
@@ -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,
});
}
@@ -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;
}
}
@@ -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,
),
);
}
@@ -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));
}
}
@@ -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();
}
}
}
@@ -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();
}
}
@@ -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;
}
}
+167
View File
@@ -0,0 +1,167 @@
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();
// Lock to portrait
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
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(
MultiProvider(
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 {
const FocusGramApp({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return MaterialApp(
title: 'FocusGram',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: isDark ? Brightness.dark : Brightness.light,
colorScheme: isDark
? ColorScheme.dark(
primary: Colors.blue.shade400,
surface: Colors.black,
)
: ColorScheme.light(primary: Colors.blue),
scaffoldBackgroundColor: isDark ? Colors.black : Colors.white,
useMaterial3: true,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
home: const InitialRouteHandler(),
);
}
}
/// Flow on every cold open:
/// 1. Onboarding (if first run)
/// 2. Cooldown Gate (if app-open cooldown active)
/// 3. Breath Gate (if enabled in settings)
/// 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});
@override
State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
}
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
bool _breathCompleted = false;
bool _appSessionStarted = false;
bool _onboardingCompleted = false;
late AppLinks _appLinks;
@override
void initState() {
super.initState();
_appLinks = AppLinks();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
// 1. Handle background links while app is running
_appLinks.uriLinkStream.listen((uri) {
debugPrint('Incoming Deep Link: $uri');
FocusGramRouter.pendingUrl.value = uri.toString();
});
// 2. Handle the initial link that opened the app
final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) {
debugPrint('Initial Deep Link: $initialUri');
FocusGramRouter.pendingUrl.value = initialUri.toString();
}
}
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
// Step 1: Onboarding
if (settings.isFirstRun && !_onboardingCompleted) {
return OnboardingPage(
onFinish: () => setState(() => _onboardingCompleted = true),
);
}
// Step 2: Cooldown gate — if too soon since last session
if (sm.isAppOpenCooldownActive) {
return const CooldownGateScreen();
}
// Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
onFinish: () => setState(() => _breathCompleted = true),
);
}
// Step 4: App session picker / resume existing session
if (!_appSessionStarted) {
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
return const MainWebViewPage();
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
/// Shown on every cold app open. Asks the user how long they plan to use
/// Instagram today. Uses an iOS-style scroll picker (ListWheelScrollView).
class AppSessionPickerScreen extends StatefulWidget {
final VoidCallback onSessionStarted;
const AppSessionPickerScreen({super.key, required this.onSessionStarted});
@override
State<AppSessionPickerScreen> createState() => _AppSessionPickerScreenState();
}
class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
static final List<int> _minuteOptions = [
5,
10,
15,
20,
25,
30,
35,
40,
45,
50,
55,
60,
];
int _selectedIndex = 2; // default: 15 min
@override
Widget build(BuildContext context) {
final selectedMinutes = _minuteOptions[_selectedIndex];
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.blue.withValues(alpha: 0.4),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.timer_outlined,
color: Colors.white,
size: 36,
),
),
const SizedBox(height: 28),
const Text(
'Set Your Intention',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 10),
const Text(
'How long do you plan to use\nInstagram right now?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white54,
fontSize: 15,
height: 1.5,
),
),
const Spacer(flex: 1),
// iOS-style scroll picker
SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
// Selection highlight
Container(
height: 50,
margin: const EdgeInsets.symmetric(horizontal: 0),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
width: 1,
),
),
),
ListWheelScrollView.useDelegate(
itemExtent: 50,
physics: const FixedExtentScrollPhysics(),
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
return Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '${entry.value}',
style: TextStyle(
fontSize: isSelected ? 28 : 22,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w300,
color: isSelected
? Colors.white
: Colors.white38,
),
),
TextSpan(
text: ' min',
style: TextStyle(
fontSize: isSelected ? 16 : 14,
color: isSelected
? Colors.white70
: Colors.white24,
),
),
],
),
),
);
}).toList(),
),
),
],
),
),
const Spacer(flex: 1),
// Confirm button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: () => _confirm(context, selectedMinutes),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: Text(
'Start $selectedMinutes-Minute Session',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
const Text(
'You\'ll be prompted to close the app when your time is up.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white24, fontSize: 12),
),
const Spacer(flex: 1),
],
),
),
),
);
}
void _confirm(BuildContext context, int minutes) {
context.read<SessionManager>().startAppSession(minutes);
widget.onSessionStarted();
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 10-second breath.
class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish;
const BreathGateScreen({super.key, required this.onFinish});
@override
State<BreathGateScreen> createState() => _BreathGateScreenState();
}
class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _secondsRemaining = 10;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 1.5,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
_controller.repeat(reverse: true);
_startCountdown();
}
void _startCountdown() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_secondsRemaining > 0) {
setState(() => _secondsRemaining--);
} else {
setState(() {
_canContinue = true;
_timer?.cancel();
});
}
});
}
@override
void dispose() {
_controller.dispose();
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Are you sure you want to open FocusGram?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w300,
),
),
const SizedBox(height: 80),
// Animated Breath Circle
ScaleTransition(
scale: _scaleAnimation,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.blue.withValues(alpha: 0.3),
blurRadius: 30,
spreadRadius: 10,
),
],
gradient: const RadialGradient(
colors: [Colors.blue, Colors.black],
),
),
),
),
const SizedBox(height: 80),
Text(
_canContinue
? 'Breathed.'
: 'Take a deep breath for $_secondsRemaining seconds...',
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _canContinue ? widget.onFinish : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
disabledBackgroundColor: Colors.white10,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
),
child: const Text('Continue to FocusGram'),
),
),
],
),
),
),
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
/// Blocking screen shown when the user tries to reopen the app too soon
/// after their last session ended. Shows a countdown and a motivational quote.
class CooldownGateScreen extends StatefulWidget {
const CooldownGateScreen({super.key});
@override
State<CooldownGateScreen> createState() => _CooldownGateScreenState();
}
class _CooldownGateScreenState extends State<CooldownGateScreen> {
Timer? _timer;
static const List<String> _quotes = [
'"The discipline you show offline\nshapes the clarity you experience online."',
'"Every moment away from the screen\nis a moment given back to yourself."',
'"Boredom is the birthplace of creativity.\nLet it breathe."',
'"Your attention is your most valuable asset.\nSpend it wisely."',
'"Presence is a gift you give yourself first."',
'"Rest is not wasted time.\nIt is the foundation of focused action."',
];
late final String _quote;
@override
void initState() {
super.initState();
_quote = _quotes[DateTime.now().second % _quotes.length];
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final remaining = sm.appOpenCooldownRemainingSeconds;
final minutes = remaining ~/ 60;
final seconds = remaining % 60;
// If cooldown expired, pop this gate
if (!sm.isAppOpenCooldownActive) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.orange.withValues(alpha: 0.12),
border: Border.all(
color: Colors.orangeAccent.withValues(alpha: 0.4),
width: 1.5,
),
),
child: const Icon(
Icons.hourglass_top_rounded,
color: Colors.orangeAccent,
size: 38,
),
),
const SizedBox(height: 32),
const Text(
'Take a Break',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 12),
const Text(
'Your session has ended.\nCome back when the timer expires.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white54,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 48),
// Countdown
Container(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 20,
),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.orangeAccent.withValues(alpha: 0.25),
width: 1,
),
),
child: Column(
children: [
const Text(
'Return in',
style: TextStyle(
color: Colors.white38,
fontSize: 13,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 52,
fontWeight: FontWeight.w200,
letterSpacing: 4,
),
),
],
),
),
const SizedBox(height: 60),
// Quote
Text(
_quote,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white30,
fontSize: 13,
height: 1.7,
fontStyle: FontStyle.italic,
),
),
],
),
),
),
),
),
);
}
}
+373
View File
@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart';
class GuardrailsPage extends StatefulWidget {
const GuardrailsPage({super.key});
@override
State<GuardrailsPage> createState() => _GuardrailsPageState();
}
class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> _handleScheduleAction(
BuildContext context,
SessionManager sm,
Future<void> Function() action,
) async {
if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35);
if (!context.mounted || !ok) return;
}
await action();
}
Future<void> _pickNewSchedule(BuildContext context, SessionManager sm) async {
final start = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 22, minute: 0),
helpText: 'Select Start Time',
);
if (!context.mounted || start == null) return;
final end = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 7, minute: 0),
helpText: 'Select End Time',
);
if (!context.mounted || end == null) return;
await sm.addSchedule(
FocusSchedule(
startHour: start.hour,
startMinute: start.minute,
endHour: end.hour,
endMinute: end.minute,
),
);
}
Future<void> _editExistingSchedule(
BuildContext context,
SessionManager sm,
int index,
FocusSchedule s,
) async {
final start = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: s.startHour, minute: s.startMinute),
helpText: 'Edit Start Time',
);
if (!context.mounted || start == null) return;
final end = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: s.endHour, minute: s.endMinute),
helpText: 'Edit End Time',
);
if (!context.mounted || end == null) return;
await sm.updateScheduleAt(
index,
FocusSchedule(
startHour: start.hour,
startMinute: start.minute,
endHour: end.hour,
endMinute: end.minute,
),
);
}
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text(
'Guardrails',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Set your limits to stay focused. Changes to these settings require a challenge.',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
),
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Daily Reel Limit',
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
min: 5,
max: 120,
divisor: 5,
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()),
),
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Session Cooldown',
subtitle: '${sm.cooldownSeconds ~/ 60} min between sessions',
value: (sm.cooldownSeconds ~/ 60).toDouble(),
min: 5,
max: 180,
divisor: 5,
isMorePermissive: (v) => v < (sm.cooldownSeconds ~/ 60),
warningText:
'Reducing cooldown makes it easier to start new sessions. Are you sure?',
onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()),
),
Divider(color: isDark ? Colors.white10 : Colors.black12, height: 32),
SwitchListTile(
title: const Text('Scheduled Blocking'),
subtitle: Text(
'Block Instagram during specific hours',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
value: sm.scheduleEnabled,
onChanged: (v) => sm.setScheduleEnabled(v),
),
if (sm.scheduleEnabled) ...[
...sm.schedules.asMap().entries.map((entry) {
final idx = entry.key;
final s = entry.value;
return ListTile(
title: Text(
'Schedule ${idx + 1}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${sm.formatTime12h(s.startHour, s.startMinute)} - ${sm.formatTime12h(s.endHour, s.endMinute)}',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
size: 20,
),
onPressed: () => _handleScheduleAction(
context,
sm,
() => _editExistingSchedule(context, sm, idx, s),
),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
size: 20,
),
onPressed: () => _handleScheduleAction(
context,
sm,
() => sm.removeScheduleAt(idx),
),
),
],
),
);
}),
ListTile(
leading: const Icon(
Icons.add_circle_outline,
color: Colors.blueAccent,
),
title: const Text(
'Add Focus Hours',
style: TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.w600,
),
),
onTap: () => _handleScheduleAction(
context,
sm,
() => _pickNewSchedule(context, sm),
),
),
],
],
),
);
}
Widget _buildFrictionSliderTile({
required BuildContext context,
required SessionManager sm,
required String title,
required String subtitle,
required double value,
required double min,
required double max,
required int divisor,
required bool Function(double) isMorePermissive,
required String warningText,
required Future<void> Function(double) onConfirmed,
}) {
return _FrictionSliderTile(
title: title,
subtitle: subtitle,
value: value,
min: min,
max: max,
divisor: divisor,
isMorePermissive: isMorePermissive,
warningText: warningText,
onConfirmed: onConfirmed,
);
}
}
class _FrictionSliderTile extends StatefulWidget {
final String title;
final String subtitle;
final double value;
final double min;
final double max;
final int divisor;
final bool Function(double) isMorePermissive;
final String warningText;
final Future<void> Function(double) onConfirmed;
const _FrictionSliderTile({
required this.title,
required this.subtitle,
required this.value,
required this.min,
required this.max,
required this.divisor,
required this.isMorePermissive,
required this.warningText,
required this.onConfirmed,
});
@override
State<_FrictionSliderTile> createState() => _FrictionSliderTileState();
}
class _FrictionSliderTileState extends State<_FrictionSliderTile> {
late double _draftValue;
bool _pendingConfirm = false;
@override
void initState() {
super.initState();
_draftValue = widget.value;
}
@override
void didUpdateWidget(_FrictionSliderTile old) {
super.didUpdateWidget(old);
if (!_pendingConfirm) _draftValue = widget.value;
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final divisions = ((widget.max - widget.min) / widget.divisor).round();
return Column(
children: [
ListTile(
title: Text(widget.title),
subtitle: Text(
'${_draftValue.toInt()} min',
style: TextStyle(color: isDark ? Colors.white70 : Colors.black54),
),
trailing: _pendingConfirm
? Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() {
_draftValue = widget.value;
_pendingConfirm = false;
});
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final sm = context.read<SessionManager>();
int wordCount = 15;
// If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) {
wordCount = 35;
}
final success = await DisciplineChallenge.show(
context,
count: wordCount,
);
if (!context.mounted || !success) return;
await widget.onConfirmed(_draftValue);
setState(() => _pendingConfirm = false);
},
child: const Text('Apply'),
),
],
)
: null,
),
if (_pendingConfirm)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
widget.warningText,
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
),
),
Slider(
value: _draftValue,
min: widget.min,
max: widget.max,
divisions: divisions,
onChanged: (v) {
setState(() {
_draftValue = v;
_pendingConfirm = widget.isMorePermissive(v);
});
},
onChangeEnd: (v) {
if (!widget.isMorePermissive(v)) {
widget.onConfirmed(v);
setState(() => _pendingConfirm = false);
}
},
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:app_settings/app_settings.dart';
import '../services/settings_service.dart';
import '../services/notification_service.dart';
class OnboardingPage extends StatefulWidget {
final VoidCallback onFinish;
const OnboardingPage({super.key, required this.onFinish});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
// 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(
children: [
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _kTotalPages,
itemBuilder: (context, index) => slides[index],
),
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Column(
children: [
// Dot indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_kTotalPages,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 12 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Colors.blue
: Colors.white24,
borderRadius: BorderRadius.circular(4),
),
),
),
),
const SizedBox(height: 32),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(
width: double.infinity,
height: 56,
child: Builder(
builder: (context) {
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 (isLink) {
await AppSettings.openAppSettings(
type: AppSettingsType.settings,
);
} else if (isNotif) {
await Permission.notification.request();
await NotificationService().init();
}
if (!context.mounted) return;
if (isLast) {
_finish(context);
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
),
// 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),
),
),
],
),
),
],
),
);
}
void _finish(BuildContext context) {
context.read<SettingsService>().setFirstRunCompleted();
widget.onFinish();
}
}
// ── 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;
const _StaticSlide({
required this.icon,
required this.color,
required this.title,
required this.description,
this.isPermissionPage = false,
this.isAppSettingsPage = false,
this.permission,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 120, color: color),
const SizedBox(height: 48),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 18,
height: 1.5,
),
),
],
),
);
}
}
// ── 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,
),
],
),
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../services/injection_controller.dart';
import '../services/session_manager.dart';
import 'package:provider/provider.dart';
/// An isolated player for a single Reel opened from a DM.
/// Uses JS history interception to lock the user to the initial reel URL.
class ReelPlayerOverlay extends StatefulWidget {
final String url;
const ReelPlayerOverlay({super.key, required this.url});
@override
State<ReelPlayerOverlay> createState() => _ReelPlayerOverlayState();
}
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
DateTime? _startTime;
@override
void initState() {
super.initState();
_startTime = DateTime.now();
}
@override
void dispose() {
// Record viewing time toward daily count
if (_startTime != null) {
final durationSeconds = DateTime.now().difference(_startTime!).inSeconds;
if (mounted) {
context.read<SessionManager>().accrueSeconds(durationSeconds);
}
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Reel',
style: TextStyle(color: Colors.white, fontSize: 16),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orangeAccent, width: 0.5),
),
child: const Text(
'Locked',
style: TextStyle(color: Colors.orangeAccent, fontSize: 11),
),
),
),
],
),
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,
tapToUnblur: false,
enableTextSelection: true,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
// hideStoriesBar removed per user request
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;
},
),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget {
const SessionModal({super.key});
@override
State<SessionModal> createState() => _SessionModalState();
}
class _SessionModalState extends State<SessionModal> {
double _customMinutes = 5.0;
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
return Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Color(0xFF121212),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Start Reel Session',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, color: Colors.white54),
),
],
),
const SizedBox(height: 8),
Text(
'Remaining Daily: ${sm.dailyRemainingSeconds ~/ 60}m',
style: const TextStyle(color: Colors.white70),
),
if (sm.isCooldownActive)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Cooldown active: ${sm.cooldownRemainingSeconds ~/ 60}m ${sm.cooldownRemainingSeconds % 60}s left',
style: const TextStyle(color: Colors.orangeAccent),
),
),
const SizedBox(height: 24),
const Text(
'Presets',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [1, 5, 10, 15].map((m) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(m),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
),
),
);
}).toList(),
),
const SizedBox(height: 32),
const Text(
'Custom Duration',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
Slider(
value: _customMinutes,
min: 1,
max: 30,
divisions: 29,
label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(_customMinutes.toInt()),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Start Session',
style: TextStyle(fontSize: 16),
),
),
),
const SizedBox(height: 16),
],
),
);
}
void _start(int minutes) async {
final sm = context.read<SessionManager>();
// Always require word challenge for reel sessions (User request)
final success = await DisciplineChallenge.show(context);
if (!success) return;
if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context);
}
}
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
/// JavaScript to block autoplaying videos on Instagram feed/explore while:
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF
/// - Allowing user-initiated playback on click when blocking is ON
/// - NEVER blocking reels (they should always play normally per user request)
///
/// This script:
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore).
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
/// - Uses a per-element flag set by user clicks to permanently allow that video to play.
/// - Strips the autoplay attribute from dynamically added <video> elements.
/// - Respects session state - allows autoplay when session is active.
/// - NEVER blocks reels - they always play normally.
/// - Once a video is explicitly played by user, it plays fully without interruption.
const String kAutoplayBlockerJS = r'''
(function fgAutoplayBlocker() {
if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true;
// Default to blocking autoplay if not set
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false;
// Session state - set by FocusGram when session is active
// window.__focusgramSessionActive = true/false
// Helper to check if this is a reel video (should NEVER be blocked)
function isReelVideo() {
try {
const url = window.location.href || '';
// Check if we're on a reel page
if (url.includes('/reels/') || url.includes('/reel/')) {
return true;
}
return false;
} catch (_) {
return false;
}
}
// Helper to check if we should allow autoplay
function shouldBlockAutoplay() {
// If we're on reels page, never block
if (isReelVideo()) return false;
// If autoplay setting is false, don't block at all
if (window.__fgBlockAutoplay === false) return false;
// If session is active, don't block autoplay (allow all videos)
if (window.__focusgramSessionActive === true) return false;
// Otherwise block autoplay for feed/explore videos
return true;
}
// Key to mark a video as explicitly started by user (permanent for that video instance)
const ALLOW_KEY = '__fgUserExplicitlyPlayed';
// Mark video as allowed permanently once user explicitly plays it
function markAllow(video) {
try {
video[ALLOW_KEY] = true;
} catch (_) {}
}
// Check if user has explicitly played this video
function shouldAllow(video) {
try {
return video[ALLOW_KEY] === true;
} catch (_) {
return false;
}
}
// Pause video and strip autoplay attribute (for blocked autoplay videos)
function pauseAndFreezeVideo(video) {
try {
// Remove autoplay attribute completely
video.removeAttribute('autoplay');
try { video.autoplay = false; } catch (_) {}
// Pause the video
video.pause();
// Reset to beginning
video.currentTime = 0;
} catch (_) {}
}
// Store original play and pause
const _origPlay = HTMLVideoElement.prototype.play;
const _origPause = HTMLVideoElement.prototype.pause;
// Override play method
if (HTMLVideoElement.prototype.play) {
HTMLVideoElement.prototype.play = function() {
try {
// NEVER block reels - they always play normally
if (isReelVideo()) {
return _origPlay.apply(this, arguments);
}
// Check if we should block based on both settings and session
if (!shouldBlockAutoplay()) {
// Autoplay is OFF or session is active - allow all playback
return _origPlay.apply(this, arguments);
}
// If user has explicitly played this video before, allow it to continue
if (shouldAllow(this)) {
return _origPlay.apply(this, arguments);
}
// Block autoplay: pause immediately and return resolved promise
pauseAndFreezeVideo(this);
return Promise.resolve();
} catch (_) {
// Fall back to original behaviour
try {
return _origPlay.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
}
};
}
// Override pause method to work normally
if (HTMLVideoElement.prototype.pause) {
HTMLVideoElement.prototype.pause = function() {
try {
return _origPause.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
};
}
// Additional safeguard for dynamically created videos
try {
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('video').forEach(function(v) {
if (v.play) {
const originalPlay = v.play;
v.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
}
});
});
} catch (_) {}
// Also handle videos that might be created after DOMContentLoaded
try {
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.apply(this, arguments);
if (tagName.toLowerCase() === 'video') {
// Intercept the play method on dynamically created videos
const originalPlay = element.play;
if (originalPlay) {
element.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
}
}
return element;
};
} catch (_) {}
// Mark video as allowed on user interaction (click/tap) - permanent for that video
document.addEventListener('click', function(e) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
// Mark this specific video as user-initiated - permanent
markAllow(video);
// Try to play the video if it was previously blocked
if (shouldBlockAutoplay() && !shouldAllow(video)) {
// Video will be allowed now, try to play
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
document.addEventListener('touchstart', function(e) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
markAllow(video);
if (shouldBlockAutoplay() && !shouldAllow(video)) {
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
// Also handle play events directly (for Instagram's internal play buttons)
document.addEventListener('play', function(e) {
if (e.target && e.target.tagName === 'VIDEO') {
markAllow(e.target);
}
}, true);
})();
''';
+611
View File
@@ -0,0 +1,611 @@
// 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 }
);
}
})();
''';
/// Robust stories overlay - blocks clicking and applies blur when hide stories is enabled.
/// This is a more aggressive approach that places an overlay with blur on top of stories area.
const String kStoriesOverlayJS = r'''
(function() {
if (window.__fgStoriesOverlayRunning) return;
window.__fgStoriesOverlayRunning = true;
const BLOCKED_ATTR = 'data-fg-stories-blocked';
function buildOverlay(container) {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position: absolute',
'inset: 0',
'z-index: 99998',
'display: flex',
'align-items: center',
'justify-content: center',
'background: rgba(0, 0, 0, 0.6)',
'backdrop-filter: blur(10px)',
'-webkit-backdrop-filter: blur(10px)',
'border-radius: 8px',
'pointer-events: all',
'cursor: not-allowed',
].join(';');
const label = document.createElement('span');
label.textContent = 'Stories blocked';
label.style.cssText = [
'color: rgba(255, 255, 255, 0.8)',
'font-size: 12px',
'font-weight: 600',
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
'text-align: center',
'padding: 8px 16px',
'background: rgba(0, 0, 0, 0.5)',
'border-radius: 20px',
].join(';');
div.appendChild(label);
// Swallow all interaction
['click', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'mouseenter'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayStoriesContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return;
// Check if this looks like a stories container
const hasStories = container.querySelector('canvas, [style*="border-radius: 50%"], [aria-label*="story"], [role="list"]');
if (!hasStories) return;
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay(container));
}
function findAndOverlayStories() {
try {
// Method 1: Find by role="list" with story-related aria-label
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
overlayStoriesContainer(el.parentElement);
}
} catch(_) {}
});
// Method 2: Find horizontal scroll containers at top of feed
document.querySelectorAll('header + div > div, main > div > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if ((style.overflowX === 'scroll' || style.overflowX === 'auto') &&
(style.display === 'flex' || style.display === '')) {
const children = el.children;
let hasAvatar = false;
for (let i = 0; i < Math.min(children.length, 10); i++) {
const child = children[i];
const childStyle = window.getComputedStyle(child);
if (childStyle.width === '60px' || childStyle.width === '66px' ||
child.querySelector('canvas, [style*="border-radius: 50%"]')) {
hasAvatar = true;
break;
}
}
if (hasAvatar) {
overlayStoriesContainer(el);
}
}
} catch(_) {}
});
// Method 3: Find story avatars directly
document.querySelectorAll('[href*="/stories/"], [aria-label*="Your Story"]').forEach(function(el) {
try {
let container = el.parentElement;
for (let i = 0; i < 5 && container; i++) {
const style = window.getComputedStyle(container);
if (style.position !== 'static' && container.children.length < 20) {
overlayStoriesContainer(container);
break;
}
container = container.parentElement;
}
} catch(_) {}
});
} catch(_) {}
}
// Initial run
findAndOverlayStories();
// Watch for dynamic changes
let _overlayTimer = null;
new MutationObserver(function() {
clearTimeout(_overlayTimer);
_overlayTimer = setTimeout(findAndOverlayStories, 500);
}).observe(document.documentElement, { childList: true, subtree: true });
// Also run on scroll
let _scrollTimer = null;
window.addEventListener('scroll', function() {
clearTimeout(_scrollTimer);
_scrollTimer = setTimeout(findAndOverlayStories, 300);
}, { passive: 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 });
})();
''';
+570
View File
@@ -0,0 +1,570 @@
// 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;
}
/* Per-post unblur override (set by kTapToUnblurJS) */
[data-fg-unblurred="1"] img,
[data-fg-unblurred="1"] video {
filter: none !important;
-webkit-filter: none !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;
}
''';
/// Allows users to unblur blurred media by tapping it.
///
/// Behaviour:
/// - Only active when `window.__fgTapToUnblur === true`.
/// - Only applies on Home feed (`/`) and Explore (`/explore*`) where FocusGram blurs.
/// - First tap unblurs the post media and swallows the click (prevents opening).
/// - Subsequent taps behave normally (Instagram opens the post as usual).
const String kTapToUnblurJS = r'''
(function fgTapToUnblur() {
if (window.__fgTapToUnblurPatched) return;
window.__fgTapToUnblurPatched = true;
function isBlurContext() {
try {
const p = (document.body && document.body.getAttribute('path')) || window.location.pathname || '';
return p === '/' || p.indexOf('/explore') === 0;
} catch (_) {
return false;
}
}
function findMediaFromTarget(t) {
try {
if (!t) return null;
if (t.closest) {
const direct = t.closest('img,video');
if (direct) return direct;
}
// Walk up a few levels and look for a media element inside.
let n = t;
for (let i = 0; i < 6 && n; i++) {
if (n.querySelector) {
const m = n.querySelector('img,video');
if (m) return m;
}
n = n.parentElement;
}
} catch (_) {}
return null;
}
function getHost(media) {
try {
return media.closest('article') || media.closest('a') || media.parentElement;
} catch (_) {
return null;
}
}
function markUnblurred(host) {
try {
host.setAttribute('data-fg-unblurred', '1');
} catch (_) {}
}
function isUnblurred(host) {
try {
return host && host.getAttribute && host.getAttribute('data-fg-unblurred') === '1';
} catch (_) {
return false;
}
}
function unblurMedia(media) {
try {
media.style.setProperty('filter', 'none', 'important');
media.style.setProperty('-webkit-filter', 'none', 'important');
} catch (_) {}
}
document.addEventListener('click', function(e) {
try {
if (window.__fgTapToUnblur !== true) return;
if (!isBlurContext()) return;
const media = findMediaFromTarget(e.target);
if (!media) return;
const host = getHost(media);
if (!host) return;
if (isUnblurred(host)) return; // allow normal Instagram behaviour
// First tap: unblur and swallow click so it doesn't open the post.
markUnblurred(host);
unblurMedia(media);
if (e.cancelable) e.preventDefault();
e.stopPropagation();
} catch (_) {}
}, true);
})();
''';
// ── 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 with reel present
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
// Only lock scroll when reel element is actually present on the page
if (window.__fgDisableReelsEntirely === true &&
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
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
View File
@@ -0,0 +1,16 @@
/// JS to help Instagram's layout detect viewport changes when the Android
/// soft keyboard appears in a WebView container.
///
/// It listens for resize events and re-dispatches an `orientationchange`
/// event, which nudges Instagram's layout system out of the DM loading
/// spinner state.
const String kDmKeyboardFixJS = r'''
// Fix: tell Instagram's layout system the viewport has changed after keyboard events
// This resolves the loading state that appears on DM screens in WebView
window.addEventListener('resize', function() {
try {
window.dispatchEvent(new Event('orientationchange'));
} catch (_) {}
});
''';
+48
View File
@@ -0,0 +1,48 @@
/// Grayscale style injector.
/// Uses a <style> tag with !important so Instagram's CSS cannot override it.
const String kGrayscaleJS = r'''
(function fgGrayscale() {
try {
const ID = 'fg-grayscale';
function inject() {
let el = document.getElementById(ID);
if (!el) {
el = document.createElement('style');
el.id = ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = 'html { filter: grayscale(100%) !important; }';
}
inject();
if (!window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver = new MutationObserver(() => {
if (!document.getElementById('fg-grayscale')) inject();
});
window.__fgGrayscaleObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
} catch (_) {}
})();
''';
/// Removes grayscale AND disconnects the observer so it cannot re-add it.
/// Previously kGrayscaleOffJS only removed the style tag — the observer
/// immediately re-injected it, requiring an app restart to actually go off.
const String kGrayscaleOffJS = r'''
(function() {
try {
// 1. Disconnect the observer FIRST so it cannot react to the removal
if (window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver.disconnect();
window.__fgGrayscaleObserver = null;
}
// 2. Remove the style tag
const el = document.getElementById('fg-grayscale');
if (el) el.remove();
// 3. Clear any inline filter that may have been set by older code
document.documentElement.style.filter = '';
} catch (_) {}
})();
''';
+12
View File
@@ -0,0 +1,12 @@
const String kHapticBridgeScript = '''
(function() {
// Trigger native haptic feedback on double-tap (like gesture on posts)
// Uses flutter_inappwebview's callHandler instead of postMessage
document.addEventListener('dblclick', function(e) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('Haptic', 'light');
}
}, true);
})();
''';
+68
View File
@@ -0,0 +1,68 @@
// Document-start script — injected before Instagram's JS loads.
const String kNativeFeelingScript = '''
(function() {
const style = document.createElement('style');
style.id = 'fg-native-feel';
style.textContent = `
/* Hide all scrollbars */
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
*::-webkit-scrollbar {
display: none !important;
}
/* Remove blue tap highlight */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Disable text selection globally except inputs */
* {
-webkit-user-select: none !important;
user-select: none !important;
}
input, textarea, [contenteditable="true"] {
-webkit-user-select: text !important;
user-select: text !important;
}
/* Momentum scrolling */
* {
-webkit-overflow-scrolling: touch !important;
}
/* Remove focus outlines */
*:focus, *:focus-visible {
outline: none !important;
}
/* Fade images in */
img {
animation: igFadeIn 0.15s ease-in-out;
}
@keyframes igFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`;
if (document.head) {
document.head.appendChild(style);
} else {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(style);
});
}
})();
''';
// Post-load script — call in onLoadStop only.
// IMPORTANT: Do NOT add overscroll-behavior rules here — they lock the feed scroll.
const String kNativeFeelingPostLoadScript = '''
(function() {
// Smooth anchor scrolling only — do NOT apply to all containers.
document.documentElement.style.scrollBehavior = 'auto';
})();
''';
+118
View File
@@ -0,0 +1,118 @@
// Reel metadata extraction for history feature.
// Extracts title and thumbnail URL from the page and sends to Flutter.
const String kReelMetadataExtractorScript = r'''
(function() {
// Track if we've already extracted for this URL to avoid duplicates
window.__fgReelExtracted = window.__fgReelExtracted || false;
window.__fgLastExtractedUrl = window.__fgLastExtractedUrl || '';
function extractAndSend() {
const currentUrl = window.location.href;
// Skip if already extracted for this URL
if (window.__fgReelExtracted && window.__fgLastExtractedUrl === currentUrl) {
return;
}
// Check if this is a reel page
if (!currentUrl.includes('/reel/')) {
return;
}
// Try multiple sources for metadata
let title = '';
let thumbnailUrl = '';
// 1. Try Open Graph tags
const ogTitle = document.querySelector('meta[property="og:title"]');
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogTitle) title = ogTitle.content;
if (ogImage) thumbnailUrl = ogImage.content;
// 2. Fallback to document title if no OG title
if (!title && document.title) {
title = document.title.replace(' on Instagram', '').trim();
if (!title) title = 'Instagram Reel';
}
// 3. Try JSON-LD structured data
if (!thumbnailUrl) {
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
jsonLdScripts.forEach(function(script) {
try {
const data = JSON.parse(script.textContent);
if (data.image) {
if (Array.isArray(data.image)) {
thumbnailUrl = data.image[0];
} else if (typeof data.image === 'string') {
thumbnailUrl = data.image;
} else if (data.image.url) {
thumbnailUrl = data.image.url;
}
}
} catch(e) {}
});
}
// 4. Try Twitter card as fallback
if (!thumbnailUrl) {
const twitterImage = document.querySelector('meta[name="twitter:image"]');
if (twitterImage) thumbnailUrl = twitterImage.content;
}
// Skip if no thumbnail found
if (!thumbnailUrl) {
return;
}
// Mark as extracted
window.__fgReelExtracted = true;
window.__fgLastExtractedUrl = currentUrl;
// Send to Flutter
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'ReelMetadata',
JSON.stringify({
url: currentUrl,
title: title || 'Instagram Reel',
thumbnailUrl: thumbnailUrl
})
);
}
}
// Run immediately in case metadata is already loaded
extractAndSend();
// Set up MutationObserver to detect page changes and metadata loading
if (!window.__fgReelObserver) {
let debounceTimer = null;
window.__fgReelObserver = new MutationObserver(function(mutations) {
// Debounce to avoid excessive calls
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
extractAndSend();
}, 500);
});
window.__fgReelObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
}
// Also listen for URL changes (SPA navigation)
let lastUrl = location.href;
setInterval(function() {
if (location.href !== lastUrl) {
lastUrl = location.href;
window.__fgReelExtracted = false;
window.__fgLastExtractedUrl = '';
extractAndSend();
}
}, 1000);
})();
''';
+13
View File
@@ -0,0 +1,13 @@
/// JS to improve momentum scrolling behaviour inside the WebView, especially
/// for content-heavy feeds like Reels.
///
/// Applies touch-style overflow scrolling hints to the root element.
const String kScrollSmoothingJS = r'''
(function fgScrollSmoothing() {
try {
document.documentElement.style.setProperty('-webkit-overflow-scrolling', 'touch');
document.documentElement.style.setProperty('overflow-scrolling', 'touch');
} catch (_) {}
})();
''';
+32
View File
@@ -0,0 +1,32 @@
const String kSpaNavigationMonitorScript = '''
(function() {
// Monitor Instagram's SPA navigation and notify Flutter on every URL change.
// Instagram uses history.pushState — onLoadStop won't fire for these transitions.
// This is injected at document start so it wraps pushState before Instagram does.
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
function notifyUrlChange(url) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'UrlChange',
url || window.location.href
);
}
}
history.pushState = function() {
originalPushState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
window.addEventListener('popstate', () => notifyUrlChange());
})();
''';
+263
View File
@@ -0,0 +1,263 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// All JS hiders below use a 300ms debounce so they run only after mutations settle.
// ─── CSS-based ────────────────────────────────────────────────────────────────
// FIX: Like count CSS.
// Instagram's like BUTTON has aria-label="Like" (the verb) — NOT the count.
// [role="button"][aria-label$=" likes"] never matches anything.
// The COUNT lives in a[href*="/liked_by/"] (e.g. "1,234 likes" link).
// We hide that link. The JS hider below catches React-rendered span variants.
const String kHideLikeCountsCSS = '''
a[href*="/liked_by/"],
section a[href*="/liked_by/"] {
display: none !important;
}
''';
const String kHideFollowerCountsCSS = '''
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
''';
// Stories bar CSS — multiple selectors for different Instagram DOM versions.
// :has() is supported in WebKit (Instagram's engine). Targets the container,
// not individual story items which is what [aria-label*="Stories"] matches.
const String kHideStoriesBarCSS = '''
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"]:has([aria-label*="tory"]),
[role="listbox"]:has([aria-label*="tory"]),
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
''';
const String kHideExploreTabCSS = '''
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
''';
const String kHideReelsTabCSS = '''
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
''';
const String kHideShopTabCSS = '''
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
''';
// ─── JS-based ─────────────────────────────────────────────────────────────────
// Like counts — JS fallback for React-rendered count spans not caught by CSS.
// Scans for text matching "1,234 likes" / "12.3K views" patterns.
const String kHideLikeCountsJS = r'''
(function() {
function hideLikeCounts() {
try {
// Hide liked_by links and their immediate parent wrapper
document.querySelectorAll('a[href*="/liked_by/"]').forEach(function(el) {
try {
el.style.setProperty('display', 'none', 'important');
// Also hide the parent span/div that wraps the count text
if (el.parentElement) {
el.parentElement.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Scan spans for numeric like/view count text patterns
document.querySelectorAll('span').forEach(function(el) {
try {
const text = el.textContent.trim();
// Matches: "1,234 likes", "12.3K views", "1 like", "45 views", etc.
if (/^[\d,.]+[KkMm]?\s+(like|likes|view|views)$/.test(text)) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideLikeCounts();
if (!window.__fgLikeCountObserver) {
let _t = null;
window.__fgLikeCountObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideLikeCounts, 300);
});
window.__fgLikeCountObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Stories bar JS — structural detection when CSS selectors don't match.
// Two strategies:
// 1. aria-label scan on role=list/listbox elements
// 2. BoundingClientRect check: story circles are square, narrow (<120px), appear in a row
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Strategy 1: aria-label on list containers
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stor')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Strategy 2: BoundingClientRect — story circles are narrow square items in a row.
// Look for a <ul> or <div role=list> whose first child is roughly square and < 120px wide.
document.querySelectorAll('ul, [role="list"]').forEach(function(el) {
try {
const items = el.children;
if (items.length < 3) return;
const first = items[0].getBoundingClientRect();
// Story item: small, roughly square (width ≈ height), near top of viewport
if (
first.width > 0 &&
first.width < 120 &&
Math.abs(first.width - first.height) < 20 &&
first.top < 300
) {
el.style.setProperty('display', 'none', 'important');
// Also hide the section wrapping this if it has no article (pure stories row)
const section = el.closest('section, div[class]');
if (section && !section.querySelector('article')) {
section.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
// Strategy 3: horizontal overflow container before any article in the feed
document.querySelectorAll('main > div > div > div').forEach(function(container) {
try {
if (container.querySelector('article')) return;
const inner = container.querySelector('div, ul');
if (!inner) return;
const s = window.getComputedStyle(inner);
if (s.overflowX === 'scroll' || s.overflowX === 'auto') {
container.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _t = null;
window.__fgStoriesObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Sponsored posts — scans article elements for "Sponsored" text child.
// CSS cannot traverse from child text up to parent — JS only.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return;
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true;
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _t = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — scans for heading text, walks up to parent article/section.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _t = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
/// Lightweight global router for cross-widget navigation signals.
/// Used to allow the Settings page to trigger WebView navigations without
/// requiring a BuildContext reference to MainWebViewPage.
class FocusGramRouter {
FocusGramRouter._();
/// When this value is non-null, [MainWebViewPage] will load the URL
/// in the WebView and clear this value. Settings page sets this to
/// trigger in-app navigation (e.g. Instagram Settings).
static final pendingUrl = ValueNotifier<String?>(null);
}
+109
View File
@@ -0,0 +1,109 @@
// ============================================================================
// FocusGram — InjectionController
// ============================================================================
import '../scripts/core_injection.dart' as scripts;
import '../scripts/ui_hider.dart' as ui_hider;
class InjectionController {
static const String iOSUserAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
'Version/26.0 Mobile/15E148 Safari/604.1';
static const String reelsMutationObserverJS =
scripts.kReelsMutationObserverJS;
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
static String _buildMutationObserver(String cssContent) =>
'''
(function fgApplyStyles() {
const ID = 'focusgram-style';
function inject() {
let el = document.getElementById(ID);
if (!el) {
el = document.createElement('style');
el.id = ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = ${_escapeJsString(cssContent)};
}
inject();
new MutationObserver(() => { if (!document.getElementById(ID)) inject(); })
.observe(document.documentElement, { childList: true, subtree: true });
})();
''';
static String _escapeJsString(String s) {
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
return '`$escaped`';
}
static String softNavigateJS(String path) =>
'''
(function() {
const t = ${_escapeJsString(path)};
if (window.location.pathname !== t) window.location.href = t;
})();
''';
static String buildSessionStateJS(bool active) =>
'window.__focusgramSessionActive = $active;';
static String buildInjectionJS({
required bool sessionActive,
required bool blurExplore,
required bool blurReels,
required bool tapToUnblur,
required bool enableTextSelection,
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
required bool hideLikeCounts,
required bool hideFollowerCounts,
// hideStoriesBar parameter removed per user request
required bool hideExploreTab,
required bool hideReelsTab,
required bool hideShopTab,
required bool disableReelsEntirely,
}) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
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);
// Stories hiding removed per user request
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
return '''
${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())}
${scripts.kDismissAppBannerJS}
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
${scripts.kReelsMutationObserverJS}
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
${scripts.kLinkSanitizationJS}
${scripts.kThemeDetectorJS}
${scripts.kBadgeMonitorJS}
''';
}
}
+503
View File
@@ -0,0 +1,503 @@
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
// Minimal mode controls all blocking - when enabled, it forces blur and disables
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection;
final hideSponsoredPosts = settings.hideSponsoredPosts;
final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts;
// Stories hiding functionality removed per user request
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
// These are now only controllable via minimal mode submenu
final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely;
final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive,
blurExplore: blurExplore,
blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection,
hideSuggestedPosts: false, // Feature removed
hideSponsoredPosts: hideSponsoredPosts,
hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts,
// hideStoriesBar removed per user request
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
}
}
// Stories hiding functionality removed per user request
// No stories overlay injection needed
// 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
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/// Determines whether a navigation request should be blocked.
///
/// Rules:
/// - instagram.com/reels (and /reels/) = BLOCKED — this is the mindless feed tab
/// - instagram.com/reel/SHORTCODE/ = ALLOWED — a specific reel (e.g. from a DM)
/// - /explore/ is allowed (explore content is blurred via CSS instead)
/// - Only instagram.com domains are allowed (blocks external redirects)
class NavigationGuard {
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
/// Regex matching the Reels FEED root — NOT individual reels.
/// The `(/|\?|$)` suffix ensures query params (e.g. ?fg=blocked) still match.
static final _reelsFeedRegex = RegExp(
r'instagram\.com/reels(/|\?|$)',
caseSensitive: false,
);
/// Regex matching a specific reel (e.g. /reel/ABC123/).
static final _specificReelRegex = RegExp(
r'instagram\.com/reel/[^/?#]+',
caseSensitive: false,
);
/// Returns a [BlockDecision] for the given [url].
static BlockDecision evaluate({required String url}) {
Uri uri;
try {
uri = Uri.parse(url);
} catch (_) {
return const BlockDecision(blocked: false, reason: null);
}
// Allow non-HTTP schemes (about:blank, data:, etc.)
if (!uri.scheme.startsWith('http')) {
return const BlockDecision(blocked: false, reason: null);
}
// Block non-Instagram domains (prevents phishing/external redirects)
final host = uri.host.toLowerCase();
if (!_allowedHosts.any((h) => host == h)) {
return BlockDecision(
blocked: true,
reason: 'External domain blocked: $host',
);
}
// Block ONLY the Reels feed tab root (/reels, /reels/)
// but allow specific reels (/reel/SHORTCODE/) opened from DMs
if (_reelsFeedRegex.hasMatch(url)) {
return const BlockDecision(
blocked: true,
reason:
'Reels feed is disabled — open a specific reel from DMs instead',
);
}
return const BlockDecision(blocked: false, reason: null);
}
/// True if the URL is a specific individual reel (from a DM share).
static bool isSpecificReel(String url) => _specificReelRegex.hasMatch(url);
}
class BlockDecision {
final bool blocked;
final String? reason;
const BlockDecision({required this.blocked, required this.reason});
}
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> init() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _notificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (details) {
// 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({
required int id,
required String title,
required String body,
}) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'focusgram_channel',
'FocusGram Notifications',
channelDescription: 'Notifications for FocusGram sessions and alerts',
importance: Importance.max,
priority: Priority.high,
showWhen: true,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const NotificationDetails platformDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
try {
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Notification error: $e');
}
}
/// Shows a persistent (ongoing) notification that cannot be dismissed by the user
Future<void> showPersistentNotification({
required int id,
required String title,
required String body,
}) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'focusgram_persistent_channel',
'FocusGram Persistent',
channelDescription: 'Persistent notification while using FocusGram',
importance: Importance.max,
priority: Priority.high,
ongoing: true,
autoCancel: false,
showWhen: true,
playSound: false,
enableVibration: false,
category: AndroidNotificationCategory.service,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
const NotificationDetails platformDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
try {
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Persistent notification error: $e');
}
}
/// Cancels a persistent notification
Future<void> cancelPersistentNotification({required int id}) async {
try {
await _notificationsPlugin.cancel(id: id);
} catch (e) {
debugPrint('Cancel persistent notification error: $e');
}
}
/// Cancels all notifications
Future<void> cancelAllNotifications() async {
try {
await _notificationsPlugin.cancelAll();
} catch (e) {
debugPrint('Cancel all notifications error: $e');
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tracks total in-app screen time per day.
///
/// Storage format (in SharedPreferences, key `screen_time_data`):
/// {
/// "2026-02-26": 3420, // seconds
/// "2026-02-25": 1800
/// }
///
/// All data stays on-device only.
class ScreenTimeService extends ChangeNotifier {
static const String prefKey = 'screen_time_data';
SharedPreferences? _prefs;
Map<String, int> _secondsByDate = {};
Timer? _ticker;
bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_load();
}
void _load() {
final raw = _prefs?.getString(prefKey);
if (raw == null || raw.isEmpty) {
_secondsByDate = {};
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
}
} catch (_) {
_secondsByDate = {};
}
}
Future<void> _save() async {
// Prune entries older than 30 days
final now = DateTime.now();
final cutoff = now.subtract(const Duration(days: 30));
_secondsByDate.removeWhere((key, value) {
try {
final d = DateTime.parse(key);
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
} catch (_) {
return true;
}
});
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
notifyListeners();
}
String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
void startTracking() {
if (_tracking) return;
_tracking = true;
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (!_tracking) return;
final key = _todayKey();
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
// Persist every 10 seconds to reduce writes.
if (_secondsByDate[key]! % 10 == 0) {
_save();
} else {
notifyListeners();
}
});
}
void stopTracking() {
if (!_tracking) return;
_tracking = false;
_save();
}
Future<void> resetAll() async {
_secondsByDate.clear();
await _prefs?.remove(prefKey);
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}
+600
View File
@@ -0,0 +1,600 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
class FocusSchedule {
final int startHour;
final int startMinute;
final int endHour;
final int endMinute;
FocusSchedule({
required this.startHour,
required this.startMinute,
required this.endHour,
required this.endMinute,
});
Map<String, dynamic> toJson() => {
'startH': startHour,
'startM': startMinute,
'endH': endHour,
'endM': endMinute,
};
factory FocusSchedule.fromJson(Map<String, dynamic> json) => FocusSchedule(
startHour: json['startH'] as int,
startMinute: json['startM'] as int,
endHour: json['endH'] as int,
endMinute: json['endM'] as int,
);
}
/// Manages all session logic for FocusGram:
///
/// **App Session** — how long the user plans to use Instagram today.
/// Started by the AppSessionPicker on every cold open.
/// Enforced with a watchdog timer; one 10-min extension allowed.
/// Cooldown enforced between app-opens.
///
/// **Reel Session** — a period during which reels are unblocked.
/// Started manually by the user via the FAB.
/// Deducted from the daily reel quota.
class SessionManager extends ChangeNotifier {
// ── Reel-session keys ──────────────────────────────────────
static const _keyDailyDate = 'sessn_daily_date';
static const _keyDailyUsedSeconds = 'sessn_daily_used_sec';
static const _keySessionExpiry = 'sessn_expiry_ts';
static const _keyLastSessionEnd = 'sessn_last_end_ts';
static const _keyDailyLimitSec = 'sessn_daily_limit_sec';
static const _keyPerSessionSec = 'sessn_per_session_sec';
static const _keyCooldownSec = 'sessn_cooldown_sec';
// ── App-session keys ───────────────────────────────────────
static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h';
static const _keyScheduleStartMin = 'sched_start_m';
static const _keyScheduleEndHour = 'sched_end_h';
static const _keyScheduleEndMin = 'sched_end_m';
static const _keySchedulesJson = 'sched_list_json';
SharedPreferences? _prefs;
// ── Reel-session runtime ───────────────────────────────────
bool _isSessionActive = false;
DateTime? _sessionExpiry;
int _dailyUsedSeconds = 0;
DateTime? _lastSessionEnd;
Timer? _ticker;
// ── App-session runtime ────────────────────────────────────
DateTime? _appSessionEnd;
bool _appExtensionUsed = false;
DateTime? _lastAppSessionEnd;
bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0;
// ── Scheduled Blocking runtime ─────────────────────────────
bool _scheduleEnabled = false;
int _schedStartHour = 22; // Default 10 PM
int _schedStartMin = 0;
int _schedEndHour = 7;
int _schedEndMin = 0;
List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false;
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0;
int _cachedRemainingAppSessionSeconds = 0;
// ── Settings defaults ──────────────────────────────────────
int _dailyLimitSeconds = 30 * 60; // 30 min
int _perSessionSeconds = 5 * 60; // 5 min
int _cooldownSeconds = 15 * 60; // 15 min cooldown between reel sessions
// ── Public getters — Reel session ─────────────────────────
bool get isSessionActive => _isSessionActive;
int get remainingSessionSeconds {
if (!_isSessionActive || _sessionExpiry == null) return 0;
// If not in foreground, the clock "freezes" visually too (or we could shift the expiry)
final diff = _sessionExpiry!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
int get dailyUsedSeconds => _dailyUsedSeconds;
int get dailyLimitSeconds => _dailyLimitSeconds;
int get dailyRemainingSeconds {
final rem = _dailyLimitSeconds - _dailyUsedSeconds;
return rem > 0 ? rem : 0;
}
bool get isDailyLimitExhausted => dailyRemainingSeconds <= 0;
bool get isCooldownActive {
if (_lastSessionEnd == null) return false;
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
return elapsed < _cooldownSeconds;
}
int get cooldownRemainingSeconds {
if (!isCooldownActive || _lastSessionEnd == null) return 0;
final elapsed = DateTime.now().difference(_lastSessionEnd!).inSeconds;
final rem = _cooldownSeconds - elapsed;
return rem > 0 ? rem : 0;
}
int get perSessionSeconds => _perSessionSeconds;
int get cooldownSeconds => _cooldownSeconds;
DateTime? get lastSessionEnd => _lastSessionEnd;
// ── Public getters — App session ──────────────────────────
/// Whether the user has an active app session right now.
bool get isAppSessionActive {
if (_appSessionEnd == null) return false;
return DateTime.now().isBefore(_appSessionEnd!);
}
/// Seconds left in the current app session.
int get appSessionRemainingSeconds {
if (_appSessionEnd == null) return 0;
final diff = _appSessionEnd!.difference(DateTime.now()).inSeconds;
return diff > 0 ? diff : 0;
}
/// True when the app session has expired and user has not yet acted.
bool get isAppSessionExpired => _appSessionExpiredFlag;
/// Whether the 10-min extension has been used.
bool get canExtendAppSession => !_appExtensionUsed;
/// Seconds remaining in the app-open cooldown.
int get appOpenCooldownRemainingSeconds {
if (_lastAppSessionEnd == null) return 0;
final elapsed = DateTime.now().difference(_lastAppSessionEnd!).inSeconds;
final rem = _cooldownSeconds - elapsed;
return rem > 0 ? rem : 0;
}
/// True if the app-open cooldown is still active.
bool get isAppOpenCooldownActive {
if (_lastAppSessionEnd == null) return false;
return appOpenCooldownRemainingSeconds > 0;
}
/// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount;
// ── Scheduled Blocking Getters ─────────────────────────────
bool get scheduleEnabled => _scheduleEnabled;
int get schedStartHour => _schedStartHour;
int get schedStartMin => _schedStartMin;
int get schedEndHour => _schedEndHour;
int get schedEndMin => _schedEndMin;
List<FocusSchedule> get schedules => _schedules;
bool get isScheduledBlockActive {
if (!_scheduleEnabled) return false;
final now = DateTime.now();
final currentTime = now.hour * 60 + now.minute;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
if (startTime < endTime) {
// Simple range (e.g., 9:00 to 17:00)
if (currentTime >= startTime && currentTime < endTime) return true;
} else {
// Over-midnight range (e.g., 22:00 to 07:00)
if (currentTime >= startTime || currentTime < endTime) return true;
}
}
return false;
}
String? get activeScheduleText {
if (!isScheduledBlockActive) return null;
final now = DateTime.now();
final currentTime = now.hour * 60 + now.minute;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
bool active = false;
if (startTime < endTime) {
if (currentTime >= startTime && currentTime < endTime) active = true;
} else {
if (currentTime >= startTime || currentTime < endTime) active = true;
}
if (active) {
return '${formatTime12h(s.startHour, s.startMinute)} to ${formatTime12h(s.endHour, s.endMinute)}';
}
}
return null;
}
String formatTime12h(int h, int m) {
var hour = h % 12;
if (hour == 0) hour = 12;
final period = h >= 12 ? 'PM' : 'AM';
final min = m.toString().padLeft(2, '0');
return '$hour:$min $period';
}
// ── Initialization ─────────────────────────────────────────
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _resetDailyIfNeeded();
_loadPersisted();
_lastScheduleState = isScheduledBlockActive;
_startTicker();
_incrementOpenCount();
}
void setAppForeground(bool v) {
if (_isInForeground == v) return;
_isInForeground = v;
if (v) {
// Returning to foreground: resume sessions by shifting expiry
final now = DateTime.now();
if (_isSessionActive) {
_sessionExpiry = now.add(
Duration(seconds: _cachedRemainingSessionSeconds),
);
}
if (_appSessionEnd != null) {
_appSessionEnd = now.add(
Duration(seconds: _cachedRemainingAppSessionSeconds),
);
}
} else {
// Entering background: cache remaining time
_cachedRemainingSessionSeconds = remainingSessionSeconds;
_cachedRemainingAppSessionSeconds = appSessionRemainingSeconds;
}
notifyListeners();
}
Future<void> _resetDailyIfNeeded() async {
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
final stored = _prefs!.getString(_keyDailyDate) ?? '';
if (stored != today) {
await _prefs!.setString(_keyDailyDate, today);
await _prefs!.setInt(_keyDailyUsedSeconds, 0);
await _prefs!.setInt(_keyDailyOpenCount, 0);
}
}
void _loadPersisted() {
_dailyUsedSeconds = _prefs!.getInt(_keyDailyUsedSeconds) ?? 0;
_dailyLimitSeconds = _prefs!.getInt(_keyDailyLimitSec) ?? 30 * 60;
_perSessionSeconds = _prefs!.getInt(_keyPerSessionSec) ?? 5 * 60;
_cooldownSeconds = _prefs!.getInt(_keyCooldownSec) ?? 15 * 60;
_dailyOpenCount = _prefs!.getInt(_keyDailyOpenCount) ?? 0;
// Reel session
final expiryMs = _prefs!.getInt(_keySessionExpiry) ?? 0;
if (expiryMs > 0) {
final expiry = DateTime.fromMillisecondsSinceEpoch(expiryMs);
if (expiry.isAfter(DateTime.now())) {
_sessionExpiry = expiry;
_isSessionActive = true;
} else {
// Don't show notification for expired sessions from previous app session
_cleanupExpiredReelSession(showNotification: false);
}
}
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
if (lastEndMs > 0) {
_lastSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastEndMs);
}
// App session
final appEndMs = _prefs!.getInt(_keyAppSessionEnd) ?? 0;
if (appEndMs > 0) {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
}
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) {
_lastAppSessionEnd = DateTime.fromMillisecondsSinceEpoch(lastAppEndMs);
}
_scheduleEnabled = _prefs!.getBool(_keyScheduleEnabled) ?? false;
_schedStartHour = _prefs!.getInt(_keyScheduleStartHour) ?? 22;
_schedStartMin = _prefs!.getInt(_keyScheduleStartMin) ?? 0;
_schedEndHour = _prefs!.getInt(_keyScheduleEndHour) ?? 7;
_schedEndMin = _prefs!.getInt(_keyScheduleEndMin) ?? 0;
final schedJson = _prefs!.getString(_keySchedulesJson);
if (schedJson != null) {
final List decode = jsonDecode(schedJson);
_schedules = decode.map((m) => FocusSchedule.fromJson(m)).toList();
} else {
// Migrate old single schedule if it exists
_schedules = [
FocusSchedule(
startHour: _schedStartHour,
startMinute: _schedStartMin,
endHour: _schedEndHour,
endMinute: _schedEndMin,
),
];
_saveSchedulesToPrefs();
}
}
void _incrementOpenCount() {
_dailyOpenCount++;
_prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
if (!_isInForeground) return; // Freeze everything when in background
bool changed = false;
// Reel session countdown
if (_isSessionActive) {
// Recalculate expiry every tick to "pause" it while backgrounded:
// We don't change _sessionExpiry, but we increment _dailyUsedSeconds.
// If we want it to actually pause, we should probably store "remaining seconds"
// and update expiry ONLY when in foreground.
if (remainingSessionSeconds <= 0) {
// Only cleanup if session was actually active and has expired naturally
_cleanupExpiredReelSession(showNotification: true);
changed = true;
} else {
_dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) {
_cleanupExpiredReelSession(showNotification: true);
}
changed = true;
}
}
// App session expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
}
if (isCooldownActive) {
changed = true;
} else if (appOpenCooldownRemainingSeconds <= 0 &&
_lastAppSessionEnd != null) {
// Just expired
changed = true;
}
// Schedule check
final sched = isScheduledBlockActive;
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
changed = true;
// Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true;
NotificationService().showNotification(
id: 1001,
title: 'FocusGram Schedule Active',
body: 'Instagram is blocked according to your schedule.',
);
} else if (!sched) {
_scheduleNotificationShown = false;
}
}
if (changed) notifyListeners();
}
void _cleanupExpiredReelSession({bool showNotification = true}) {
// Only show notification if we haven't already shown one for this session
// and the user has enabled session end notifications
// The showNotification parameter should be false when cleaning up on app startup
// (i.e., when loading an expired session from a previous app session)
if (showNotification && !_sessionEndNotificationShown) {
_sessionEndNotificationShown = true;
// Check if user wants session end notifications
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
if (notifySessionEnd) {
NotificationService().showNotification(
id: 999,
title: 'Session Ended',
body: 'Your Reel session has expired. Time to focus!',
);
}
}
_isSessionActive = false;
_sessionExpiry = null;
_lastSessionEnd = DateTime.now();
_prefs?.setInt(_keySessionExpiry, 0);
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
}
// ── Reel session API ───────────────────────────────────────
bool startSession(int minutes) {
if (isDailyLimitExhausted) return false;
if (isCooldownActive) return false;
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true;
_sessionEndNotificationShown = false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
}
void endSession() {
if (!_isSessionActive) return;
// Don't show notification when user manually ends the session
_cleanupExpiredReelSession(showNotification: false);
notifyListeners();
}
void accrueSeconds(int seconds) {
_dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp(
0,
_dailyLimitSeconds,
);
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) {
// Daily limit exhausted - show notification
_cleanupExpiredReelSession(showNotification: true);
}
notifyListeners();
}
// ── App session API ────────────────────────────────────────
/// Start an app session of [minutes] (160).
void startAppSession(int minutes) {
final end = DateTime.now().add(Duration(minutes: minutes));
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
notifyListeners();
}
/// Extend the app session by 10 minutes. Only works once.
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
_appSessionEnd = base.add(const Duration(minutes: 10));
_appExtensionUsed = true;
_appSessionExpiredFlag = false;
_prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, true);
notifyListeners();
return true;
}
/// Called when the user closes the app voluntarily or after extension denial.
void endAppSession() {
_lastAppSessionEnd = DateTime.now();
_appSessionEnd = null;
_appSessionExpiredFlag = false;
_prefs?.setInt(
_keyLastAppSessEnd,
_lastAppSessionEnd!.millisecondsSinceEpoch,
);
_prefs?.setInt(_keyAppSessionEnd, 0);
notifyListeners();
}
// ── Settings mutations ─────────────────────────────────────
Future<void> setDailyLimitMinutes(int minutes) async {
_dailyLimitSeconds = minutes * 60;
await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
Future<void> setPerSessionMinutes(int minutes) async {
_perSessionSeconds = minutes * 60;
await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds);
notifyListeners();
}
Future<void> setCooldownMinutes(int minutes) async {
_cooldownSeconds = minutes * 60;
await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds);
notifyListeners();
}
Future<void> setScheduleEnabled(bool v) async {
_scheduleEnabled = v;
await _prefs?.setBool(_keyScheduleEnabled, v);
notifyListeners();
}
Future<void> setScheduleTime({
required int startH,
required int startM,
required int endH,
required int endM,
}) async {
_schedEndHour = endH;
_schedEndMin = endM;
// Update the first schedule for compatibility? Or just replace all?
// Let's replace all schedules with this one if this method is called.
_schedules = [
FocusSchedule(
startHour: startH,
startMinute: startM,
endHour: endH,
endMinute: endM,
),
];
await _prefs?.setInt(_keyScheduleStartHour, startH);
await _prefs?.setInt(_keyScheduleStartMin, startM);
await _prefs?.setInt(_keyScheduleEndHour, endH);
await _prefs?.setInt(_keyScheduleEndMin, endM);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> _saveSchedulesToPrefs() async {
final json = jsonEncode(_schedules.map((s) => s.toJson()).toList());
await _prefs?.setString(_keySchedulesJson, json);
}
Future<void> addSchedule(FocusSchedule s) async {
_schedules.add(s);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> removeScheduleAt(int index) async {
if (index >= 0 && index < _schedules.length) {
_schedules.removeAt(index);
await _saveSchedulesToPrefs();
notifyListeners();
}
}
Future<void> updateScheduleAt(int index, FocusSchedule s) async {
if (index >= 0 && index < _schedules.length) {
_schedules[index] = s;
await _saveSchedulesToPrefs();
notifyListeners();
}
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}
+484
View File
@@ -0,0 +1,484 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels';
static const _keyTapToUnblur = 'set_tap_to_unblur';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings';
static const _keyIsFirstRun = 'set_is_first_run';
// Focus / playback
static const _keyBlockAutoplay = 'block_autoplay';
// Grayscale mode - now supports multiple schedules
static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules';
// Content filtering / UI hiding
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideShopTab = 'hide_shop_tab';
// Minimal mode
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Minimal mode state tracking for smart restore
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
// 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';
static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent';
SharedPreferences? _prefs;
bool _blurExplore = true;
bool _blurReels = false;
bool _tapToUnblur = true;
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true;
bool _grayscaleEnabled = false;
// Grayscale schedules - list of {enabled, startTime, endTime}
// startTime and endTime are in format "HH:MM"
List<Map<String, dynamic>> _grayscaleSchedules = [];
bool _hideSponsoredPosts = false;
bool _hideLikeCounts = false;
bool _hideFollowerCounts = false;
bool _hideShopTab = false;
// These are now controlled internally by minimal mode
bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false;
bool _minimalModeEnabled = false;
// Tracking for smart restore
bool _prevDisableReels = false;
bool _prevDisableExplore = false;
bool _prevBlurExplore = false;
bool _reelsHistoryEnabled = true;
// Privacy defaults - notifications OFF by default
bool _sanitizeLinks = true;
bool _notifyDMs = false;
bool _notifyActivity = false;
bool _notifySessionEnd = false;
bool _notifyPersistent = false;
List<String> _enabledTabs = [
'Home',
'Search',
'Reels',
'Messages',
'Profile',
];
bool _isFirstRun = true;
bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels;
bool get tapToUnblur => _tapToUnblur;
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd;
bool get notifyPersistent => _notifyPersistent;
bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab;
// These are now controlled by minimal mode only
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
/// True if grayscale should currently be applied, considering the manual
/// toggle and the optional schedules.
bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true;
if (_grayscaleSchedules.isEmpty) return false;
final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute;
for (final schedule in _grayscaleSchedules) {
if (schedule['enabled'] != true) continue;
try {
final startParts = (schedule['startTime'] as String).split(':');
final endParts = (schedule['endTime'] as String).split(':');
if (startParts.length != 2 || endParts.length != 2) continue;
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
// Handle overnight schedules (e.g., 21:00 to 06:00)
if (endMinutes < startMinutes) {
// Overnight: active if current time is >= start OR < end
if (currentMinutes >= startMinutes || currentMinutes < endMinutes) {
return true;
}
} else {
// Same day: active if current time is between start and end
if (currentMinutes >= startMinutes && currentMinutes < endMinutes) {
return true;
}
}
} catch (_) {
continue;
}
}
return false;
}
// Privacy getters
bool get sanitizeLinks => _sanitizeLinks;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
_tapToUnblur = _prefs!.getBool(_keyTapToUnblur) ?? true;
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
// Load grayscale schedules
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
_grayscaleSchedules = List<Map<String, dynamic>>.from(
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
);
} catch (_) {
_grayscaleSchedules = [];
}
}
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
// Load minimal mode
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
// These are now internal states, not user-facing settings
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
_notifyPersistent = _prefs!.getBool(_keyNotifyPersistent) ?? false;
_enabledTabs =
(_prefs!.getStringList(_keyEnabledTabs) ??
['Home', 'Search', 'Reels', 'Messages', 'Profile'])
..remove('Create');
if (!_enabledTabs.contains('Messages') && _enabledTabs.length < 5) {
// Migration: add Messages if missing
_enabledTabs.insert(3, 'Messages');
}
_isFirstRun = _prefs!.getBool(_keyIsFirstRun) ?? true;
notifyListeners();
}
Future<void> setFirstRunCompleted() async {
_isFirstRun = false;
await _prefs?.setBool(_keyIsFirstRun, false);
notifyListeners();
}
Future<void> setBlurExplore(bool v) async {
_blurExplore = v;
// Sync blur explore with blur reels - enabling one enables the other
if (v && !_blurReels) {
_blurReels = true;
await _prefs?.setBool(_keyBlurReels, true);
}
await _prefs?.setBool(_keyBlurExplore, v);
notifyListeners();
}
Future<void> setBlurReels(bool v) async {
_blurReels = v;
// Sync blur reels with blur explore - enabling one enables the other
if (v && !_blurExplore) {
_blurExplore = true;
await _prefs?.setBool(_keyBlurExplore, true);
}
await _prefs?.setBool(_keyBlurReels, v);
notifyListeners();
}
Future<void> setTapToUnblur(bool v) async {
_tapToUnblur = v;
await _prefs?.setBool(_keyTapToUnblur, v);
notifyListeners();
}
Future<void> setRequireLongPress(bool v) async {
_requireLongPress = v;
await _prefs?.setBool(_keyRequireLongPress, v);
notifyListeners();
}
Future<void> setShowBreathGate(bool v) async {
_showBreathGate = v;
await _prefs?.setBool(_keyShowBreathGate, v);
notifyListeners();
}
Future<void> setRequireWordChallenge(bool v) async {
_requireWordChallenge = v;
await _prefs?.setBool(_keyRequireWordChallenge, v);
notifyListeners();
}
Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
notifyListeners();
}
Future<void> setShowInstaSettings(bool v) async {
_showInstaSettings = v;
await _prefs?.setBool(_keyShowInstaSettings, v);
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> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
_grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners();
}
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
_grayscaleSchedules.add(schedule);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
notifyListeners();
}
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules[index] = schedule;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
notifyListeners();
}
}
Future<void> removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
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> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
/// Setter for internal disable reels state (used by minimal mode submenu)
Future<void> setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v);
notifyListeners();
}
/// Setter for internal disable explore state (used by minimal mode submenu)
Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v);
notifyListeners();
}
/// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async {
if (v) {
// Turning ON - save current states BEFORE enabling minimal mode
_prevDisableReels = _disableReelsEntirely;
_prevDisableExplore = _disableExploreEntirely;
_prevBlurExplore = _blurExplore;
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
// Enable all minimal mode settings
_minimalModeEnabled = true;
_disableReelsEntirely = true;
_disableExploreEntirely = true;
_blurExplore = true;
await _prefs?.setBool(_keyMinimalModeEnabled, true);
await _prefs?.setBool('internal_disable_reels_entirely', true);
await _prefs?.setBool('internal_disable_explore_entirely', true);
await _prefs?.setBool(_keyBlurExplore, true);
} else {
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
_minimalModeEnabled = false;
// Simply restore to the states that were saved BEFORE minimal mode was enabled
_disableReelsEntirely = _prevDisableReels;
_disableExploreEntirely = _prevDisableExplore;
_blurExplore = _prevBlurExplore;
// Save the restored states
await _prefs?.setBool(_keyMinimalModeEnabled, false);
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
}
notifyListeners();
}
Future<void> setReelsHistoryEnabled(bool v) async {
_reelsHistoryEnabled = v;
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
notifyListeners();
}
void setDarkMode(bool dark) {
if (_isDarkMode != dark) {
_isDarkMode = dark;
notifyListeners();
}
}
Future<void> setSanitizeLinks(bool v) async {
_sanitizeLinks = v;
await _prefs?.setBool(_keySanitizeLinks, v);
notifyListeners();
}
Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v);
notifyListeners();
}
Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v);
notifyListeners();
}
Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v);
notifyListeners();
}
Future<void> setNotifyPersistent(bool v) async {
_notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v);
notifyListeners();
}
Future<void> toggleTab(String tab) async {
if (_enabledTabs.contains(tab)) {
if (_enabledTabs.length > 1) {
_enabledTabs.remove(tab);
}
} else {
_enabledTabs.add(tab);
}
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
notifyListeners();
}
Future<void> reorderTab(int oldIndex, int newIndex) async {
if (newIndex > oldIndex) newIndex -= 1;
final String item = _enabledTabs.removeAt(oldIndex);
_enabledTabs.insert(newIndex, item);
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
notifyListeners();
}
}
+641
View File
@@ -0,0 +1,641 @@
import 'package:flutter/material.dart';
import 'package:flutter_windowmanager_plus/flutter_windowmanager_plus.dart';
class DisciplineChallenge {
static const List<String> _words = [
'discipline',
'focus',
'growth',
'mindful',
'purpose',
'control',
'strength',
'clarity',
'vision',
'action',
'habit',
'success',
'power',
'balance',
'wisdom',
'patience',
'intent',
'choice',
'calm',
'peace',
'truth',
'grit',
'work',
'time',
'life',
'soul',
'mind',
'body',
'heart',
'will',
'hope',
'love',
'kind',
'help',
'give',
'take',
'stay',
'move',
'jump',
'run',
'walk',
'talk',
'sing',
'play',
'read',
'write',
'draw',
'cook',
'bake',
'farm',
'fish',
'hunt',
'grow',
'learn',
'teach',
'lead',
'follow',
'build',
'fix',
'save',
'spend',
'win',
'lose',
'try',
'fail',
'rise',
'fall',
'start',
'stop',
'wait',
'now',
'here',
'slow',
'fast',
'high',
'low',
'deep',
'wide',
'long',
'short',
'big',
'small',
'old',
'new',
'good',
'bad',
'real',
'fake',
'pure',
'dark',
'light',
'fire',
'water',
'earth',
'air',
'wind',
'storm',
'sun',
'moon',
'star',
'sky',
'road',
'path',
'gate',
'door',
'room',
'house',
'home',
'city',
'town',
'land',
'sea',
'ocean',
'lake',
'river',
'wood',
'tree',
'leaf',
'root',
'seed',
'fruit',
'food',
'bread',
'cake',
'milk',
'wine',
'beer',
'salt',
'gold',
'iron',
'rock',
'sand',
'dust',
'bone',
'blood',
'skin',
'hair',
'eyes',
'ears',
'nose',
'mouth',
'hand',
'foot',
'wing',
'tail',
'bird',
'cat',
'dog',
'bear',
'wolf',
'lion',
'deer',
'horse',
'cow',
'pig',
'sheep',
'goat',
'bee',
'ant',
'fly',
'worm',
'snake',
'frog',
'turtle',
'crab',
'whale',
'shark',
'dolphin',
'eagle',
'hawk',
'owl',
'swan',
'duck',
'goose',
'rose',
'lily',
'pine',
'oak',
'fern',
'moss',
'weed',
'grass',
'corn',
'rice',
'bean',
'pea',
'nut',
'oil',
'honey',
'wax',
'silk',
'wool',
'flax',
'hemp',
'paper',
'ink',
'pen',
'book',
'lamp',
'bed',
'chair',
'desk',
'wall',
'roof',
'step',
'mile',
'inch',
'yard',
'hour',
'day',
'week',
'month',
'year',
'age',
'past',
'next',
'death',
'birth',
'name',
'word',
'song',
'poem',
'story',
'myth',
'fact',
'law',
'rule',
'king',
'queen',
'lord',
'lady',
'man',
'woman',
'child',
'youth',
'elder',
'friend',
'foe',
'guest',
'host',
'war',
'fight',
'deal',
'buy',
'sell',
'pay',
'debt',
'cost',
'coin',
'cash',
'bank',
'shop',
'mall',
'mill',
'port',
'ship',
'boat',
'car',
'bus',
'bike',
'train',
'plane',
'street',
'hill',
'peak',
'cave',
'well',
'bridge',
'tower',
'fort',
'camp',
'tent',
'ash',
'smoke',
'coal',
'log',
'branch',
'stick',
'tool',
'hammer',
'nail',
'saw',
'knife',
'fork',
'spoon',
'bowl',
'cup',
'plate',
'pot',
'pan',
'stove',
'oven',
'sink',
'bath',
'soap',
'towel',
'comb',
'brush',
'mirror',
'clock',
'watch',
'ring',
'belt',
'boot',
'shoe',
'sock',
'hat',
'coat',
'shirt',
'pant',
'dress',
'skirt',
'bag',
'box',
'case',
'lock',
'key',
'bell',
'horn',
'drum',
'pipe',
'lute',
'harp',
'flute',
'reed',
'cord',
'rope',
'knot',
'net',
'hook',
'line',
'bait',
'trap',
'plow',
'hoe',
'rake',
'spade',
'crop',
'wheat',
'oats',
'rye',
'zinc',
'lead',
'copper',
'tin',
'silver',
'stone',
'clay',
'mud',
'rain',
'snow',
'hail',
'mist',
'fog',
'cloud',
'dawn',
'dusk',
'noon',
'night',
'ghost',
'angel',
'devil',
'god',
'fate',
'doom',
'fear',
'joy',
'woe',
'pain',
'care',
'hate',
'rage',
'lust',
'greed',
'pride',
'envy',
'sloth',
'wrath',
'holy',
'sin',
'hell',
'heaven',
'void',
'space',
'form',
'idea',
'thought',
'dot',
'circle',
'square',
'point',
'edge',
'flow',
'wave',
'tide',
'shore',
'bank',
'cliff',
'vale',
'meadow',
'field',
'plain',
'desert',
'forest',
'jungle',
'swamp',
'marsh',
'glade',
'grove',
'peak',
'ridge',
'slope',
'track',
'trail',
'lane',
'path',
'alley',
'court',
'plaza',
'park',
'bridge',
'tunnel',
'arch',
'dome',
'spire',
'tower',
'wall',
'fence',
'gate',
'stair',
'floor',
'beam',
'pole',
'mast',
'sail',
'deck',
'hull',
'keel',
'oar',
'helm',
'anchor',
'net',
'rope',
'knot',
'line',
'hook',
'bait',
'trap',
'net',
'spear',
'bow',
'arrow',
'sword',
'shield',
'armor',
'helm',
'boot',
'glove',
'cloak',
'scarf',
'belt',
'ujwal',
'ring',
'gem',
'jewel',
'pearl',
'gold',
'silver',
'bronze',
'iron',
'steel',
'brass',
'glass',
'stone',
'brick',
'tile',
'wood',
'clay',
'wax',
'ink',
'paint',
'dye',
'glue',
'oil',
'coal',
'gas',
'steam',
'heat',
'cold',
'ice',
'frost',
'thaw',
'melt',
'burn',
'glow',
'shine',
'beam',
'ray',
'spark',
'flash',
'flare',
'blast',
'shock',
'wave',
'pulse',
'beat',
'rhythm',
'tone',
'note',
'tune',
'song',
];
/// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 15}) async {
final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' ');
final controller = TextEditingController();
// Prevent screenshots on Android
try {
await FlutterWindowManagerPlus.addFlags(
FlutterWindowManagerPlus.FLAG_SECURE,
);
} catch (_) {}
if (!context.mounted) return false;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row(
children: [
Icon(Icons.psychology, color: Colors.blue, size: 24),
SizedBox(width: 10),
Text(
'Discipline Challenge',
style: TextStyle(color: Colors.white, fontSize: 18),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'FocusGram is currently blocked to help you stay focused. Type the following sequence exactly to proceed:',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Text(
challenge,
style: const TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
fontSize: 14,
height: 1.5,
),
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
enableInteractiveSelection:
false, // Prevents copy/paste/selection
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Type here...',
hintStyle: const TextStyle(color: Colors.white24),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white38),
),
),
ElevatedButton(
onPressed: () {
if (controller.text.trim() == challenge) {
Navigator.pop(ctx, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Incorrect sequence. Please try again.'),
backgroundColor: Colors.redAccent,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('Confirm'),
),
],
),
);
// Re-enable screenshots
try {
await FlutterWindowManagerPlus.clearFlags(
FlutterWindowManagerPlus.FLAG_SECURE,
);
} catch (_) {}
return result ?? false;
}
}