diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index a8088aa..efd4422 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 0bc9fdc..c5bcdb2 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 907db7f..f79d08f 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 3978d81..0eefa50 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index fcef524..ccc070e 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4891768..646888c 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 2332984..5526198 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 64205f4..1746aee 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 9fc60ef..70279c5 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 6ffbd9f..9eac20c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/images/focusgram.ico b/assets/images/focusgram.ico index fc0fb57..3f6d0c1 100644 Binary files a/assets/images/focusgram.ico and b/assets/images/focusgram.ico differ diff --git a/assets/images/focusgram.png b/assets/images/focusgram.png index 8c4d9b7..390149f 100644 Binary files a/assets/images/focusgram.png and b/assets/images/focusgram.png differ diff --git a/lib/main.dart b/lib/main.dart index 9d35426..df72110 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { bool _breathCompleted = false; bool _appSessionStarted = false; + bool _onboardingCompleted = false; @override Widget build(BuildContext context) { final sm = context.watch(); final settings = context.watch(); - // 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(); } } diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index d843070..ced7ca1 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -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 _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 _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 []; + }); + } + + _controller ..setNavigationDelegate( NavigationDelegate( onPageStarted: (url) { @@ -195,6 +218,8 @@ class _MainWebViewPageState extends State _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 }, ), ) + ..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 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 await _controller.loadRequest(Uri.parse('https://www.instagram.com$path')); } - Future _onTabTapped(int index) async { - if (index == _currentIndex) { - await _controller.reload(); - return; - } - setState(() => _currentIndex = index); + Future _onTabTapped(String label) async { + final sm = context.read(); - 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().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 } } + 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 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 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 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(); + 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(); 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; + } + } } // ────────────────────────────────────────────────────────────────────────────── diff --git a/lib/screens/onboarding_page.dart b/lib/screens/onboarding_page.dart new file mode 100644 index 0000000..22dfeeb --- /dev/null +++ b/lib/screens/onboarding_page.dart @@ -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 createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _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().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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index 4166b40..cfd2349 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -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(); + 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, + ), + ), + ); + } +} diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart index 7aa9157..8ace09d 100644 --- a/lib/services/injection_controller.dart +++ b/lib/services/injection_controller.dart @@ -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 diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..021d40c --- /dev/null +++ b/lib/services/notification_service.dart @@ -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 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 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, + ); + } +} diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart index 425ba62..313dbad 100644 --- a/lib/services/session_manager.dart +++ b/lib/services/session_manager.dart @@ -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 ─────────────────────────────────────── diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index a946b6f..a6e653c 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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 _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 get enabledTabs => _enabledTabs; + bool get isFirstRun => _isFirstRun; Future 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 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 setShowInstaSettings(bool v) async { + _showInstaSettings = v; + await _prefs?.setBool(_keyShowInstaSettings, v); + notifyListeners(); + } + + Future 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(); + } } diff --git a/pubspec.lock b/pubspec.lock index 8c39466..2bb8728 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index dcac008..96e8918 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: