Added Notification Bridge.

Added an onboarding screen.
Fixed problem wherebottom bar blocked sending message.
added a option to go to instagram's settings page in our settings page.
added a notifications icon in bottombar.
Other few Improvements and Bug fixes.
This commit is contained in:
Ujwal
2026-02-23 11:37:15 +05:45
parent e23731d9e8
commit 878e625f0e
22 changed files with 726 additions and 78 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'services/session_manager.dart';
import 'services/settings_service.dart';
import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart';
import 'screens/app_session_picker.dart';
@@ -59,10 +60,11 @@ class FocusGramApp extends StatelessWidget {
}
/// Flow on every cold open:
/// 1. Cooldown Gate (if app-open cooldown active)
/// 2. Breath Gate (if enabled in settings)
/// 3. App Session Picker (always)
/// 4. Main WebView
/// 1. Onboarding (if first run)
/// 2. Cooldown Gate (if app-open cooldown active)
/// 3. Breath Gate (if enabled in settings)
/// 4. App Session Picker (always)
/// 5. Main WebView
class InitialRouteHandler extends StatefulWidget {
const InitialRouteHandler({super.key});
@@ -73,32 +75,40 @@ class InitialRouteHandler extends StatefulWidget {
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
bool _breathCompleted = false;
bool _appSessionStarted = false;
bool _onboardingCompleted = false;
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
// Step 1: Cooldown gate — if too soon since last session
// Step 1: Onboarding
if (settings.isFirstRun && !_onboardingCompleted) {
return OnboardingPage(
onFinish: () => setState(() => _onboardingCompleted = true),
);
}
// Step 2: Cooldown gate — if too soon since last session
if (sm.isAppOpenCooldownActive) {
return const CooldownGateScreen();
}
// Step 2: Breath gate
// Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
onFinish: () => setState(() => _breathCompleted = true),
);
}
// Step 3: App session picker
// Step 4: App session picker
if (!_appSessionStarted) {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
}
// Step 4: Main app
// Step 5: Main app
return const MainWebViewPage();
}
}

View File

