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
new file mode 100644
index 0000000..a8088aa
Binary files /dev/null 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
new file mode 100644
index 0000000..0bc9fdc
Binary files /dev/null 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
new file mode 100644
index 0000000..907db7f
Binary files /dev/null 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
new file mode 100644
index 0000000..3978d81
Binary files /dev/null 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
new file mode 100644
index 0000000..fcef524
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..5f349f7
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
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 db77bb4..4891768 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 17987b7..2332984 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 09d4391..64205f4 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 d5f1c8d..9fc60ef 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 4d6372e..6ffbd9f 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/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..beab31f
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/assets/images/focusgram.ico b/assets/images/focusgram.ico
new file mode 100644
index 0000000..fc0fb57
Binary files /dev/null and b/assets/images/focusgram.ico differ
diff --git a/assets/images/focusgram.png b/assets/images/focusgram.png
new file mode 100644
index 0000000..8c4d9b7
Binary files /dev/null and b/assets/images/focusgram.png differ
diff --git a/lib/screens/about_page.dart b/lib/screens/about_page.dart
index fd03287..a7e10f5 100644
--- a/lib/screens/about_page.dart
+++ b/lib/screens/about_page.dart
@@ -1,9 +1,87 @@
+import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
+import 'package:http/http.dart' as http;
-class AboutPage extends StatelessWidget {
+class AboutPage extends StatefulWidget {
const AboutPage({super.key});
+ @override
+ State createState() => _AboutPageState();
+}
+
+class _AboutPageState extends State {
+ final String _currentVersion = '0.8.5';
+ bool _isChecking = false;
+
+ Future _checkUpdate() async {
+ setState(() => _isChecking = true);
+ try {
+ final response = await http
+ .get(
+ Uri.parse(
+ 'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest',
+ ),
+ )
+ .timeout(const Duration(seconds: 10));
+
+ if (response.statusCode == 200) {
+ final data = json.decode(response.body);
+ final latestVersion = data['tag_name'].toString().replaceAll('v', '');
+ final downloadUrl = data['html_url'];
+
+ if (latestVersion != _currentVersion) {
+ _showUpdateDialog(latestVersion, downloadUrl);
+ } else {
+ _showSnackBar('You are up to date! 🎉');
+ }
+ } else {
+ _showSnackBar('Could not check for updates.');
+ }
+ } catch (_) {
+ _showSnackBar('Connectivity issue. Try again later.');
+ } finally {
+ if (mounted) setState(() => _isChecking = false);
+ }
+ }
+
+ void _showUpdateDialog(String version, String url) {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: const Color(0xFF1A1A1A),
+ title: const Text(
+ 'Update Available!',
+ style: TextStyle(color: Colors.white),
+ ),
+ content: Text(
+ 'A new version ($version) is available on GitHub.',
+ style: const TextStyle(color: Colors.white70),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Later', style: TextStyle(color: Colors.white38)),
+ ),
+ ElevatedButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ _launchURL(url);
+ },
+ style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
+ child: const Text('Download'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showSnackBar(String msg) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
+ );
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -48,9 +126,9 @@ class AboutPage extends StatelessWidget {
),
),
const SizedBox(height: 8),
- const Text(
- 'Version 1.1.0',
- style: TextStyle(color: Colors.white38, fontSize: 13),
+ Text(
+ 'Version $_currentVersion',
+ style: const TextStyle(color: Colors.white38, fontSize: 13),
),
const SizedBox(height: 40),
const Text(
@@ -67,7 +145,30 @@ class AboutPage extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
- const SizedBox(height: 60),
+ const SizedBox(height: 40),
+ ElevatedButton.icon(
+ onPressed: _isChecking ? null : _checkUpdate,
+ icon: _isChecking
+ ? const SizedBox(
+ width: 14,
+ height: 14,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Icon(Icons.update),
+ label: Text(_isChecking ? 'Checking...' : 'Check for Update'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blueAccent.withValues(alpha: 0.2),
+ foregroundColor: Colors.white,
+ minimumSize: const Size(200, 45),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () =>
_launchURL('https://github.com/Ujwal223/FocusGram'),
@@ -76,6 +177,7 @@ class AboutPage extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white10,
foregroundColor: Colors.white,
+ minimumSize: const Size(200, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -84,7 +186,10 @@ class AboutPage extends StatelessWidget {
const SizedBox(height: 20),
const Text(
'FocusGram is not affiliated with Instagram.',
- style: TextStyle(color: Colors.white12, fontSize: 10),
+ style: TextStyle(
+ color: Color.fromARGB(48, 255, 255, 255),
+ fontSize: 10,
+ ),
),
],
),
@@ -98,8 +203,6 @@ class AboutPage extends StatelessWidget {
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
- } catch (_) {
- // Ignore if cannot launch
- }
+ } catch (_) {}
}
}
diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart
index 37969bb..d843070 100644
--- a/lib/screens/main_webview_page.dart
+++ b/lib/screens/main_webview_page.dart
@@ -8,6 +8,7 @@ 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 'settings_page.dart';
@@ -23,14 +24,25 @@ class _MainWebViewPageState extends State
late final WebViewController _controller;
int _currentIndex = 0;
bool _isLoading = true;
-
- // Cached username for profile navigation
- String? _cachedUsername;
-
// Watchdog for app-session expiry
Timer? _watchdog;
bool _extensionDialogShown = false;
bool _lastSessionActive = false;
+ String? _cachedUsername;
+ String _currentUrl = 'https://www.instagram.com/';
+ bool _hasError = false;
+
+ /// 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() {
@@ -39,9 +51,10 @@ class _MainWebViewPageState extends State
_initWebView();
_startWatchdog();
- // Listen to session changes to update JS context immediately
+ // Listen to session & settings changes
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read().addListener(_onSessionChanged);
+ context.read().addListener(_onSettingsChanged);
_lastSessionActive = context.read().isSessionActive;
});
}
@@ -53,6 +66,14 @@ class _MainWebViewPageState extends State
_lastSessionActive = sm.isSessionActive;
_applyInjections();
}
+ // Force rebuild for timer updates
+ setState(() {});
+ }
+
+ void _onSettingsChanged() {
+ if (!mounted) return;
+ _applyInjections();
+ _controller.reload();
}
@override
@@ -60,6 +81,7 @@ class _MainWebViewPageState extends State
WidgetsBinding.instance.removeObserver(this);
_watchdog?.cancel();
context.read().removeListener(_onSessionChanged);
+ context.read().removeListener(_onSettingsChanged);
super.dispose();
}
@@ -156,13 +178,20 @@ class _MainWebViewPageState extends State
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
- // Only show loading if it's a real page load (not SPA nav)
- if (!url.contains('#')) {
- if (mounted) setState(() => _isLoading = true);
+ if (mounted) {
+ setState(() {
+ _isLoading = !url.contains('#');
+ _currentUrl = url; // Update immediately to hide/show UI
+ });
}
},
onPageFinished: (url) {
- if (mounted) setState(() => _isLoading = false);
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ _currentUrl = url;
+ });
+ }
_applyInjections();
_updateCurrentTab(url);
_cacheUsername();
@@ -181,6 +210,16 @@ class _MainWebViewPageState extends State
return NavigationDecision.prevent;
}
+ // Facebook Login Warning
+ if (uri != null &&
+ uri.host.contains('facebook.com') &&
+ _isOnOnboardingPage) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Sorry, Please use Email login')),
+ );
+ return NavigationDecision.prevent;
+ }
+
final decision = NavigationGuard.evaluate(url: request.url);
if (decision.blocked) {
@@ -202,24 +241,49 @@ class _MainWebViewPageState extends State
},
),
)
- ..loadRequest(Uri.parse('https://www.instagram.com/'));
+ ..loadRequest(Uri.parse('https://www.instagram.com/accounts/login/'));
}
void _applyInjections() {
+ if (!mounted) return;
+ if (_isOnOnboardingPage) return; // Restore native login/signup behavior
+
final sessionManager = context.read();
final settings = context.read();
final js = InjectionController.buildInjectionJS(
sessionActive: sessionManager.isSessionActive,
blurExplore: settings.blurExplore,
+ blurReels: settings.blurReels,
+ ghostMode: settings.ghostMode,
+ enableTextSelection: settings.enableTextSelection,
);
_controller.runJavaScript(js);
}
+ Future _signOut() async {
+ final manager = WebViewCookieManager();
+ await manager.clearCookies();
+ await _controller.clearCache();
+ // Force immediate state update and navigation
+ if (mounted) {
+ setState(() {
+ _currentIndex = 0;
+ _cachedUsername = null;
+ _isLoading = true; // Show indicator during reload
+ });
+ await _controller.loadRequest(
+ Uri.parse('https://www.instagram.com/accounts/login/'),
+ );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(const SnackBar(content: Text('Signed out successfully')));
+ }
+ }
+
Future _cacheUsername() async {
- if (_cachedUsername != null) return; // Already known
try {
final result = await _controller.runJavaScriptReturningResult(
- InjectionController.getLoggedInUsernameJS,
+ "document.querySelector('header h2')?.innerText || ''",
);
final raw = result.toString().replaceAll('"', '').replaceAll("'", '');
if (raw.isNotEmpty && raw != 'null' && raw != 'undefined') {
@@ -268,8 +332,10 @@ class _MainWebViewPageState extends State
}
Future _onTabTapped(int index) async {
- // Don't re-navigate if already on this tab
- if (index == _currentIndex) return;
+ if (index == _currentIndex) {
+ await _controller.reload();
+ return;
+ }
setState(() => _currentIndex = index);
switch (index) {
@@ -284,9 +350,8 @@ class _MainWebViewPageState extends State
case 2:
if (context.read().isSessionActive) {
await _navigateTo('/reels/');
- } else {
- _openSessionModal();
}
+ // If not active, do nothing (disabled as requested)
break;
case 3:
await _navigateTo('/direct/inbox/');
@@ -326,13 +391,28 @@ class _MainWebViewPageState extends State
backgroundColor: Colors.black,
body: Stack(
children: [
- // ── WebView: full screen (behind everything) ────────────────
- Positioned.fill(child: WebViewWidget(controller: _controller)),
+ // ── Main Content Layout ────────────────────────────────────
+ SafeArea(
+ child: Column(
+ children: [
+ if (!_isOnOnboardingPage) _BrandedTopBar(),
+ Expanded(child: WebViewWidget(controller: _controller)),
+ ],
+ ),
+ ),
- // ── Thin loading indicator at very top ──────────────────────
+ if (_hasError)
+ _NoInternetScreen(
+ onRetry: () {
+ setState(() => _hasError = false);
+ _controller.reload();
+ },
+ ),
+
+ // ── Thin loading indicator (Placed below Top Bar) ──────────
if (_isLoading)
Positioned(
- top: 0,
+ top: 60 + MediaQuery.of(context).padding.top,
left: 0,
right: 0,
child: const LinearProgressIndicator(
@@ -342,36 +422,26 @@ class _MainWebViewPageState extends State
),
),
- // ── The Edge Panel (replaced _StatusBar) ────────────────────
- // No Positioned wrapper here: _EdgePanel returns its own Positioned siblings
+ // ── The Edge Panel ──────────────────────────────────────────
_EdgePanel(controller: _controller),
- // ── 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,
+ // ── Our bottom bar ──────────────────────────────────────────
+ if (!_isOnOnboardingPage)
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: _FocusGramNavBar(
+ currentIndex: _currentIndex,
+ onTap: _onTabTapped,
+ height: barHeight * 0.99, // 1% reduction
+ ),
),
- ),
],
),
),
);
}
-
- void _openSessionModal() {
- showModalBottomSheet(
- context: context,
- backgroundColor: Colors.transparent,
- builder: (_) => const SessionModal(),
- );
- }
}
// ──────────────────────────────────────────────────────────────────────────────
@@ -424,7 +494,6 @@ class _EdgePanelState extends State<_EdgePanel> {
// Hits will pass through the Stack to the WebView except on our children.
return Stack(
children: [
- // ── Tap-to-close Backdrop (only when expanded) ──
if (_isExpanded)
Positioned.fill(
child: GestureDetector(
@@ -584,7 +653,13 @@ class _EdgePanelState extends State<_EdgePanel> {
),
const SizedBox(height: 8),
Text(
- _formatTime(sm.remainingSessionSeconds),
+ context.read().isSessionActive
+ ? _formatTime(
+ context
+ .read()
+ .remainingSessionSeconds,
+ )
+ : 'Off',
style: TextStyle(
color: barColor,
fontSize: 40,
@@ -592,67 +667,58 @@ class _EdgePanelState extends State<_EdgePanel> {
letterSpacing: 2,
),
),
- const SizedBox(height: 24),
- // Daily Remaining
- const Text(
- 'DAILY QUOTA',
- style: TextStyle(
- color: Colors.white30,
- fontSize: 11,
- letterSpacing: 1,
- ),
- ),
- const SizedBox(height: 8),
- Text(
+ const SizedBox(height: 20),
+ _buildStatRow(
+ 'REEL QUOTA',
'${sm.dailyRemainingSeconds ~/ 60}m Left',
- style: const TextStyle(
- color: Colors.white70,
- fontSize: 18,
- fontWeight: FontWeight.w500,
- ),
+ Icons.timer_outlined,
+ ),
+ _buildStatRow(
+ 'AUTO-CLOSE',
+ _formatTime(sm.appSessionRemainingSeconds),
+ Icons.hourglass_empty_rounded,
+ ),
+ _buildStatRow(
+ 'COOLDOWN',
+ sm.isCooldownActive
+ ? _formatTime(sm.cooldownRemainingSeconds)
+ : 'Off',
+ Icons.coffee_rounded,
+ isWarning: sm.isCooldownActive,
),
const SizedBox(height: 32),
- const Divider(color: Colors.white10, height: 1),
- const SizedBox(height: 20),
- // Settings Link
- InkWell(
- onTap: () {
- _toggleExpansion();
- Navigator.push(
- context,
- MaterialPageRoute(builder: (_) => const SettingsPage()),
- );
- },
- borderRadius: BorderRadius.circular(12),
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 8.0),
- child: Row(
- children: [
- Container(
- padding: const EdgeInsets.all(6),
- decoration: BoxDecoration(
- color: Colors.blue.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(8),
- ),
- child: const Icon(
- Icons.tune_rounded,
- color: Colors.blueAccent,
- size: 18,
- ),
- ),
- const SizedBox(width: 14),
- const Text(
- 'Preferences',
- style: TextStyle(
- color: Colors.white,
- fontSize: 15,
- fontWeight: FontWeight.w500,
- ),
- ),
- ],
+ if (!context
+ .findAncestorStateOfType<_MainWebViewPageState>()!
+ ._isOnOnboardingPage) ...[
+ const Divider(color: Colors.white10),
+ const SizedBox(height: 8),
+ ListTile(
+ onTap: () async {
+ _toggleExpansion();
+ final state = context
+ .findAncestorStateOfType<_MainWebViewPageState>();
+ if (state != null) {
+ await state._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,
),
- ),
+ const SizedBox(height: 8),
+ ],
],
),
),
@@ -662,6 +728,60 @@ class _EdgePanelState extends State<_EdgePanel> {
);
}
+ Widget _buildStatRow(
+ String label,
+ String value,
+ IconData icon, {
+ bool isWarning = false,
+ }) {
+ 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)
+ : Colors.white.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(
+ icon,
+ color: isWarning ? Colors.redAccent : Colors.white70,
+ size: 16,
+ ),
+ ),
+ const SizedBox(width: 16),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: const TextStyle(
+ color: Colors.white38,
+ fontSize: 9,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ value,
+ style: TextStyle(
+ color: isWarning ? Colors.redAccent : Colors.white,
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ fontFeatures: const [FontFeature.tabularFigures()],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
@@ -670,9 +790,35 @@ class _EdgePanelState extends State<_EdgePanel> {
}
// ──────────────────────────────────────────────────────────────────────────────
-// Custom Bottom Nav Bar — minimal, Instagram-like
+// Branded Top Bar — minimal, Instagram-like font
// ──────────────────────────────────────────────────────────────────────────────
+class _BrandedTopBar extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: 60,
+ decoration: const BoxDecoration(
+ color: Colors.black,
+ border: Border(bottom: BorderSide(color: Colors.white12, width: 0.5)),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ 'FocusGram',
+ style: GoogleFonts.grandHotel(
+ color: Colors.white,
+ fontSize: 32,
+ letterSpacing: 0.5,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _FocusGramNavBar extends StatelessWidget {
final int currentIndex;
final Future Function(int) onTap;
@@ -711,6 +857,7 @@ class _FocusGramNavBar extends StatelessWidget {
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 60,
+ height: double.infinity,
child: Center(
child: Icon(
isSelected ? filledIcon : outlinedIcon,
@@ -727,3 +874,54 @@ class _FocusGramNavBar extends StatelessWidget {
);
}
}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// No Internet Screen — minimal, branded
+// ──────────────────────────────────────────────────────────────────────────────
+
+class _NoInternetScreen extends StatelessWidget {
+ final VoidCallback onRetry;
+ const _NoInternetScreen({required this.onRetry});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ color: Colors.black,
+ width: double.infinity,
+ height: double.infinity,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.wifi_off_rounded, color: Colors.white24, size: 80),
+ const SizedBox(height: 24),
+ const Text(
+ 'No Connection',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'Please check your internet settings.',
+ style: TextStyle(color: Colors.white38, fontSize: 14),
+ ),
+ const SizedBox(height: 40),
+ ElevatedButton(
+ onPressed: onRetry,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.white,
+ foregroundColor: Colors.black,
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(20),
+ ),
+ ),
+ child: const Text('Retry'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart
index 5a83964..87e9049 100644
--- a/lib/screens/reel_player_overlay.dart
+++ b/lib/screens/reel_player_overlay.dart
@@ -41,6 +41,9 @@ class _ReelPlayerOverlayState extends State {
InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
+ blurReels: false,
+ ghostMode: false,
+ enableTextSelection: true,
),
);
},
diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart
index 38d43ef..4166b40 100644
--- a/lib/screens/settings_page.dart
+++ b/lib/screens/settings_page.dart
@@ -47,6 +47,13 @@ class SettingsPage extends StatelessWidget {
icon: Icons.visibility_off_outlined,
destination: const _DistractionSettingsPage(),
),
+ _buildSettingsTile(
+ context: context,
+ title: 'Extras',
+ subtitle: 'Ghost mode, text selection and experimental features',
+ icon: Icons.extension_outlined,
+ destination: const _ExtrasSettingsPage(),
+ ),
_buildSettingsTile(
context: context,
title: 'About',
@@ -365,3 +372,53 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
);
}
}
+
+class _ExtrasSettingsPage extends StatelessWidget {
+ const _ExtrasSettingsPage();
+
+ @override
+ Widget build(BuildContext context) {
+ final settings = context.watch();
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ backgroundColor: Colors.black,
+ title: const Text('Extras', style: TextStyle(fontSize: 17)),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ),
+ body: ListView(
+ children: [
+ SwitchListTile(
+ title: const Text(
+ 'Ghost Mode',
+ style: TextStyle(color: Colors.white),
+ ),
+ subtitle: const Text(
+ 'Hides "typing..." and "seen" status in DMs',
+ style: TextStyle(color: Colors.white54, fontSize: 13),
+ ),
+ value: settings.ghostMode,
+ onChanged: (v) => settings.setGhostMode(v),
+ activeThumbColor: Colors.blue,
+ ),
+ SwitchListTile(
+ title: const Text(
+ 'Enable Text Selection',
+ style: TextStyle(color: Colors.white),
+ ),
+ subtitle: const Text(
+ 'Allows copying text from posts and captions',
+ style: TextStyle(color: Colors.white54, fontSize: 13),
+ ),
+ value: settings.enableTextSelection,
+ onChanged: (v) => settings.setEnableTextSelection(v),
+ activeThumbColor: Colors.blue,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart
index 7cf40bb..7aa9157 100644
--- a/lib/services/injection_controller.dart
+++ b/lib/services/injection_controller.dart
@@ -1,70 +1,116 @@
-/// Generates all CSS and JavaScript injection strings for the WebView.
-///
-/// Strategy:
-/// - Instagram's own bottom nav bar is hidden via both CSS and a periodic JS
-/// removal loop, since SPA re-renders can outpace MutationObserver.
-/// - Reel elements are hidden/blurred based on settings/session state.
-/// - A MutationObserver keeps re-applying the rules after SPA re-renders.
-/// - App-install banners are auto-dismissed.
+/// Controller for injecting custom JS and CSS into the WebView.
+/// Uses a combination of static strings and dynamic builders to:
+/// - Hide native navigation elements.
+/// - Inject FocusGram branding into the native header.
+/// - Implement "Ghost Mode" (stealth features).
+/// - Manage Reels/Explore distractions.
class InjectionController {
- /// iOS Safari user-agent — reduces login friction with Instagram.
+ /// The requested iOS 18.6 User Agent for Instagram App feel.
static const String iOSUserAgent =
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) '
- 'AppleWebKit/605.1.15 (KHTML, like Gecko) '
- 'Version/17.0 Mobile/15E148 Safari/604.1';
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]';
- // ── CSS injection ───────────────────────────────────────────────────────────
+ // ── CSS & JS injection ──────────────────────────────────────────────────────
+
+ /// CSS to fix UI nuances like tap highlights.
+ static const String _globalUIFixesCSS = '''
+ * {
+ -webkit-tap-highlight-color: transparent !important;
+ outline: none !important;
+ }
+ ''';
+
+ /// CSS to disable text selection globally.
+ static const String _disableSelectionCSS = '''
+ * {
+ -webkit-user-select: none !important;
+ user-select: none !important;
+ }
+ ''';
+
+ /// Ghost Mode JS: Intercepts network calls to block seen/typing receipts.
+ static const String _ghostModeJS = '''
+ (function() {
+ const blockedUrls = [
+ '/api/v1/direct_v2/set_reel_seen/',
+ '/api/v1/direct_v2/threads/set_typing_status/',
+ '/api/v1/stories/reel/seen/',
+ '/api/v1/direct_v2/mark_visual_item_seen/'
+ ];
+
+ // Proxy fetch
+ const originalFetch = window.fetch;
+ window.fetch = function(url, options) {
+ if (typeof url === 'string') {
+ if (blockedUrls.some(u => url.includes(u))) {
+ return Promise.resolve(new Response(null, { status: 204 }));
+ }
+ }
+ return originalFetch.apply(this, arguments);
+ };
+
+ // Proxy XHR
+ const originalOpen = XMLHttpRequest.prototype.open;
+ XMLHttpRequest.prototype.open = function(method, url) {
+ this._blocked = blockedUrls.some(u => url.includes(u));
+ return originalOpen.apply(this, arguments);
+ };
+ const originalSend = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.send = function() {
+ if (this._blocked) return;
+ return originalSend.apply(this, arguments);
+ };
+ })();
+ ''';
+
+ /// Branding JS: Replaces Instagram logo with FocusGram while keeping icons.
+ static const String _brandingJS = '''
+ (function() {
+ function applyBranding() {
+ const igLogo = document.querySelector('svg[aria-label="Instagram"], svg[aria-label="Direct"]');
+ if (igLogo && !igLogo.dataset.focusgrammed) {
+ const container = igLogo.parentElement;
+ if (container) {
+ igLogo.style.display = 'none';
+ igLogo.dataset.focusgrammed = 'true';
+
+ const brandText = document.createElement('span');
+ brandText.innerText = 'FocusGram';
+ brandText.style.fontFamily = '"Grand Hotel", cursive';
+ brandText.style.fontSize = '24px';
+ brandText.style.color = 'white';
+ brandText.style.marginLeft = '8px';
+ brandText.style.verticalAlign = 'middle';
+
+ container.appendChild(brandText);
+ }
+ }
+ }
+ applyBranding();
+ const observer = new MutationObserver(applyBranding);
+ observer.observe(document.body, { childList: true, subtree: true });
+ })();
+ ''';
/// Robust CSS that hides Instagram's native bottom nav bar.
- /// Covers all known selector patterns including dynamic class names.
static const String _hideInstagramNavCSS = '''
- /* ── Instagram bottom navigation bar — hide completely ── */
- /* Role-based selectors */
- div[role="tablist"],
- nav[role="navigation"],
- /* Fixed-position bottom bar */
- div[style*="position: fixed"][style*="bottom"],
- div[style*="position:fixed"][style*="bottom"],
- /* Instagram legacy class names */
- ._acbl, ._aa4b, ._aahi, ._ab8s,
- /* Section nav elements */
- section nav,
- /* Any nav inside the main app shell */
- #react-root nav,
- /* The outer wrapper of the bottom bar (PWA/mobile web) */
- [class*="x1n2onr6"][class*="x1vjfegm"] > nav,
- /* Catch-all: any fixed bottom element containing nav links */
- footer nav,
- div[class*="bottom"] nav {
+ div[role="tablist"], nav[role="navigation"],
+ ._acbl, ._aa4b, ._aahi, ._ab8s, section nav, footer nav {
display: none !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
- /* Ensure the body doesn't add bottom padding for the nav */
body, #react-root, main {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
}
''';
- /// CSS to hide Reel-related elements everywhere (feed, profile, search).
- /// Used when session is NOT active.
+ /// CSS to hide Reel-related elements everywhere.
static const String _hideReelsCSS = '''
- /* Hide reel thumbnails and links */
- a[href*="/reel/"],
- a[href*="/reels"],
- [aria-label*="Reel"],
- [aria-label*="Reels"],
- div[data-media-type="2"],
- /* Profile grid reel filter tabs */
- [aria-label="Reels"],
- /* Reel indicators on feed thumbnails */
- svg[aria-label="Reels"],
- /* Video/reel chips in feed */
- [class*="reel"],
- [class*="Reel"] {
+ a[href*="/reel/"], a[href*="/reels"], [aria-label*="Reel"], [aria-label*="Reels"],
+ div[data-media-type="2"], [aria-label="Reels"], svg[aria-label="Reels"] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
@@ -72,95 +118,49 @@ class InjectionController {
''';
/// CSS that adds bottom padding so feed content doesn't hide behind our bar.
- /// Added more selectors to cover dynamic drawers like Notes and Reactions.
static const String _bottomPaddingCSS = '''
body, #react-root > div, [role="presentation"] > div {
padding-bottom: 72px !important;
}
- /* Special handling for dynamic bottom drawers */
- div[style*="bottom: 0px"], div[style*="bottom: 0"] {
+ div[style*="bottom: 0px"], div[style*="bottom: 0"], form[method="POST"] {
padding-bottom: 72px !important;
}
- ''';
-
- /// CSS to push IG content down so it doesn't hide behind our status bar.
- static const String _topPaddingCSS = '''
- header, #react-root > div > div > div:first-child {
- margin-top: 44px !important;
- }
- /* Shift fixed headers down */
- div[style*="position: fixed"][style*="top: 0"] {
- top: 44px !important;
+ div[role="main"] div[style*="position: fixed"] {
+ bottom: 72px !important;
}
''';
- /// CSS to blur Explore feed posts/reels (keeps stories visible).
+ /// CSS to blur Explore feed posts/reels.
static const String _blurExploreCSS = '''
- /* Blur Explore grid posts and reel cards (not stories row) */
main[role="main"] section > div > div:not(:first-child) a img,
main[role="main"] section > div > div:not(:first-child) video,
- main[role="main"] section > div > div:not(:first-child) [class*="x6s0dn4"],
- main[role="main"] article img,
- main[role="main"] article video,
- /* Explore page grid */
- ._aagv img,
- ._aagv video {
+ main[role="main"] article img, main[role="main"] article video,
+ ._aagv img, ._aagv video {
filter: blur(12px) !important;
pointer-events: none !important;
}
- /* Overlay to block tapping blurred content */
- ._aagv::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: 99;
- cursor: not-allowed;
- }
- ._aagv {
- position: relative !important;
- overflow: hidden !important;
+ ''';
+
+ static const String _blurReelsCSS = '''
+ a[href*="/reel/"] img, a[href*="/reels"] img {
+ filter: blur(12px) !important;
}
''';
- /// Auto-dismiss "Open in App" banner that Instagram shows in mobile browsers.
+ /// Auto-dismiss "Open in App" banner.
static const String _dismissAppBannerJS = '''
(function dismissBanners() {
- const selectors = [
- '[id*="app-banner"]',
- '[class*="app-banner"]',
- '[data-testid*="app-banner"]',
- 'div[role="dialog"][aria-label*="app"]',
- 'div[role="dialog"][aria-label*="App"]',
- ];
- selectors.forEach(sel => {
- document.querySelectorAll(sel).forEach(el => el.remove());
- });
+ const selectors = ['[id*="app-banner"]', '[class*="app-banner"]', 'div[role="dialog"][aria-label*="app"]'];
+ selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));
})();
''';
- /// Periodic remover: every 500ms force-removes the bottom nav.
- /// Complements the MutationObserver for sites that rebuild DOM faster.
+ /// Periodic remover for bottom nav.
static const String _periodicNavRemoverJS = '''
(function periodicNavRemove() {
function removeNav() {
- // Target all fixed-bottom elements that could be the nav bar
- document.querySelectorAll([
- 'div[role="tablist"]',
- 'nav[role="navigation"]',
- '._acbl', '._aa4b', '._aahi', '._ab8s',
- 'section nav',
- 'footer nav'
- ].join(',')).forEach(function(el) {
- el.style.cssText += ';display:none!important;height:0!important;overflow:hidden!important;';
- });
- // Also hide any element that is fixed at the bottom and contains nav links
- document.querySelectorAll('div[style]').forEach(function(el) {
- const s = el.style;
- if ((s.position === 'fixed' || s.position === 'sticky') &&
- (s.bottom === '0px' || s.bottom === '0') &&
- el.querySelector('a,button')) {
- el.style.cssText += ';display:none!important;';
- }
+ document.querySelectorAll('div[role="tablist"], nav[role="navigation"], footer nav').forEach(el => {
+ el.style.cssText += ';display:none!important;height:0!important;';
});
}
removeNav();
@@ -168,12 +168,11 @@ class InjectionController {
})();
''';
- /// MutationObserver that continuously re-applies CSS after SPA re-renders.
+ /// MutationObserver that continuously re-applies CSS.
static String _buildMutationObserver(String cssContent) =>
'''
(function applyFocusGramStyles() {
const STYLE_ID = 'focusgram-injected-style';
-
function injectCSS() {
let el = document.getElementById(STYLE_ID);
if (!el) {
@@ -183,175 +182,88 @@ class InjectionController {
}
el.textContent = ${_escapeJsString(cssContent)};
}
-
injectCSS();
-
- const observer = new MutationObserver(function() {
- if (!document.getElementById(STYLE_ID)) {
- injectCSS();
- }
- });
-
- observer.observe(document.documentElement, {
- childList: true,
- subtree: true,
+ const observer = new MutationObserver(() => {
+ if (!document.getElementById(STYLE_ID)) injectCSS();
});
+ observer.observe(document.documentElement, { childList: true, subtree: true });
})();
''';
static String _escapeJsString(String s) {
- // Wrap in JS template literal backticks; escape any internal backticks.
final escaped = s.replaceAll(r'\', r'\\').replaceAll('`', r'\`');
return '`$escaped`';
}
// ── Navigation helpers ──────────────────────────────────────────────────────
- /// JS that soft-navigates Instagram's SPA without a full page reload.
- /// [path] should start with / e.g. '/direct/inbox/'.
static String softNavigateJS(String path) =>
'''
(function() {
const target = ${_escapeJsString(path)};
- // Try React Router / Instagram SPA navigation first (pushState trick)
if (window.location.pathname !== target) {
window.location.href = target;
}
})();
''';
- /// JS to click Instagram's native "create post" button.
static const String clickCreateButtonJS = '''
(function() {
- const btn = document.querySelector(
- '[aria-label="New post"], [aria-label="Create"], svg[aria-label="New post"]'
- );
- if (btn) {
- btn.closest('a, button') ? btn.closest('a, button').click() : btn.click();
- } else {
- // Fallback: navigate to home first, create will open as modal
- window.location.href = '/';
- }
+ const btn = document.querySelector('[aria-label="New post"], [aria-label="Create"]');
+ if (btn) btn.closest('a, button') ? btn.closest('a, button').click() : btn.click();
})();
''';
- /// JS to get the currently logged-in user's username.
- static const String getLoggedInUsernameJS = '''
- (function() {
- try {
- // Try shared data approach
- const scripts = Array.from(document.querySelectorAll('script[type="application/json"]'));
- for (const s of scripts) {
- try {
- const d = JSON.parse(s.textContent);
- if (d && d.config && d.config.viewer && d.config.viewer.username) {
- return d.config.viewer.username;
- }
- } catch(e){}
- }
- // Try window additionalDataLoaded
- if (window.__additionalDataLoaded) {
- const keys = Object.keys(window.__additionalDataLoaded || {});
- for (const k of keys) {
- const v = window.__additionalDataLoaded[k];
- if (v && v.data && v.data.user && v.data.user.username) {
- return v.data.user.username;
- }
- }
- }
- // Fallback: try profile anchor in nav
- const profileLink = document.querySelector('a[href][aria-label*="rofile"]');
- if (profileLink) {
- const href = profileLink.getAttribute('href');
- if (href) {
- const parts = href.replace(/^[/]/, "").split("/");
- if (parts[0] && parts[0].length > 0) return parts[0];
- }
- }
- return null;
- } catch(e) { return null; }
- })();
- ''';
-
- /// MutationObserver to watch for Reel players and lock their scrolling.
+ /// MutationObserver for Reel scroll locking.
static const String reelsMutationObserverJS = '''
(function() {
function lockReelScroll(reelContainer) {
if (reelContainer.dataset.scrollLocked) return;
-
- // If session is active, don't lock
- if (window.__focusgramSessionActive === true) return;
-
reelContainer.dataset.scrollLocked = 'true';
-
let startY = 0;
-
- reelContainer.addEventListener('touchstart', (e) => {
- startY = e.touches[0].clientY;
- }, { passive: true });
-
+ reelContainer.addEventListener('touchstart', (e) => startY = e.touches[0].clientY, { passive: true });
reelContainer.addEventListener('touchmove', (e) => {
+ if (window.__focusgramSessionActive === true) return;
const deltaY = e.touches[0].clientY - startY;
- // Block upward swipe (next reel), allow downward (go back)
- if (deltaY < -10) {
- if (e.cancelable) {
- e.preventDefault();
- e.stopPropagation();
- }
+ if (deltaY < -10 && e.cancelable) {
+ e.preventDefault();
+ e.stopPropagation();
}
}, { passive: false });
}
-
- // Watch for reel player being injected into DOM
const observer = new MutationObserver(() => {
- // Instagram's reel player containers — multiple selectors for resilience
- const reelContainers = document.querySelectorAll(
- '[class*="reel"], [class*="Reel"], video'
- );
- reelContainers.forEach((el) => {
- // If it's a video or a reel container, wrap it
+ document.querySelectorAll('[class*="ReelsVideoPlayer"], video').forEach((el) => {
+ if (el.tagName === 'VIDEO' && el.closest('article')) return;
lockReelScroll(el);
- // Also try parent if it's a video
- if (el.tagName === 'VIDEO' && el.parentElement) {
- lockReelScroll(el.parentElement);
- }
+ if (el.tagName === 'VIDEO' && el.parentElement) lockReelScroll(el.parentElement);
});
});
-
observer.observe(document.body, { childList: true, subtree: true });
})();
''';
- /// JS to disable swipe-to-next behavior inside the isolated Reel player.
- static const String disableReelSwipeJS = '''
- (function disableSwipeNavigation() {
- if (window.__focusgramSessionActive === true) return;
- let startX = 0;
- document.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, {passive: true});
- document.addEventListener('touchmove', e => {
- const dx = Math.abs(e.touches[0].clientX - startX);
- if (dx > 30) e.preventDefault();
- }, {passive: false});
- })();
- ''';
-
- /// JS to update the session state variable in the page context.
static String buildSessionStateJS(bool active) =>
'window.__focusgramSessionActive = $active;';
- // ── Public API ──────────────────────────────────────────────────────────────
-
- /// Full injection JS to run on every page load.
+ /// Full injection JS to run on page load.
static String buildInjectionJS({
required bool sessionActive,
required bool blurExplore,
+ required bool blurReels,
+ required bool ghostMode,
+ required bool enableTextSelection,
}) {
final StringBuffer css = StringBuffer();
+ css.writeln(_globalUIFixesCSS);
+ if (!enableTextSelection) css.writeln(_disableSelectionCSS);
css.write(_hideInstagramNavCSS);
css.write(_bottomPaddingCSS);
- css.write(_topPaddingCSS);
- if (!sessionActive) css.write(_hideReelsCSS);
- if (blurExplore) css.write(_blurExploreCSS);
+
+ if (!sessionActive) {
+ css.write(_hideReelsCSS);
+ if (blurExplore) css.write(_blurExploreCSS);
+ if (blurReels) css.write(_blurReelsCSS);
+ }
return '''
${buildSessionStateJS(sessionActive)}
@@ -359,6 +271,8 @@ class InjectionController {
$_periodicNavRemoverJS
$_dismissAppBannerJS
$reelsMutationObserverJS
+ $_brandingJS
+ ${ghostMode ? _ghostModeJS : ''}
''';
}
}
diff --git a/lib/services/session_manager.dart b/lib/services/session_manager.dart
index e6d9526..425ba62 100644
--- a/lib/services/session_manager.dart
+++ b/lib/services/session_manager.dart
@@ -101,6 +101,7 @@ class SessionManager extends ChangeNotifier {
int get perSessionSeconds => _perSessionSeconds;
int get cooldownSeconds => _cooldownSeconds;
+ DateTime? get lastSessionEnd => _lastSessionEnd;
// ── Public getters — App session ──────────────────────────
diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart
index b2ab6d8..a946b6f 100644
--- a/lib/services/settings_service.dart
+++ b/lib/services/settings_service.dart
@@ -8,6 +8,8 @@ class SettingsService extends ChangeNotifier {
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge';
+ static const _keyGhostMode = 'set_ghost_mode';
+ static const _keyEnableTextSelection = 'set_enable_text_selection';
SharedPreferences? _prefs;
@@ -15,14 +17,17 @@ class SettingsService extends ChangeNotifier {
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 _requireWordChallenge =
- true; // Random word sequence challenge before changes
+ bool _requireWordChallenge = true;
+ bool _ghostMode = true; // Default: hide seen/typing
+ bool _enableTextSelection = false; // Default: disabled
bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels;
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
+ bool get ghostMode => _ghostMode;
+ bool get enableTextSelection => _enableTextSelection;
Future init() async {
_prefs = await SharedPreferences.getInstance();
@@ -31,6 +36,8 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
+ _ghostMode = _prefs!.getBool(_keyGhostMode) ?? true;
+ _enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
notifyListeners();
}
@@ -63,4 +70,16 @@ class SettingsService extends ChangeNotifier {
await _prefs?.setBool(_keyRequireWordChallenge, v);
notifyListeners();
}
+
+ Future setGhostMode(bool v) async {
+ _ghostMode = v;
+ await _prefs?.setBool(_keyGhostMode, v);
+ notifyListeners();
+ }
+
+ Future setEnableTextSelection(bool v) async {
+ _enableTextSelection = v;
+ await _prefs?.setBool(_keyEnableTextSelection, v);
+ notifyListeners();
+ }
}
diff --git a/lib/utils/discipline_challenge.dart b/lib/utils/discipline_challenge.dart
index b136db8..5bdaf88 100644
--- a/lib/utils/discipline_challenge.dart
+++ b/lib/utils/discipline_challenge.dart
@@ -543,7 +543,7 @@ class DisciplineChallenge {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
- 'Type the following sequence exactly to proceed:',
+ 'FocusGram is currently blocked to help you stay focused. Type the following sequence exactly to proceed:',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 16),
diff --git a/pubspec.lock b/pubspec.lock
index e03fe53..8c39466 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
args:
dependency: transitive
description:
@@ -33,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.4"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
clock:
dependency: transitive
description:
@@ -41,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
+ code_assets:
+ dependency: transitive
+ description:
+ name: code_assets
+ sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
collection:
dependency: transitive
description:
@@ -49,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
dbus:
dependency: transitive
description:
@@ -86,6 +126,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -136,8 +184,32 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- http:
+ glob:
dependency: transitive
+ description:
+ name: glob
+ sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.3"
+ google_fonts:
+ dependency: "direct main"
+ description:
+ name: google_fonts
+ sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
+ url: "https://pub.dev"
+ source: hosted
+ version: "8.0.2"
+ hooks:
+ dependency: transitive
+ description:
+ name: hooks
+ sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.1"
+ http:
+ dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -152,6 +224,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
intl:
dependency: "direct main"
description:
@@ -160,6 +240,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.11.0"
leak_tracker:
dependency: transitive
description:
@@ -192,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -216,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
+ native_toolchain_c:
+ dependency: transitive
+ description:
+ name: native_toolchain_c
+ sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.17.4"
nested:
dependency: transitive
description:
@@ -224,6 +328,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
+ objective_c:
+ dependency: transitive
+ description:
+ name: objective_c
+ sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.3.0"
path:
dependency: transitive
description:
@@ -232,6 +344,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
+ path_provider:
+ dependency: transitive
+ description:
+ name: path_provider
+ sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.5"
+ path_provider_android:
+ dependency: transitive
+ description:
+ name: path_provider_android
+ sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.22"
+ path_provider_foundation:
+ dependency: transitive
+ description:
+ name: path_provider_foundation
+ sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -280,6 +416,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
provider:
dependency: "direct main"
description:
@@ -288,6 +432,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
+ pub_semver:
+ dependency: transitive
+ description:
+ name: pub_semver
+ sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
shared_preferences:
dependency: "direct main"
description:
@@ -549,6 +701,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
sdks:
dart: ">=3.10.7 <4.0.0"
- flutter: ">=3.38.0"
+ flutter: ">=3.38.4"
diff --git a/pubspec.yaml b/pubspec.yaml
index bf79a66..dcac008 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -28,11 +28,28 @@ dependencies:
# URL launcher for About page links — latest stable
url_launcher: ^6.3.2
+ google_fonts: ^8.0.2
+ http: ^1.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
+ flutter_launcher_icons: ^0.13.1
flutter:
uses-material-design: true
+
+ assets:
+ - assets/images/focusgram.png
+ - assets/images/focusgram.ico
+
+flutter_launcher_icons:
+ android: true
+ ios: false
+ image_path: "assets/images/focusgram.png"
+ adaptive_icon_background: "#000000"
+ adaptive_icon_foreground: "assets/images/focusgram.png"
+ min_sdk_android: 21
+
+
diff --git a/test/widget_test.dart b/test/widget_test.dart
deleted file mode 100644
index 32a6ecb..0000000
--- a/test/widget_test.dart
+++ /dev/null
@@ -1,10 +0,0 @@
-import 'package:flutter_test/flutter_test.dart';
-import 'package:focusgram/main.dart';
-
-void main() {
- // Widget tests for FocusGram are not yet implemented.
- // The app requires SharedPreferences and WebView which need mocking.
- test('placeholder', () {
- expect(FocusGramApp, isNotNull);
- });
-}