mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-20 22:54:41 +02:00
feat:
added "Scheduled Blocking" improved reel blocking logic changed from topbar to sidepanel improved seddings page added about page
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
PRD.md
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user