@@ -4,13 +4,15 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/navigation_guard.dart';
import 'package:google_fonts/google_fonts.dart';
import 'session_modal.dart';
import '../services/notification_service.dart';
import 'settings_page.dart';
import 'app_session_picker.dart';
class MainWebViewPage extends StatefulWidget {
const MainWebViewPage({super.key});
@@ -44,6 +46,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_currentUrl.contains('instagram.com/accounts/login');
}
/// Helper to determine if we are inside Direct Messages.
bool get _isInDirect {
return _currentUrl.contains('instagram.com/direct/');
}
@override
void initState() {
super.initState();
@@ -174,7 +181,23 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(InjectionController.iOSUserAgent)
..setBackgroundColor(Colors.black)
..setBackgroundColor(Colors.black);
// Support file uploads on Android
if (_controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(_controller.platform as AndroidWebViewController).setOnShowFileSelector((
FileSelectorParams params,
) async {
// Standard Android implementation triggers the file picker intent automatically
// when this callback is set, or we can use file_picker package if needed.
// For now, returning empty list if we want to custom handle, or better:
// By default, setting a non-null callback enables the system picker.
return <String>[];
});
}
_controller
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
@@ -195,6 +218,8 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_applyInjections();
_updateCurrentTab(url);
_cacheUsername();
// Inject Notification Bridge Hook
_controller.runJavaScript(InjectionController.notificationBridgeJS);
// Inject MutationObserver to lock reel scrolling resiliently
_controller.runJavaScript(
InjectionController.reelsMutationObserverJS,
@@ -241,6 +266,19 @@ class _MainWebViewPageState extends State<MainWebViewPage>
},
),
)
..addJavaScriptChannel(
'FocusGramNotificationChannel',
onMessageReceived: (message) {
try {
// Instagram sends notification data; we bridge to native
NotificationService().showNotification(
id: DateTime.now().millisecond,
title: 'Instagram',
body: message.message,
);
} catch (_) {}
},
)
..loadRequest(Uri.parse('https://www.instagram.com/accounts/login/'));
}
@@ -274,6 +312,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
await _controller.loadRequest(
Uri.parse('https://www.instagram.com/accounts/login/'),
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Signed out successfully')));
@@ -331,32 +370,31 @@ class _MainWebViewPageState extends State<MainWebViewPage>
await _controller.loadRequest(Uri.parse('https://www.instagram.com$path'));
}
Future<void> _onTabTapped(int index) async {
if (index == _currentIndex) {
await _controller.reload();
return;
}
setState(() => _currentIndex = index);
Future<void> _onTabTapped(String label) async {
final sm = context.read<SessionManager>();
switch (index) {
case 0:
switch (label) {
case 'Home':
await _navigateTo('/');
break;
case 1:
// Search tab - user reported "dark page" at /explore/search/
// Let's try /explore/ directly which usually shows the search bar on mobile web
case 'Search':
await _navigateTo('/explore/');
break;
case 2:
if (context.read<SessionManager>().isSessionActive) {
case 'Create':
await _navigateTo('/reels/create/'); // Default create path
break;
case 'Notifications':
await _navigateTo('/notifications/');
break;
case 'Reels':
if (sm.isSessionActive) {
await _navigateTo('/reels/');
} else {
// Show session picker if no session active
_showSessionPicker();
}
// If not active, do nothing (disabled as requested)
break;
case 3:
await _navigateTo('/direct/inbox/');
break;
case 4:
case 'Profile':
if (_cachedUsername != null) {
await _navigateTo('/$_cachedUsername/');
} else {
@@ -371,10 +409,39 @@ class _MainWebViewPageState extends State<MainWebViewPage>
}
}
void _showSessionPicker() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: const BoxDecoration(
color: Color(0xFF121212),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(25),
topRight: Radius.circular(25),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(25),
topRight: Radius.circular(25),
),
child: Navigator(
onGenerateRoute: (_) => MaterialPageRoute(
builder: (ctx) => AppSessionPickerScreen(
onSessionStarted: () => Navigator.pop(context),
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
const barHeight = 60.0;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
@@ -382,8 +449,6 @@ class _MainWebViewPageState extends State<MainWebViewPage>
if (await _controller.canGoBack()) {
await _controller.goBack();
} else {
// If no history, we can either minimize or close.
// SystemNavigator.pop() is usually what users expect for "Close".
SystemNavigator.pop();
}
},
@@ -415,27 +480,19 @@ class _MainWebViewPageState extends State<MainWebViewPage>
top: 60 + MediaQuery.of(context).padding.top,
left: 0,
right: 0,
child: const LinearProgressIndicator(
backgroundColor: Colors.transparent,
color: Colors.blue,
minHeight: 2,
),
child: const _InstagramGradientProgressBar(),
),
// ── The Edge Panel ──────────────────────────────────────────
_EdgePanel(controller: _controller),
// ── Our bottom bar ──────────────────────────────────────────
if (!_isOnOnboardingPage)
if (!_isOnOnboardingPage && !_isInDirect)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _FocusGramNavBar(
currentIndex: _currentIndex,
onTap: _onTabTapped,
height: barHeight * 0.99, // 1% reduction
),
child: const _FocusGramNavBar(),
),
],
),
@@ -819,49 +876,92 @@ class _BrandedTopBar extends StatelessWidget {
}
}
class _FocusGramNavBar extends StatelessWidget {
final int currentIndex;
final Future<void> Function(int) onTap;
final double height;
const _FocusGramNavBar({
required this.currentIndex,
required this.onTap,
this.height = 52,
});
class _InstagramGradientProgressBar extends StatelessWidget {
const _InstagramGradientProgressBar();
@override
Widget build(BuildContext context) {
final items = [
(Icons.home_outlined, Icons.home_rounded, 'Home'),
(Icons.search, Icons.search, 'Search'),
(Icons.play_circle_outline, Icons.play_circle_filled, 'Session'),
(Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'),
(Icons.person_outline, Icons.person, 'Profile'),
];
return SizedBox(
height: 2.5,
child: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFFEDA75), // Yellow
Color(0xFFFA7E1E), // Orange
Color(0xFFD62976), // Pink
Color(0xFF962FBF), // Purple
Color(0xFF4F5BD5), // Blue
],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
],
),
);
}
}
class _FocusGramNavBar extends StatelessWidget {
const _FocusGramNavBar();
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final allItems = {
'Home': (Icons.home_outlined, Icons.home_rounded),
'Search': (Icons.search, Icons.search),
'Create': (Icons.add_box_outlined, Icons.add_box),
'Notifications': (Icons.favorite_border, Icons.favorite),
'Reels': (Icons.play_circle_outline, Icons.play_circle_filled),
'Profile': (Icons.person_outline, Icons.person),
};
final activeTabs = settings.enabledTabs;
final state = context.findAncestorStateOfType<_MainWebViewPageState>()!;
final sm = context.watch<SessionManager>();
return Container(
color: Colors.black,
child: SafeArea(
top: false,
child: SizedBox(
height: height,
child: Container(
height: 56,
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.white12, width: 0.5)),
),
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;
children: activeTabs.map((tab) {
final icons = allItems[tab];
if (icons == null) return const SizedBox();
final isSelected = _isTabSelected(
tab,
state._currentUrl,
state._cachedUsername,
);
return GestureDetector(
onTap: () => onTap(i),
onTap: () => state._onTabTapped(tab),
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 60,
width:
MediaQuery.of(context).size.width /
activeTabs.length.clamp(1, 6),
height: double.infinity,
child: Center(
child: Icon(
isSelected ? filledIcon : outlinedIcon,
color: isSelected ? Colors.white : Colors.white54,
isSelected ? icons.$2 : icons.$1,
color: isSelected
? Colors.white
: (tab == 'Reels' && !sm.isSessionActive)
? Colors.white24
: Colors.white54,
size: 26,
),
),
@@ -873,6 +973,26 @@ class _FocusGramNavBar extends StatelessWidget {
),
);
}
bool _isTabSelected(String tab, String url, String? username) {
final path = Uri.tryParse(url)?.path ?? '';
switch (tab) {
case 'Home':
return path == '/' || path.isEmpty;
case 'Search':
return path.startsWith('/explore');
case 'Create':
return path.startsWith('/create');
case 'Notifications':
return path.startsWith('/notifications');
case 'Reels':
return path.startsWith('/reels');
case 'Profile':
return username != null && path.startsWith('/$username');
default:
return false;
}
}
}
// ──────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.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;
final List<OnboardingData> _pages = [
OnboardingData(
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
icon: Icons.auto_awesome,
color: Colors.blue,
),
OnboardingData(
title: 'Ghost Mode',
description:
'Browse with total privacy. We block typing indicators and read receipts automatically.',
icon: Icons.visibility_off,
color: Colors.purple,
),
OnboardingData(
title: 'Session Management',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
icon: Icons.timer,
color: Colors.orange,
),
OnboardingData(
title: 'Upload Content',
description:
'We need access to your gallery if you want to upload stories or posts directly from FocusGram.',
icon: Icons.photo_library,
color: Colors.orange,
isPermissionPage: true,
permission: Permission.photos,
),
OnboardingData(
title: 'Stay Notified',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
icon: Icons.notifications_active,
color: Colors.green,
isPermissionPage: true,
permission: Permission.notification,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _pages.length,
itemBuilder: (context, index) =>
_OnboardingSlide(data: _pages[index]),
),
Positioned(
bottom: 50,
left: 0,
right: 0,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_pages.length,
(index) => Container(
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),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
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();
}
},
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,
),
),
),
),
),
],
),
),
],
),
);
}
void _finish() {
context.read<SettingsService>().setFirstRunCompleted();
widget.onFinish();
}
}
class OnboardingData {
final String title;
final String description;
final IconData icon;
final Color color;
final bool isPermissionPage;
final Permission? permission;
OnboardingData({
required this.title,
required this.description,
required this.icon,
required this.color,
this.isPermissionPage = false,
this.permission,
});
}
class _OnboardingSlide extends StatelessWidget {
final OnboardingData data;
const _OnboardingSlide({required this.data});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(data.icon, size: 120, color: data.color),
const SizedBox(height: 48),
Text(
data.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
data.description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 18,
height: 1.5,
),
),
],
),
);
}
}

