Files
FocusGram-Android/lib/screens/main_webview_page.dart
Ujwal 7bb472d212 What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
2026-03-04 10:48:14 +05:45

1844 lines
72 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/injection_manager.dart';
import '../scripts/autoplay_blocker.dart';
import '../scripts/native_feel.dart';
import '../scripts/haptic_bridge.dart';
import '../scripts/spa_navigation_monitor.dart';
import '../scripts/content_disabling.dart';
import '../services/screen_time_service.dart';
import '../services/navigation_guard.dart';
import '../services/focusgram_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../services/notification_service.dart';
import '../features/update_checker/update_checker_service.dart';
import '../utils/discipline_challenge.dart';
import 'settings_page.dart';
import '../features/loading/skeleton_screen.dart';
import '../features/preloader/instagram_preloader.dart';
class MainWebViewPage extends StatefulWidget {
const MainWebViewPage({super.key});
@override
State<MainWebViewPage> createState() => _MainWebViewPageState();
}
class _MainWebViewPageState extends State<MainWebViewPage>
with WidgetsBindingObserver {
InAppWebViewController? _controller;
late final PullToRefreshController _pullToRefreshController;
InjectionManager? _injectionManager;
final GlobalKey<_EdgePanelState> _edgePanelKey = GlobalKey<_EdgePanelState>();
bool _showSkeleton =
true; // true from the start so skeleton covers black Scaffold before WebView first paints
bool _isLoading = true;
Timer? _watchdog;
// FIX 4: Safety timer to clear stuck loading state
Timer? _loadingTimeout;
bool _extensionDialogShown = false;
bool _lastSessionActive = false;
String _currentUrl = 'https://www.instagram.com/';
bool _hasError = false;
bool _reelsBlockedOverlay = false;
bool _exploreBlockedOverlay = false;
bool _isPreloaded = false;
bool _minimalModeBannerDismissed = false;
DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0);
SkeletonType _skeletonType = SkeletonType.generic;
/// Helper to determine if we are on a login/onboarding page.
bool get _isOnOnboardingPage {
final path = Uri.tryParse(_currentUrl)?.path ?? '';
final lowerPath = path.toLowerCase();
return lowerPath.contains('/accounts/login') ||
lowerPath.contains('/accounts/emailsignup') ||
lowerPath.contains('/accounts/signup') ||
lowerPath.contains('/legal/') ||
lowerPath.contains('/help/') ||
_currentUrl.contains('instagram.com/accounts/login');
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initPullToRefresh();
_initWebView();
_startWatchdog();
// Check for updates on launch
context.read<UpdateCheckerService>().checkForUpdates();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SessionManager>().addListener(_onSessionChanged);
context.read<SettingsService>().addListener(_onSettingsChanged);
_lastSessionActive = context.read<SessionManager>().isSessionActive;
// Initialise structural snapshots so first change is detected correctly
final settings = context.read<SettingsService>();
_lastMinimalMode = settings.minimalModeEnabled;
_lastDisableReels = settings.disableReelsEntirely;
_lastDisableExplore = settings.disableExploreEntirely;
_lastBlockAutoplay = settings.blockAutoplay;
});
FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged);
}
void _onPendingUrlChanged() {
final url = FocusGramRouter.pendingUrl.value;
if (url != null && url.isNotEmpty) {
FocusGramRouter.pendingUrl.value = null;
_controller?.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
}
}
/// Sets the isolated reel player flag in the WebView so the scroll-lock
/// knows it should block swipe-to-next-reel.
Future<void> _setIsolatedPlayer(bool active) async {
await _controller?.evaluateJavascript(
source: 'window.__focusgramIsolatedPlayer = $active;',
);
}
void _onSessionChanged() {
if (!mounted) return;
final sm = context.read<SessionManager>();
if (_lastSessionActive != sm.isSessionActive) {
_lastSessionActive = sm.isSessionActive;
if (_lastSessionActive) {
HapticFeedback.mediumImpact();
} else {
HapticFeedback.heavyImpact();
}
if (_controller != null && _injectionManager != null) {
_injectionManager!.runAllPostLoadInjections(_currentUrl);
}
// If session became active and we were showing overlay, hide it
if (_lastSessionActive && _reelsBlockedOverlay) {
setState(() => _reelsBlockedOverlay = false);
}
}
setState(() {});
}
// Debounce timer so rapid toggles don't spam reloads
Timer? _reloadDebounce;
// Snapshot of structural settings — used to detect when a reload is needed
bool _lastMinimalMode = false;
bool _lastDisableReels = false;
bool _lastDisableExplore = false;
bool _lastBlockAutoplay = false;
void _onSettingsChanged() {
if (!mounted) return;
final settings = context.read<SettingsService>();
// 1. Apply all cosmetic changes immediately via injection
if (_controller != null) {
_controller!.evaluateJavascript(
source: 'window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur};',
);
}
if (_controller != null && _injectionManager != null) {
_injectionManager!.runAllPostLoadInjections(_currentUrl);
}
// 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state)
setState(() {});
// 3. Detect structural changes that need a full reload.
// CSS injection alone can't undo Instagram's already-rendered React DOM.
final structuralChange =
settings.minimalModeEnabled != _lastMinimalMode ||
settings.disableReelsEntirely != _lastDisableReels ||
settings.disableExploreEntirely != _lastDisableExplore ||
settings.blockAutoplay != _lastBlockAutoplay;
_lastMinimalMode = settings.minimalModeEnabled;
_lastDisableReels = settings.disableReelsEntirely;
_lastDisableExplore = settings.disableExploreEntirely;
_lastBlockAutoplay = settings.blockAutoplay;
if (structuralChange && _controller != null) {
// Debounce: if user toggles rapidly, only reload once they stop
_reloadDebounce?.cancel();
_reloadDebounce = Timer(const Duration(milliseconds: 600), () {
if (mounted) _controller?.reload();
});
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_watchdog?.cancel();
_loadingTimeout?.cancel();
_reloadDebounce?.cancel();
FocusGramRouter.pendingUrl.removeListener(_onPendingUrlChanged);
context.read<SessionManager>().removeListener(_onSessionChanged);
context.read<SettingsService>().removeListener(_onSettingsChanged);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!mounted) return;
final sm = context.read<SessionManager>();
final screenTime = context.read<ScreenTimeService>();
final settings = context.read<SettingsService>();
if (state == AppLifecycleState.resumed) {
sm.setAppForeground(true);
screenTime.startTracking();
// Cancel persistent notification when app comes to foreground
NotificationService().cancelPersistentNotification(id: 5001);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
sm.setAppForeground(false);
screenTime.stopTracking();
// Show persistent notification when schedules are active (if enabled)
if (settings.notifyPersistent) {
final isScheduleActive = sm.isScheduledBlockActive;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
if (isScheduleActive) {
NotificationService().showPersistentNotification(
id: 5001,
title: 'FocusGram - Schedule Active',
body: 'Instagram is blocked during your focus hours',
);
} else if (isGrayscaleActive) {
NotificationService().showPersistentNotification(
id: 5001,
title: 'FocusGram - Grayscale Active',
body: 'Instagram is in grayscale mode',
);
}
}
}
}
void _startWatchdog() {
_watchdog = Timer.periodic(const Duration(seconds: 15), (_) {
if (!mounted) return;
final sm = context.read<SessionManager>();
if (sm.isAppSessionExpired && !_extensionDialogShown) {
_extensionDialogShown = true;
_showSessionExpiredDialog(sm);
}
});
}
// FIX 4: Cancel any existing loading timeout and start a fresh one.
// If onLoadStop or onReceivedError haven't fired after 12 seconds,
// force-clear the loading/skeleton state so the app never appears stuck.
void _resetLoadingTimeout() {
_loadingTimeout?.cancel();
_loadingTimeout = Timer(const Duration(seconds: 12), () {
if (!mounted) return;
if (_isLoading || _showSkeleton) {
setState(() {
_isLoading = false;
_showSkeleton = false;
});
}
});
}
void _showSessionExpiredDialog(SessionManager sm) {
// Helper function to handle "Close App" action
void closeApp() {
Navigator.of(context, rootNavigator: true).pop();
sm.endAppSession();
SystemNavigator.pop();
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
// Intercept back button - treat it as "Close App" action
if (!didPop) {
closeApp();
}
},
child: AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text(
'Session Complete ✓',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your planned Instagram time is up.',
style: TextStyle(color: Colors.white70),
),
if (sm.canExtendAppSession) ...[
const SizedBox(height: 8),
const Text(
'You can extend once by 10 minutes.',
style: TextStyle(color: Colors.white54, fontSize: 13),
),
],
],
),
actions: [
TextButton(
onPressed: closeApp,
child: const Text(
'Close App',
style: TextStyle(color: Colors.redAccent),
),
),
if (sm.canExtendAppSession)
ElevatedButton(
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
sm.extendAppSession();
_extensionDialogShown = false;
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('+10 minutes'),
),
],
),
),
);
}
void _initPullToRefresh() {
_pullToRefreshController = PullToRefreshController(
settings: PullToRefreshSettings(color: Colors.blue),
onRefresh: () async {
await _controller?.reload();
},
);
}
void _initWebView() {
// Preloader disabled — keepAlive WebView silently fails when app cold-starts,
// leaving _isPreloaded = true with no content, causing permanent black screen.
// The fresh load path is reliable; the ~300ms preload gain is not worth it.
_isPreloaded = false;
setState(() {
_currentUrl = 'https://www.instagram.com/accounts/login/';
});
// If not preloaded, controller will be created in onWebViewCreated
_injectionManager = null;
// Nothing else to do here configuration is on the InAppWebView widget
}
Future<void> _signOut() async {
final cookieManager = CookieManager.instance();
await cookieManager.deleteAllCookies();
await InAppWebViewController.clearAllCache();
if (mounted) {
setState(() {
_showSkeleton = true;
_isLoading = true;
_reelsBlockedOverlay = false;
});
await _controller?.loadUrl(
urlRequest: URLRequest(
url: WebUri('https://www.instagram.com/accounts/login/'),
),
);
}
}
/// Formats [seconds] as `MM:SS` for the cooldown countdown display.
static String _fmtSeconds(int seconds) {
final m = (seconds ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
Future<void> _showReelSessionPicker() async {
final settings = context.read<SettingsService>();
if (settings.requireWordChallenge) {
final passed = await DisciplineChallenge.show(context);
if (!passed || !mounted) return;
}
_showReelSessionPickerBottomSheet();
}
void _showReelSessionPickerBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: BoxDecoration(
color: const Color(0xFF121212),
borderRadius: BorderRadius.circular(25),
),
child: Column(
children: [
const SizedBox(height: 16),
const Text(
'Start Reel Session',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Reels will be unblocked for the duration you choose.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, fontSize: 13),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
_buildReelSessionTile(5),
_buildReelSessionTile(10),
_buildReelSessionTile(15),
const SizedBox(height: 40),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white38),
),
),
],
),
),
],
),
),
);
}
Widget _buildReelSessionTile(int mins) {
final sm = context.read<SessionManager>();
return ListTile(
title: Text('$mins Minutes', style: const TextStyle(color: Colors.white)),
trailing: const Icon(
Icons.arrow_forward_ios,
color: Colors.white24,
size: 14,
),
onTap: () async {
Navigator.pop(context);
if (sm.startSession(mins)) {
HapticFeedback.mediumImpact();
setState(() => _reelsBlockedOverlay = false);
await _controller?.loadUrl(
urlRequest: URLRequest(
url: WebUri('https://www.instagram.com/reels/'),
),
);
}
},
);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (_reelsBlockedOverlay) {
setState(() => _reelsBlockedOverlay = false);
await _controller?.goBack();
return;
}
final didNavigate =
await (_controller
?.evaluateJavascript(
source:
'(function(){'
' var before = window.location.href;'
' history.back();'
' return before;'
'})()',
)
.then((_) => true)
.catchError((_) => false)) ??
false;
if (didNavigate == true) {
await Future.delayed(const Duration(milliseconds: 120));
return;
}
if (await (_controller?.canGoBack() ?? Future.value(false))) {
await _controller?.goBack();
} else {
SystemNavigator.pop();
}
},
child: Scaffold(
// FIX 1: Use a solid color that matches the WebView background.
// When transparentBackground is false (see WebView settings), the
// WebView renders its own white/black background. Using black here
// matches the dark-mode WebView bg and prevents "flash of white".
backgroundColor: Colors.black,
body: Stack(
children: [
SafeArea(
child: Column(
children: [
const _UpdateBanner(),
if (!_isOnOnboardingPage)
_BrandedTopBar(
onFocusControlTap: () =>
_edgePanelKey.currentState?._toggleExpansion(),
),
Expanded(
child: Consumer<SessionManager>(
builder: (ctx, sm, _) {
if (sm.isScheduledBlockActive) {
return Container(
color: Colors.black,
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.bedtime_rounded,
color: Colors.blueAccent,
size: 80,
),
const SizedBox(height: 24),
Text(
'Focus Hours Active',
style: GoogleFonts.grandHotel(
color: Colors.white,
fontSize: 42,
),
),
const SizedBox(height: 12),
Text(
'Instagram is blocked according to your schedule (${sm.activeScheduleText ?? 'Focus Hours'}).',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 48),
const Text(
'Your future self will thank you for the extra sleep and focus.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white38,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
final settings = context.read<SettingsService>();
return Stack(
children: [
InAppWebView(
keepAlive: InstagramPreloader.keepAlive,
initialUrlRequest: _isPreloaded
? null
: URLRequest(
url: WebUri(
'https://www.instagram.com/accounts/login/',
),
),
initialSettings: InAppWebViewSettings(
userAgent: InjectionController.iOSUserAgent,
mediaPlaybackRequiresUserGesture:
settings.blockAutoplay,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
// FIX 2: Set to false so the WebView renders
// its own opaque background. When true + black
// Scaffold, you see black until Instagram
// finishes painting — looks like a freeze/hang.
transparentBackground: false,
safeBrowsingEnabled: false,
disableContextMenu: false,
supportZoom: false,
allowsInlineMediaPlayback: true,
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
),
initialUserScripts: UnmodifiableListView([
UserScript(
source:
'window.__fgBlockAutoplay = ${settings.blockAutoplay}; window.__fgTapToUnblur = ${settings.blurExplore && settings.tapToUnblur}; window.__focusgramSessionActive = ${sm.isSessionActive};',
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kSpaNavigationMonitorScript,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kNativeFeelingScript,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
if (settings.disableReelsEntirely)
UserScript(
source: kDisableReelsEntirelyCssScript,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
if (settings.disableExploreEntirely)
UserScript(
source: kDisableExploreEntirelyCssScript,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kHapticBridgeScript,
injectionTime:
UserScriptInjectionTime.AT_DOCUMENT_START,
),
]),
pullToRefreshController: _pullToRefreshController,
onWebViewCreated: (controller) async {
_controller = controller;
// Capture settingsService before async gap to avoid BuildContext warning
final settingsService = context
.read<SettingsService>();
final prefs =
await SharedPreferences.getInstance();
_injectionManager = InjectionManager(
controller: controller,
prefs: prefs,
sessionManager: sm,
);
_injectionManager!.setSettingsService(
settingsService,
);
_registerJavaScriptHandlers(controller);
// Start safety timeout — clears loading state
// if onLoadStop never fires (e.g. network stall).
_resetLoadingTimeout();
},
onLoadStart: (controller, url) {
if (!mounted) return;
final u = url?.toString() ?? '';
final lower = u.toLowerCase();
final isOnboardingUrl =
lower.contains('/accounts/login') ||
lower.contains('/accounts/emailsignup') ||
lower.contains('/accounts/signup') ||
lower.contains('/legal/') ||
lower.contains('/help/');
setState(() {
_isLoading = true;
_lastMainFrameLoadStartedAt = DateTime.now();
_currentUrl = u;
_hasError = false;
_showSkeleton = !isOnboardingUrl;
// Update skeleton type based on the URL being loaded
_skeletonType = getSkeletonTypeFromUrl(u);
});
// FIX 4: Reset the safety timeout on each new load
_resetLoadingTimeout();
},
onLoadStop: (controller, url) async {
_pullToRefreshController.endRefreshing();
if (!mounted) return;
// FIX 4: Cancel the safety timeout — load completed normally
_loadingTimeout?.cancel();
final current = url?.toString() ?? '';
setState(() {
_isLoading = false;
_currentUrl = current;
_hasError = false;
});
await _injectionManager
?.runAllPostLoadInjections(current);
await controller.evaluateJavascript(
source:
InjectionController.notificationBridgeJS,
);
final isIsolatedReel =
current.contains('/reel/') &&
!current.contains('/reels/');
await _setIsolatedPlayer(isIsolatedReel);
await controller.evaluateJavascript(
source: kNativeFeelingPostLoadScript,
);
await Future.delayed(
const Duration(milliseconds: 100),
);
if (mounted) {
setState(() => _showSkeleton = false);
}
},
shouldOverrideUrlLoading:
(controller, navigationAction) async {
final url =
navigationAction.request.url
?.toString() ??
'';
final uri = navigationAction.request.url;
final appSettings = context
.read<SettingsService>();
final disableReels =
appSettings.disableReelsEntirely;
final disableExplore =
appSettings.disableExploreEntirely;
bool isReelsUrl(String u) =>
u.contains('/reel/') ||
u.contains('/reels/');
bool isExploreUrl(String u) =>
u.contains('/explore/');
void showBlocked(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(msg),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(
16,
0,
16,
20,
),
),
);
}
if (disableReels && isReelsUrl(url)) {
showBlocked('Reels are disabled');
return NavigationActionPolicy.CANCEL;
}
if (disableExplore && isExploreUrl(url)) {
// Show overlay immediately without navigating away
setState(() => _exploreBlockedOverlay = true);
// Don't go back - just block the navigation
return NavigationActionPolicy.CANCEL;
}
if (uri != null &&
uri.host.contains('instagram.com') &&
(url.contains('accounts/settings') ||
url.contains('accounts/edit'))) {
return NavigationActionPolicy.ALLOW;
}
if (url.contains('/reels/') &&
!context
.read<SessionManager>()
.isSessionActive) {
setState(
() => _reelsBlockedOverlay = true,
);
return NavigationActionPolicy.CANCEL;
}
if (uri != null &&
!uri.host.contains('instagram.com') &&
!uri.host.contains('facebook.com') &&
!uri.host.contains(
'cdninstagram.com',
) &&
!uri.host.contains('fbcdn.net')) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
return NavigationActionPolicy.CANCEL;
}
final decision = NavigationGuard.evaluate(
url: url,
);
if (decision.blocked) {
if (url.contains('/reels/')) {
setState(
() => _reelsBlockedOverlay = true,
);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
onReceivedError: (controller, request, error) {
// FIX 5: Clear loading state on ANY main-frame
// error, not just HOST_LOOKUP and TIMEOUT.
// Previously, errors like CONNECTION_REFUSED or
// FAILED_URL_BLOCKED left _isLoading = true
// forever, causing the apparent "hang".
if (request.isForMainFrame == true) {
_loadingTimeout?.cancel();
if (mounted) {
setState(() {
_isLoading = false;
_showSkeleton = false;
// Only show the full error screen for
// network-level failures, not blocked URLs
if (error.type ==
WebResourceErrorType
.HOST_LOOKUP ||
error.type ==
WebResourceErrorType.TIMEOUT) {
_hasError = true;
}
});
}
}
},
),
if (_showSkeleton)
SkeletonScreen(skeletonType: _skeletonType),
if (!_isOnOnboardingPage &&
settings.minimalModeEnabled &&
!_minimalModeBannerDismissed)
Positioned(
left: 12,
right: 12,
top: 12,
child: _MinimalModeBanner(
onDismiss: () {
HapticFeedback.lightImpact();
setState(
() => _minimalModeBannerDismissed = true,
);
},
),
),
// Instagram's native bottom nav is used directly.
// NativeBottomNav overlay removed — faster, looks native,
// and reels tap naturally hits shouldOverrideUrlLoading.
],
);
},
),
),
],
),
),
if (_hasError)
_NoInternetScreen(
onRetry: () {
setState(() => _hasError = false);
_controller?.reload();
},
),
if (_isLoading)
Positioned(
top:
(_isOnOnboardingPage ? 0 : 60) +
MediaQuery.of(context).padding.top,
left: 0,
right: 0,
child: const _InstagramGradientProgressBar(),
),
_EdgePanel(key: _edgePanelKey),
if (_exploreBlockedOverlay)
Positioned.fill(
child: Consumer<SettingsService>(
builder: (ctx, settings, _) {
final isDark = settings.isDarkMode;
final bg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black;
final textDim = isDark ? Colors.white70 : Colors.black87;
final textSub = isDark ? Colors.white38 : Colors.black45;
return Container(
color: bg.withValues(alpha: 0.95),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.explore_off_rounded,
color: Colors.orangeAccent,
size: 80,
),
const SizedBox(height: 24),
Text(
'Explore is Disabled',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 42,
),
),
const SizedBox(height: 12),
Text(
'Explore is disabled in your FocusGram settings.',
textAlign: TextAlign.center,
style: TextStyle(
color: textDim,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 48),
Text(
'You can re-enable Explore in Settings > Focus.',
textAlign: TextAlign.center,
style: TextStyle(
color: textSub,
fontSize: 12,
),
),
const SizedBox(height: 20),
TextButton(
onPressed: () {
setState(() => _exploreBlockedOverlay = false);
_controller?.goBack();
},
child: Text(
'Go Back',
style: TextStyle(color: textSub),
),
),
],
),
);
},
),
),
if (_reelsBlockedOverlay)
Positioned.fill(
child: Consumer<SettingsService>(
builder: (ctx, settings, _) {
final isDark = settings.isDarkMode;
final bg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black;
final textDim = isDark ? Colors.white70 : Colors.black87;
final textSub = isDark ? Colors.white38 : Colors.black45;
return Container(
color: bg.withValues(alpha: 0.95),
padding: const EdgeInsets.all(32),
child: Consumer<SessionManager>(
builder: (ctx, sm, _) {
final onCooldown = sm.isCooldownActive;
final quotaFinished = sm.dailyRemainingSeconds <= 0;
final reelsHardDisabled =
settings.disableReelsEntirely;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
quotaFinished
? Icons.timer_off_rounded
: Icons.lock_clock_rounded,
color: quotaFinished
? Colors.redAccent
: Colors.blueAccent,
size: 80,
),
const SizedBox(height: 24),
Text(
quotaFinished
? 'Daily Quota Finished'
: (reelsHardDisabled
? 'Reels are Disabled'
: 'Reels are Blocked'),
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 42,
),
),
const SizedBox(height: 12),
Text(
quotaFinished
? 'You have reached your planned limit for today. Step away and focus on what matters most.'
: (reelsHardDisabled
? 'Reels are disabled in your settings.'
: 'Start a planned reel session to access the feed. Use Instagram for connection, not distraction.'),
textAlign: TextAlign.center,
style: TextStyle(
color: textDim,
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 48),
if (quotaFinished) ...[
Text(
'Your discipline is your strength.',
style: TextStyle(
color: Colors.greenAccent.withValues(
alpha: 0.8,
),
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 24),
Text(
'To adjust your daily limit, go to Settings > Guardrails.',
textAlign: TextAlign.center,
style: TextStyle(
color: textSub,
fontSize: 12,
),
),
] else if (reelsHardDisabled) ...[
Text(
'You can re-enable Reels in Settings > Focus.',
textAlign: TextAlign.center,
style: TextStyle(
color: textSub,
fontSize: 12,
),
),
] else if (onCooldown) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 14,
),
decoration: BoxDecoration(
color: Colors.orange.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.orange.withValues(
alpha: 0.4,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.hourglass_bottom_rounded,
color: Colors.orangeAccent,
size: 18,
),
const SizedBox(width: 8),
Text(
'Cooldown: ${_fmtSeconds(sm.cooldownRemainingSeconds)}',
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 8),
Text(
'Wait for the cooldown to expire before starting a new session.',
textAlign: TextAlign.center,
style: TextStyle(
color: textSub,
fontSize: 12,
),
),
] else
ElevatedButton(
onPressed: _showReelSessionPicker,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: const Text(
'Start Session',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
TextButton(
onPressed: () {
setState(() => _reelsBlockedOverlay = false);
_controller?.goBack();
},
child: Text(
'Go Back',
style: TextStyle(color: textSub),
),
),
],
);
},
),
);
},
),
),
],
),
),
);
}
void _registerJavaScriptHandlers(InAppWebViewController controller) {
controller.addJavaScriptHandler(
handlerName: 'FocusGramNotificationChannel',
callback: (args) {
if (!mounted) return null;
final settings = context.read<SettingsService>();
final msg = (args.isNotEmpty ? args[0] : '') as String;
if (DateTime.now().difference(_lastMainFrameLoadStartedAt).inSeconds <
6) {
return null;
}
String title = '';
String body = '';
bool isDM = false;
if (msg.contains(': ')) {
final parts = msg.split(': ');
title = parts[0];
body = parts.sublist(1).join(': ');
isDM =
title.toLowerCase().contains('message') ||
title.toLowerCase().contains('direct');
} else {
isDM = msg == 'DM';
title = isDM ? 'Instagram Message' : 'Instagram Notification';
body = isDM
? 'Someone messaged you'
: 'New activity in notifications';
}
if (isDM && !settings.notifyDMs) return null;
if (!isDM && !settings.notifyActivity) return null;
try {
NotificationService().showNotification(
// Use hash of message for unique ID - prevents random duplicate notifications
id: (msg.hashCode.abs() % 100000) + 2000,
title: title,
body: body,
);
} catch (_) {}
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'FocusGramBlocked',
callback: (args) {
if (!mounted) return null;
final what = (args.isNotEmpty ? args[0] : '') as String? ?? '';
final text = what == 'reels'
? 'Reels are disabled'
: (what == 'explore' ? 'Explore is disabled' : 'Content disabled');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 20),
),
);
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'FocusGramShareChannel',
callback: (args) {
if (!mounted) return;
try {
final data = (args.isNotEmpty ? args[0] : '') as String;
String url = data;
try {
final match = RegExp(r'"url":"([^"]+)"').firstMatch(data);
if (match != null) url = match.group(1) ?? data;
} catch (_) {}
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link copied (tracking removed)'),
backgroundColor: Color(0xFF1A1A2E),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.fromLTRB(16, 0, 16, 20),
),
);
} catch (_) {}
},
);
controller.addJavaScriptHandler(
handlerName: 'FocusGramThemeChannel',
callback: (args) {
final value = (args.isNotEmpty ? args[0] : '') as String;
context.read<SettingsService>().setDarkMode(value == 'dark');
},
);
controller.addJavaScriptHandler(
handlerName: 'Haptic',
callback: (args) {
HapticFeedback.lightImpact();
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'UrlChange',
callback: (args) async {
final url = (args.isNotEmpty ? args[0] : '') as String? ?? '';
await _injectionManager?.runAllPostLoadInjections(url);
if (!mounted) return;
setState(() {
_currentUrl = url;
// SPA navigations never fire onLoadStop — clear skeleton here
// so it doesn't stay visible forever (e.g. when navigating to DMs)
_showSkeleton = false;
_isLoading = false;
});
final settings = context.read<SettingsService>();
final disableReels = settings.disableReelsEntirely;
final disableExplore = settings.disableExploreEntirely;
final path = Uri.tryParse(url)?.path ?? url;
final isReels = path.startsWith('/reels') || path.startsWith('/reel/');
final isExplore = path.startsWith('/explore');
// Block reel navigation that slipped through (e.g. DM-embedded reels)
if (disableReels && isReels) {
setState(() => _reelsBlockedOverlay = true);
await _controller?.goBack();
return;
}
if (_controller != null) {
if (disableExplore && isExplore) {
await _controller!.loadUrl(
urlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
);
}
}
// Update isolated player flag for DM-embedded reels
final isIsolatedReel =
path.contains('/reel/') && !path.startsWith('/reels');
await _setIsolatedPlayer(isIsolatedReel);
return null;
},
);
}
}
// ─── Supporting widgets (unchanged) ──────────────────────────────────────────
class _MinimalModeBanner extends StatelessWidget {
final VoidCallback onDismiss;
const _MinimalModeBanner({required this.onDismiss});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: (isDark ? Colors.black : Colors.white).withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white12 : Colors.black12,
width: 0.5,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.35 : 0.12),
blurRadius: 18,
spreadRadius: 2,
),
],
),
child: Row(
children: [
Expanded(
child: Text(
'Minimal mode — Feed & DMs only 🎯',
style: TextStyle(
color: isDark ? Colors.white : Colors.black,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
InkWell(
onTap: onDismiss,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.close,
size: 18,
color: isDark ? Colors.white70 : Colors.black54,
),
),
),
],
),
),
);
}
}
class _EdgePanel extends StatefulWidget {
const _EdgePanel({super.key});
@override
State<_EdgePanel> createState() => _EdgePanelState();
}
class _EdgePanelState extends State<_EdgePanel> {
bool _isExpanded = false;
void _toggleExpansion() => setState(() => _isExpanded = !_isExpanded);
@override
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final int remaining = sm.remainingSessionSeconds;
final double progress = sm.perSessionSeconds > 0
? (remaining / sm.perSessionSeconds).clamp(0.0, 1.0)
: 0;
Color barColor = progress < 0.2
? Colors.redAccent
: (progress < 0.5 ? Colors.yellowAccent : Colors.blueAccent);
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final reelsHardDisabled =
settings.disableReelsEntirely;
final panelBg = isDark ? const Color(0xFF121212) : Colors.white;
final textDim = isDark ? Colors.white70 : Colors.black87;
final textSub = isDark ? Colors.white30 : Colors.black38;
final border = isDark ? Colors.white12 : Colors.black12;
return Stack(
children: [
if (_isExpanded)
Positioned.fill(
child: GestureDetector(
onTap: _toggleExpansion,
behavior: HitTestBehavior.opaque,
child: Container(
color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05),
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutQuart,
left: _isExpanded ? 0 : -220,
top: MediaQuery.of(context).size.height * 0.25 + 30,
child: Container(
width: 210,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: panelBg.withValues(alpha: 0.98),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(28),
bottomRight: Radius.circular(28),
),
border: Border.all(color: border, width: 0.5),
boxShadow: [
BoxShadow(
color: (isDark ? Colors.black : Colors.black12).withValues(
alpha: 0.3,
),
blurRadius: 30,
spreadRadius: 5,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'FOCUS CONTROL',
style: TextStyle(
color: Colors.blueAccent,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
),
IconButton(
icon: Icon(
Icons.chevron_left_rounded,
color: textDim,
size: 28,
),
onPressed: _toggleExpansion,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 32),
Text(
'REEL SESSION',
style: TextStyle(
color: textSub,
fontSize: 11,
letterSpacing: 1,
),
),
const SizedBox(height: 8),
Text(
sm.isSessionActive
? _formatTime(sm.remainingSessionSeconds)
: 'Off',
style: TextStyle(
color: barColor,
fontSize: 40,
fontWeight: FontWeight.w200,
letterSpacing: 2,
),
),
const SizedBox(height: 20),
_buildStatRow(
'REEL QUOTA',
'${sm.dailyRemainingSeconds ~/ 60}m Left',
Icons.timer_outlined,
isDark: isDark,
),
_buildStatRow(
'AUTO-CLOSE',
_formatTime(sm.appSessionRemainingSeconds),
Icons.hourglass_empty_rounded,
isDark: isDark,
),
_buildStatRow(
'COOLDOWN',
sm.isCooldownActive
? _formatTime(sm.cooldownRemainingSeconds)
: 'Off',
Icons.coffee_rounded,
isWarning: sm.isCooldownActive,
isDark: isDark,
),
if (!reelsHardDisabled &&
!sm.isSessionActive &&
sm.dailyRemainingSeconds > 0) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
context
.findAncestorStateOfType<_MainWebViewPageState>()
?._showReelSessionPicker();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Start Session',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
] else if (reelsHardDisabled) ...[
const SizedBox(height: 10),
Text(
'Reels disabled in settings',
style: TextStyle(color: textSub, fontSize: 11),
),
],
const SizedBox(height: 32),
Divider(color: isDark ? Colors.white10 : Colors.black12),
const SizedBox(height: 8),
ListTile(
onTap: () {
_toggleExpansion();
context
.findAncestorStateOfType<_MainWebViewPageState>()
?._signOut();
},
leading: const Icon(
Icons.logout_rounded,
color: Colors.redAccent,
size: 20,
),
title: const Text(
'Switch Account',
style: TextStyle(
color: Colors.redAccent,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
dense: true,
contentPadding: EdgeInsets.zero,
),
],
),
),
),
],
);
}
Widget _buildStatRow(
String label,
String value,
IconData icon, {
bool isWarning = false,
bool isDark = true,
}) {
final textMain = isDark ? Colors.white : Colors.black;
final textSub = isDark ? Colors.white38 : Colors.black38;
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isWarning
? Colors.redAccent.withValues(alpha: 0.1)
: (isDark ? Colors.white : Colors.black).withValues(
alpha: 0.05,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: isWarning
? Colors.redAccent
: (isDark ? Colors.white70 : Colors.black54),
size: 16,
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: textSub,
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
color: isWarning ? Colors.redAccent : textMain,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
],
),
);
}
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
const _BrandedTopBar({this.onFocusControlTap});
@override
Widget build(BuildContext context) {
final isDark = context.watch<SettingsService>().isDarkMode;
final barBg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black;
final iconColor = isDark ? Colors.white70 : Colors.black54;
final border = isDark ? Colors.white12 : Colors.black12;
return Container(
height: 60,
decoration: BoxDecoration(
color: barBg,
border: Border(bottom: BorderSide(color: border, width: 0.5)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.settings_outlined, color: iconColor, size: 22),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
),
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
),
],
),
),
);
}
}
class _InstagramGradientProgressBar extends StatelessWidget {
const _InstagramGradientProgressBar();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 2.5,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFFFEDA75),
Color(0xFFFA7E1E),
Color(0xFFD62976),
Color(0xFF962FBF),
Color(0xFF4F5BD5),
],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
);
}
}
class _UpdateBanner extends StatelessWidget {
const _UpdateBanner();
@override
Widget build(BuildContext context) {
return Consumer<UpdateCheckerService>(
builder: (context, update, _) {
if (!update.hasUpdate) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade700, Colors.blue.shade900],
),
),
child: Row(
children: [
const Icon(
Icons.system_update_alt,
color: Colors.white,
size: 18,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Update Available: ${update.updateInfo?.latestVersion ?? ''}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
const SizedBox(height: 2),
InkWell(
onTap: () {
final url = update.updateInfo?.releaseUrl;
if (url != null && url.isNotEmpty) {
launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
}
},
child: const Text(
'Download on GitHub →',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
decoration: TextDecoration.underline,
),
),
),
],
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => update.dismissUpdate(),
icon: const Icon(Icons.close, color: Colors.white70, size: 18),
),
],
),
);
},
);
}
}
class _NoInternetScreen extends StatelessWidget {
final VoidCallback onRetry;
const _NoInternetScreen({required this.onRetry});
@override
Widget build(BuildContext context) {
final isDark = context.watch<SettingsService>().isDarkMode;
return Container(
color: isDark ? Colors.black : Colors.white,
width: double.infinity,
height: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.wifi_off_rounded,
color: isDark ? Colors.white24 : Colors.black12,
size: 80,
),
const SizedBox(height: 24),
Text(
'No Connection',
style: TextStyle(
color: isDark ? Colors.white : Colors.black,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Please check your internet settings.',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black38,
fontSize: 14,
),
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: onRetry,
style: ElevatedButton.styleFrom(
backgroundColor: isDark ? Colors.white : Colors.black,
foregroundColor: isDark ? Colors.black : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Retry'),
),
],
),
);
}
}