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 'package:provider/provider.dart';
|
||||||
import 'services/session_manager.dart';
|
import 'services/session_manager.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'screens/onboarding_page.dart';
|
||||||
import 'screens/main_webview_page.dart';
|
import 'screens/main_webview_page.dart';
|
||||||
import 'screens/breath_gate_screen.dart';
|
import 'screens/breath_gate_screen.dart';
|
||||||
import 'screens/app_session_picker.dart';
|
import 'screens/app_session_picker.dart';
|
||||||
@@ -59,10 +60,11 @@ class FocusGramApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Flow on every cold open:
|
/// Flow on every cold open:
|
||||||
/// 1. Cooldown Gate (if app-open cooldown active)
|
/// 1. Onboarding (if first run)
|
||||||
/// 2. Breath Gate (if enabled in settings)
|
/// 2. Cooldown Gate (if app-open cooldown active)
|
||||||
/// 3. App Session Picker (always)
|
/// 3. Breath Gate (if enabled in settings)
|
||||||
/// 4. Main WebView
|
/// 4. App Session Picker (always)
|
||||||
|
/// 5. Main WebView
|
||||||
class InitialRouteHandler extends StatefulWidget {
|
class InitialRouteHandler extends StatefulWidget {
|
||||||
const InitialRouteHandler({super.key});
|
const InitialRouteHandler({super.key});
|
||||||
|
|
||||||
@@ -73,32 +75,40 @@ class InitialRouteHandler extends StatefulWidget {
|
|||||||
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||||
bool _breathCompleted = false;
|
bool _breathCompleted = false;
|
||||||
bool _appSessionStarted = false;
|
bool _appSessionStarted = false;
|
||||||
|
bool _onboardingCompleted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sm = context.watch<SessionManager>();
|
final sm = context.watch<SessionManager>();
|
||||||
final settings = context.watch<SettingsService>();
|
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) {
|
if (sm.isAppOpenCooldownActive) {
|
||||||
return const CooldownGateScreen();
|
return const CooldownGateScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Breath gate
|
// Step 3: Breath gate
|
||||||
if (settings.showBreathGate && !_breathCompleted) {
|
if (settings.showBreathGate && !_breathCompleted) {
|
||||||
return BreathGateScreen(
|
return BreathGateScreen(
|
||||||
onFinish: () => setState(() => _breathCompleted = true),
|
onFinish: () => setState(() => _breathCompleted = true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: App session picker
|
// Step 4: App session picker
|
||||||
if (!_appSessionStarted) {
|
if (!_appSessionStarted) {
|
||||||
return AppSessionPickerScreen(
|
return AppSessionPickerScreen(
|
||||||
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
onSessionStarted: () => setState(() => _appSessionStarted = true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Main app
|
// Step 5: Main app
|
||||||
return const MainWebViewPage();
|
return const MainWebViewPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||||
import '../services/session_manager.dart';
|
import '../services/session_manager.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/injection_controller.dart';
|
import '../services/injection_controller.dart';
|
||||||
import '../services/navigation_guard.dart';
|
import '../services/navigation_guard.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'session_modal.dart';
|
import '../services/notification_service.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
|
import 'app_session_picker.dart';
|
||||||
|
|
||||||
class MainWebViewPage extends StatefulWidget {
|
class MainWebViewPage extends StatefulWidget {
|
||||||
const MainWebViewPage({super.key});
|
const MainWebViewPage({super.key});
|
||||||
@@ -44,6 +46,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
_currentUrl.contains('instagram.com/accounts/login');
|
_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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -174,7 +181,23 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
_controller = WebViewController()
|
_controller = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..setUserAgent(InjectionController.iOSUserAgent)
|
..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(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
NavigationDelegate(
|
||||||
onPageStarted: (url) {
|
onPageStarted: (url) {
|
||||||
@@ -195,6 +218,8 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
_applyInjections();
|
_applyInjections();
|
||||||
_updateCurrentTab(url);
|
_updateCurrentTab(url);
|
||||||
_cacheUsername();
|
_cacheUsername();
|
||||||
|
// Inject Notification Bridge Hook
|
||||||
|
_controller.runJavaScript(InjectionController.notificationBridgeJS);
|
||||||
// Inject MutationObserver to lock reel scrolling resiliently
|
// Inject MutationObserver to lock reel scrolling resiliently
|
||||||
_controller.runJavaScript(
|
_controller.runJavaScript(
|
||||||
InjectionController.reelsMutationObserverJS,
|
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/'));
|
..loadRequest(Uri.parse('https://www.instagram.com/accounts/login/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +312,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
await _controller.loadRequest(
|
await _controller.loadRequest(
|
||||||
Uri.parse('https://www.instagram.com/accounts/login/'),
|
Uri.parse('https://www.instagram.com/accounts/login/'),
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('Signed out successfully')));
|
).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'));
|
await _controller.loadRequest(Uri.parse('https://www.instagram.com$path'));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onTabTapped(int index) async {
|
Future<void> _onTabTapped(String label) async {
|
||||||
if (index == _currentIndex) {
|
final sm = context.read<SessionManager>();
|
||||||
await _controller.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _currentIndex = index);
|
|
||||||
|
|
||||||
switch (index) {
|
switch (label) {
|
||||||
case 0:
|
case 'Home':
|
||||||
await _navigateTo('/');
|
await _navigateTo('/');
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 'Search':
|
||||||
// Search tab - user reported "dark page" at /explore/search/
|
|
||||||
// Let's try /explore/ directly which usually shows the search bar on mobile web
|
|
||||||
await _navigateTo('/explore/');
|
await _navigateTo('/explore/');
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 'Create':
|
||||||
if (context.read<SessionManager>().isSessionActive) {
|
await _navigateTo('/reels/create/'); // Default create path
|
||||||
|
break;
|
||||||
|
case 'Notifications':
|
||||||
|
await _navigateTo('/notifications/');
|
||||||
|
break;
|
||||||
|
case 'Reels':
|
||||||
|
if (sm.isSessionActive) {
|
||||||
await _navigateTo('/reels/');
|
await _navigateTo('/reels/');
|
||||||
|
} else {
|
||||||
|
// Show session picker if no session active
|
||||||
|
_showSessionPicker();
|
||||||
}
|
}
|
||||||
// If not active, do nothing (disabled as requested)
|
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 'Profile':
|
||||||
await _navigateTo('/direct/inbox/');
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
if (_cachedUsername != null) {
|
if (_cachedUsername != null) {
|
||||||
await _navigateTo('/$_cachedUsername/');
|
await _navigateTo('/$_cachedUsername/');
|
||||||
} else {
|
} 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const barHeight = 60.0;
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (didPop, result) async {
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
@@ -382,8 +449,6 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
if (await _controller.canGoBack()) {
|
if (await _controller.canGoBack()) {
|
||||||
await _controller.goBack();
|
await _controller.goBack();
|
||||||
} else {
|
} else {
|
||||||
// If no history, we can either minimize or close.
|
|
||||||
// SystemNavigator.pop() is usually what users expect for "Close".
|
|
||||||
SystemNavigator.pop();
|
SystemNavigator.pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -415,27 +480,19 @@ class _MainWebViewPageState extends State<MainWebViewPage>
|
|||||||
top: 60 + MediaQuery.of(context).padding.top,
|
top: 60 + MediaQuery.of(context).padding.top,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: const LinearProgressIndicator(
|
child: const _InstagramGradientProgressBar(),
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
color: Colors.blue,
|
|
||||||
minHeight: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── The Edge Panel ──────────────────────────────────────────
|
// ── The Edge Panel ──────────────────────────────────────────
|
||||||
_EdgePanel(controller: _controller),
|
_EdgePanel(controller: _controller),
|
||||||
|
|
||||||
// ── Our bottom bar ──────────────────────────────────────────
|
// ── Our bottom bar ──────────────────────────────────────────
|
||||||
if (!_isOnOnboardingPage)
|
if (!_isOnOnboardingPage && !_isInDirect)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: _FocusGramNavBar(
|
child: const _FocusGramNavBar(),
|
||||||
currentIndex: _currentIndex,
|
|
||||||
onTap: _onTabTapped,
|
|
||||||
height: barHeight * 0.99, // 1% reduction
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -819,49 +876,92 @@ class _BrandedTopBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FocusGramNavBar extends StatelessWidget {
|
class _InstagramGradientProgressBar extends StatelessWidget {
|
||||||
final int currentIndex;
|
const _InstagramGradientProgressBar();
|
||||||
final Future<void> Function(int) onTap;
|
|
||||||
final double height;
|
|
||||||
|
|
||||||
const _FocusGramNavBar({
|
|
||||||
required this.currentIndex,
|
|
||||||
required this.onTap,
|
|
||||||
this.height = 52,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = [
|
return SizedBox(
|
||||||
(Icons.home_outlined, Icons.home_rounded, 'Home'),
|
height: 2.5,
|
||||||
(Icons.search, Icons.search, 'Search'),
|
child: Stack(
|
||||||
(Icons.play_circle_outline, Icons.play_circle_filled, 'Session'),
|
children: [
|
||||||
(Icons.chat_bubble_outline, Icons.chat_bubble, 'Messages'),
|
Container(
|
||||||
(Icons.person_outline, Icons.person, 'Profile'),
|
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(
|
return Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: SizedBox(
|
child: Container(
|
||||||
height: height,
|
height: 56,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: Colors.white12, width: 0.5)),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: items.asMap().entries.map((entry) {
|
children: activeTabs.map((tab) {
|
||||||
final i = entry.key;
|
final icons = allItems[tab];
|
||||||
final (outlinedIcon, filledIcon, label) = entry.value;
|
if (icons == null) return const SizedBox();
|
||||||
final isSelected = i == currentIndex;
|
|
||||||
|
final isSelected = _isTabSelected(
|
||||||
|
tab,
|
||||||
|
state._currentUrl,
|
||||||
|
state._cachedUsername,
|
||||||
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => onTap(i),
|
onTap: () => state._onTabTapped(tab),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 60,
|
width:
|
||||||
|
MediaQuery.of(context).size.width /
|
||||||
|
activeTabs.length.clamp(1, 6),
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isSelected ? filledIcon : outlinedIcon,
|
isSelected ? icons.$2 : icons.$1,
|
||||||
color: isSelected ? Colors.white : Colors.white54,
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: (tab == 'Reels' && !sm.isSessionActive)
|
||||||
|
? Colors.white24
|
||||||
|
: Colors.white54,
|
||||||
size: 26,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 'package:provider/provider.dart';
|
||||||
import '../services/session_manager.dart';
|
import '../services/session_manager.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'guardrails_page.dart';
|
import 'guardrails_page.dart';
|
||||||
import 'about_page.dart';
|
import 'about_page.dart';
|
||||||
|
|
||||||
@@ -62,6 +63,39 @@ class SettingsPage extends StatelessWidget {
|
|||||||
destination: const AboutPage(),
|
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 SizedBox(height: 40),
|
||||||
const Center(
|
const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -379,6 +413,15 @@ class _ExtrasSettingsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsService>();
|
final settings = context.watch<SettingsService>();
|
||||||
|
final allTabs = [
|
||||||
|
'Home',
|
||||||
|
'Search',
|
||||||
|
'Create',
|
||||||
|
'Notifications',
|
||||||
|
'Reels',
|
||||||
|
'Profile',
|
||||||
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -391,6 +434,7 @@ class _ExtrasSettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
const _SettingsSectionHeader(title: 'EXPERIMENT'),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Ghost Mode',
|
'Ghost Mode',
|
||||||
@@ -417,8 +461,58 @@ class _ExtrasSettingsPage extends StatelessWidget {
|
|||||||
onChanged: (v) => settings.setEnableTextSelection(v),
|
onChanged: (v) => settings.setEnableTextSelection(v),
|
||||||
activeThumbColor: Colors.blue,
|
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;
|
-webkit-tap-highlight-color: transparent !important;
|
||||||
outline: none !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.
|
/// CSS to disable text selection globally.
|
||||||
@@ -93,14 +103,19 @@ class InjectionController {
|
|||||||
|
|
||||||
/// Robust CSS that hides Instagram's native bottom nav bar.
|
/// Robust CSS that hides Instagram's native bottom nav bar.
|
||||||
static const String _hideInstagramNavCSS = '''
|
static const String _hideInstagramNavCSS = '''
|
||||||
div[role="tablist"], nav[role="navigation"],
|
/* Hide bottom nav but keep search header */
|
||||||
._acbl, ._aa4b, ._aahi, ._ab8s, section nav, footer nav {
|
div[role="tablist"], footer nav, ._acbl, ._aa4b {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
pointer-events: none !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 {
|
body, #react-root, main {
|
||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
margin-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.
|
/// MutationObserver for Reel scroll locking.
|
||||||
static const String reelsMutationObserverJS = '''
|
static const String reelsMutationObserverJS = '''
|
||||||
(function() {
|
(function() {
|
||||||
@@ -267,6 +306,8 @@ class InjectionController {
|
|||||||
|
|
||||||
return '''
|
return '''
|
||||||
${buildSessionStateJS(sessionActive)}
|
${buildSessionStateJS(sessionActive)}
|
||||||
|
/* Set path attribute on body for CSS targeting */
|
||||||
|
document.body.setAttribute('path', window.location.pathname);
|
||||||
${_buildMutationObserver(css.toString())}
|
${_buildMutationObserver(css.toString())}
|
||||||
$_periodicNavRemoverJS
|
$_periodicNavRemoverJS
|
||||||
$_dismissAppBannerJS
|
$_dismissAppBannerJS
|
||||||
|
|||||||
@@ -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:flutter/foundation.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
/// Manages all session logic for FocusGram:
|
/// Manages all session logic for FocusGram:
|
||||||
///
|
///
|
||||||
@@ -308,6 +309,13 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_lastSessionEnd = DateTime.now();
|
_lastSessionEnd = DateTime.now();
|
||||||
_prefs?.setInt(_keySessionExpiry, 0);
|
_prefs?.setInt(_keySessionExpiry, 0);
|
||||||
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
|
_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 ───────────────────────────────────────
|
// ── Reel session API ───────────────────────────────────────
|
||||||
|
|||||||
@@ -10,16 +10,23 @@ class SettingsService extends ChangeNotifier {
|
|||||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||||
static const _keyGhostMode = 'set_ghost_mode';
|
static const _keyGhostMode = 'set_ghost_mode';
|
||||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
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;
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
bool _blurExplore = true; // Default: blur explore feed posts/reels
|
bool _blurExplore = true;
|
||||||
bool _blurReels = false; // If false: hide reels in feed (after session ends)
|
bool _blurReels = false;
|
||||||
bool _requireLongPress = true; // Long-press FAB to start session
|
bool _requireLongPress = true;
|
||||||
bool _showBreathGate = true; // Show breathing gate on every open
|
bool _showBreathGate = true;
|
||||||
bool _requireWordChallenge = true;
|
bool _requireWordChallenge = true;
|
||||||
bool _ghostMode = true; // Default: hide seen/typing
|
bool _ghostMode = true;
|
||||||
bool _enableTextSelection = false; // Default: disabled
|
bool _enableTextSelection = false;
|
||||||
|
bool _showInstaSettings = true;
|
||||||
|
List<String> _enabledTabs = ['Home', 'Search', 'Create', 'Reels', 'Profile'];
|
||||||
|
bool _isFirstRun = true;
|
||||||
|
|
||||||
bool get blurExplore => _blurExplore;
|
bool get blurExplore => _blurExplore;
|
||||||
bool get blurReels => _blurReels;
|
bool get blurReels => _blurReels;
|
||||||
@@ -28,6 +35,9 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool get requireWordChallenge => _requireWordChallenge;
|
bool get requireWordChallenge => _requireWordChallenge;
|
||||||
bool get ghostMode => _ghostMode;
|
bool get ghostMode => _ghostMode;
|
||||||
bool get enableTextSelection => _enableTextSelection;
|
bool get enableTextSelection => _enableTextSelection;
|
||||||
|
bool get showInstaSettings => _showInstaSettings;
|
||||||
|
List<String> get enabledTabs => _enabledTabs;
|
||||||
|
bool get isFirstRun => _isFirstRun;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
@@ -38,6 +48,17 @@ class SettingsService extends ChangeNotifier {
|
|||||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||||
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? true;
|
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? true;
|
||||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
_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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +103,22 @@ class SettingsService extends ChangeNotifier {
|
|||||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||||
notifyListeners();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.0.2
|
||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||