UPDATES: updated UI from sidebar to topbar(again)

fixed external redirect on instagram m's settings.
fixed bug where it opened app session instead of reel session.
hided vertical scroll bar.
removed custom bottom bar.
fixed bug where it wasnt showing searchbar in /explore.

FIXED/ADDED/IMPROVED A LOT MORE THINGS.

Ready for Release
This commit is contained in:
Ujwal
2026-02-24 00:04:23 +05:45
parent 878e625f0e
commit 5232b8b0a9
48 changed files with 5258 additions and 1127 deletions
+38 -6
View File
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'services/session_manager.dart';
import 'services/settings_service.dart';
import 'services/focusgram_router.dart';
import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart';
import 'screens/app_session_picker.dart';
import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -23,6 +26,7 @@ void main() async {
await sessionManager.init();
await settingsService.init();
await NotificationService().init();
runApp(
MultiProvider(
@@ -40,16 +44,21 @@ class FocusGramApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return MaterialApp(
title: 'FocusGram',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: Colors.blue.shade400,
surface: Colors.black,
),
scaffoldBackgroundColor: Colors.black,
brightness: isDark ? Brightness.dark : Brightness.light,
colorScheme: isDark
? ColorScheme.dark(
primary: Colors.blue.shade400,
surface: Colors.black,
)
: ColorScheme.light(primary: Colors.blue),
scaffoldBackgroundColor: isDark ? Colors.black : Colors.white,
useMaterial3: true,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
@@ -76,6 +85,29 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
bool _breathCompleted = false;
bool _appSessionStarted = false;
bool _onboardingCompleted = false;
late AppLinks _appLinks;
@override
void initState() {
super.initState();
_appLinks = AppLinks();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
// 1. Handle background links while app is running
_appLinks.uriLinkStream.listen((uri) {
debugPrint('Incoming Deep Link: $uri');
FocusGramRouter.pendingUrl.value = uri.toString();
});
// 2. Handle the initial link that opened the app
final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) {
debugPrint('Initial Deep Link: $initialUri');
FocusGramRouter.pendingUrl.value = initialUri.toString();
}
}
@override
Widget build(BuildContext context) {
+8 -5
View File
@@ -11,7 +11,7 @@ class AboutPage extends StatefulWidget {
}
class _AboutPageState extends State<AboutPage> {
final String _currentVersion = '0.8.5';
final String _currentVersion = '0.9.8';
bool _isChecking = false;
Future<void> _checkUpdate() async {
@@ -110,10 +110,13 @@ class _AboutPageState extends State<AboutPage> {
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.psychology,
color: Colors.blue,
size: 50,
child: ClipOval(
child: Image.asset(
'assets/images/focusgram.png',
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
+164 -82
View File
@@ -1,19 +1,93 @@
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 StatelessWidget {
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(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'Guardrails',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
@@ -25,11 +99,14 @@ class GuardrailsPage extends StatelessWidget {
),
body: ListView(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
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: Colors.white54, fontSize: 13),
style: TextStyle(
color: isDark ? Colors.white54 : Colors.black54,
fontSize: 13,
),
),
),
_buildFrictionSliderTile(
@@ -60,84 +137,83 @@ class GuardrailsPage extends StatelessWidget {
'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',
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: Colors.blue,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white54 : Colors.black54,
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,
...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,
),
);
if (time != null) {
sm.setScheduleTime(
startH: time.hour,
startM: time.minute,
endH: sm.schedEndHour,
endM: sm.schedEndMin,
);
}
},
),
),
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(
'End Time',
style: TextStyle(color: Colors.white),
'Add Focus Hours',
style: TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.w600,
),
),
trailing: Text(
'${sm.schedEndHour.toString().padLeft(2, '0')}:${sm.schedEndMin.toString().padLeft(2, '0')}',
style: const TextStyle(color: Colors.blue),
onTap: () => _handleScheduleAction(
context,
sm,
() => _pickNewSchedule(context, sm),
),
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,
);
}
},
),
],
],
@@ -217,18 +293,17 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
@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,
style: const TextStyle(color: Colors.white),
),
title: Text(widget.title),
subtitle: Text(
'${_draftValue.toInt()} min',
style: const TextStyle(color: Colors.white70),
style: TextStyle(color: isDark ? Colors.white70 : Colors.black54),
),
trailing: _pendingConfirm
? Row(
@@ -241,15 +316,22 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
_pendingConfirm = false;
});
},
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white38),
),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
final success = await DisciplineChallenge.show(context);
if (!success) return;
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);
},
File diff suppressed because it is too large Load Diff
+57 -41
View File
@@ -1,6 +1,7 @@
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';
@@ -39,6 +40,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
icon: Icons.timer,
color: Colors.orange,
),
OnboardingData(
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" -> "Add link" and select all.',
icon: Icons.link,
color: Colors.cyan,
isAppSettingsPage: true,
),
OnboardingData(
title: 'Upload Content',
description:
@@ -101,48 +110,53 @@ class _OnboardingPageState extends State<OnboardingPage> {
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () async {
if (_pages[_currentPage].isPermissionPage) {
if (_pages[_currentPage].permission != null) {
await _pages[_currentPage].permission!.request();
}
if (_pages[_currentPage].title == 'Stay Notified') {
await NotificationService().init();
}
if (_currentPage == _pages.length - 1) {
_finish();
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
} else if (_currentPage < _pages.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} else {
_finish();
}
child: Builder(
builder: (context) {
final data = _pages[_currentPage];
return ElevatedButton(
onPressed: () async {
if (data.isAppSettingsPage) {
await AppSettings.openAppSettings(
type: AppSettingsType.settings,
);
} else if (data.isPermissionPage) {
if (data.permission != null) {
await data.permission!.request();
}
if (data.title == 'Stay Notified') {
await NotificationService().init();
}
}
if (_currentPage == _pages.length - 1) {
_finish();
} 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(
_currentPage == _pages.length - 1
? 'Get Started'
: (data.isAppSettingsPage
? 'Configure'
: 'Next'),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
_currentPage == _pages.length - 1
? 'Get Started'
: 'Next',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
@@ -166,6 +180,7 @@ class OnboardingData {
final IconData icon;
final Color color;
final bool isPermissionPage;
final bool isAppSettingsPage;
final Permission? permission;
OnboardingData({
@@ -174,6 +189,7 @@ class OnboardingData {
required this.icon,
required this.color,
this.isPermissionPage = false,
this.isAppSettingsPage = false,
this.permission,
});
}
+8 -1
View File
@@ -32,6 +32,10 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
_controller.runJavaScript(
'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
_controller.runJavaScript(
InjectionController.reelsMutationObserverJS,
@@ -42,7 +46,10 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostMode: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: true,
),
);
+314 -131
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/focusgram_router.dart';
import 'guardrails_page.dart';
import 'about_page.dart';
@@ -13,11 +13,11 @@ class SettingsPage extends StatelessWidget {
Widget build(BuildContext context) {
// Watching services ensures the UI rebuilds when settings or session state change.
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'FocusGram',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
@@ -55,6 +55,13 @@ class SettingsPage extends StatelessWidget {
icon: Icons.extension_outlined,
destination: const _ExtrasSettingsPage(),
),
_buildSettingsTile(
context: context,
title: 'Notifications',
subtitle: 'Manage message and activity alerts',
icon: Icons.notifications_active_outlined,
destination: const _NotificationSettingsPage(),
),
_buildSettingsTile(
context: context,
title: 'About',
@@ -63,44 +70,39 @@ class SettingsPage extends StatelessWidget {
destination: const AboutPage(),
),
const Divider(
color: Colors.white10,
height: 40,
indent: 16,
endIndent: 16,
),
const Divider(height: 40, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(
Icons.settings_outlined,
color: Colors.purpleAccent,
),
title: const Text(
'Instagram Settings',
style: TextStyle(color: Colors.white),
),
title: const Text('Instagram Settings'),
subtitle: const Text(
'Open native Instagram account settings',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(fontSize: 12),
),
trailing: const Icon(
Icons.open_in_new,
color: Colors.white24,
size: 14,
),
onTap: () async {
final uri = Uri.parse(
'https://www.instagram.com/accounts/settings/?entrypoint=profile',
);
await launchUrl(uri, mode: LaunchMode.externalApplication);
onTap: () {
// Bug 6 fix: navigate inside the WebView instead of external browser
Navigator.pop(context);
FocusGramRouter.pendingUrl.value =
'https://www.instagram.com/accounts/settings/?entrypoint=profile';
},
),
const SizedBox(height: 40),
const Center(
Center(
child: Text(
'FocusGram · Built for discipline',
style: TextStyle(color: Colors.white12, fontSize: 12),
style: TextStyle(
color: isDark ? Colors.white12 : Colors.black12,
fontSize: 12,
),
),
),
const SizedBox(height: 24),
@@ -118,16 +120,9 @@ class SettingsPage extends StatelessWidget {
}) {
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,
),
title: Text(title),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 13)),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => destination),
@@ -140,9 +135,9 @@ class SettingsPage extends StatelessWidget {
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF111111),
color: Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white10),
border: Border.all(color: Colors.blue.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
@@ -177,16 +172,16 @@ class SettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(color: Colors.white38, fontSize: 11),
),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 11)),
],
);
}
Widget _dividerCell() =>
Container(width: 1, height: 36, color: Colors.white10);
Widget _dividerCell() => Container(
width: 1,
height: 36,
color: Colors.blue.withValues(alpha: 0.1),
);
}
class _DistractionSettingsPage extends StatelessWidget {
@@ -196,9 +191,7 @@ class _DistractionSettingsPage extends StatelessWidget {
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'Distraction Management',
style: TextStyle(fontSize: 17),
@@ -211,52 +204,30 @@ class _DistractionSettingsPage extends StatelessWidget {
body: ListView(
children: [
SwitchListTile(
title: const Text(
'Blur Explore feed',
style: TextStyle(color: Colors.white),
),
title: const Text('Blur Posts and Explore'),
subtitle: const Text(
'Blurs posts and reels in Explore by default',
style: TextStyle(color: Colors.white54, fontSize: 13),
'Blurs images and videos on the home feed and Explore page',
style: TextStyle(fontSize: 13),
),
value: settings.blurExplore,
onChanged: (v) => settings.setBlurExplore(v),
activeThumbColor: Colors.blue,
),
SwitchListTile(
title: const Text(
'Mindfulness Gate',
style: TextStyle(color: Colors.white),
),
title: const Text('Mindfulness Gate'),
subtitle: const Text(
'Show breathing exercise before opening',
style: TextStyle(color: Colors.white54, fontSize: 13),
style: TextStyle(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),
),
title: const Text('Strict Changes (Word Challenge)'),
subtitle: const Text(
'Requires 15-word typing challenge before lax changes',
style: TextStyle(color: Colors.white54, fontSize: 13),
style: TextStyle(fontSize: 13),
),
value: settings.requireWordChallenge,
onChanged: (v) => settings.setRequireWordChallenge(v),
@@ -268,8 +239,6 @@ class _DistractionSettingsPage extends StatelessWidget {
}
}
/// 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;
@@ -320,13 +289,10 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(
widget.title,
style: const TextStyle(color: Colors.white),
),
title: Text(widget.title),
subtitle: Text(
'${_draftValue.toInt()} min',
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: const TextStyle(fontSize: 13),
),
trailing: _pendingConfirm
? Row(
@@ -339,10 +305,7 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
_pendingConfirm = false;
});
},
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white38),
),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
@@ -413,19 +376,10 @@ class _ExtrasSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final allTabs = [
'Home',
'Search',
'Create',
'Notifications',
'Reels',
'Profile',
];
final isDark = settings.isDarkMode;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('Extras', style: TextStyle(fontSize: 17)),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
@@ -435,59 +389,48 @@ class _ExtrasSettingsPage extends StatelessWidget {
body: ListView(
children: [
const _SettingsSectionHeader(title: 'EXPERIMENT'),
SwitchListTile(
title: const Text(
'Ghost Mode',
style: TextStyle(color: Colors.white),
ListTile(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const _GhostModeSettingsPage()),
),
subtitle: const Text(
'Hides "typing..." and "seen" status in DMs',
style: TextStyle(color: Colors.white54, fontSize: 13),
leading: Icon(
Icons.visibility_off_outlined,
color: isDark ? Colors.white70 : Colors.black87,
),
value: settings.ghostMode,
onChanged: (v) => settings.setGhostMode(v),
activeThumbColor: Colors.blue,
title: const Text('Ghost Mode'),
subtitle: Text(
settings.anyGhostModeEnabled
? 'Active — some receipts are hidden'
: 'Disabled',
style: TextStyle(
color: settings.anyGhostModeEnabled
? Colors.blue
: (isDark ? Colors.white38 : Colors.black38),
fontSize: 13,
),
),
trailing: const Icon(Icons.chevron_right),
),
SwitchListTile(
title: const Text(
'Enable Text Selection',
style: TextStyle(color: Colors.white),
),
title: const Text('Enable Text Selection'),
subtitle: const Text(
'Allows copying text from posts and captions',
style: TextStyle(color: Colors.white54, fontSize: 13),
style: TextStyle(fontSize: 13),
),
value: settings.enableTextSelection,
onChanged: (v) => settings.setEnableTextSelection(v),
activeThumbColor: Colors.blue,
),
const _SettingsSectionHeader(title: 'BOTTOM BAR'),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 8,
children: allTabs.map((tab) {
final isEnabled = settings.enabledTabs.contains(tab);
return FilterChip(
label: Text(tab),
selected: isEnabled,
onSelected: (_) => settings.toggleTab(tab),
backgroundColor: Colors.white10,
selectedColor: Colors.blue.withValues(alpha: 0.3),
checkmarkColor: Colors.blue,
labelStyle: TextStyle(
color: isEnabled ? Colors.blue : Colors.white60,
fontSize: 12,
),
);
}).toList(),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 16),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Toggle tabs to customize your navigation bar. At least one tab must be enabled.',
style: TextStyle(color: Colors.white24, fontSize: 11),
'Experimental features: Some features may break if Instagram updates their website.',
style: TextStyle(
color: isDark ? Colors.white24 : Colors.black26,
fontSize: 11,
),
),
),
],
@@ -516,3 +459,243 @@ class _SettingsSectionHeader extends StatelessWidget {
);
}
}
class _GhostModeSettingsPage extends StatelessWidget {
const _GhostModeSettingsPage();
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text('Ghost Mode', style: TextStyle(fontSize: 17)),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Control which activity receipts are hidden from other users. ',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black45,
fontSize: 12,
height: 1.4,
),
),
),
const _SettingsSectionHeader(title: 'MESSAGING'),
SwitchListTile(
secondary: Icon(
Icons.keyboard_outlined,
color: isDark ? Colors.white54 : Colors.black54,
),
title: const Text('Hide typing indicator'),
subtitle: Text(
"Others won't see the 'typing...' status when you write a message",
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black45,
fontSize: 12,
),
),
value: settings.ghostTyping,
onChanged: (v) => settings.setGhostTyping(v),
activeThumbColor: Colors.blue,
),
Stack(
children: [
AbsorbPointer(
child: Opacity(
opacity: 0.5,
child: SwitchListTile(
secondary: Icon(
Icons.done_all_rounded,
color: isDark ? Colors.white54 : Colors.black54,
),
title: const Text('Hide seen status'),
subtitle: Text(
"Others won't see when you've read their DMs",
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black45,
fontSize: 12,
),
),
value: settings.ghostSeen,
onChanged: (v) => settings.setGhostSeen(v),
activeThumbColor: Colors.blue,
),
),
),
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.blue, width: 0.5),
),
child: const Text(
'COMING SOON',
style: TextStyle(
color: Colors.blue,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
SwitchListTile(
secondary: Icon(
Icons.image_outlined,
color: isDark ? Colors.white54 : Colors.black54,
),
title: const Text('Hide DM photo seen status'),
subtitle: Text(
'Prevents Instagram from marking photos/videos in DMs as viewed',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black45,
fontSize: 12,
),
),
value: settings.ghostDmPhotos,
onChanged: (v) => settings.setGhostDmPhotos(v),
activeThumbColor: Colors.blue,
),
const _SettingsSectionHeader(title: 'STORIES'),
SwitchListTile(
secondary: Icon(
Icons.auto_stories_outlined,
color: isDark ? Colors.white54 : Colors.black54,
),
title: const Text('Story ghost mode'),
subtitle: Text(
'Watch stories without appearing in the viewer list',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black45,
fontSize: 12,
),
),
value: settings.ghostStories,
onChanged: (v) => settings.setGhostStories(v),
activeThumbColor: Colors.blue,
),
const SizedBox(height: 32),
],
),
);
}
}
class _NotificationSettingsPage extends StatelessWidget {
const _NotificationSettingsPage();
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text('Notifications', style: TextStyle(fontSize: 17)),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
children: [
const Row(
children: [
Icon(
Icons.info_outline,
color: Colors.blueAccent,
size: 20,
),
SizedBox(width: 12),
Text(
'Important Note',
style: TextStyle(
color: Colors.blueAccent,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 10),
Text(
'FocusGram monitors your session locally. For notifications to work, the app must be running in the background (minimized). If you force-close or swipe away the app from your task switcher, notifications will stop until you reopen it.',
style: TextStyle(
color: isDark ? Colors.white70 : Colors.black87,
fontSize: 13,
height: 1.4,
),
),
],
),
),
SwitchListTile(
secondary: const Icon(Icons.mail_outline, color: Colors.blueAccent),
title: const Text('Direct Messages'),
subtitle: const Text(
'Notify when you receive a new DM',
style: TextStyle(fontSize: 13),
),
value: settings.notifyDMs,
onChanged: (v) => settings.setNotifyDMs(v),
activeThumbColor: Colors.blue,
),
SwitchListTile(
secondary: const Icon(
Icons.favorite_border,
color: Colors.blueAccent,
),
title: const Text('General Activity'),
subtitle: const Text(
'Likes, mentions, and other interactions',
style: TextStyle(fontSize: 13),
),
value: settings.notifyActivity,
onChanged: (v) => settings.setNotifyActivity(v),
activeThumbColor: Colors.blue,
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Note: Push notifications are generated by the app local service by monitoring the web sessions. This does not rely on Instagram servers sending notifications to your device.',
style: TextStyle(
color: isDark ? Colors.white24 : Colors.black26,
fontSize: 11,
),
),
),
],
),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/foundation.dart';
/// Lightweight global router for cross-widget navigation signals.
/// Used to allow the Settings page to trigger WebView navigations without
/// requiring a BuildContext reference to MainWebViewPage.
class FocusGramRouter {
FocusGramRouter._();
/// When this value is non-null, [MainWebViewPage] will load the URL
/// in the WebView and clear this value. Settings page sets this to
/// trigger in-app navigation (e.g. Instagram Settings).
static final pendingUrl = ValueNotifier<String?>(null);
}
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -9,8 +9,9 @@ class NavigationGuard {
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
/// Regex matching the Reels FEED root — NOT individual reels.
/// The `(/|\?|$)` suffix ensures query params (e.g. ?fg=blocked) still match.
static final _reelsFeedRegex = RegExp(
r'instagram\.com/reels/?$',
r'instagram\.com/reels(/|\?|$)',
caseSensitive: false,
);
@@ -36,7 +37,7 @@ class NavigationGuard {
// Block non-Instagram domains (prevents phishing/external redirects)
final host = uri.host.toLowerCase();
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
if (!_allowedHosts.any((h) => host == h)) {
return BlockDecision(
blocked: true,
reason: 'External domain blocked: $host',
+11 -6
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
@@ -59,11 +60,15 @@ class NotificationService {
iOS: iosDetails,
);
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
try {
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Notification error: $e');
}
}
}
+140 -12
View File
@@ -1,9 +1,38 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
class FocusSchedule {
final int startHour;
final int startMinute;
final int endHour;
final int endMinute;
FocusSchedule({
required this.startHour,
required this.startMinute,
required this.endHour,
required this.endMinute,
});
Map<String, dynamic> toJson() => {
'startH': startHour,
'startM': startMinute,
'endH': endHour,
'endM': endMinute,
};
factory FocusSchedule.fromJson(Map<String, dynamic> json) => FocusSchedule(
startHour: json['startH'] as int,
startMinute: json['startM'] as int,
endHour: json['endH'] as int,
endMinute: json['endM'] as int,
);
}
/// Manages all session logic for FocusGram:
///
/// **App Session** — how long the user plans to use Instagram today.
@@ -34,6 +63,7 @@ class SessionManager extends ChangeNotifier {
static const _keyScheduleStartMin = 'sched_start_m';
static const _keyScheduleEndHour = 'sched_end_h';
static const _keyScheduleEndMin = 'sched_end_m';
static const _keySchedulesJson = 'sched_list_json';
SharedPreferences? _prefs;
@@ -56,8 +86,10 @@ class SessionManager extends ChangeNotifier {
bool _scheduleEnabled = false;
int _schedStartHour = 22; // Default 10 PM
int _schedStartMin = 0;
int _schedEndHour = 7; // Default 7 AM
int _schedEndHour = 7;
int _schedEndMin = 0;
List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false;
bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0;
@@ -148,21 +180,56 @@ class SessionManager extends ChangeNotifier {
int get schedStartMin => _schedStartMin;
int get schedEndHour => _schedEndHour;
int get schedEndMin => _schedEndMin;
List<FocusSchedule> get schedules => _schedules;
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;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
if (startTime < endTime) {
// Simple range (e.g., 9:00 to 17:00)
if (currentTime >= startTime && currentTime < endTime) return true;
} else {
// Over-midnight range (e.g., 22:00 to 07:00)
if (currentTime >= startTime || currentTime < endTime) return true;
}
}
return false;
}
String? get activeScheduleText {
if (!isScheduledBlockActive) return null;
final now = DateTime.now();
final currentTime = now.hour * 60 + now.minute;
for (final s in _schedules) {
final startTime = s.startHour * 60 + s.startMinute;
final endTime = s.endHour * 60 + s.endMinute;
bool active = false;
if (startTime < endTime) {
if (currentTime >= startTime && currentTime < endTime) active = true;
} else {
if (currentTime >= startTime || currentTime < endTime) active = true;
}
if (active) {
return '${formatTime12h(s.startHour, s.startMinute)} to ${formatTime12h(s.endHour, s.endMinute)}';
}
}
return null;
}
String formatTime12h(int h, int m) {
var hour = h % 12;
if (hour == 0) hour = 12;
final period = h >= 12 ? 'PM' : 'AM';
final min = m.toString().padLeft(2, '0');
return '$hour:$min $period';
}
// ── Initialization ─────────────────────────────────────────
@@ -170,6 +237,7 @@ class SessionManager extends ChangeNotifier {
_prefs = await SharedPreferences.getInstance();
await _resetDailyIfNeeded();
_loadPersisted();
_lastScheduleState = isScheduledBlockActive;
_startTicker();
_incrementOpenCount();
}
@@ -249,6 +317,23 @@ class SessionManager extends ChangeNotifier {
_schedStartMin = _prefs!.getInt(_keyScheduleStartMin) ?? 0;
_schedEndHour = _prefs!.getInt(_keyScheduleEndHour) ?? 7;
_schedEndMin = _prefs!.getInt(_keyScheduleEndMin) ?? 0;
final schedJson = _prefs!.getString(_keySchedulesJson);
if (schedJson != null) {
final List decode = jsonDecode(schedJson);
_schedules = decode.map((m) => FocusSchedule.fromJson(m)).toList();
} else {
// Migrate old single schedule if it exists
_schedules = [
FocusSchedule(
startHour: _schedStartHour,
startMinute: _schedStartMin,
endHour: _schedEndHour,
endMinute: _schedEndMin,
),
];
_saveSchedulesToPrefs();
}
}
void _incrementOpenCount() {
@@ -300,6 +385,13 @@ class SessionManager extends ChangeNotifier {
changed = true;
}
// Schedule check
final sched = isScheduledBlockActive;
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
changed = true;
}
if (changed) notifyListeners();
}
@@ -418,17 +510,53 @@ class SessionManager extends ChangeNotifier {
required int endH,
required int endM,
}) async {
_schedStartHour = startH;
_schedStartMin = startM;
_schedEndHour = endH;
_schedEndMin = endM;
// Update the first schedule for compatibility? Or just replace all?
// Let's replace all schedules with this one if this method is called.
_schedules = [
FocusSchedule(
startHour: startH,
startMinute: startM,
endHour: endH,
endMinute: endM,
),
];
await _prefs?.setInt(_keyScheduleStartHour, startH);
await _prefs?.setInt(_keyScheduleStartMin, startM);
await _prefs?.setInt(_keyScheduleEndHour, endH);
await _prefs?.setInt(_keyScheduleEndMin, endM);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> _saveSchedulesToPrefs() async {
final json = jsonEncode(_schedules.map((s) => s.toJson()).toList());
await _prefs?.setString(_keySchedulesJson, json);
}
Future<void> addSchedule(FocusSchedule s) async {
_schedules.add(s);
await _saveSchedulesToPrefs();
notifyListeners();
}
Future<void> removeScheduleAt(int index) async {
if (index >= 0 && index < _schedules.length) {
_schedules.removeAt(index);
await _saveSchedulesToPrefs();
notifyListeners();
}
}
Future<void> updateScheduleAt(int index, FocusSchedule s) async {
if (index >= 0 && index < _schedules.length) {
_schedules[index] = s;
await _saveSchedulesToPrefs();
notifyListeners();
}
}
@override
void dispose() {
_ticker?.cancel();
+141 -14
View File
@@ -8,13 +8,25 @@ class SettingsService extends ChangeNotifier {
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyGhostMode = 'set_ghost_mode';
static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings';
static const _keyIsFirstRun = 'set_is_first_run';
// Granular Ghost Mode keys
static const _keyGhostTyping = 'set_ghost_typing';
static const _keyGhostSeen = 'set_ghost_seen';
static const _keyGhostStories = 'set_ghost_stories';
static const _keyGhostDmPhotos = 'set_ghost_dm_photos';
// Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms';
static const _keyNotifyActivity = 'set_notify_activity';
// Legacy key for migration
static const _keyGhostModeLegacy = 'set_ghost_mode';
SharedPreferences? _prefs;
bool _blurExplore = true;
@@ -22,10 +34,28 @@ class SettingsService extends ChangeNotifier {
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
bool _ghostMode = true;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
List<String> _enabledTabs = ['Home', 'Search', 'Create', 'Reels', 'Profile'];
bool _isDarkMode = true; // Default to dark as per existing app theme
// Granular Ghost Mode defaults (all on)
bool _ghostTyping = true;
bool _ghostSeen = true;
bool _ghostStories = true;
bool _ghostDmPhotos = true;
// Privacy defaults
bool _sanitizeLinks = true;
bool _notifyDMs = true;
bool _notifyActivity = true;
List<String> _enabledTabs = [
'Home',
'Search',
'Reels',
'Messages',
'Profile',
];
bool _isFirstRun = true;
bool get blurExplore => _blurExplore;
@@ -33,11 +63,26 @@ class SettingsService extends ChangeNotifier {
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
bool get ghostMode => _ghostMode;
bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
// Granular Ghost Mode getters
bool get ghostTyping => _ghostTyping;
bool get ghostSeen => _ghostSeen;
bool get ghostStories => _ghostStories;
bool get ghostDmPhotos => _ghostDmPhotos;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
/// True if ANY ghost mode setting is enabled (for injection logic).
bool get anyGhostModeEnabled =>
_ghostTyping || _ghostSeen || _ghostStories || _ghostDmPhotos;
// Privacy getters
bool get sanitizeLinks => _sanitizeLinks;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@@ -46,12 +91,42 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? true;
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
// Migrate legacy ghostMode key -> all granular keys
final legacyGhostMode = _prefs!.getBool(_keyGhostModeLegacy);
if (legacyGhostMode != null) {
// Seed all four granular keys with the legacy value
_ghostTyping = legacyGhostMode;
_ghostSeen = legacyGhostMode;
_ghostStories = legacyGhostMode;
_ghostDmPhotos = legacyGhostMode;
// Save granular keys and remove legacy key
await _prefs!.setBool(_keyGhostTyping, legacyGhostMode);
await _prefs!.setBool(_keyGhostSeen, legacyGhostMode);
await _prefs!.setBool(_keyGhostStories, legacyGhostMode);
await _prefs!.setBool(_keyGhostDmPhotos, legacyGhostMode);
await _prefs!.remove(_keyGhostModeLegacy);
} else {
_ghostTyping = _prefs!.getBool(_keyGhostTyping) ?? true;
_ghostSeen = _prefs!.getBool(_keyGhostSeen) ?? true;
_ghostStories = _prefs!.getBool(_keyGhostStories) ?? true;
_ghostDmPhotos = _prefs!.getBool(_keyGhostDmPhotos) ?? true;
}
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? true;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? true;
_enabledTabs =
_prefs!.getStringList(_keyEnabledTabs) ??
['Home', 'Search', 'Create', 'Reels', 'Profile'];
(_prefs!.getStringList(_keyEnabledTabs) ??
['Home', 'Search', 'Reels', 'Messages', 'Profile'])
..remove('Create');
if (!_enabledTabs.contains('Messages') && _enabledTabs.length < 5) {
// Migration: add Messages if missing
_enabledTabs.insert(3, 'Messages');
}
_isFirstRun = _prefs!.getBool(_keyIsFirstRun) ?? true;
notifyListeners();
}
@@ -92,12 +167,6 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -110,6 +179,56 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
void setDarkMode(bool dark) {
if (_isDarkMode != dark) {
_isDarkMode = dark;
notifyListeners();
}
}
// Granular Ghost Mode setters
Future<void> setGhostTyping(bool v) async {
_ghostTyping = v;
await _prefs?.setBool(_keyGhostTyping, v);
notifyListeners();
}
Future<void> setGhostSeen(bool v) async {
_ghostSeen = v;
await _prefs?.setBool(_keyGhostSeen, v);
notifyListeners();
}
Future<void> setGhostStories(bool v) async {
_ghostStories = v;
await _prefs?.setBool(_keyGhostStories, v);
notifyListeners();
}
Future<void> setGhostDmPhotos(bool v) async {
_ghostDmPhotos = v;
await _prefs?.setBool(_keyGhostDmPhotos, v);
notifyListeners();
}
Future<void> setSanitizeLinks(bool v) async {
_sanitizeLinks = v;
await _prefs?.setBool(_keySanitizeLinks, v);
notifyListeners();
}
Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v);
notifyListeners();
}
Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v);
notifyListeners();
}
Future<void> toggleTab(String tab) async {
if (_enabledTabs.contains(tab)) {
if (_enabledTabs.length > 1) {
@@ -121,4 +240,12 @@ class SettingsService extends ChangeNotifier {
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
notifyListeners();
}
Future<void> reorderTab(int oldIndex, int newIndex) async {
if (newIndex > oldIndex) newIndex -= 1;
final String item = _enabledTabs.removeAt(oldIndex);
_enabledTabs.insert(newIndex, item);
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
notifyListeners();
}
}
+22 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_windowmanager_plus/flutter_windowmanager_plus.dart';
class DisciplineChallenge {
static const List<String> _words = [
@@ -516,11 +517,20 @@ class DisciplineChallenge {
];
/// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context) async {
static Future<bool> show(BuildContext context, {int count = 15}) async {
final list = List<String>.from(_words)..shuffle();
final challenge = list.take(15).join(' ');
final challenge = list.take(count).join(' ');
final controller = TextEditingController();
// Prevent screenshots on Android
try {
await FlutterWindowManagerPlus.addFlags(
FlutterWindowManagerPlus.FLAG_SECURE,
);
} catch (_) {}
if (!context.mounted) return false;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -569,6 +579,8 @@ class DisciplineChallenge {
TextField(
controller: controller,
autofocus: true,
enableInteractiveSelection:
false, // Prevents copy/paste/selection
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Type here...',
@@ -616,6 +628,14 @@ class DisciplineChallenge {
],
),
);
// Re-enable screenshots
try {
await FlutterWindowManagerPlus.clearFlags(
FlutterWindowManagerPlus.FLAG_SECURE,
);
} catch (_) {}
return result ?? false;
}
}