mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-08-17 11:40:21 +02:00
improvement: improved UI Elements and some optimizations
This commit is contained in:
@@ -9,7 +9,6 @@ import '../services/injection_controller.dart';
|
|||||||
import '../services/navigation_guard.dart';
|
import '../services/navigation_guard.dart';
|
||||||
import 'session_modal.dart';
|
import 'session_modal.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'reel_player_overlay.dart';
|
|
||||||
|
|
||||||
class MainWebViewPage extends StatefulWidget {
|
class MainWebViewPage extends StatefulWidget {
|
||||||
const MainWebViewPage({super.key});
|
const MainWebViewPage({super.key});
|
||||||
@@ -116,8 +115,6 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initWebView() {
|
void _initWebView() {
|
||||||
final sessionManager = context.read<SessionManager>();
|
|
||||||
|
|
||||||
_controller = WebViewController()
|
_controller = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
..setUserAgent(InjectionController.iOSUserAgent)
|
..setUserAgent(InjectionController.iOSUserAgent)
|
||||||
@@ -134,23 +131,20 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
|
|||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
_applyInjections();
|
_applyInjections();
|
||||||
_updateCurrentTab(url);
|
_updateCurrentTab(url);
|
||||||
// Cache username whenever we finish loading any page
|
|
||||||
_cacheUsername();
|
_cacheUsername();
|
||||||
|
// Inject swipe-blocker when on a specific reel page
|
||||||
|
if (NavigationGuard.isSpecificReel(url)) {
|
||||||
|
_controller.runJavaScript(InjectionController.reelSwipeBlockerJS);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onNavigationRequest: (request) {
|
onNavigationRequest: (request) {
|
||||||
final isDmReel = NavigationGuard.isDmReelLink(request.url);
|
final decision = NavigationGuard.evaluate(url: request.url);
|
||||||
|
|
||||||
final decision = NavigationGuard.evaluate(
|
|
||||||
url: request.url,
|
|
||||||
sessionActive: sessionManager.isSessionActive,
|
|
||||||
isDmReelException: isDmReel,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (decision.blocked) {
|
if (decision.blocked) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(decision.reason ?? 'Blocked'),
|
content: Text(decision.reason ?? 'Navigation blocked'),
|
||||||
backgroundColor: Colors.red.shade900,
|
backgroundColor: Colors.red.shade900,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
||||||
@@ -161,22 +155,6 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
|
|||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open DM reel in isolated player
|
|
||||||
if (isDmReel && !sessionManager.isSessionActive) {
|
|
||||||
final canonicalUrl = NavigationGuard.canonicalizeDmReelUrl(
|
|
||||||
request.url,
|
|
||||||
);
|
|
||||||
if (canonicalUrl != null) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => ReelPlayerOverlay(url: canonicalUrl),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return NavigationDecision.prevent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NavigationDecision.navigate;
|
return NavigationDecision.navigate;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -290,39 +268,49 @@ class _MainWebViewPageState extends State<MainWebViewPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final topPad = MediaQuery.of(context).padding.top;
|
||||||
|
const barHeight = 60.0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
|
||||||
bottom: false,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Status Bar — always on top
|
|
||||||
_StatusBar(),
|
|
||||||
|
|
||||||
// WebView
|
|
||||||
Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
WebViewWidget(controller: _controller),
|
|
||||||
// Thin loading bar (not full-screen spinner)
|
|
||||||
if (_isLoading)
|
|
||||||
const LinearProgressIndicator(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
color: Colors.blue,
|
|
||||||
minHeight: 2,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottomNavigationBar: _FocusGramNavBar(
|
|
||||||
currentIndex: _currentIndex,
|
|
||||||
onTap: _onTabTapped,
|
|
||||||
),
|
|
||||||
floatingActionButton: _SessionFAB(onTap: _openSessionModal),
|
floatingActionButton: _SessionFAB(onTap: _openSessionModal),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// ── WebView: full screen (behind everything) ────────────────
|
||||||
|
Positioned.fill(child: WebViewWidget(controller: _controller)),
|
||||||
|
|
||||||
|
// ── Thin loading indicator at very top ──────────────────────
|
||||||
|
if (_isLoading)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: const LinearProgressIndicator(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
color: Colors.blue,
|
||||||
|
minHeight: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Status bar overlaid at top (below system status bar) ────
|
||||||
|
Positioned(top: topPad, left: 0, right: 0, child: _StatusBar()),
|
||||||
|
|
||||||
|
// ── Our bottom bar overlaid on top of Instagram's bar ───────
|
||||||
|
// Making it taller than Instagram's native bar (~50dp) means
|
||||||
|
// theirs is fully hidden behind ours — no CSS needed as fallback.
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: _FocusGramNavBar(
|
||||||
|
currentIndex: _currentIndex,
|
||||||
|
onTap: _onTabTapped,
|
||||||
|
height: barHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +414,13 @@ class _StatusBar extends StatelessWidget {
|
|||||||
class _FocusGramNavBar extends StatelessWidget {
|
class _FocusGramNavBar extends StatelessWidget {
|
||||||
final int currentIndex;
|
final int currentIndex;
|
||||||
final Future<void> Function(int) onTap;
|
final Future<void> Function(int) onTap;
|
||||||
|
final double height;
|
||||||
|
|
||||||
const _FocusGramNavBar({required this.currentIndex, required this.onTap});
|
const _FocusGramNavBar({
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.onTap,
|
||||||
|
this.height = 52,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -444,7 +437,7 @@ class _FocusGramNavBar extends StatelessWidget {
|
|||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 52,
|
height: height,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: items.asMap().entries.map((entry) {
|
children: items.asMap().entries.map((entry) {
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ class InjectionController {
|
|||||||
}
|
}
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
/// CSS that adds bottom padding so feed content doesn't hide behind our bar.
|
||||||
|
static const String _bottomPaddingCSS = '''
|
||||||
|
body, #react-root > div {
|
||||||
|
padding-bottom: 64px !important;
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
/// CSS to blur Explore feed posts/reels (keeps stories visible).
|
/// CSS to blur Explore feed posts/reels (keeps stories visible).
|
||||||
static const String _blurExploreCSS = '''
|
static const String _blurExploreCSS = '''
|
||||||
/* Blur Explore grid posts and reel cards (not stories row) */
|
/* Blur Explore grid posts and reel cards (not stories row) */
|
||||||
@@ -250,6 +257,27 @@ class InjectionController {
|
|||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
/// JS to disable vertical swipe gestures that drive reel-to-reel transition.
|
||||||
|
static const String reelSwipeBlockerJS = '''
|
||||||
|
(function() {
|
||||||
|
let _touchStartY = 0;
|
||||||
|
document.addEventListener('touchstart', function(e) {
|
||||||
|
_touchStartY = e.touches[0].clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', function(e) {
|
||||||
|
const deltaY = e.touches[0].clientY - _touchStartY;
|
||||||
|
// If swiping UP (negative delta), block it to prevent next reel load
|
||||||
|
if (deltaY < -10) {
|
||||||
|
if (e.cancelable) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
|
||||||
// ── Reel scroll-lock ────────────────────────────────────────────────────────
|
// ── Reel scroll-lock ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// JS that prevents the user from scrolling to a different reel.
|
/// JS that prevents the user from scrolling to a different reel.
|
||||||
@@ -329,6 +357,7 @@ class InjectionController {
|
|||||||
}) {
|
}) {
|
||||||
final StringBuffer css = StringBuffer();
|
final StringBuffer css = StringBuffer();
|
||||||
css.write(_hideInstagramNavCSS);
|
css.write(_hideInstagramNavCSS);
|
||||||
|
css.write(_bottomPaddingCSS); // Ensure content isn't behind our bar
|
||||||
if (!sessionActive) css.write(_hideReelsCSS);
|
if (!sessionActive) css.write(_hideReelsCSS);
|
||||||
if (blurExplore) css.write(_blurExploreCSS);
|
if (blurExplore) css.write(_blurExploreCSS);
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
/// Determines whether a navigation request should be blocked.
|
/// Determines whether a navigation request should be blocked.
|
||||||
///
|
///
|
||||||
/// Rules:
|
/// Rules:
|
||||||
/// - /reels/* and /reel/* are blocked unless [sessionActive] is true OR
|
/// - instagram.com/reels (and /reels/) = BLOCKED — this is the mindless feed tab
|
||||||
/// [isDmReelException] is true (single DM reel open).
|
/// - instagram.com/reel/SHORTCODE/ = ALLOWED — a specific reel (e.g. from a DM)
|
||||||
/// - /explore/ is allowed (but explore content is blurred via CSS).
|
/// - /explore/ is allowed (explore content is blurred via CSS instead)
|
||||||
/// - Only instagram.com domains are allowed (blocks external redirects).
|
/// - Only instagram.com domains are allowed (blocks external redirects)
|
||||||
class NavigationGuard {
|
class NavigationGuard {
|
||||||
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
|
static const _allowedHosts = ['instagram.com', 'www.instagram.com'];
|
||||||
|
|
||||||
static const _blockedPathPrefixes = ['/reels', '/reel/'];
|
/// Regex matching the Reels FEED root — NOT individual reels.
|
||||||
|
static final _reelsFeedRegex = RegExp(
|
||||||
|
r'instagram\.com/reels/?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Regex matching a specific reel (e.g. /reel/ABC123/).
|
||||||
|
static final _specificReelRegex = RegExp(
|
||||||
|
r'instagram\.com/reel/[^/?#]+',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
/// Returns a [BlockDecision] for the given [url].
|
/// Returns a [BlockDecision] for the given [url].
|
||||||
static BlockDecision evaluate({
|
static BlockDecision evaluate({required String url}) {
|
||||||
required String url,
|
|
||||||
required bool sessionActive,
|
|
||||||
required bool isDmReelException,
|
|
||||||
}) {
|
|
||||||
Uri uri;
|
Uri uri;
|
||||||
try {
|
try {
|
||||||
uri = Uri.parse(url);
|
uri = Uri.parse(url);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return BlockDecision(blocked: false, reason: null);
|
return const BlockDecision(blocked: false, reason: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow non-HTTP schemes (about:blank, data:, etc.)
|
// Allow non-HTTP schemes (about:blank, data:, etc.)
|
||||||
if (!uri.scheme.startsWith('http')) {
|
if (!uri.scheme.startsWith('http')) {
|
||||||
return BlockDecision(blocked: false, reason: null);
|
return const BlockDecision(blocked: false, reason: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block non-Instagram domains (prevents phishing redirects)
|
// Block non-Instagram domains (prevents phishing/external redirects)
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
|
if (!_allowedHosts.any((h) => host == h || host.endsWith('.$h'))) {
|
||||||
return BlockDecision(
|
return BlockDecision(
|
||||||
@@ -37,49 +43,21 @@ class NavigationGuard {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check reel/reels path
|
// Block ONLY the Reels feed tab root (/reels, /reels/)
|
||||||
final path = uri.path.toLowerCase();
|
// but allow specific reels (/reel/SHORTCODE/) opened from DMs
|
||||||
final isReelUrl = _blockedPathPrefixes.any((p) => path.startsWith(p));
|
if (_reelsFeedRegex.hasMatch(url)) {
|
||||||
|
return const BlockDecision(
|
||||||
if (isReelUrl) {
|
|
||||||
if (sessionActive || isDmReelException) {
|
|
||||||
return BlockDecision(blocked: false, reason: null);
|
|
||||||
}
|
|
||||||
return BlockDecision(
|
|
||||||
blocked: true,
|
blocked: true,
|
||||||
reason: 'Reel navigation blocked — no active session',
|
reason:
|
||||||
|
'Reels feed is disabled — open a specific reel from DMs instead',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return BlockDecision(blocked: false, reason: null);
|
return const BlockDecision(blocked: false, reason: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the URL looks like a Reel link from a DM.
|
/// True if the URL is a specific individual reel (from a DM share).
|
||||||
static bool isDmReelLink(String url) {
|
static bool isSpecificReel(String url) => _specificReelRegex.hasMatch(url);
|
||||||
try {
|
|
||||||
final uri = Uri.parse(url);
|
|
||||||
final path = uri.path.toLowerCase();
|
|
||||||
return path.startsWith('/reel/') || path.startsWith('/reels/');
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts a canonical single-reel URL from a DM reel link.
|
|
||||||
/// Strips query params that might trigger Reels feed.
|
|
||||||
static String? canonicalizeDmReelUrl(String url) {
|
|
||||||
try {
|
|
||||||
final uri = Uri.parse(url);
|
|
||||||
// Keep only the reel path, strip all query parameters
|
|
||||||
return Uri(
|
|
||||||
scheme: 'https',
|
|
||||||
host: 'www.instagram.com',
|
|
||||||
path: uri.path,
|
|
||||||
).toString();
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class BlockDecision {
|
class BlockDecision {
|
||||||
|
|||||||
Reference in New Issue
Block a user