mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-21 15:06:49 +02:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
''';
|
||||
@@ -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 });
|
||||
})();
|
||||
''';
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
@@ -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 (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
@@ -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 (_) {}
|
||||
})();
|
||||
''';
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
|
||||
@@ -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';
|
||||
})();
|
||||
''';
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
@@ -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 (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
@@ -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());
|
||||
})();
|
||||
''';
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] (1–60).
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user