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.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 114 KiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
218
lib/screens/onboarding_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
69
lib/services/notification_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
48
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||