From 354f7413d19d20348e95fc7509141b23098990d5 Mon Sep 17 00:00:00 2001 From: Ujwal Date: Sun, 22 Feb 2026 23:59:20 +0545 Subject: [PATCH] feat: added "Scheduled Blocking" improved reel blocking logic changed from topbar to sidepanel improved seddings page added about page --- .gitignore | 1 + lib/screens/about_page.dart | 102 +++++ lib/screens/breath_gate_screen.dart | 8 +- lib/screens/cooldown_gate_screen.dart | 201 +++++----- lib/screens/guardrails_page.dart | 285 ++++++++++++++ lib/screens/main_webview_page.dart | 498 +++++++++++++++++-------- lib/screens/reel_player_overlay.dart | 4 +- lib/screens/session_modal.dart | 10 +- lib/screens/settings_page.dart | 270 ++++++-------- lib/services/injection_controller.dart | 137 +++---- lib/services/session_manager.dart | 120 +++++- lib/services/settings_service.dart | 11 + lib/utils/discipline_challenge.dart | 128 +++++++ pubspec.lock | 66 +++- pubspec.yaml | 3 + 15 files changed, 1336 insertions(+), 508 deletions(-) create mode 100644 lib/screens/about_page.dart create mode 100644 lib/screens/guardrails_page.dart create mode 100644 lib/utils/discipline_challenge.dart diff --git a/.gitignore b/.gitignore index 3820a95..f32192b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .svn/ .swiftpm/ migrate_working_dir/ +PRD.md # IntelliJ related *.iml diff --git a/lib/screens/about_page.dart b/lib/screens/about_page.dart new file mode 100644 index 0000000..71fd823 --- /dev/null +++ b/lib/screens/about_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + title: const Text( + 'About FocusGram', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.psychology, + color: Colors.blue, + size: 50, + ), + ), + const SizedBox(height: 24), + const Text( + 'FocusGram', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Text( + 'Version 1.1.0', + style: TextStyle(color: Colors.white38, fontSize: 13), + ), + const SizedBox(height: 40), + const Text( + 'Developed with passion for digital discipline by', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(height: 4), + const Text( + 'Ujwal Chapagain', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 60), + ElevatedButton.icon( + onPressed: () => + _launchURL('https://github.com/Ujwal223/FocusGram'), + icon: const Icon(Icons.code), + label: const Text('View on GitHub'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white10, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'FocusGram is not affiliated with Instagram.', + style: TextStyle(color: Colors.white12, fontSize: 10), + ), + ], + ), + ), + ), + ); + } + + Future _launchURL(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } +} diff --git a/lib/screens/breath_gate_screen.dart b/lib/screens/breath_gate_screen.dart index 9e6e465..436e31e 100644 --- a/lib/screens/breath_gate_screen.dart +++ b/lib/screens/breath_gate_screen.dart @@ -2,7 +2,7 @@ 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. +/// Forces the user to take a deep 10-second breath. class BreathGateScreen extends StatefulWidget { final VoidCallback onFinish; @@ -16,7 +16,7 @@ class _BreathGateScreenState extends State with TickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; - int _secondsRemaining = 8; + int _secondsRemaining = 10; Timer? _timer; bool _canContinue = false; @@ -24,10 +24,10 @@ class _BreathGateScreenState extends State void initState() { super.initState(); - // 8-second breathing animation: 4s in, 4s out + // 10-second breathing animation: 5s in, 5s out _controller = AnimationController( vsync: this, - duration: const Duration(seconds: 4), + duration: const Duration(seconds: 5), ); _scaleAnimation = Tween( diff --git a/lib/screens/cooldown_gate_screen.dart b/lib/screens/cooldown_gate_screen.dart index 07ec1f0..0904292 100644 --- a/lib/screens/cooldown_gate_screen.dart +++ b/lib/screens/cooldown_gate_screen.dart @@ -56,111 +56,112 @@ class _CooldownGateScreenState extends State { 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, + 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, ), ), - 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, + 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, + ), + ), + ], ), - - 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), - ], + ), ), ), ), diff --git a/lib/screens/guardrails_page.dart b/lib/screens/guardrails_page.dart new file mode 100644 index 0000000..2e5e95b --- /dev/null +++ b/lib/screens/guardrails_page.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/session_manager.dart'; +import '../utils/discipline_challenge.dart'; + +class GuardrailsPage extends StatelessWidget { + const GuardrailsPage({super.key}); + + @override + Widget build(BuildContext context) { + final sm = context.watch(); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + 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: [ + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Set your limits to stay focused. Changes to these settings require a challenge.', + style: TextStyle(color: Colors.white54, 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()), + ), + const Divider(color: Colors.white10, height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Scheduled Blocking', + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + SwitchListTile( + title: const Text( + 'Enable Blocking Schedule', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Block Instagram during specific hours', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + value: sm.scheduleEnabled, + onChanged: (v) => sm.setScheduleEnabled(v), + ), + if (sm.scheduleEnabled) ...[ + ListTile( + title: const Text( + 'Start Time', + style: TextStyle(color: Colors.white), + ), + trailing: Text( + '${sm.schedStartHour.toString().padLeft(2, '0')}:${sm.schedStartMin.toString().padLeft(2, '0')}', + style: const TextStyle(color: Colors.blue), + ), + onTap: () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: sm.schedStartHour, + minute: sm.schedStartMin, + ), + ); + if (time != null) { + sm.setScheduleTime( + startH: time.hour, + startM: time.minute, + endH: sm.schedEndHour, + endM: sm.schedEndMin, + ); + } + }, + ), + ListTile( + title: const Text( + 'End Time', + style: TextStyle(color: Colors.white), + ), + trailing: Text( + '${sm.schedEndHour.toString().padLeft(2, '0')}:${sm.schedEndMin.toString().padLeft(2, '0')}', + style: const TextStyle(color: Colors.blue), + ), + onTap: () async { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: sm.schedEndHour, + minute: sm.schedEndMin, + ), + ); + if (time != null) { + sm.setScheduleTime( + startH: sm.schedStartHour, + startM: sm.schedStartMin, + endH: time.hour, + endM: time.minute, + ); + } + }, + ), + ], + ], + ), + ); + } + + 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 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 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 + Widget build(BuildContext context) { + final divisions = ((widget.max - widget.min) / widget.divisor).round(); + + return Column( + children: [ + ListTile( + title: Text( + widget.title, + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + '${_draftValue.toInt()} min', + style: const TextStyle(color: Colors.white70), + ), + 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 { + final success = await DisciplineChallenge.show(context); + if (!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); + } + }, + ), + ], + ); + } +} diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index 25b87d3..9bd2ab4 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -17,7 +17,8 @@ class MainWebViewPage extends StatefulWidget { State createState() => _MainWebViewPageState(); } -class _MainWebViewPageState extends State { +class _MainWebViewPageState extends State + with WidgetsBindingObserver { late final WebViewController _controller; int _currentIndex = 0; bool _isLoading = true; @@ -32,16 +33,31 @@ class _MainWebViewPageState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _initWebView(); _startWatchdog(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _watchdog?.cancel(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!mounted) return; + final sm = context.read(); + if (state == AppLifecycleState.resumed) { + sm.setAppForeground(true); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + sm.setAppForeground(false); + } + } + void _startWatchdog() { _watchdog = Timer.periodic(const Duration(seconds: 15), (_) { if (!mounted) return; @@ -132,10 +148,10 @@ class _MainWebViewPageState extends State { _applyInjections(); _updateCurrentTab(url); _cacheUsername(); - // Inject swipe-blocker when on a specific reel page - if (NavigationGuard.isSpecificReel(url)) { - _controller.runJavaScript(InjectionController.reelSwipeBlockerJS); - } + // Inject MutationObserver to lock reel scrolling resiliently + _controller.runJavaScript( + InjectionController.reelsMutationObserverJS, + ); }, onNavigationRequest: (request) { final decision = NavigationGuard.evaluate(url: request.url); @@ -234,17 +250,12 @@ class _MainWebViewPageState extends State { await _navigateTo('/'); break; case 1: - await _navigateTo('/explore/search/'); + // Search tab - user reported "dark page" at /explore/search/ + // Let's try /explore/ directly which usually shows the search bar on mobile web + await _navigateTo('/explore/'); break; case 2: - // Try to click Instagram's create button via JS - try { - await _controller.runJavaScript( - InjectionController.clickCreateButtonJS, - ); - } catch (_) { - await _navigateTo('/'); - } + _openSessionModal(); break; case 3: await _navigateTo('/direct/inbox/'); @@ -253,12 +264,10 @@ class _MainWebViewPageState extends State { 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/'); } } @@ -268,48 +277,59 @@ class _MainWebViewPageState extends State { @override Widget build(BuildContext context) { - final topPad = MediaQuery.of(context).padding.top; const barHeight = 60.0; - return Scaffold( - backgroundColor: Colors.black, - floatingActionButton: _SessionFAB(onTap: _openSessionModal), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, - body: Stack( - children: [ - // ── WebView: full screen (behind everything) ──────────────── - Positioned.fill(child: WebViewWidget(controller: _controller)), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + if (await _controller.canGoBack()) { + await _controller.goBack(); + } else { + // If no history, we can either minimize or close. + // SystemNavigator.pop() is usually what users expect for "Close". + SystemNavigator.pop(); + } + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // ── WebView: full screen (behind everything) ──────────────── + Positioned.fill(child: WebViewWidget(controller: _controller)), - // ── Thin loading indicator at very top ────────────────────── - if (_isLoading) + // ── Thin loading indicator at very top ────────────────────── + if (_isLoading) + Positioned( + top: 0, + left: 0, + right: 0, + child: const LinearProgressIndicator( + backgroundColor: Colors.transparent, + color: Colors.blue, + minHeight: 2, + ), + ), + + // ── The Edge Panel (replaced _StatusBar) ──────────────────── + // No Positioned wrapper here: _EdgePanel returns its own Positioned siblings + _EdgePanel(controller: _controller), + + // ── Our bottom bar overlaid on top of Instagram's bar ─────── + // Making it taller than Instagram's native bar (~50dp) means + // theirs is fully hidden behind ours — no CSS needed as fallback. Positioned( - top: 0, + bottom: 0, left: 0, right: 0, - child: const LinearProgressIndicator( - backgroundColor: Colors.transparent, - color: Colors.blue, - minHeight: 2, + child: _FocusGramNavBar( + currentIndex: _currentIndex, + onTap: _onTabTapped, + height: barHeight, ), ), - - // ── Status bar overlaid at top (below system status bar) ──── - Positioned(top: topPad, left: 0, right: 0, child: _StatusBar()), - - // ── Our bottom bar overlaid on top of Instagram's bar ─────── - // Making it taller than Instagram's native bar (~50dp) means - // theirs is fully hidden behind ours — no CSS needed as fallback. - Positioned( - bottom: 0, - left: 0, - right: 0, - child: _FocusGramNavBar( - currentIndex: _currentIndex, - onTap: _onTabTapped, - height: barHeight, - ), - ), - ], + ], + ), ), ); } @@ -317,7 +337,6 @@ class _MainWebViewPageState extends State { void _openSessionModal() { showModalBottomSheet( context: context, - isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => const SessionModal(), ); @@ -325,86 +344,288 @@ class _MainWebViewPageState extends State { } // ────────────────────────────────────────────────────────────────────────────── -// Status Bar Widget — only rebuilds when session state changes +// Edge Panel Widget — Samsung-style swipe-to-reveal side panel // ────────────────────────────────────────────────────────────────────────────── -class _StatusBar extends StatelessWidget { +class _EdgePanel extends StatefulWidget { + final WebViewController controller; + const _EdgePanel({required this.controller}); + + @override + State<_EdgePanel> createState() => _EdgePanelState(); +} + +class _EdgePanelState extends State<_EdgePanel> { + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _toggleExpansion() { + setState(() { + _isExpanded = !_isExpanded; + }); + } + @override Widget build(BuildContext context) { final sm = context.watch(); + final int remaining = sm.remainingSessionSeconds; + final double progress = sm.perSessionSeconds > 0 + ? (remaining / sm.perSessionSeconds).clamp(0.0, 1.0) + : 0; - 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; + Color barColor = Colors.grey.withValues(alpha: 0.6); + if (progress < 0.2) { + barColor = Colors.redAccent; + } else if (progress < 0.5) { + barColor = Colors.yellowAccent.withValues(alpha: 0.8); } - // 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')}' - : ''; + // We use a transparent Stack filling the screen to position elements anywhere. + // Hits will pass through the Stack to the WebView except on our children. + return Stack( + children: [ + // ── The Handle (Minimized State) ── + if (!_isExpanded) + Positioned( + left: 0, + top: MediaQuery.of(context).size.height * 0.35, + child: Material( + color: Colors.transparent, + child: Column( + children: [ + GestureDetector( + onHorizontalDragUpdate: (details) { + if (details.delta.dx > 10) _toggleExpansion(); + }, + onTap: _toggleExpansion, + child: Container( + width: 10, + height: 100, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + border: Border.all(color: Colors.white24, width: 0.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 10, + ), + ], + ), + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 2, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + width: 4, + decoration: BoxDecoration( + color: barColor, + borderRadius: BorderRadius.circular(4), + ), + // Height determined by progress + height: (progress * 88).clamp(4.0, 88.0), + ), + ), + ), + ), + const SizedBox(height: 12), + // Gear icon below handle + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + shape: BoxShape.circle, + border: Border.all(color: Colors.white24, width: 0.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + ), + ], + ), + child: const Icon( + Icons.settings_rounded, + color: Colors.white70, + size: 18, + ), + ), + ), + ], + ), + ), + ), - 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, + // ── The Panel (Expanded State) ── + AnimatedPositioned( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutQuart, + left: _isExpanded ? 0 : -220, + top: MediaQuery.of(context).size.height * 0.25, + child: GestureDetector( + onHorizontalDragUpdate: (details) { + if (details.delta.dx < -10) _toggleExpansion(); + }, + child: Container( + width: 210, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF121212).withValues(alpha: 0.98), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(28), + bottomRight: Radius.circular(28), + ), + border: Border.all(color: Colors.white12, width: 0.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 30, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'FOCUS CONTROL', + style: TextStyle( + color: Colors.blueAccent, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + IconButton( + icon: const Icon( + Icons.chevron_left_rounded, + color: Colors.white70, + size: 28, + ), + onPressed: _toggleExpansion, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 32), + // Reel Session Timer + const Text( + 'REEL SESSION', + style: TextStyle( + color: Colors.white30, + fontSize: 11, + letterSpacing: 1, + ), + ), + const SizedBox(height: 8), + Text( + _formatTime(sm.remainingSessionSeconds), + style: TextStyle( + color: barColor, + fontSize: 40, + fontWeight: FontWeight.w200, + letterSpacing: 2, + ), + ), + const SizedBox(height: 24), + // Daily Remaining + const Text( + 'DAILY QUOTA', + style: TextStyle( + color: Colors.white30, + fontSize: 11, + letterSpacing: 1, + ), + ), + const SizedBox(height: 8), + Text( + '${sm.dailyRemainingSeconds ~/ 60}m Left', + style: const TextStyle( + color: Colors.white70, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 32), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: 20), + // Settings Link + InkWell( + onTap: () { + _toggleExpansion(); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.tune_rounded, + color: Colors.blueAccent, + size: 18, + ), + ), + const SizedBox(width: 14), + const Text( + 'Preferences', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), ), ), - 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), - ), - ], - ), + ), + ], ); } + + String _formatTime(int seconds) { + final m = seconds ~/ 60; + final s = seconds % 60; + return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } } // ────────────────────────────────────────────────────────────────────────────── @@ -427,7 +648,7 @@ class _FocusGramNavBar extends StatelessWidget { final items = [ (Icons.home_outlined, Icons.home_rounded, 'Home'), (Icons.search, Icons.search, 'Search'), - (Icons.add_box_outlined, Icons.add_box_rounded, 'Create'), + (Icons.play_circle_outline, Icons.play_circle_filled, 'Session'), (Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'), (Icons.person_outline, Icons.person, 'Profile'), ]; @@ -465,42 +686,3 @@ class _FocusGramNavBar extends StatelessWidget { ); } } - -// ────────────────────────────────────────────────────────────────────────────── -// Session FAB -// ────────────────────────────────────────────────────────────────────────────── - -class _SessionFAB extends StatelessWidget { - final VoidCallback onTap; - const _SessionFAB({required this.onTap}); - - @override - Widget build(BuildContext context) { - final sm = context.watch(); - final settings = context.watch(); - - 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; - } -} diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart index b342b07..5a83964 100644 --- a/lib/screens/reel_player_overlay.dart +++ b/lib/screens/reel_player_overlay.dart @@ -32,9 +32,9 @@ class _ReelPlayerOverlayState extends State { ..setNavigationDelegate( NavigationDelegate( onPageFinished: (url) { - // Apply scroll-lock: prevents swiping to next reel in the feed + // Apply scroll-lock via MutationObserver: prevents swiping to next reel _controller.runJavaScript( - InjectionController.reelScrollLockJS(widget.url), + InjectionController.reelsMutationObserverJS, ); // Also hide Instagram's bottom nav inside this overlay _controller.runJavaScript( diff --git a/lib/screens/session_modal.dart b/lib/screens/session_modal.dart index 6a3154e..25e9aa8 100644 --- a/lib/screens/session_modal.dart +++ b/lib/screens/session_modal.dart @@ -1,6 +1,7 @@ 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}); @@ -123,10 +124,15 @@ class _SessionModalState extends State { ); } - void _start(int minutes) { + void _start(int minutes) async { final sm = context.read(); + + // Always require word challenge for reel sessions (User request) + final success = await DisciplineChallenge.show(context); + if (!success) return; + if (sm.startSession(minutes)) { - Navigator.pop(context); + if (mounted) Navigator.pop(context); } } } diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index 7a4b8cd..fd8a120 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/session_manager.dart'; import '../services/settings_service.dart'; +import 'guardrails_page.dart'; +import 'about_page.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -30,100 +32,29 @@ class SettingsPage extends StatelessWidget { // ── Stats row ─────────────────────────────────────────── _buildStatsRow(sm), - // ── Consumption Limits ────────────────────────────────── - _buildSectionHeader('Reel Consumption Limits'), - _buildFrictionSliderTile( + // ── Settings Subsections ────────────────────────────── + _buildSettingsTile( 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()), + title: 'Guardrails', + subtitle: 'Daily limit, cooldown, and scheduled blocking', + icon: Icons.shield_outlined, + destination: const GuardrailsPage(), ), - _buildFrictionSliderTile( + _buildSettingsTile( 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()), + title: 'Distraction Management', + subtitle: 'Blur explore and reel controls', + icon: Icons.visibility_off_outlined, + destination: _DistractionSettingsPage(settings: settings), + ), + _buildSettingsTile( + context: context, + title: 'About', + subtitle: 'Developer info and GitHub', + icon: Icons.info_outline, + destination: const AboutPage(), ), - // ── 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( @@ -137,6 +68,32 @@ class SettingsPage extends StatelessWidget { ); } + Widget _buildSettingsTile({ + required BuildContext context, + required String title, + required String subtitle, + required IconData icon, + required Widget destination, + }) { + return ListTile( + leading: Icon(icon, color: Colors.blue), + title: Text(title, style: const TextStyle(color: Colors.white)), + subtitle: Text( + subtitle, + style: const TextStyle(color: Colors.white54, fontSize: 13), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + color: Colors.white24, + size: 14, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => destination), + ), + ); + } + Widget _buildStatsRow(SessionManager sm) { return Container( margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), @@ -189,75 +146,80 @@ class SettingsPage extends StatelessWidget { 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, +class _DistractionSettingsPage extends StatelessWidget { + final SettingsService settings; + const _DistractionSettingsPage({required this.settings}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + title: const Text( + 'Distraction Management', + style: TextStyle(fontSize: 17), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.pop(context), ), ), - ); - } - - /// 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 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), + body: ListView( + children: [ + 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, + ), + SwitchListTile( + title: const Text( + 'Mindfulness Gate', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Show breathing exercise before opening', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + value: settings.showBreathGate, + onChanged: (v) => settings.setShowBreathGate(v), + activeThumbColor: Colors.blue, + ), + SwitchListTile( + title: const Text( + 'Long-press for Session', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Requires 2s hold to start a Reel session', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + value: settings.requireLongPress, + onChanged: (v) => settings.setRequireLongPress(v), + activeThumbColor: Colors.blue, + ), + SwitchListTile( + title: const Text( + 'Strict Changes (Word Challenge)', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Requires 15-word typing challenge before lax changes', + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + value: settings.requireWordChallenge, + onChanged: (v) => settings.setRequireWordChallenge(v), + activeThumbColor: Colors.blue, ), ], ), diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart index c752f5c..903a785 100644 --- a/lib/services/injection_controller.dart +++ b/lib/services/injection_controller.dart @@ -9,9 +9,9 @@ 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) ' + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' - 'Version/17.5 Mobile/15E148 Safari/604.1'; + 'Version/17.0 Mobile/15E148 Safari/604.1'; // ── CSS injection ─────────────────────────────────────────────────────────── @@ -72,9 +72,25 @@ class InjectionController { '''; /// CSS that adds bottom padding so feed content doesn't hide behind our bar. + /// Added more selectors to cover dynamic drawers like Notes and Reactions. static const String _bottomPaddingCSS = ''' - body, #react-root > div { - padding-bottom: 64px !important; + body, #react-root > div, [role="presentation"] > div { + padding-bottom: 72px !important; + } + /* Special handling for dynamic bottom drawers */ + div[style*="bottom: 0px"], div[style*="bottom: 0"] { + padding-bottom: 72px !important; + } + '''; + + /// CSS to push IG content down so it doesn't hide behind our status bar. + static const String _topPaddingCSS = ''' + header, #react-root > div > div > div:first-child { + margin-top: 44px !important; + } + /* Shift fixed headers down */ + div[style*="position: fixed"][style*="top: 0"] { + top: 44px !important; } '''; @@ -257,84 +273,50 @@ class InjectionController { })(); '''; - /// JS to disable vertical swipe gestures that drive reel-to-reel transition. - static const String reelSwipeBlockerJS = ''' + /// MutationObserver to watch for Reel players and lock their scrolling. + static const String reelsMutationObserverJS = ''' (function() { - let _touchStartY = 0; - document.addEventListener('touchstart', function(e) { - _touchStartY = e.touches[0].clientY; - }, { passive: true }); - - document.addEventListener('touchmove', function(e) { - const deltaY = e.touches[0].clientY - _touchStartY; - // If swiping UP (negative delta), block it to prevent next reel load - if (deltaY < -10) { - if (e.cancelable) { - e.preventDefault(); - e.stopPropagation(); - } - } - }, { passive: false }); - })(); - '''; - - // ── 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 + function lockReelScroll(reelContainer) { + if (reelContainer.dataset.scrollLocked) return; + reelContainer.dataset.scrollLocked = 'true'; + let startY = 0; - document.addEventListener('touchstart', function(e) { + + reelContainer.addEventListener('touchstart', (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(); + + reelContainer.addEventListener('touchmove', (e) => { + const deltaY = e.touches[0].clientY - startY; + // Block upward swipe (next reel), allow downward (go back) + if (deltaY < -10) { + if (e.cancelable) { + e.preventDefault(); + e.stopPropagation(); + } } }, { passive: false }); - })(); - '''; - } + } + + // Watch for reel player being injected into DOM + const observer = new MutationObserver(() => { + // Instagram's reel player containers — multiple selectors for resilience + const reelContainers = document.querySelectorAll( + '[class*="reel"], [class*="Reel"], video' + ); + reelContainers.forEach((el) => { + // If it's a video or a reel container, wrap it + lockReelScroll(el); + // Also try parent if it's a video + if (el.tagName === 'VIDEO' && el.parentElement) { + lockReelScroll(el.parentElement); + } + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + })(); + '''; /// JS to disable swipe-to-next behavior inside the isolated Reel player. static const String disableReelSwipeJS = ''' @@ -357,7 +339,8 @@ class InjectionController { }) { final StringBuffer css = StringBuffer(); css.write(_hideInstagramNavCSS); - css.write(_bottomPaddingCSS); // Ensure content isn't behind our bar + css.write(_bottomPaddingCSS); + css.write(_topPaddingCSS); if (!sessionActive) css.write(_hideReelsCSS); if (blurExplore) css.write(_blurExploreCSS); diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart index 1bc5ec4..e6d9526 100644 --- a/lib/services/session_manager.dart +++ b/lib/services/session_manager.dart @@ -28,6 +28,11 @@ class SessionManager extends ChangeNotifier { 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'; SharedPreferences? _prefs; @@ -46,6 +51,17 @@ class SessionManager extends ChangeNotifier { 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; // Default 7 AM + int _schedEndMin = 0; + + 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 @@ -56,6 +72,7 @@ class SessionManager extends ChangeNotifier { 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; } @@ -123,6 +140,29 @@ class SessionManager extends ChangeNotifier { /// 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; + + bool get isScheduledBlockActive { + if (!_scheduleEnabled) return false; + final now = DateTime.now(); + final currentTime = now.hour * 60 + now.minute; + final startTime = _schedStartHour * 60 + _schedStartMin; + final endTime = _schedEndHour * 60 + _schedEndMin; + + if (startTime < endTime) { + // Simple range (e.g., 9:00 to 17:00) + return currentTime >= startTime && currentTime < endTime; + } else { + // Over-midnight range (e.g., 22:00 to 07:00) + return currentTime >= startTime || currentTime < endTime; + } + } + // ── Initialization ───────────────────────────────────────── Future init() async { _prefs = await SharedPreferences.getInstance(); @@ -132,6 +172,31 @@ class SessionManager extends ChangeNotifier { _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 _resetDailyIfNeeded() async { final today = DateFormat('yyyy-MM-dd').format(DateTime.now()); final stored = _prefs!.getString(_keyDailyDate) ?? ''; @@ -176,6 +241,12 @@ class SessionManager extends ChangeNotifier { 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; } void _incrementOpenCount() { @@ -189,10 +260,17 @@ class SessionManager extends ChangeNotifier { } 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) { _cleanupExpiredReelSession(); changed = true; @@ -205,14 +283,20 @@ class SessionManager extends ChangeNotifier { } // App session expiry check - if (_appSessionEnd != null && - !_appSessionExpiredFlag && - DateTime.now().isAfter(_appSessionEnd!)) { - _appSessionExpiredFlag = true; - changed = true; + if (_appSessionEnd != null && !_appSessionExpiredFlag) { + if (DateTime.now().isAfter(_appSessionEnd!)) { + _appSessionExpiredFlag = true; + changed = true; + } } - if (isCooldownActive) changed = true; + if (isCooldownActive) { + changed = true; + } else if (appOpenCooldownRemainingSeconds <= 0 && + _lastAppSessionEnd != null) { + // Just expired + changed = true; + } if (changed) notifyListeners(); } @@ -313,10 +397,26 @@ class SessionManager extends ChangeNotifier { notifyListeners(); } - Future resetDailyCounter() async { - _dailyUsedSeconds = 0; - await _prefs?.setInt(_keyDailyUsedSeconds, 0); - if (_isSessionActive) endSession(); + Future setScheduleEnabled(bool v) async { + _scheduleEnabled = v; + await _prefs?.setBool(_keyScheduleEnabled, v); + notifyListeners(); + } + + Future setScheduleTime({ + required int startH, + required int startM, + required int endH, + required int endM, + }) async { + _schedStartHour = startH; + _schedStartMin = startM; + _schedEndHour = endH; + _schedEndMin = endM; + await _prefs?.setInt(_keyScheduleStartHour, startH); + await _prefs?.setInt(_keyScheduleStartMin, startM); + await _prefs?.setInt(_keyScheduleEndHour, endH); + await _prefs?.setInt(_keyScheduleEndMin, endM); notifyListeners(); } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 3b18686..b2ab6d8 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -7,6 +7,7 @@ class SettingsService extends ChangeNotifier { static const _keyBlurReels = 'set_blur_reels'; static const _keyRequireLongPress = 'set_require_long_press'; static const _keyShowBreathGate = 'set_show_breath_gate'; + static const _keyRequireWordChallenge = 'set_require_word_challenge'; SharedPreferences? _prefs; @@ -14,11 +15,14 @@ class SettingsService extends ChangeNotifier { 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 _requireWordChallenge = + true; // Random word sequence challenge before changes bool get blurExplore => _blurExplore; bool get blurReels => _blurReels; bool get requireLongPress => _requireLongPress; bool get showBreathGate => _showBreathGate; + bool get requireWordChallenge => _requireWordChallenge; Future init() async { _prefs = await SharedPreferences.getInstance(); @@ -26,6 +30,7 @@ class SettingsService extends ChangeNotifier { _blurReels = _prefs!.getBool(_keyBlurReels) ?? false; _requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true; _showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true; + _requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true; notifyListeners(); } @@ -52,4 +57,10 @@ class SettingsService extends ChangeNotifier { await _prefs?.setBool(_keyShowBreathGate, v); notifyListeners(); } + + Future setRequireWordChallenge(bool v) async { + _requireWordChallenge = v; + await _prefs?.setBool(_keyRequireWordChallenge, v); + notifyListeners(); + } } diff --git a/lib/utils/discipline_challenge.dart b/lib/utils/discipline_challenge.dart new file mode 100644 index 0000000..7a65fe8 --- /dev/null +++ b/lib/utils/discipline_challenge.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +class DisciplineChallenge { + static const List _words = [ + 'discipline', + 'focus', + 'growth', + 'mindful', + 'purpose', + 'control', + 'strength', + 'clarity', + 'vision', + 'action', + 'habit', + 'success', + 'power', + 'balance', + 'wisdom', + 'patience', + 'intent', + 'choice', + ]; + + /// Shows the word challenge dialog. Returns true if successful. + static Future show(BuildContext context) async { + final list = List.from(_words)..shuffle(); + final challenge = list.take(15).join(' '); + final controller = TextEditingController(); + + final result = await showDialog( + 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( + '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, + 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'), + ), + ], + ), + ); + return result ?? false; + } +} diff --git a/pubspec.lock b/pubspec.lock index bbe73fa..e03fe53 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -413,6 +413,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -487,4 +551,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.10.7 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index d04562c..bf79a66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,9 @@ dependencies: # Local notifications for session reminders — latest stable flutter_local_notifications: ^20.1.0 + # URL launcher for About page links — latest stable + url_launcher: ^6.3.2 + dev_dependencies: flutter_test: sdk: flutter