Added Extra features like "Ghost Mode"
Added Logout Functionality
Chagned UA to latest version of iOS.
Added app icons.
Improved reels player and fixed bugs.
Discipline challenge is now compulsory for watching reels.
Now the UI doesnt feel like it is in a cheap browser.
This commit is contained in:
Ujwal
2026-02-23 10:53:38 +05:45
parent fe2d793b93
commit e23731d9e8
25 changed files with 822 additions and 351 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+112 -9
View File
@@ -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<AboutPage> createState() => _AboutPageState();
}
class _AboutPageState extends State<AboutPage> {
final String _currentVersion = '0.8.5';
bool _isChecking = false;
Future<void> _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 (_) {}
}
}
+299 -101
View File
@@ -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<MainWebViewPage>
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<MainWebViewPage>
_initWebView();
_startWatchdog();
// Listen to session changes to update JS context immediately
// Listen to session & settings changes
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SessionManager>().addListener(_onSessionChanged);
context.read<SettingsService>().addListener(_onSettingsChanged);
_lastSessionActive = context.read<SessionManager>().isSessionActive;
});
}
@@ -53,6 +66,14 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_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<MainWebViewPage>
WidgetsBinding.instance.removeObserver(this);
_watchdog?.cancel();
context.read<SessionManager>().removeListener(_onSessionChanged);
context.read<SettingsService>().removeListener(_onSettingsChanged);
super.dispose();
}
@@ -156,13 +178,20 @@ class _MainWebViewPageState extends State<MainWebViewPage>
..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<MainWebViewPage>
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<MainWebViewPage>
},
),
)
..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<SessionManager>();
final settings = context.read<SettingsService>();
final js = InjectionController.buildInjectionJS(
sessionActive: sessionManager.isSessionActive,
blurExplore: settings.blurExplore,
blurReels: settings.blurReels,
ghostMode: settings.ghostMode,
enableTextSelection: settings.enableTextSelection,
);
_controller.runJavaScript(js);
}
Future<void> _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<void> _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<MainWebViewPage>
}
Future<void> _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<MainWebViewPage>
case 2:
if (context.read<SessionManager>().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<MainWebViewPage>
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<MainWebViewPage>
),
),
// ── 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<SessionManager>().isSessionActive
? _formatTime(
context
.read<SessionManager>()
.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<void> 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'),
),
],
),
);
}
}
+3
View File
@@ -41,6 +41,9 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostMode: false,
enableTextSelection: true,
),
);
},
+57
View File
@@ -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<SettingsService>();
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,
),
],
),
);
}
}
+140 -226
View File
@@ -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 : ''}
''';
}
}
+1
View File
@@ -101,6 +101,7 @@ class SessionManager extends ChangeNotifier {
int get perSessionSeconds => _perSessionSeconds;
int get cooldownSeconds => _cooldownSeconds;
DateTime? get lastSessionEnd => _lastSessionEnd;
// Public getters App session
+21 -2
View File
@@ -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<void> 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<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
notifyListeners();
}
}
+1 -1
View File
@@ -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),
+162 -2
View File
@@ -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"
+17
View File
@@ -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
-10
View File
@@ -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);
});
}