What's new

- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
This commit is contained in:
Ujwal
2026-03-04 10:48:14 +05:45
commit 7bb472d212
92 changed files with 14740 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
/// Shown on every cold app open. Asks the user how long they plan to use
/// Instagram today. Uses an iOS-style scroll picker (ListWheelScrollView).
class AppSessionPickerScreen extends StatefulWidget {
final VoidCallback onSessionStarted;
const AppSessionPickerScreen({super.key, required this.onSessionStarted});
@override
State<AppSessionPickerScreen> createState() => _AppSessionPickerScreenState();
}
class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
static final List<int> _minuteOptions = [
5,
10,
15,
20,
25,
30,
35,
40,
45,
50,
55,
60,
];
int _selectedIndex = 2; // default: 15 min
@override
Widget build(BuildContext context) {
final selectedMinutes = _minuteOptions[_selectedIndex];
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.blue.withValues(alpha: 0.4),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.timer_outlined,
color: Colors.white,
size: 36,
),
),
const SizedBox(height: 28),
const Text(
'Set Your Intention',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
const SizedBox(height: 10),
const Text(
'How long do you plan to use\nInstagram right now?',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white54,
fontSize: 15,
height: 1.5,
),
),
const Spacer(flex: 1),
// iOS-style scroll picker
SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
// Selection highlight
Container(
height: 50,
margin: const EdgeInsets.symmetric(horizontal: 0),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
width: 1,
),
),
),
ListWheelScrollView.useDelegate(
itemExtent: 50,
physics: const FixedExtentScrollPhysics(),
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
return Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '${entry.value}',
style: TextStyle(
fontSize: isSelected ? 28 : 22,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w300,
color: isSelected
? Colors.white
: Colors.white38,
),
),
TextSpan(
text: ' min',
style: TextStyle(
fontSize: isSelected ? 16 : 14,
color: isSelected
? Colors.white70
: Colors.white24,
),
),
],
),
),
);
}).toList(),
),
),
],
),
),
const Spacer(flex: 1),
// Confirm button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: () => _confirm(context, selectedMinutes),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: Text(
'Start $selectedMinutes-Minute Session',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
const Text(
'You\'ll be prompted to close the app when your time is up.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white24, fontSize: 12),
),
const Spacer(flex: 1),
],
),
),
),
);
}
void _confirm(BuildContext context, int minutes) {
context.read<SessionManager>().startAppSession(minutes);
widget.onSessionStarted();
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 10-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 = 10;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
);
_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 FocusGram?',
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 FocusGram'),
),
),
],
),
),
),
);
}
}
+170
View File
@@ -0,0 +1,170 @@
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: 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,
),
),
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,
),
),
],
),
),
),
),
),
);
}
}
+373
View File
@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart';
class GuardrailsPage extends StatefulWidget {
const GuardrailsPage({super.key});
@override
State<GuardrailsPage> createState() => _GuardrailsPageState();
}
class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> _handleScheduleAction(
BuildContext context,
SessionManager sm,
Future<void> Function() action,
) async {
if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35);
if (!context.mounted || !ok) return;
}
await action();
}
Future<void> _pickNewSchedule(BuildContext context, SessionManager sm) async {
final start = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 22, minute: 0),
helpText: 'Select Start Time',
);
if (!context.mounted || start == null) return;
final end = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 7, minute: 0),
helpText: 'Select End Time',
);
if (!context.mounted || end == null) return;
await sm.addSchedule(
FocusSchedule(
startHour: start.hour,
startMinute: start.minute,
endHour: end.hour,
endMinute: end.minute,
),
);
}
Future<void> _editExistingSchedule(
BuildContext context,
SessionManager sm,
int index,
FocusSchedule s,
) async {
final start = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: s.startHour, minute: s.startMinute),
helpText: 'Edit Start Time',
);
if (!context.mounted || start == null) return;
final end = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: s.endHour, minute: s.endMinute),
helpText: 'Edit End Time',
);
if (!context.mounted || end == null) return;
await sm.updateScheduleAt(
index,
FocusSchedule(
startHour: start.hour,
startMinute: start.minute,
endHour: end.hour,
endMinute: end.minute,
),
);
}
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
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: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Set your limits to stay focused. Changes to these settings require a challenge.',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
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()),
),
Divider(color: isDark ? Colors.white10 : Colors.black12, height: 32),
SwitchListTile(
title: const Text('Scheduled Blocking'),
subtitle: Text(
'Block Instagram during specific hours',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
value: sm.scheduleEnabled,
onChanged: (v) => sm.setScheduleEnabled(v),
),
if (sm.scheduleEnabled) ...[
...sm.schedules.asMap().entries.map((entry) {
final idx = entry.key;
final s = entry.value;
return ListTile(
title: Text(
'Schedule ${idx + 1}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${sm.formatTime12h(s.startHour, s.startMinute)} - ${sm.formatTime12h(s.endHour, s.endMinute)}',
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
size: 20,
),
onPressed: () => _handleScheduleAction(
context,
sm,
() => _editExistingSchedule(context, sm, idx, s),
),
),
IconButton(
icon: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
size: 20,
),
onPressed: () => _handleScheduleAction(
context,
sm,
() => sm.removeScheduleAt(idx),
),
),
],
),
);
}),
ListTile(
leading: const Icon(
Icons.add_circle_outline,
color: Colors.blueAccent,
),
title: const Text(
'Add Focus Hours',
style: TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.w600,
),
),
onTap: () => _handleScheduleAction(
context,
sm,
() => _pickNewSchedule(context, sm),
),
),
],
],
),
);
}
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
void didUpdateWidget(_FrictionSliderTile old) {
super.didUpdateWidget(old);
if (!_pendingConfirm) _draftValue = widget.value;
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final divisions = ((widget.max - widget.min) / widget.divisor).round();
return Column(
children: [
ListTile(
title: Text(widget.title),
subtitle: Text(
'${_draftValue.toInt()} min',
style: TextStyle(color: isDark ? Colors.white70 : Colors.black54),
),
trailing: _pendingConfirm
? Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() {
_draftValue = widget.value;
_pendingConfirm = false;
});
},
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final sm = context.read<SessionManager>();
int wordCount = 15;
// If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) {
wordCount = 35;
}
final success = await DisciplineChallenge.show(
context,
count: wordCount,
);
if (!context.mounted || !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);
}
},
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+398
View File
@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:app_settings/app_settings.dart';
import '../services/settings_service.dart';
import '../services/notification_service.dart';
class OnboardingPage extends StatefulWidget {
final VoidCallback onFinish;
const OnboardingPage({super.key, required this.onFinish});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5;
static const int _kBlurPage = 3;
static const int _kLinkPage = 2;
static const int _kNotifPage = 4;
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final List<Widget> slides = [
// ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide(
icon: Icons.auto_awesome,
color: Colors.blue,
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
),
// ── Page 1: Session Management ───────────────────────────────────────
_StaticSlide(
icon: Icons.timer,
color: Colors.orange,
title: 'Session Management',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
),
// ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide(
icon: Icons.link,
color: Colors.cyan,
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
isAppSettingsPage: true,
),
// ── Page 3: Blur Settings ────────────────────────────────────────────
_BlurSettingsSlide(settings: settings),
// ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide(
icon: Icons.notifications_active,
color: Colors.green,
title: 'Stay Notified',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
isPermissionPage: true,
permission: Permission.notification,
),
];
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _kTotalPages,
itemBuilder: (context, index) => slides[index],
),
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Column(
children: [
// Dot indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_kTotalPages,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 12 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Colors.blue
: Colors.white24,
borderRadius: BorderRadius.circular(4),
),
),
),
),
const SizedBox(height: 32),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(
width: double.infinity,
height: 56,
child: Builder(
builder: (context) {
final isLast = _currentPage == _kTotalPages - 1;
final isLink = _currentPage == _kLinkPage;
final isNotif = _currentPage == _kNotifPage;
final isBlur = _currentPage == _kBlurPage;
String label;
if (isLast) {
label = 'Get Started';
} else if (isLink) {
label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) {
label = 'Save & Continue';
} else {
label = 'Next';
}
return ElevatedButton(
onPressed: () async {
if (isLink) {
await AppSettings.openAppSettings(
type: AppSettingsType.settings,
);
} else if (isNotif) {
await Permission.notification.request();
await NotificationService().init();
}
if (!context.mounted) return;
if (isLast) {
_finish(context);
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
),
// Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1)
TextButton(
onPressed: () => _finish(context),
child: const Text(
'Skip',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
),
],
),
),
],
),
);
}
void _finish(BuildContext context) {
context.read<SettingsService>().setFirstRunCompleted();
widget.onFinish();
}
}
// ── Static info slide ──────────────────────────────────────────────────────────
class _StaticSlide extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String description;
final bool isPermissionPage;
final bool isAppSettingsPage;
final Permission? permission;
const _StaticSlide({
required this.icon,
required this.color,
required this.title,
required this.description,
this.isPermissionPage = false,
this.isAppSettingsPage = false,
this.permission,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 120, color: color),
const SizedBox(height: 48),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 18,
height: 1.5,
),
),
],
),
);
}
}
// ── Blur settings slide ────────────────────────────────────────────────────────
class _BlurSettingsSlide extends StatelessWidget {
final SettingsService settings;
const _BlurSettingsSlide({required this.settings});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(32, 40, 32, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Center(
child: Icon(
Icons.blur_on_rounded,
size: 90,
color: Colors.purpleAccent,
),
),
const SizedBox(height: 36),
const Center(
child: Text(
'Distraction Shield',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
const Center(
child: Text(
'Blur feeds you don\'t want to be tempted by. You can change these anytime in Settings.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white60,
fontSize: 16,
height: 1.5,
),
),
),
const SizedBox(height: 40),
// Blur Home Feed toggle
_BlurToggleTile(
icon: Icons.home_rounded,
label: 'Blur Home Feed',
subtitle: 'Posts in your feed will be blurred until tapped',
value: settings.blurReels,
onChanged: (v) => settings.setBlurReels(v),
),
const SizedBox(height: 16),
// Blur Explore toggle
_BlurToggleTile(
icon: Icons.explore_rounded,
label: 'Blur Explore Feed',
subtitle: 'Explore thumbnails stay blurred until you tap',
value: settings.blurExplore,
onChanged: (v) => settings.setBlurExplore(v),
),
],
),
);
}
}
class _BlurToggleTile extends StatelessWidget {
final IconData icon;
final String label;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _BlurToggleTile({
required this.icon,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: value
? Colors.purpleAccent.withValues(alpha: 0.12)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: value
? Colors.purpleAccent.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
Icon(
icon,
color: value ? Colors.purpleAccent : Colors.white38,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: value ? Colors.white : Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeThumbColor: Colors.purpleAccent,
),
],
),
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.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> {
DateTime? _startTime;
@override
void initState() {
super.initState();
_startTime = DateTime.now();
}
@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: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
userAgent: InjectionController.iOSUserAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
supportZoom: false,
allowsInlineMediaPlayback: true,
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
),
onWebViewCreated: (controller) {
// Controller is not stored; this overlay is self-contained.
},
onLoadStop: (controller, url) async {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
await controller.evaluateJavascript(
source: 'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
await controller.evaluateJavascript(
source: InjectionController.reelsMutationObserverJS,
);
// Also apply FocusGram baseline CSS (hides bottom nav etc.)
await controller.evaluateJavascript(
source: InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: true,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
// hideStoriesBar removed per user request
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
),
);
},
shouldOverrideUrlLoading: (controller, action) async {
// Keep this overlay locked to instagram.com pages only
final uri = action.request.url;
if (uri == null) return NavigationActionPolicy.CANCEL;
if (!uri.host.contains('instagram.com')) {
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
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});
@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) 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)) {
if (mounted) Navigator.pop(context);
}
}
}
File diff suppressed because it is too large Load Diff