first commit

This commit is contained in:
Ujwal
2026-02-22 22:00:52 +05:45
commit a848b9222d
40 changed files with 3730 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'services/session_manager.dart';
import 'services/settings_service.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';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Lock to portrait
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
final sessionManager = SessionManager();
final settingsService = SettingsService();
await sessionManager.init();
await settingsService.init();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: sessionManager),
ChangeNotifierProvider.value(value: settingsService),
],
child: const FocusGramApp(),
),
);
}
class FocusGramApp extends StatelessWidget {
const FocusGramApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FocusGram',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: Colors.blue.shade400,
surface: Colors.black,
),
scaffoldBackgroundColor: Colors.black,
useMaterial3: true,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
home: const InitialRouteHandler(),
);
}
}
/// Flow on every cold open:
/// 1. Cooldown Gate (if app-open cooldown active)
/// 2. Breath Gate (if enabled in settings)
/// 3. App Session Picker (always)
/// 4. 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;
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
// Step 1: Cooldown gate — if too soon since last session
if (sm.isAppOpenCooldownActive) {
return const CooldownGateScreen();
}
// Step 2: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
onFinish: () => setState(() => _breathCompleted = true),
);
}
// Step 3: App session picker
if (!_appSessionStarted) {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
}
// Step 4: Main app
return const MainWebViewPage();
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
/// Shown on every cold app open. Asks the user how long they plan to use
/// Instagram today. Uses an iOS-style scroll picker (ListWheelScrollView).
class AppSessionPickerScreen extends StatefulWidget {
final VoidCallback onSessionStarted;
const AppSessionPickerScreen({super.key, required this.onSessionStarted});
@override
State<AppSessionPickerScreen> createState() => _AppSessionPickerScreenState();
}
class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
static final List<int> _minuteOptions = [
5,
10,
15,
20,
25,
30,
35,
40,
45,
50,
55,
60,
];
int _selectedIndex = 2; // default: 15 min
@override
Widget build(BuildContext context) {
final selectedMinutes = _minuteOptions[_selectedIndex];
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.blue.withValues(alpha: 0.4),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.timer_outlined,
color: Colors.white,
size: 36,
),
),
const SizedBox(height: 28),
const Text(
'Set Your Intention',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 10),
const Text(
'How long do you plan to use\nInstagram right now?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white54,
fontSize: 15,
height: 1.5,
),
),
const Spacer(flex: 1),
// iOS-style scroll picker
SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
// Selection highlight
Container(
height: 50,
margin: const EdgeInsets.symmetric(horizontal: 0),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
width: 1,
),
),
),
ListWheelScrollView.useDelegate(
itemExtent: 50,
physics: const FixedExtentScrollPhysics(),
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
return Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '${entry.value}',
style: TextStyle(
fontSize: isSelected ? 28 : 22,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w300,
color: isSelected
? Colors.white
: Colors.white38,
),
),
TextSpan(
text: ' min',
style: TextStyle(
fontSize: isSelected ? 16 : 14,
color: isSelected
? Colors.white70
: Colors.white24,
),
),
],
),
),
);
}).toList(),
),
),
],
),
),
const Spacer(flex: 1),
// Confirm button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: () => _confirm(context, selectedMinutes),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: Text(
'Start $selectedMinutes-Minute Session',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
const Text(
'You\'ll be prompted to close the app when your time is up.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white24, fontSize: 12),
),
const Spacer(flex: 1),
],
),
),
),
);
}
void _confirm(BuildContext context, int minutes) {
context.read<SessionManager>().startAppSession(minutes);
widget.onSessionStarted();
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 8-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 = 8;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
// 8-second breathing animation: 4s in, 4s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
);
_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 Instagram?',
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 Instagram'),
),
),
],
),
),
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
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: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// 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',
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 Spacer(flex: 1),
// Quote
Text(
_quote,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white30,
fontSize: 13,
height: 1.7,
fontStyle: FontStyle.italic,
),
),
const Spacer(flex: 2),
],
),
),
),
);
}
}
+513
View File
@@ -0,0 +1,513 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/navigation_guard.dart';
import 'session_modal.dart';
import 'settings_page.dart';
import 'reel_player_overlay.dart';
class MainWebViewPage extends StatefulWidget {
const MainWebViewPage({super.key});
@override
State<MainWebViewPage> createState() => _MainWebViewPageState();
}
class _MainWebViewPageState extends State<MainWebViewPage> {
late final WebViewController _controller;
int _currentIndex = 0;
bool _isLoading = true;
// Cached username for profile navigation
String? _cachedUsername;
// Watchdog for app-session expiry
Timer? _watchdog;
bool _extensionDialogShown = false;
@override
void initState() {
super.initState();
_initWebView();
_startWatchdog();
}
@override
void dispose() {
_watchdog?.cancel();
super.dispose();
}
void _startWatchdog() {
_watchdog = Timer.periodic(const Duration(seconds: 15), (_) {
if (!mounted) return;
final sm = context.read<SessionManager>();
if (sm.isAppSessionExpired && !_extensionDialogShown) {
_extensionDialogShown = true;
_showSessionExpiredDialog(sm);
}
});
}
void _showSessionExpiredDialog(SessionManager sm) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text(
'Session Complete ✓',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your planned Instagram time is up.',
style: TextStyle(color: Colors.white70),
),
if (sm.canExtendAppSession) ...[
const SizedBox(height: 8),
const Text(
'You can extend once by 10 minutes.',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
],
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
sm.endAppSession();
SystemNavigator.pop(); // Force close
},
child: const Text(
'Close App',
style: TextStyle(color: Colors.redAccent),
),
),
if (sm.canExtendAppSession)
ElevatedButton(
onPressed: () {
Navigator.pop(context);
sm.extendAppSession();
_extensionDialogShown =
false; // Reset so watchdog can fire again at next expiry
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('+10 minutes'),
),
],
),
);
}
void _initWebView() {
final sessionManager = context.read<SessionManager>();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(InjectionController.iOSUserAgent)
..setBackgroundColor(Colors.black)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
// Only show loading if it's a real page load (not SPA nav)
if (!url.contains('#')) {
if (mounted) setState(() => _isLoading = true);
}
},
onPageFinished: (url) {
if (mounted) setState(() => _isLoading = false);
_applyInjections();
_updateCurrentTab(url);
// Cache username whenever we finish loading any page
_cacheUsername();
},
onNavigationRequest: (request) {
final isDmReel = NavigationGuard.isDmReelLink(request.url);
final decision = NavigationGuard.evaluate(
url: request.url,
sessionActive: sessionManager.isSessionActive,
isDmReelException: isDmReel,
);
if (decision.blocked) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(decision.reason ?? 'Blocked'),
backgroundColor: Colors.red.shade900,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 80),
duration: const Duration(seconds: 2),
),
);
}
return NavigationDecision.prevent;
}
// Open DM reel in isolated player
if (isDmReel && !sessionManager.isSessionActive) {
final canonicalUrl = NavigationGuard.canonicalizeDmReelUrl(
request.url,
);
if (canonicalUrl != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReelPlayerOverlay(url: canonicalUrl),
),
);
return NavigationDecision.prevent;
}
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse('https://www.instagram.com/'));
}
void _applyInjections() {
final sessionManager = context.read<SessionManager>();
final settings = context.read<SettingsService>();
final js = InjectionController.buildInjectionJS(
sessionActive: sessionManager.isSessionActive,
blurExplore: settings.blurExplore,
);
_controller.runJavaScript(js);
}
Future<void> _cacheUsername() async {
if (_cachedUsername != null) return; // Already known
try {
final result = await _controller.runJavaScriptReturningResult(
InjectionController.getLoggedInUsernameJS,
);
final raw = result.toString().replaceAll('"', '').replaceAll("'", '');
if (raw.isNotEmpty && raw != 'null' && raw != 'undefined') {
_cachedUsername = raw;
}
} catch (_) {}
}
void _updateCurrentTab(String url) {
final uri = Uri.tryParse(url);
if (uri == null) return;
final path = uri.path;
int newIndex = _currentIndex;
if (path == '/' || path.isEmpty) {
newIndex = 0;
} else if (path.startsWith('/explore') || path.startsWith('/search')) {
newIndex = 1;
} else if (path.startsWith('/direct')) {
newIndex = 3;
} else if (_cachedUsername != null &&
path.startsWith('/$_cachedUsername')) {
newIndex = 4;
}
if (newIndex != _currentIndex) {
setState(() => _currentIndex = newIndex);
}
}
/// Navigate using JS when already on Instagram (avoids full page reload).
/// Falls back to loadRequest if not on instagram.com.
Future<void> _navigateTo(String path) async {
try {
final currentUrl = await _controller.currentUrl();
if (currentUrl != null && currentUrl.contains('instagram.com')) {
// SPA soft nav — instant, no full reload
await _controller.runJavaScript(
InjectionController.softNavigateJS(path),
);
return;
}
} catch (_) {}
// Fallback: full load
await _controller.loadRequest(Uri.parse('https://www.instagram.com$path'));
}
Future<void> _onTabTapped(int index) async {
// Don't re-navigate if already on this tab
if (index == _currentIndex) return;
setState(() => _currentIndex = index);
switch (index) {
case 0:
await _navigateTo('/');
break;
case 1:
await _navigateTo('/explore/search/');
break;
case 2:
// Try to click Instagram's create button via JS
try {
await _controller.runJavaScript(
InjectionController.clickCreateButtonJS,
);
} catch (_) {
await _navigateTo('/');
}
break;
case 3:
await _navigateTo('/direct/inbox/');
break;
case 4:
if (_cachedUsername != null) {
await _navigateTo('/$_cachedUsername/');
} else {
// Try to get username first then navigate
await _cacheUsername();
if (_cachedUsername != null) {
await _navigateTo('/$_cachedUsername/');
} else {
// Last fallback: navigate to accounts/edit — usually has username
await _navigateTo('/accounts/edit/');
}
}
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
bottom: false,
child: Column(
children: [
// Status Bar — always on top
_StatusBar(),
// WebView
Expanded(
child: Stack(
children: [
WebViewWidget(controller: _controller),
// Thin loading bar (not full-screen spinner)
if (_isLoading)
const LinearProgressIndicator(
backgroundColor: Colors.transparent,
color: Colors.blue,
minHeight: 2,
),
],
),
),
],
),
),
bottomNavigationBar: _FocusGramNavBar(
currentIndex: _currentIndex,
onTap: _onTabTapped,
),
floatingActionButton: _SessionFAB(onTap: _openSessionModal),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
void _openSessionModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const SessionModal(),
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Status Bar Widget — only rebuilds when session state changes
// ──────────────────────────────────────────────────────────────────────────────
class _StatusBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
String label;
Color dotColor;
IconData dotIcon;
if (sm.isSessionActive) {
final m = sm.remainingSessionSeconds ~/ 60;
final s = sm.remainingSessionSeconds % 60;
label =
'Reels: ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
dotColor = Colors.greenAccent;
dotIcon = Icons.play_circle_outline;
} else if (sm.isCooldownActive) {
final m = sm.cooldownRemainingSeconds ~/ 60;
label = 'Cooldown: ${m}m left';
dotColor = Colors.orangeAccent;
dotIcon = Icons.timer_outlined;
} else {
label = 'Reels Blocked';
dotColor = Colors.redAccent;
dotIcon = Icons.block;
}
// App session indicator
final appM = sm.appSessionRemainingSeconds ~/ 60;
final appS = sm.appSessionRemainingSeconds % 60;
final appLabel = sm.isAppSessionActive
? 'App: ${appM.toString().padLeft(2, '0')}:${appS.toString().padLeft(2, '0')}'
: '';
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 14),
color: Colors.black,
child: Row(
children: [
// Status dot
Icon(dotIcon, color: dotColor, size: 13),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: dotColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// App session timer
if (appLabel.isNotEmpty)
Text(
appLabel,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
if (appLabel.isNotEmpty) const SizedBox(width: 10),
// Daily reel usage
Text(
'Daily: ${sm.dailyRemainingSeconds ~/ 60}m',
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
const SizedBox(width: 10),
// Settings icon
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
child: const Icon(Icons.tune, color: Colors.white38, size: 18),
),
],
),
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Custom Bottom Nav Bar — minimal, Instagram-like
// ──────────────────────────────────────────────────────────────────────────────
class _FocusGramNavBar extends StatelessWidget {
final int currentIndex;
final Future<void> Function(int) onTap;
const _FocusGramNavBar({required this.currentIndex, required this.onTap});
@override
Widget build(BuildContext context) {
final items = [
(Icons.home_outlined, Icons.home_rounded, 'Home'),
(Icons.search, Icons.search, 'Search'),
(Icons.add_box_outlined, Icons.add_box_rounded, 'Create'),
(Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'),
(Icons.person_outline, Icons.person, 'Profile'),
];
return Container(
color: Colors.black,
child: SafeArea(
top: false,
child: SizedBox(
height: 52,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: items.asMap().entries.map((entry) {
final i = entry.key;
final (outlinedIcon, filledIcon, label) = entry.value;
final isSelected = i == currentIndex;
return GestureDetector(
onTap: () => onTap(i),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 60,
child: Center(
child: Icon(
isSelected ? filledIcon : outlinedIcon,
color: isSelected ? Colors.white : Colors.white54,
size: 26,
),
),
),
);
}).toList(),
),
),
),
);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Session FAB
// ──────────────────────────────────────────────────────────────────────────────
class _SessionFAB extends StatelessWidget {
final VoidCallback onTap;
const _SessionFAB({required this.onTap});
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
if (sm.isSessionActive) {
// Show "end session" button when session is active
return FloatingActionButton.small(
backgroundColor: Colors.green.shade700,
onPressed: () => sm.endSession(),
child: const Icon(Icons.stop, color: Colors.white, size: 18),
);
}
final fab = FloatingActionButton.small(
backgroundColor: Colors.blue.shade700,
onPressed: settings.requireLongPress ? null : onTap,
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 22,
),
);
if (settings.requireLongPress) {
return GestureDetector(onLongPress: onTap, child: fab);
}
return fab;
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.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> {
late final WebViewController _controller;
DateTime? _startTime;
@override
void initState() {
super.initState();
_startTime = DateTime.now();
_initWebView();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(InjectionController.iOSUserAgent)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) {
// Apply scroll-lock: prevents swiping to next reel in the feed
_controller.runJavaScript(
InjectionController.reelScrollLockJS(widget.url),
);
// Also hide Instagram's bottom nav inside this overlay
_controller.runJavaScript(
InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
),
);
},
onNavigationRequest: (request) {
// Allow only the initial reel URL and instagram.com generally
final uri = Uri.tryParse(request.url);
if (uri == null) return NavigationDecision.prevent;
final host = uri.host;
if (!host.contains('instagram.com')) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.url));
}
@override
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: WebViewWidget(controller: _controller),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.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) {
final sm = context.read<SessionManager>();
if (sm.startSession(minutes)) {
Navigator.pop(context);
}
}
}
+405
View File
@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final sm = context.watch<SessionManager>();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'FocusGram',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
// ── Stats row ───────────────────────────────────────────
_buildStatsRow(sm),
// ── Consumption Limits ──────────────────────────────────
_buildSectionHeader('Reel Consumption Limits'),
_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,
warningText:
'Increasing your daily limit may make it easier to mindlessly 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,
warningText:
'Reducing the cooldown makes it easier to start new reel sessions. Are you sure?',
onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()),
),
// ── Distraction Management ──────────────────────────────
_buildSectionHeader('Distraction Management'),
SwitchListTile(
title: const Text(
'Blur Explore feed',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Blurs posts and reels in Explore by default',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
value: settings.blurExplore,
onChanged: (v) => settings.setBlurExplore(v),
activeThumbColor: Colors.blue,
),
// ── Friction & Discipline ───────────────────────────────
_buildSectionHeader('Friction & Discipline'),
SwitchListTile(
title: const Text(
'Mindfulness Gate',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Show breathing exercise before opening Instagram',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
value: settings.showBreathGate,
onChanged: (v) => settings.setShowBreathGate(v),
activeThumbColor: Colors.blue,
),
SwitchListTile(
title: const Text(
'Long-press to start Reel session',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Requires 2s hold on the play button',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
value: settings.requireLongPress,
onChanged: (v) => settings.setRequireLongPress(v),
activeThumbColor: Colors.blue,
),
const Divider(color: Colors.white10, height: 40),
// ── Danger zone ─────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton(
onPressed: () => _confirmReset(context, sm),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withAlpha(
(255 * 0.08).round(),
), // Changed from withOpacity
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('Reset Daily Usage Counter'),
),
),
const SizedBox(height: 40),
const Center(
child: Text(
'FocusGram · Built for discipline',
style: TextStyle(color: Colors.white12, fontSize: 12),
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildStatsRow(SessionManager sm) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF111111),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
],
),
);
}
Widget _statCell(String label, String value, Color color) {
return Column(
children: [
Text(
value,
style: TextStyle(
color: color,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
],
);
}
Widget _dividerCell() =>
Container(width: 1, height: 36, color: Colors.white10);
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
child: Text(
title.toUpperCase(),
style: const TextStyle(
color: Colors.blue,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.3,
),
),
);
}
/// A slider tile that shows a friction dialog before accepting a larger value.
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 String warningText,
required Future<void> Function(double) onConfirmed,
}) {
return _FrictionSliderTile(
title: title,
subtitle: subtitle,
value: value,
min: min,
max: max,
divisor: divisor,
warningText: warningText,
onConfirmed: onConfirmed,
);
}
void _confirmReset(BuildContext context, SessionManager sm) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text(
'Reset Counter?',
style: TextStyle(color: Colors.white),
),
content: const Text(
'This will reset your daily reel usage to zero minutes.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
sm.resetDailyCounter();
Navigator.pop(ctx);
},
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
}
}
/// Stateful slider tile that shows a friction dialog when the user moves the
/// slider to a value greater than the current persisted value.
class _FrictionSliderTile extends StatefulWidget {
final String title;
final String subtitle;
final double value;
final double min;
final double max;
final int divisor;
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.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);
// Keep draft in sync if external value changed (e.g. after reset)
if (!_pendingConfirm) _draftValue = widget.value;
}
@override
Widget build(BuildContext context) {
final divisions = ((widget.max - widget.min) / widget.divisor).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(
widget.title,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
'${_draftValue.toInt()} min',
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
trailing: _pendingConfirm
? Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() {
_draftValue = widget.value;
_pendingConfirm = false;
});
},
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white38),
),
),
ElevatedButton(
onPressed: () async {
setState(() => _pendingConfirm = false);
await widget.onConfirmed(_draftValue);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Apply',
style: TextStyle(fontSize: 12),
),
),
],
)
: null,
),
if (_pendingConfirm)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
widget.warningText,
style: TextStyle(
color: Colors.orangeAccent.withValues(alpha: 0.8),
fontSize: 12,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Slider(
value: _draftValue,
min: widget.min,
max: widget.max,
divisions: divisions,
activeColor: _pendingConfirm ? Colors.orange : Colors.blue,
onChanged: (v) {
setState(() {
_draftValue = v;
// Show friction warning when moving to a larger (more permissive) value
_pendingConfirm = v > widget.value;
});
},
onChangeEnd: (v) {
// If decreasing (more strict), apply immediately without dialog
if (v <= widget.value) {
widget.onConfirmed(v);
setState(() => _pendingConfirm = false);
}
},
),
),
],
);
}
}
+341
View File
@@ -0,0 +1,341 @@
/// Generates all CSS and JavaScript injection strings for the WebView.
///
/// Strategy:
/// - Instagram's own bottom nav bar is hidden via both CSS and a periodic JS
/// removal loop, since SPA re-renders can outpace MutationObserver.
/// - Reel elements are hidden/blurred based on settings/session state.
/// - A MutationObserver keeps re-applying the rules after SPA re-renders.
/// - App-install banners are auto-dismissed.
class InjectionController {
/// iOS Safari user-agent — reduces login friction with Instagram.
static const String iOSUserAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
'Version/17.5 Mobile/15E148 Safari/604.1';
// ── CSS injection ───────────────────────────────────────────────────────────
/// Robust CSS that hides Instagram's native bottom nav bar.
/// Covers all known selector patterns including dynamic class names.
static const String _hideInstagramNavCSS = '''
/* ── Instagram bottom navigation bar — hide completely ── */
/* Role-based selectors */
div[role="tablist"],
nav[role="navigation"],
/* Fixed-position bottom bar */
div[style*="position: fixed"][style*="bottom"],
div[style*="position:fixed"][style*="bottom"],
/* Instagram legacy class names */
._acbl, ._aa4b, ._aahi, ._ab8s,
/* Section nav elements */
section nav,
/* Any nav inside the main app shell */
#react-root nav,
/* The outer wrapper of the bottom bar (PWA/mobile web) */
[class*="x1n2onr6"][class*="x1vjfegm"] > nav,
/* Catch-all: any fixed bottom element containing nav links */
footer nav,
div[class*="bottom"] nav {
display: none !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
/* Ensure the body doesn't add bottom padding for the nav */
body, #react-root, main {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
''';
/// CSS to hide Reel-related elements everywhere (feed, profile, search).
/// Used when session is NOT active.
static const String _hideReelsCSS = '''
/* Hide reel thumbnails and links */
a[href*="/reel/"],
a[href*="/reels"],
[aria-label*="Reel"],
[aria-label*="Reels"],
div[data-media-type="2"],
/* Profile grid reel filter tabs */
[aria-label="Reels"],
/* Reel indicators on feed thumbnails */
svg[aria-label="Reels"],
/* Video/reel chips in feed */
[class*="reel"],
[class*="Reel"] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
''';
/// CSS to blur Explore feed posts/reels (keeps stories visible).
static const String _blurExploreCSS = '''
/* Blur Explore grid posts and reel cards (not stories row) */
main[role="main"] section > div > div:not(:first-child) a img,
main[role="main"] section > div > div:not(:first-child) video,
main[role="main"] section > div > div:not(:first-child) [class*="x6s0dn4"],
main[role="main"] article img,
main[role="main"] article video,
/* Explore page grid */
._aagv img,
._aagv video {
filter: blur(12px) !important;
pointer-events: none !important;
}
/* Overlay to block tapping blurred content */
._aagv::after {
content: "";
position: absolute;
inset: 0;
z-index: 99;
cursor: not-allowed;
}
._aagv {
position: relative !important;
overflow: hidden !important;
}
''';
/// Auto-dismiss "Open in App" banner that Instagram shows in mobile browsers.
static const String _dismissAppBannerJS = '''
(function dismissBanners() {
const selectors = [
'[id*="app-banner"]',
'[class*="app-banner"]',
'[data-testid*="app-banner"]',
'div[role="dialog"][aria-label*="app"]',
'div[role="dialog"][aria-label*="App"]',
];
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => el.remove());
});
})();
''';
/// Periodic remover: every 500ms force-removes the bottom nav.
/// Complements the MutationObserver for sites that rebuild DOM faster.
static const String _periodicNavRemoverJS = '''
(function periodicNavRemove() {
function removeNav() {
// Target all fixed-bottom elements that could be the nav bar
document.querySelectorAll([
'div[role="tablist"]',
'nav[role="navigation"]',
'._acbl', '._aa4b', '._aahi', '._ab8s',
'section nav',
'footer nav'
].join(',')).forEach(function(el) {
el.style.cssText += ';display:none!important;height:0!important;overflow:hidden!important;';
});
// Also hide any element that is fixed at the bottom and contains nav links
document.querySelectorAll('div[style]').forEach(function(el) {
const s = el.style;
if ((s.position === 'fixed' || s.position === 'sticky') &&
(s.bottom === '0px' || s.bottom === '0') &&
el.querySelector('a,button')) {
el.style.cssText += ';display:none!important;';
}
});
}
removeNav();
setInterval(removeNav, 500);
})();
''';
/// MutationObserver that continuously re-applies CSS after SPA re-renders.
static String _buildMutationObserver(String cssContent) =>
'''
(function applyFocusGramStyles() {
const STYLE_ID = 'focusgram-injected-style';
function injectCSS() {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
document.head.appendChild(el);
}
el.textContent = ${_escapeJsString(cssContent)};
}
injectCSS();
const observer = new MutationObserver(function() {
if (!document.getElementById(STYLE_ID)) {
injectCSS();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
})();
''';
static String _escapeJsString(String s) {
// Wrap in JS template literal backticks; escape any internal backticks.
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
return '`$escaped`';
}
// ── Navigation helpers ──────────────────────────────────────────────────────
/// JS that soft-navigates Instagram's SPA without a full page reload.
/// [path] should start with / e.g. '/direct/inbox/'.
static String softNavigateJS(String path) =>
'''
(function() {
const target = ${_escapeJsString(path)};
// Try React Router / Instagram SPA navigation first (pushState trick)
if (window.location.pathname !== target) {
window.location.href = target;
}
})();
''';
/// JS to click Instagram's native "create post" button.
static const String clickCreateButtonJS = '''
(function() {
const btn = document.querySelector(
'[aria-label="New post"], [aria-label="Create"], svg[aria-label="New post"]'
);
if (btn) {
btn.closest('a, button') ? btn.closest('a, button').click() : btn.click();
} else {
// Fallback: navigate to home first, create will open as modal
window.location.href = '/';
}
})();
''';
/// JS to get the currently logged-in user's username.
static const String getLoggedInUsernameJS = '''
(function() {
try {
// Try shared data approach
const scripts = Array.from(document.querySelectorAll('script[type="application/json"]'));
for (const s of scripts) {
try {
const d = JSON.parse(s.textContent);
if (d && d.config && d.config.viewer && d.config.viewer.username) {
return d.config.viewer.username;
}
} catch(e){}
}
// Try window additionalDataLoaded
if (window.__additionalDataLoaded) {
const keys = Object.keys(window.__additionalDataLoaded || {});
for (const k of keys) {
const v = window.__additionalDataLoaded[k];
if (v && v.data && v.data.user && v.data.user.username) {
return v.data.user.username;
}
}
}
// Fallback: try profile anchor in nav
const profileLink = document.querySelector('a[href][aria-label*="rofile"]');
if (profileLink) {
const href = profileLink.getAttribute('href');
if (href) {
const parts = href.replace(/^[/]/, "").split("/");
if (parts[0] && parts[0].length > 0) return parts[0];
}
}
return null;
} catch(e) { return null; }
})();
''';
// ── Reel scroll-lock ────────────────────────────────────────────────────────
/// JS that prevents the user from scrolling to a different reel.
/// Intercepts history changes — if a /reel/ URL changes, navigate back.
static String reelScrollLockJS(String canonicalUrl) {
final escapedUrl = _escapeJsString(canonicalUrl);
return '''
(function lockReel() {
const LOCKED_URL = $escapedUrl;
function extractReelId(url) {
const m = url.match(/\\/reel\\/([^\\/\\?#]+)/);
return m ? m[1] : null;
}
const lockedId = extractReelId(LOCKED_URL);
if (!lockedId) return;
// Override pushState and replaceState
const _pushState = history.pushState.bind(history);
const _replaceState = history.replaceState.bind(history);
function checkAndRevert(newUrl) {
const newId = extractReelId(newUrl || window.location.href);
if (newId && newId !== lockedId) {
// Different reel — go back to ours
setTimeout(function() {
window.location.replace(LOCKED_URL);
}, 50);
}
}
history.pushState = function(state, title, url) {
_pushState(state, title, url);
checkAndRevert(url);
};
history.replaceState = function(state, title, url) {
_replaceState(state, title, url);
checkAndRevert(url);
};
window.addEventListener('popstate', function() {
checkAndRevert(window.location.href);
});
// Also disable vertical swipe gestures that drive reel-to-reel
let startY = 0;
document.addEventListener('touchstart', function(e) {
startY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchmove', function(e) {
const dy = e.touches[0].clientY - startY;
if (Math.abs(dy) > 20) {
e.preventDefault();
}
}, { passive: false });
})();
''';
}
/// JS to disable swipe-to-next behavior inside the isolated Reel player.
static const String disableReelSwipeJS = '''
(function disableSwipeNavigation() {
let startX = 0;
document.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, {passive: true});
document.addEventListener('touchmove', e => {
const dx = Math.abs(e.touches[0].clientX - startX);
if (dx > 30) e.preventDefault();
}, {passive: false});
})();
''';
// ── Public API ──────────────────────────────────────────────────────────────
/// Full injection JS to run on every page load.
static String buildInjectionJS({
required bool sessionActive,
required bool blurExplore,
}) {
final StringBuffer css = StringBuffer();
css.write(_hideInstagramNavCSS);
if (!sessionActive) css.write(_hideReelsCSS);
if (blurExplore) css.write(_blurExploreCSS);
return '''
${_buildMutationObserver(css.toString())}
$_periodicNavRemoverJS
$_dismissAppBannerJS
''';
}
}
+89
View File
@@ -0,0 +1,89 @@
/// Determines whether a navigation request should be blocked.
///
/// Rules:
/// - /reels/* and /reel/* are blocked unless [sessionActive] is true OR
/// [isDmReelException] is true (single DM reel open).
/// - /explore/ is allowed (but explore content is blurred via CSS).
/// - Only instagram.com domains are allowed (blocks external redirects).
class NavigationGuard {
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
static const _blockedPathPrefixes = ['/reels', '/reel/'];
/// Returns a [BlockDecision] for the given [url].
static BlockDecision evaluate({
required String url,
required bool sessionActive,
required bool isDmReelException,
}) {
Uri uri;
try {
uri = Uri.parse(url);
} catch (_) {
return BlockDecision(blocked: false, reason: null);
}
// Allow non-HTTP schemes (about:blank, data:, etc.)
if (!uri.scheme.startsWith('http')) {
return BlockDecision(blocked: false, reason: null);
}
// Block non-Instagram domains (prevents phishing redirects)
final host = uri.host.toLowerCase();
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
return BlockDecision(
blocked: true,
reason: 'External domain blocked: $host',
);
}
// Check reel/reels path
final path = uri.path.toLowerCase();
final isReelUrl = _blockedPathPrefixes.any((p) => path.startsWith(p));
if (isReelUrl) {
if (sessionActive || isDmReelException) {
return BlockDecision(blocked: false, reason: null);
}
return BlockDecision(
blocked: true,
reason: 'Reel navigation blocked — no active session',
);
}
return BlockDecision(blocked: false, reason: null);
}
/// Returns true if the URL looks like a Reel link from a DM.
static bool isDmReelLink(String url) {
try {
final uri = Uri.parse(url);
final path = uri.path.toLowerCase();
return path.startsWith('/reel/') || path.startsWith('/reels/');
} catch (_) {
return false;
}
}
/// Extracts a canonical single-reel URL from a DM reel link.
/// Strips query params that might trigger Reels feed.
static String? canonicalizeDmReelUrl(String url) {
try {
final uri = Uri.parse(url);
// Keep only the reel path, strip all query parameters
return Uri(
scheme: 'https',
host: 'www.instagram.com',
path: uri.path,
).toString();
} catch (_) {
return null;
}
}
}
class BlockDecision {
final bool blocked;
final String? reason;
const BlockDecision({required this.blocked, required this.reason});
}
+328
View File
@@ -0,0 +1,328 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 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';
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;
// ── 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;
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;
// ── 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;
// ── Initialization ─────────────────────────────────────────
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _resetDailyIfNeeded();
_loadPersisted();
_startTicker();
_incrementOpenCount();
}
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 {
_cleanupExpiredReelSession();
}
}
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);
}
}
void _incrementOpenCount() {
_dailyOpenCount++;
_prefs?.setInt(_keyDailyOpenCount, _dailyOpenCount);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
bool changed = false;
// Reel session countdown
if (_isSessionActive) {
if (remainingSessionSeconds <= 0) {
_cleanupExpiredReelSession();
changed = true;
} else {
_dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) _cleanupExpiredReelSession();
changed = true;
}
}
// App session expiry check
if (_appSessionEnd != null &&
!_appSessionExpiredFlag &&
DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
if (isCooldownActive) changed = true;
if (changed) notifyListeners();
}
void _cleanupExpiredReelSession() {
_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;
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
}
void endSession() {
if (!_isSessionActive) return;
_cleanupExpiredReelSession();
notifyListeners();
}
void accrueSeconds(int seconds) {
_dailyUsedSeconds = (_dailyUsedSeconds + seconds).clamp(
0,
_dailyLimitSeconds,
);
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
notifyListeners();
}
// ── App session API ────────────────────────────────────────
/// Start an app session of [minutes] (160).
void startAppSession(int minutes) {
final end = DateTime.now().add(Duration(minutes: minutes));
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
notifyListeners();
}
/// Extend the app session by 10 minutes. Only works once.
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
_appSessionEnd = base.add(const Duration(minutes: 10));
_appExtensionUsed = true;
_appSessionExpiredFlag = false;
_prefs?.setInt(_keyAppSessionEnd, _appSessionEnd!.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, true);
notifyListeners();
return true;
}
/// Called when the user closes the app voluntarily or after extension denial.
void endAppSession() {
_lastAppSessionEnd = DateTime.now();
_appSessionEnd = null;
_appSessionExpiredFlag = false;
_prefs?.setInt(
_keyLastAppSessEnd,
_lastAppSessionEnd!.millisecondsSinceEpoch,
);
_prefs?.setInt(_keyAppSessionEnd, 0);
notifyListeners();
}
// ── Settings mutations ─────────────────────────────────────
Future<void> setDailyLimitMinutes(int minutes) async {
_dailyLimitSeconds = minutes * 60;
await _prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
Future<void> setPerSessionMinutes(int minutes) async {
_perSessionSeconds = minutes * 60;
await _prefs?.setInt(_keyPerSessionSec, _perSessionSeconds);
notifyListeners();
}
Future<void> setCooldownMinutes(int minutes) async {
_cooldownSeconds = minutes * 60;
await _prefs?.setInt(_keyCooldownSec, _cooldownSeconds);
notifyListeners();
}
Future<void> resetDailyCounter() async {
_dailyUsedSeconds = 0;
await _prefs?.setInt(_keyDailyUsedSeconds, 0);
if (_isSessionActive) endSession();
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}
+55
View File
@@ -0,0 +1,55 @@
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 _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate';
SharedPreferences? _prefs;
bool _blurExplore = true; // Default: blur explore feed posts/reels
bool _blurReels = false; // If false: hide reels in feed (after session ends)
bool _requireLongPress = true; // Long-press FAB to start session
bool _showBreathGate = true; // Show breathing gate on every open
bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels;
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
notifyListeners();
}
Future<void> setBlurExplore(bool v) async {
_blurExplore = v;
await _prefs?.setBool(_keyBlurExplore, v);
notifyListeners();
}
Future<void> setBlurReels(bool v) async {
_blurReels = v;
await _prefs?.setBool(_keyBlurReels, 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();
}
}