View File

@@ -2,6 +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 'guardrails_page.dart';
import 'about_page.dart';
@@ -62,6 +63,39 @@ class SettingsPage extends StatelessWidget {
destination: const AboutPage(),
),
const Divider(
color: Colors.white10,
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),
),
subtitle: const Text(
'Open native Instagram account settings',
style: TextStyle(color: Colors.white54, 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);
},
),
const SizedBox(height: 40),
const Center(
child: Text(
@@ -379,6 +413,15 @@ class _ExtrasSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final allTabs = [
'Home',
'Search',
'Create',
'Notifications',
'Reels',
'Profile',
];
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
@@ -391,6 +434,7 @@ class _ExtrasSettingsPage extends StatelessWidget {
),
body: ListView(
children: [
const _SettingsSectionHeader(title: 'EXPERIMENT'),
SwitchListTile(
title: const Text(
'Ghost Mode',
@@ -417,8 +461,58 @@ class _ExtrasSettingsPage extends StatelessWidget {
onChanged: (v) => settings.setEnableTextSelection(v),
activeThumbColor: Colors.blue,
),
const _SettingsSectionHeader(title: 'BOTTOM BAR'),
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),
child: Text(
'Toggle tabs to customize your navigation bar. At least one tab must be enabled.',
style: TextStyle(color: Colors.white24, fontSize: 11),
),
),
],
),
);
}
}
class _SettingsSectionHeader extends StatelessWidget {
final String title;
const _SettingsSectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: const TextStyle(
color: Colors.blue,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
);
}
}

View File

