added "Scheduled Blocking"
improved reel blocking logic
changed from topbar to sidepanel
improved seddings page
added about page
This commit is contained in:
Ujwal
2026-02-22 23:59:20 +05:45
parent 9ab4fc503a
commit 354f7413d1
15 changed files with 1336 additions and 508 deletions
+1
View File
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
PRD.md
# IntelliJ related
*.iml
+102
View File
@@ -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<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+4 -4
View File
@@ -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<BreathGateScreen>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _secondsRemaining = 8;
int _secondsRemaining = 10;
Timer? _timer;
bool _canContinue = false;
@@ -24,10 +24,10 @@ class _BreathGateScreenState extends State<BreathGateScreen>
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<double>(
+101 -100
View File
@@ -56,111 +56,112 @@ class _CooldownGateScreenState extends State<CooldownGateScreen> {
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),
],
),
),
),
),
+285
View File
@@ -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<SessionManager>();
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<void> Function(double) onConfirmed,
}) {
return _FrictionSliderTile(
title: title,
subtitle: subtitle,
value: value,
min: min,
max: max,
divisor: divisor,
isMorePermissive: isMorePermissive,
warningText: warningText,
onConfirmed: onConfirmed,
);
}
}
class _FrictionSliderTile extends StatefulWidget {
final String title;
final String subtitle;
final double value;
final double min;
final double max;
final int divisor;
final bool Function(double) isMorePermissive;
final String warningText;
final Future<void> Function(double) onConfirmed;
const _FrictionSliderTile({
required this.title,
required this.subtitle,
required this.value,
required this.min,
required this.max,
required this.divisor,
required this.isMorePermissive,
required this.warningText,
required this.onConfirmed,
});
@override
State<_FrictionSliderTile> createState() => _FrictionSliderTileState();
}
class _FrictionSliderTileState extends State<_FrictionSliderTile> {
late double _draftValue;
bool _pendingConfirm = false;
@override
void initState() {
super.initState();
_draftValue = widget.value;
}
@override
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);
}
},
),
],
);
}
}
+340 -158
View File
@@ -17,7 +17,8 @@ class MainWebViewPage extends StatefulWidget {
State<MainWebViewPage> createState() => _MainWebViewPageState();
}
class _MainWebViewPageState extends State<MainWebViewPage> {
class _MainWebViewPageState extends State<MainWebViewPage>
with WidgetsBindingObserver {
late final WebViewController _controller;
int _currentIndex = 0;
bool _isLoading = true;
@@ -32,16 +33,31 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
@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<SessionManager>();
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<MainWebViewPage> {
_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<MainWebViewPage> {
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<MainWebViewPage> {
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<MainWebViewPage> {
@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<MainWebViewPage> {
void _openSessionModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const SessionModal(),
);
@@ -325,86 +344,288 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
}
// ──────────────────────────────────────────────────────────────────────────────
// 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<SessionManager>();
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<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;
}
}
+2 -2
View File
@@ -32,9 +32,9 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
..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(
+8 -2
View File
@@ -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<SessionModal> {
);
}
void _start(int minutes) {
void _start(int minutes) async {
final sm = context.read<SessionManager>();
// Always require word challenge for reel sessions (User request)
final success = await DisciplineChallenge.show(context);
if (!success) return;
if (sm.startSession(minutes)) {
Navigator.pop(context);
if (mounted) Navigator.pop(context);
}
}
}
+116 -154
View File
@@ -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<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),
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,
),
],
),
+60 -77
View File
@@ -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);
+110 -10
View File
@@ -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<void> 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<void> _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<void> resetDailyCounter() async {
_dailyUsedSeconds = 0;
await _prefs?.setInt(_keyDailyUsedSeconds, 0);
if (_isSessionActive) endSession();
Future<void> setScheduleEnabled(bool v) async {
_scheduleEnabled = v;
await _prefs?.setBool(_keyScheduleEnabled, v);
notifyListeners();
}
Future<void> setScheduleTime({
required int startH,
required int startM,
required int endH,
required int endM,
}) async {
_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();
}
+11
View File
@@ -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<void> 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<void> setRequireWordChallenge(bool v) async {
_requireWordChallenge = v;
await _prefs?.setBool(_keyRequireWordChallenge, v);
notifyListeners();
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
class DisciplineChallenge {
static const List<String> _words = [
'discipline',
'focus',
'growth',
'mindful',
'purpose',
'control',
'strength',
'clarity',
'vision',
'action',
'habit',
'success',
'power',
'balance',
'wisdom',
'patience',
'intent',
'choice',
];
/// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context) async {
final list = List<String>.from(_words)..shuffle();
final challenge = list.take(15).join(' ');
final controller = TextEditingController();
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row(
children: [
Icon(Icons.psychology, color: Colors.blue, size: 24),
SizedBox(width: 10),
Text(
'Discipline Challenge',
style: TextStyle(color: Colors.white, fontSize: 18),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'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;
}
}
+65 -1
View File
@@ -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"
+3
View File
@@ -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