mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-08-30 17:10:37 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
|
||||
/// Shown on every cold app open. Asks the user how long they plan to use
|
||||
/// Instagram today. Uses an iOS-style scroll picker (ListWheelScrollView).
|
||||
class AppSessionPickerScreen extends StatefulWidget {
|
||||
final VoidCallback onSessionStarted;
|
||||
const AppSessionPickerScreen({super.key, required this.onSessionStarted});
|
||||
|
||||
@override
|
||||
State<AppSessionPickerScreen> createState() => _AppSessionPickerScreenState();
|
||||
}
|
||||
|
||||
class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
static final List<int> _minuteOptions = [
|
||||
5,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
45,
|
||||
50,
|
||||
55,
|
||||
60,
|
||||
];
|
||||
int _selectedIndex = 2; // default: 15 min
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedMinutes = _minuteOptions[_selectedIndex];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Icon
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade700, Colors.blue.shade400],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withValues(alpha: 0.4),
|
||||
blurRadius: 24,
|
||||
spreadRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.timer_outlined,
|
||||
color: Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
const Text(
|
||||
'Set Your Intention',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'How long do you plan to use\nInstagram right now?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// iOS-style scroll picker
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Selection highlight
|
||||
Container(
|
||||
height: 50,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListWheelScrollView.useDelegate(
|
||||
itemExtent: 50,
|
||||
physics: const FixedExtentScrollPhysics(),
|
||||
perspective: 0.003,
|
||||
squeeze: 1.1,
|
||||
diameterRatio: 2.5,
|
||||
onSelectedItemChanged: (i) {
|
||||
setState(() => _selectedIndex = i);
|
||||
},
|
||||
controller: FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
),
|
||||
childDelegate: ListWheelChildListDelegate(
|
||||
children: _minuteOptions.asMap().entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedIndex;
|
||||
return Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${entry.value}',
|
||||
style: TextStyle(
|
||||
fontSize: isSelected ? 28 : 22,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.w300,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.white38,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' min',
|
||||
style: TextStyle(
|
||||
fontSize: isSelected ? 16 : 14,
|
||||
color: isSelected
|
||||
? Colors.white70
|
||||
: Colors.white24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Confirm button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _confirm(context, selectedMinutes),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: Text(
|
||||
'Start $selectedMinutes-Minute Session',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'You\'ll be prompted to close the app when your time is up.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white24, fontSize: 12),
|
||||
),
|
||||
const Spacer(flex: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirm(BuildContext context, int minutes) {
|
||||
context.read<SessionManager>().startAppSession(minutes);
|
||||
widget.onSessionStarted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 8-second breath.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
}
|
||||
|
||||
class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
int _secondsRemaining = 8;
|
||||
Timer? _timer;
|
||||
bool _canContinue = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 8-second breathing animation: 4s in, 4s out
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.5,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
|
||||
_controller.repeat(reverse: true);
|
||||
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_secondsRemaining > 0) {
|
||||
setState(() => _secondsRemaining--);
|
||||
} else {
|
||||
setState(() {
|
||||
_canContinue = true;
|
||||
_timer?.cancel();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Are you sure you want to open Instagram?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
// Animated Breath Circle
|
||||
ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
blurRadius: 30,
|
||||
spreadRadius: 10,
|
||||
),
|
||||
],
|
||||
gradient: const RadialGradient(
|
||||
colors: [Colors.blue, Colors.black],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 80),
|
||||
|
||||
Text(
|
||||
_canContinue
|
||||
? 'Breathed.'
|
||||
: 'Take a deep breath for $_secondsRemaining seconds...',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _canContinue ? widget.onFinish : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: Colors.white10,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
),
|
||||
child: const Text('Continue to Instagram'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
|
||||
/// Blocking screen shown when the user tries to reopen the app too soon
|
||||
/// after their last session ended. Shows a countdown and a motivational quote.
|
||||
class CooldownGateScreen extends StatefulWidget {
|
||||
const CooldownGateScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CooldownGateScreen> createState() => _CooldownGateScreenState();
|
||||
}
|
||||
|
||||
class _CooldownGateScreenState extends State<CooldownGateScreen> {
|
||||
Timer? _timer;
|
||||
static const List<String> _quotes = [
|
||||
'"The discipline you show offline\nshapes the clarity you experience online."',
|
||||
'"Every moment away from the screen\nis a moment given back to yourself."',
|
||||
'"Boredom is the birthplace of creativity.\nLet it breathe."',
|
||||
'"Your attention is your most valuable asset.\nSpend it wisely."',
|
||||
'"Presence is a gift you give yourself first."',
|
||||
'"Rest is not wasted time.\nIt is the foundation of focused action."',
|
||||
];
|
||||
late final String _quote;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_quote = _quotes[DateTime.now().second % _quotes.length];
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
final remaining = sm.appOpenCooldownRemainingSeconds;
|
||||
final minutes = remaining ~/ 60;
|
||||
final seconds = remaining % 60;
|
||||
|
||||
// If cooldown expired, pop this gate
|
||||
if (!sm.isAppOpenCooldownActive) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Icon
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.orange.withValues(alpha: 0.12),
|
||||
border: Border.all(
|
||||
color: Colors.orangeAccent.withValues(alpha: 0.4),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.hourglass_top_rounded,
|
||||
color: Colors.orangeAccent,
|
||||
size: 38,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'Take a Break',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Your session has ended.\nCome back when the timer expires.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Countdown
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.orangeAccent.withValues(alpha: 0.25),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Return in',
|
||||
style: TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 52,
|
||||
fontWeight: FontWeight.w200,
|
||||
letterSpacing: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Quote
|
||||
Text(
|
||||
_quote,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white30,
|
||||
fontSize: 13,
|
||||
height: 1.7,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/injection_controller.dart';
|
||||
import '../services/navigation_guard.dart';
|
||||
import 'session_modal.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'reel_player_overlay.dart';
|
||||
|
||||
class MainWebViewPage extends StatefulWidget {
|
||||
const MainWebViewPage({super.key});
|
||||
|
||||
@override
|
||||
State<MainWebViewPage> createState() => _MainWebViewPageState();
|
||||
}
|
||||
|
||||
class _MainWebViewPageState extends State<MainWebViewPage> {
|
||||
late final WebViewController _controller;
|
||||
int _currentIndex = 0;
|
||||
bool _isLoading = true;
|
||||
|
||||
// Cached username for profile navigation
|
||||
String? _cachedUsername;
|
||||
|
||||
// Watchdog for app-session expiry
|
||||
Timer? _watchdog;
|
||||
bool _extensionDialogShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initWebView();
|
||||
_startWatchdog();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_watchdog?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startWatchdog() {
|
||||
_watchdog = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
if (!mounted) return;
|
||||
final sm = context.read<SessionManager>();
|
||||
if (sm.isAppSessionExpired && !_extensionDialogShown) {
|
||||
_extensionDialogShown = true;
|
||||
_showSessionExpiredDialog(sm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showSessionExpiredDialog(SessionManager sm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text(
|
||||
'Session Complete ✓',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Your planned Instagram time is up.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
if (sm.canExtendAppSession) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'You can extend once by 10 minutes.',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
sm.endAppSession();
|
||||
SystemNavigator.pop(); // Force close
|
||||
},
|
||||
child: const Text(
|
||||
'Close App',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
if (sm.canExtendAppSession)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
sm.extendAppSession();
|
||||
_extensionDialogShown =
|
||||
false; // Reset so watchdog can fire again at next expiry
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('+10 minutes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
final sessionManager = context.read<SessionManager>();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(InjectionController.iOSUserAgent)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
// Only show loading if it's a real page load (not SPA nav)
|
||||
if (!url.contains('#')) {
|
||||
if (mounted) setState(() => _isLoading = true);
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
_applyInjections();
|
||||
_updateCurrentTab(url);
|
||||
// Cache username whenever we finish loading any page
|
||||
_cacheUsername();
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
final isDmReel = NavigationGuard.isDmReelLink(request.url);
|
||||
|
||||
final decision = NavigationGuard.evaluate(
|
||||
url: request.url,
|
||||
sessionActive: sessionManager.isSessionActive,
|
||||
isDmReelException: isDmReel,
|
||||
);
|
||||
|
||||
if (decision.blocked) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(decision.reason ?? 'Blocked'),
|
||||
backgroundColor: Colors.red.shade900,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
// Open DM reel in isolated player
|
||||
if (isDmReel && !sessionManager.isSessionActive) {
|
||||
final canonicalUrl = NavigationGuard.canonicalizeDmReelUrl(
|
||||
request.url,
|
||||
);
|
||||
if (canonicalUrl != null) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ReelPlayerOverlay(url: canonicalUrl),
|
||||
),
|
||||
);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse('https://www.instagram.com/'));
|
||||
}
|
||||
|
||||
void _applyInjections() {
|
||||
final sessionManager = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
final js = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionManager.isSessionActive,
|
||||
blurExplore: settings.blurExplore,
|
||||
);
|
||||
_controller.runJavaScript(js);
|
||||
}
|
||||
|
||||
Future<void> _cacheUsername() async {
|
||||
if (_cachedUsername != null) return; // Already known
|
||||
try {
|
||||
final result = await _controller.runJavaScriptReturningResult(
|
||||
InjectionController.getLoggedInUsernameJS,
|
||||
);
|
||||
final raw = result.toString().replaceAll('"', '').replaceAll("'", '');
|
||||
if (raw.isNotEmpty && raw != 'null' && raw != 'undefined') {
|
||||
_cachedUsername = raw;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _updateCurrentTab(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
final path = uri.path;
|
||||
|
||||
int newIndex = _currentIndex;
|
||||
if (path == '/' || path.isEmpty) {
|
||||
newIndex = 0;
|
||||
} else if (path.startsWith('/explore') || path.startsWith('/search')) {
|
||||
newIndex = 1;
|
||||
} else if (path.startsWith('/direct')) {
|
||||
newIndex = 3;
|
||||
} else if (_cachedUsername != null &&
|
||||
path.startsWith('/$_cachedUsername')) {
|
||||
newIndex = 4;
|
||||
}
|
||||
|
||||
if (newIndex != _currentIndex) {
|
||||
setState(() => _currentIndex = newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate using JS when already on Instagram (avoids full page reload).
|
||||
/// Falls back to loadRequest if not on instagram.com.
|
||||
Future<void> _navigateTo(String path) async {
|
||||
try {
|
||||
final currentUrl = await _controller.currentUrl();
|
||||
if (currentUrl != null && currentUrl.contains('instagram.com')) {
|
||||
// SPA soft nav — instant, no full reload
|
||||
await _controller.runJavaScript(
|
||||
InjectionController.softNavigateJS(path),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: full load
|
||||
await _controller.loadRequest(Uri.parse('https://www.instagram.com$path'));
|
||||
}
|
||||
|
||||
Future<void> _onTabTapped(int index) async {
|
||||
// Don't re-navigate if already on this tab
|
||||
if (index == _currentIndex) return;
|
||||
setState(() => _currentIndex = index);
|
||||
|
||||
switch (index) {
|
||||
case 0:
|
||||
await _navigateTo('/');
|
||||
break;
|
||||
case 1:
|
||||
await _navigateTo('/explore/search/');
|
||||
break;
|
||||
case 2:
|
||||
// Try to click Instagram's create button via JS
|
||||
try {
|
||||
await _controller.runJavaScript(
|
||||
InjectionController.clickCreateButtonJS,
|
||||
);
|
||||
} catch (_) {
|
||||
await _navigateTo('/');
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
await _navigateTo('/direct/inbox/');
|
||||
break;
|
||||
case 4:
|
||||
if (_cachedUsername != null) {
|
||||
await _navigateTo('/$_cachedUsername/');
|
||||
} else {
|
||||
// Try to get username first then navigate
|
||||
await _cacheUsername();
|
||||
if (_cachedUsername != null) {
|
||||
await _navigateTo('/$_cachedUsername/');
|
||||
} else {
|
||||
// Last fallback: navigate to accounts/edit — usually has username
|
||||
await _navigateTo('/accounts/edit/');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// Status Bar — always on top
|
||||
_StatusBar(),
|
||||
|
||||
// WebView
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
// Thin loading bar (not full-screen spinner)
|
||||
if (_isLoading)
|
||||
const LinearProgressIndicator(
|
||||
backgroundColor: Colors.transparent,
|
||||
color: Colors.blue,
|
||||
minHeight: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _FocusGramNavBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: _onTabTapped,
|
||||
),
|
||||
floatingActionButton: _SessionFAB(onTap: _openSessionModal),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
}
|
||||
|
||||
void _openSessionModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const SessionModal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Status Bar Widget — only rebuilds when session state changes
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
String label;
|
||||
Color dotColor;
|
||||
IconData dotIcon;
|
||||
|
||||
if (sm.isSessionActive) {
|
||||
final m = sm.remainingSessionSeconds ~/ 60;
|
||||
final s = sm.remainingSessionSeconds % 60;
|
||||
label =
|
||||
'Reels: ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
dotColor = Colors.greenAccent;
|
||||
dotIcon = Icons.play_circle_outline;
|
||||
} else if (sm.isCooldownActive) {
|
||||
final m = sm.cooldownRemainingSeconds ~/ 60;
|
||||
label = 'Cooldown: ${m}m left';
|
||||
dotColor = Colors.orangeAccent;
|
||||
dotIcon = Icons.timer_outlined;
|
||||
} else {
|
||||
label = 'Reels Blocked';
|
||||
dotColor = Colors.redAccent;
|
||||
dotIcon = Icons.block;
|
||||
}
|
||||
|
||||
// App session indicator
|
||||
final appM = sm.appSessionRemainingSeconds ~/ 60;
|
||||
final appS = sm.appSessionRemainingSeconds % 60;
|
||||
final appLabel = sm.isAppSessionActive
|
||||
? 'App: ${appM.toString().padLeft(2, '0')}:${appS.toString().padLeft(2, '0')}'
|
||||
: '';
|
||||
|
||||
return Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: Colors.black,
|
||||
child: Row(
|
||||
children: [
|
||||
// Status dot
|
||||
Icon(dotIcon, color: dotColor, size: 13),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: dotColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// App session timer
|
||||
if (appLabel.isNotEmpty)
|
||||
Text(
|
||||
appLabel,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
if (appLabel.isNotEmpty) const SizedBox(width: 10),
|
||||
// Daily reel usage
|
||||
Text(
|
||||
'Daily: ${sm.dailyRemainingSeconds ~/ 60}m',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Settings icon
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||
),
|
||||
child: const Icon(Icons.tune, color: Colors.white38, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Custom Bottom Nav Bar — minimal, Instagram-like
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FocusGramNavBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final Future<void> Function(int) onTap;
|
||||
|
||||
const _FocusGramNavBar({required this.currentIndex, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
(Icons.home_outlined, Icons.home_rounded, 'Home'),
|
||||
(Icons.search, Icons.search, 'Search'),
|
||||
(Icons.add_box_outlined, Icons.add_box_rounded, 'Create'),
|
||||
(Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'),
|
||||
(Icons.person_outline, Icons.person, 'Profile'),
|
||||
];
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final (outlinedIcon, filledIcon, label) = entry.value;
|
||||
final isSelected = i == currentIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => onTap(i),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isSelected ? filledIcon : outlinedIcon,
|
||||
color: isSelected ? Colors.white : Colors.white54,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Session FAB
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SessionFAB extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _SessionFAB({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
final settings = context.watch<SettingsService>();
|
||||
|
||||
if (sm.isSessionActive) {
|
||||
// Show "end session" button when session is active
|
||||
return FloatingActionButton.small(
|
||||
backgroundColor: Colors.green.shade700,
|
||||
onPressed: () => sm.endSession(),
|
||||
child: const Icon(Icons.stop, color: Colors.white, size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
final fab = FloatingActionButton.small(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
onPressed: settings.requireLongPress ? null : onTap,
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
);
|
||||
|
||||
if (settings.requireLongPress) {
|
||||
return GestureDetector(onLongPress: onTap, child: fab);
|
||||
}
|
||||
return fab;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../services/injection_controller.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// An isolated player for a single Reel opened from a DM.
|
||||
/// Uses JS history interception to lock the user to the initial reel URL.
|
||||
class ReelPlayerOverlay extends StatefulWidget {
|
||||
final String url;
|
||||
const ReelPlayerOverlay({super.key, required this.url});
|
||||
|
||||
@override
|
||||
State<ReelPlayerOverlay> createState() => _ReelPlayerOverlayState();
|
||||
}
|
||||
|
||||
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
late final WebViewController _controller;
|
||||
DateTime? _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTime = DateTime.now();
|
||||
_initWebView();
|
||||
}
|
||||
|
||||
void _initWebView() {
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(InjectionController.iOSUserAgent)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (url) {
|
||||
// Apply scroll-lock: prevents swiping to next reel in the feed
|
||||
_controller.runJavaScript(
|
||||
InjectionController.reelScrollLockJS(widget.url),
|
||||
);
|
||||
// Also hide Instagram's bottom nav inside this overlay
|
||||
_controller.runJavaScript(
|
||||
InjectionController.buildInjectionJS(
|
||||
sessionActive: true,
|
||||
blurExplore: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
onNavigationRequest: (request) {
|
||||
// Allow only the initial reel URL and instagram.com generally
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.prevent;
|
||||
final host = uri.host;
|
||||
if (!host.contains('instagram.com')) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.url));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Record viewing time toward daily count
|
||||
if (_startTime != null) {
|
||||
final durationSeconds = DateTime.now().difference(_startTime!).inSeconds;
|
||||
if (mounted) {
|
||||
context.read<SessionManager>().accrueSeconds(durationSeconds);
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'Reel',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orangeAccent, width: 0.5),
|
||||
),
|
||||
child: const Text(
|
||||
'Locked',
|
||||
style: TextStyle(color: Colors.orangeAccent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
|
||||
class SessionModal extends StatefulWidget {
|
||||
const SessionModal({super.key});
|
||||
|
||||
@override
|
||||
State<SessionModal> createState() => _SessionModalState();
|
||||
}
|
||||
|
||||
class _SessionModalState extends State<SessionModal> {
|
||||
double _customMinutes = 5.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF121212),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Start Reel Session',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Remaining Daily: ${sm.dailyRemainingSeconds ~/ 60}m',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
if (sm.isCooldownActive)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'Cooldown active: ${sm.cooldownRemainingSeconds ~/ 60}m ${sm.cooldownRemainingSeconds % 60}s left',
|
||||
style: const TextStyle(color: Colors.orangeAccent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Presets',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [1, 5, 10, 15].map((m) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'Custom Duration',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Slider(
|
||||
value: _customMinutes,
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
label: '${_customMinutes.toInt()}m',
|
||||
onChanged: (v) => setState(() => _customMinutes = v),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(_customMinutes.toInt()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Start Session',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _start(int minutes) {
|
||||
final sm = context.read<SessionManager>();
|
||||
if (sm.startSession(minutes)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsService>();
|
||||
final sm = context.watch<SessionManager>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text(
|
||||
'FocusGram',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// ── Stats row ───────────────────────────────────────────
|
||||
_buildStatsRow(sm),
|
||||
|
||||
// ── Consumption Limits ──────────────────────────────────
|
||||
_buildSectionHeader('Reel Consumption Limits'),
|
||||
_buildFrictionSliderTile(
|
||||
context: context,
|
||||
sm: sm,
|
||||
title: 'Daily Reel Limit',
|
||||
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
|
||||
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
|
||||
min: 5,
|
||||
max: 120,
|
||||
divisor: 5,
|
||||
warningText:
|
||||
'Increasing your daily limit may make it easier to mindlessly scroll. Are you sure?',
|
||||
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()),
|
||||
),
|
||||
_buildFrictionSliderTile(
|
||||
context: context,
|
||||
sm: sm,
|
||||
title: 'Session Cooldown',
|
||||
subtitle: '${sm.cooldownSeconds ~/ 60} min between sessions',
|
||||
value: (sm.cooldownSeconds ~/ 60).toDouble(),
|
||||
min: 5,
|
||||
max: 180,
|
||||
divisor: 5,
|
||||
warningText:
|
||||
'Reducing the cooldown makes it easier to start new reel sessions. Are you sure?',
|
||||
onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()),
|
||||
),
|
||||
|
||||
// ── Distraction Management ──────────────────────────────
|
||||
_buildSectionHeader('Distraction Management'),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Blur Explore feed',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Blurs posts and reels in Explore by default',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.blurExplore,
|
||||
onChanged: (v) => settings.setBlurExplore(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
|
||||
// ── Friction & Discipline ───────────────────────────────
|
||||
_buildSectionHeader('Friction & Discipline'),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Mindfulness Gate',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Show breathing exercise before opening Instagram',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.showBreathGate,
|
||||
onChanged: (v) => settings.setShowBreathGate(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text(
|
||||
'Long-press to start Reel session',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Requires 2s hold on the play button',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
value: settings.requireLongPress,
|
||||
onChanged: (v) => settings.setRequireLongPress(v),
|
||||
activeThumbColor: Colors.blue,
|
||||
),
|
||||
|
||||
const Divider(color: Colors.white10, height: 40),
|
||||
|
||||
// ── Danger zone ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _confirmReset(context, sm),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red.withAlpha(
|
||||
(255 * 0.08).round(),
|
||||
), // Changed from withOpacity
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent, width: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text('Reset Daily Usage Counter'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const Center(
|
||||
child: Text(
|
||||
'FocusGram · Built for discipline',
|
||||
style: TextStyle(color: Colors.white12, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsRow(SessionManager sm) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF111111),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
|
||||
_dividerCell(),
|
||||
_statCell(
|
||||
'Reels Used',
|
||||
'${sm.dailyUsedSeconds ~/ 60}m',
|
||||
Colors.orangeAccent,
|
||||
),
|
||||
_dividerCell(),
|
||||
_statCell(
|
||||
'Remaining',
|
||||
'${sm.dailyRemainingSeconds ~/ 60}m',
|
||||
Colors.greenAccent,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statCell(String label, String value, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dividerCell() =>
|
||||
Container(width: 1, height: 36, color: Colors.white10);
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A slider tile that shows a friction dialog before accepting a larger value.
|
||||
Widget _buildFrictionSliderTile({
|
||||
required BuildContext context,
|
||||
required SessionManager sm,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required double value,
|
||||
required double min,
|
||||
required double max,
|
||||
required int divisor,
|
||||
required String warningText,
|
||||
required Future<void> Function(double) onConfirmed,
|
||||
}) {
|
||||
return _FrictionSliderTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
divisor: divisor,
|
||||
warningText: warningText,
|
||||
onConfirmed: onConfirmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmReset(BuildContext context, SessionManager sm) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1A1A),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Text(
|
||||
'Reset Counter?',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
content: const Text(
|
||||
'This will reset your daily reel usage to zero minutes.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
sm.resetDailyCounter();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text(
|
||||
'Reset',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful slider tile that shows a friction dialog when the user moves the
|
||||
/// slider to a value greater than the current persisted value.
|
||||
class _FrictionSliderTile extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final int divisor;
|
||||
final String warningText;
|
||||
final Future<void> Function(double) onConfirmed;
|
||||
|
||||
const _FrictionSliderTile({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.divisor,
|
||||
required this.warningText,
|
||||
required this.onConfirmed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FrictionSliderTile> createState() => _FrictionSliderTileState();
|
||||
}
|
||||
|
||||
class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
||||
late double _draftValue;
|
||||
bool _pendingConfirm = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_draftValue = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FrictionSliderTile old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Keep draft in sync if external value changed (e.g. after reset)
|
||||
if (!_pendingConfirm) _draftValue = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final divisions = ((widget.max - widget.min) / widget.divisor).round();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${_draftValue.toInt()} min',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
trailing: _pendingConfirm
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_draftValue = widget.value;
|
||||
_pendingConfirm = false;
|
||||
});
|
||||
},
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
setState(() => _pendingConfirm = false);
|
||||
await widget.onConfirmed(_draftValue);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'Apply',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (_pendingConfirm)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
widget.warningText,
|
||||
style: TextStyle(
|
||||
color: Colors.orangeAccent.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Slider(
|
||||
value: _draftValue,
|
||||
min: widget.min,
|
||||
max: widget.max,
|
||||
divisions: divisions,
|
||||
activeColor: _pendingConfirm ? Colors.orange : Colors.blue,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_draftValue = v;
|
||||
// Show friction warning when moving to a larger (more permissive) value
|
||||
_pendingConfirm = v > widget.value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (v) {
|
||||
// If decreasing (more strict), apply immediately without dialog
|
||||
if (v <= widget.value) {
|
||||
widget.onConfirmed(v);
|
||||
setState(() => _pendingConfirm = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user