@@ -17,6 +17,16 @@ class InjectionController {
-webkit-tap-highlight-color: transparent !important;
outline: none !important;
}
/* Hide all scrollbars */
::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
''';
/// CSS to disable text selection globally.
@@ -93,14 +103,19 @@ class InjectionController {
/// Robust CSS that hides Instagram's native bottom nav bar.
static const String _hideInstagramNavCSS = '''
div[role="tablist"], nav[role="navigation"],
._acbl, ._aa4b, ._aahi, ._ab8s, section nav, footer nav {
/* Hide bottom nav but keep search header */
div[role="tablist"], footer nav, ._acbl, ._aa4b {
display: none !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
/* Only hide top nav if not on search page */
body:not([path*="/explore/search/"]) nav[role="navigation"],
body:not([path*="/explore/search/"]) section nav {
display: none !important;
}
body, #react-root, main {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
@@ -214,6 +229,30 @@ class InjectionController {
})();
''';
/// Hijacks the Web Notification API to bridge Instagram notifications to native.
static String get notificationBridgeJS => """
(function() {
const NativeNotification = window.Notification;
if (!NativeNotification) return;
window.Notification = function(title, options) {
const body = (options && options.body) ? options.body : "";
// Pass to Flutter
if (window.FocusGramNotificationChannel) {
window.FocusGramNotificationChannel.postMessage(title + ": " + body);
}
return new NativeNotification(title, options);
};
window.Notification.permission = "granted";
window.Notification.requestPermission = function() {
return Promise.resolve("granted");
};
})();
""";
/// MutationObserver for Reel scroll locking.
static const String reelsMutationObserverJS = '''
(function() {
@@ -267,6 +306,8 @@ class InjectionController {
return '''
${buildSessionStateJS(sessionActive)}
/* Set path attribute on body for CSS targeting */
document.body.setAttribute('path', window.location.pathname);
${_buildMutationObserver(css.toString())}
$_periodicNavRemoverJS
$_dismissAppBannerJS

View File

@@ -0,0 +1,69 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> init() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _notificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (details) {
// Handle notification tap
},
);
}
Future<void> showNotification({
required int id,
required String title,
required String body,
}) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'focusgram_channel',
'FocusGram Notifications',
channelDescription: 'Notifications for FocusGram sessions and alerts',
importance: Importance.max,
priority: Priority.high,
showWhen: true,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const NotificationDetails platformDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Manages all session logic for FocusGram:
///
@@ -308,6 +309,13 @@ class SessionManager extends ChangeNotifier {
_lastSessionEnd = DateTime.now();
_prefs?.setInt(_keySessionExpiry, 0);
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
// Alert User
NotificationService().showNotification(
id: 999,
title: 'Session Ended',
body: 'Your Reel session has expired. Time to focus!',
);
}
// ── Reel session API ───────────────────────────────────────

View File

@@ -10,16 +10,23 @@ class SettingsService extends ChangeNotifier {
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';
SharedPreferences? _prefs;
bool _blurExplore = true; // Default: blur explore feed posts/reels
bool _blurReels = false; // If false: hide reels in feed (after session ends)
bool _requireLongPress = true; // Long-press FAB to start session
bool _showBreathGate = true; // Show breathing gate on every open
bool _blurExplore = true;
bool _blurReels = false;
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
bool _ghostMode = true; // Default: hide seen/typing
bool _enableTextSelection = false; // Default: disabled
bool _ghostMode = true;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
List<String> _enabledTabs = ['Home', 'Search', 'Create', 'Reels', 'Profile'];
bool _isFirstRun = true;
bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels;
@@ -28,6 +35,9 @@ class SettingsService extends ChangeNotifier {
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;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@@ -38,6 +48,17 @@ class SettingsService extends ChangeNotifier {
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? true;
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_enabledTabs =
_prefs!.getStringList(_keyEnabledTabs) ??
['Home', 'Search', 'Create', 'Reels', 'Profile'];
_isFirstRun = _prefs!.getBool(_keyIsFirstRun) ?? true;
notifyListeners();
}
Future<void> setFirstRunCompleted() async {
_isFirstRun = false;
await _prefs?.setBool(_keyIsFirstRun, false);
notifyListeners();
}
@@ -82,4 +103,22 @@ class SettingsService extends ChangeNotifier {
await _prefs?.setBool(_keyEnableTextSelection, v);
notifyListeners();
}
Future<void> setShowInstaSettings(bool v) async {
_showInstaSettings = v;
await _prefs?.setBool(_keyShowInstaSettings, v);
notifyListeners();
}
Future<void> toggleTab(String tab) async {
if (_enabledTabs.contains(tab)) {
if (_enabledTabs.length > 1) {
_enabledTabs.remove(tab);
}
} else {
_enabledTabs.add(tab);
}
await _prefs?.setStringList(_keyEnabledTabs, _enabledTabs);
notifyListeners();
}
}

View File

@@ -392,6 +392,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:

View File

@@ -30,6 +30,7 @@ dependencies:
url_launcher: ^6.3.2
google_fonts: ^8.0.2
http: ^1.3.0
permission_handler: ^12.0.1
dev_dependencies:
flutter_test: