Feature Pack with bug fixes for V2

This commit is contained in:
Ujwal223
2026-06-09 23:39:43 +05:45
parent f1bd12f0bd
commit 39b6545e4a
53 changed files with 7314 additions and 328 deletions
@@ -2,9 +2,10 @@ import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart';
import '../../scripts/focus_scripts.dart';
import '../../scripts/reel_metadata_extractor.dart';
class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView;
@@ -13,7 +14,7 @@ class InstagramPreloader {
static bool isReady = false;
static Future<void> start(String userAgent) async {
if (_headlessWebView != null) return; // don't start twice
if (_headlessWebView != null) return;
_headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive,
@@ -31,12 +32,10 @@ class InstagramPreloader {
safeBrowsingEnabled: false,
),
initialUserScripts: UnmodifiableListView([
// DM Ghost — comprehensive blocking, gated by window.__fgFullDmGhost flag.
// it should have worked, but sadly it didnt
UserScript(
source: 'window.__fgBlockAutoplay = true;',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
source: kFullDmGhostJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
@@ -47,6 +46,7 @@ class InstagramPreloader {
source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
// ReelMetadataExtractor removed — reel history feature deleted
]),
onWebViewCreated: (c) {
controller = c;
@@ -8,6 +8,8 @@ class ReelsHistoryEntry {
final String title;
final String thumbnailUrl;
final DateTime visitedAt;
final int durationSeconds; // How long the session lasted
final int adsWatchedInSession; // How many ads watched during this session
const ReelsHistoryEntry({
required this.id,
@@ -15,6 +17,8 @@ class ReelsHistoryEntry {
required this.title,
required this.thumbnailUrl,
required this.visitedAt,
this.durationSeconds = 0,
this.adsWatchedInSession = 0,
});
Map<String, dynamic> toJson() => {
@@ -23,6 +27,8 @@ class ReelsHistoryEntry {
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
'durationSeconds': durationSeconds,
'adsWatchedInSession': adsWatchedInSession,
};
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
@@ -34,6 +40,8 @@ class ReelsHistoryEntry {
visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
adsWatchedInSession: (json['adsWatchedInSession'] as num?)?.toInt() ?? 0,
);
}
}
@@ -71,6 +79,8 @@ class ReelsHistoryService {
required String url,
required String title,
required String thumbnailUrl,
int durationSeconds = 0,
int adsWatchedInSession = 0,
}) async {
if (url.isEmpty) return;
final now = DateTime.now().toUtc();
@@ -89,6 +99,8 @@ class ReelsHistoryService {
title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl,
visitedAt: now,
durationSeconds: durationSeconds,
adsWatchedInSession: adsWatchedInSession,
);
final updated = [entry, ...entries];
@@ -104,6 +116,44 @@ class ReelsHistoryService {
await _save(entries);
}
/// Get average reels watched per day in the last 7 days.
Future<double> getWeeklyAverageReels() async {
final entries = await getEntries();
if (entries.isEmpty) return 0;
final now = DateTime.now();
final sevenDaysAgo = now.subtract(const Duration(days: 7));
final recent = entries.where((e) => e.visitedAt.isAfter(sevenDaysAgo)).toList();
if (recent.isEmpty) return 0;
return recent.length / 7.0;
}
/// Get reel counts grouped by day (for the level system).
Future<Map<String, int>> getDailyReelCounts({int days = 30}) async {
final entries = await getEntries();
final now = DateTime.now();
final cutoff = now.subtract(Duration(days: days));
final recent = entries.where((e) => e.visitedAt.isAfter(cutoff)).toList();
final Map<String, int> counts = {};
for (final entry in recent) {
final dayKey = '${entry.visitedAt.year}-'
'${entry.visitedAt.month.toString().padLeft(2, '0')}-'
'${entry.visitedAt.day.toString().padLeft(2, '0')}';
counts[dayKey] = (counts[dayKey] ?? 0) + 1;
}
return counts;
}
/// Get total reels watched in the last [days] days.
Future<int> getRecentReelCount({int days = 7}) async {
final entries = await getEntries();
final now = DateTime.now();
final cutoff = now.subtract(Duration(days: days));
return entries.where((e) => e.visitedAt.isAfter(cutoff)).length;
}
Future<void> clearAll() async {
final prefs = await _getPrefs();
await prefs.remove(_prefsKey);
@@ -74,7 +74,7 @@ class UpdateCheckerService extends ChangeNotifier {
_isDismissed = false;
notifyListeners();
} catch (e) {
debugPrint('Update check failed: $e');
// debugPrint('Update check failed: $e');
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
class FocusSettings {
final bool ghostMode; // hide read receipts
final bool ghostMode; // DM ghost — blocks seen/DM signals comprehensively
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
+70 -5
View File
@@ -4,11 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'package:hive_flutter/hive_flutter.dart';
// google_mobile_ads removed — switched to Adsterra only
import 'services/session_manager.dart';
import 'services/settings_service.dart';
import 'services/screen_time_service.dart';
import 'services/focusgram_router.dart';
import 'services/injection_controller.dart';
import 'services/credit_store.dart';
import 'services/bait_engine.dart';
import 'services/app_lock_service.dart';
import 'services/level_service.dart';
import 'services/snapshot_service.dart';
import 'screens/app_lock_screen.dart';
import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart';
@@ -28,23 +36,40 @@ void main() async {
DeviceOrientation.portraitDown,
]);
// ── Initialise storage & SDKs ──────────────────────────────
await Hive.initFlutter();
final creditStore = CreditStore();
final baitEngine = BaitEngine();
final levelService = LevelService();
final appLockService = AppLockService();
final snapshotService = SnapshotService();
final sessionManager = SessionManager();
final settingsService = SettingsService();
final screenTimeService = ScreenTimeService();
final updateChecker = UpdateCheckerService();
await creditStore.init();
await baitEngine.init();
await appLockService.init();
await levelService.init();
await snapshotService.init();
await sessionManager.init();
await settingsService.init();
await screenTimeService.init();
await NotificationService().init();
await NotificationService().init(requestPermissions: true);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: sessionManager),
ChangeNotifierProvider.value(value: settingsService),
ChangeNotifierProvider.value(value: screenTimeService),
ChangeNotifierProvider.value(value: creditStore),
ChangeNotifierProvider.value(value: baitEngine),
ChangeNotifierProvider.value(value: levelService),
ChangeNotifierProvider.value(value: appLockService),
ChangeNotifierProvider.value(value: snapshotService),
ChangeNotifierProvider.value(value: updateChecker),
],
child: const FocusGramApp(),
@@ -98,7 +123,8 @@ class InitialRouteHandler extends StatefulWidget {
State<InitialRouteHandler> createState() => _InitialRouteHandlerState();
}
class _InitialRouteHandlerState extends State<InitialRouteHandler> {
class _InitialRouteHandlerState extends State<InitialRouteHandler>
with WidgetsBindingObserver {
bool _breathCompleted = false;
bool _appSessionStarted = false;
bool _onboardingCompleted = false;
@@ -107,6 +133,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_appLinks = AppLinks();
_initDeepLinks();
@@ -115,17 +142,44 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final appLock = context.read<AppLockService>();
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
appLock.onBackgrounded();
} else if (state == AppLifecycleState.resumed) {
if (appLock.shouldLockOnResume) {
appLock.onLockScreenShown();
_showLockScreen();
}
}
}
void _showLockScreen() {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppLockScreen(forAppWide: true)),
);
}
Future<void> _initDeepLinks() async {
// 1. Handle background links while app is running
_appLinks.uriLinkStream.listen((uri) {
debugPrint('Incoming Deep Link: $uri');
// debugPrint('Incoming Deep Link: $uri');
FocusGramRouter.pendingUrl.value = uri.toString();
});
// 2. Handle the initial link that opened the app
final initialUri = await _appLinks.getInitialLink();
if (initialUri != null) {
debugPrint('Initial Deep Link: $initialUri');
// debugPrint('Initial Deep Link: $initialUri');
FocusGramRouter.pendingUrl.value = initialUri.toString();
}
}
@@ -134,6 +188,17 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
Widget build(BuildContext context) {
final sm = context.watch<SessionManager>();
final settings = context.watch<SettingsService>();
final appLock = context.watch<AppLockService>();
// Step 0: App-wide lock (shows before everything)
if (appLock.needsUnlockOnStart && !_appSessionStarted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!appLock.isShowingLock) {
appLock.onLockScreenShown();
_showLockScreen();
}
});
}
// Step 1: Onboarding
if (settings.isFirstRun && !_onboardingCompleted) {
+302
View File
@@ -0,0 +1,302 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
/// Full-screen ad page. User MUST click the ad to earn the reward.
///
/// Flow:
/// 1. Ad loads in WebView for 20s
/// 2. User taps the ad → opens in external browser via url_launcher
/// 3. Timer continues counting to 20s regardless
/// 4. After 20s, "Continue & Earn Reward" button unlocks if BOTH ads clicked
/// 5. If ads not clicked within time, a Retry button appears to reload
const String _kAdHtml = '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; background:#111; display:flex; flex-direction:column; align-items:center; justify-content:space-around; }
.ad-slot { width:100%; text-align:center; }
</style>
</head>
<body>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 1</div>
<script async="async" data-cfasync="false" src="https://pl18364273.effectivecpmnetwork.com/e8a9b107824c939fb63d96c218c1336a/invoke.js"></script>
<div id="container-e8a9b107824c939fb63d96c218c1336a"></div>
</div>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 2</div>
<script>
atOptions = {'key':'99233324430f9128f2b01c30b6eebc20','format':'iframe','height':250,'width':300,'params':{}};
</script>
<script src="https://www.highperformanceformat.com/99233324430f9128f2b01c30b6eebc20/invoke.js"></script>
</div>
</body>
</html>
''';
class AdsterraAdScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const AdsterraAdScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 20,
});
@override
State<AdsterraAdScreen> createState() => _AdsterraAdScreenState();
}
class _AdsterraAdScreenState extends State<AdsterraAdScreen> {
int _elapsed = 0;
Timer? _timer;
int _adsClicked = 0; // count of ad clicks (need 2 for reward)
bool _retrying = false;
InAppWebViewController? _webController;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _elapsed++);
});
}
Future<void> _retry() async {
setState(() {
_retrying = true;
_elapsed = 0;
_adsClicked = 0;
});
_startTimer();
try {
await _webController?.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
} catch (_) {}
if (mounted) setState(() => _retrying = false);
}
@override
Widget build(BuildContext context) {
final timerDone = _elapsed >= widget.requiredSeconds;
final bothClicked = _adsClicked >= 2;
final done = timerDone && bothClicked;
// When timer expired but ads not clicked, wait a bit then allow skip
final canSkip = timerDone && !bothClicked;
String statusText;
Color statusColor;
if (bothClicked && timerDone) {
statusText = 'Ready!';
statusColor = Colors.greenAccent;
} else if (bothClicked) {
statusText = 'Both ads clicked! Waiting for timer…';
statusColor = Colors.greenAccent;
} else {
statusText = 'Tap BOTH ads below to earn XP ($_adsClicked/2)';
statusColor = Colors.white.withValues(alpha: 0.4);
}
String buttonText;
bool buttonEnabled;
VoidCallback? buttonAction;
if (done) {
buttonText = 'Continue & Earn Reward';
buttonEnabled = true;
buttonAction = () => Navigator.pop(context, true);
} else if (timerDone && !bothClicked) {
buttonText = 'Tap both ads to continue';
buttonEnabled = false;
buttonAction = null;
} else {
final remaining = widget.requiredSeconds - _elapsed;
buttonText = 'Wait ${remaining > 0 ? remaining : 0}s';
buttonEnabled = false;
buttonAction = null;
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Top bar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.videocam, color: Colors.white54, size: 18),
const SizedBox(width: 8),
const Text('Sponsored',
style: TextStyle(color: Colors.white54, fontSize: 13)),
const Spacer(),
Text('${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white54,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (_elapsed / widget.requiredSeconds).clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
done ? Colors.greenAccent : Colors.blueAccent),
),
),
// Hint text
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
statusText,
style: TextStyle(color: statusColor, fontSize: 11),
),
),
// Ad WebView
Expanded(
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
useHybridComposition: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
),
onWebViewCreated: (c) async {
_webController = c;
await c.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
onLoadStop: (_, url) {
// ad loaded
},
shouldOverrideUrlLoading: (controller, nav) async {
final url = nav.request.url?.toString() ?? '';
if (url.isNotEmpty &&
!url.contains('adsterra.com') &&
!url.startsWith('about:')) {
if (_adsClicked < 2) _adsClicked++;
if (mounted) setState(() {});
await launchUrl(Uri.parse(url),
mode: LaunchMode.externalApplication);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
),
// Button area
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: buttonEnabled ? buttonAction : null,
style: ElevatedButton.styleFrom(
backgroundColor: done ? Colors.greenAccent : Colors.grey,
foregroundColor: done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
icon: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
size: 22),
label: Text(
buttonText,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
// Retry / Skip buttons when timer done but ads not clicked
if (canSkip && !_retrying) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: _retry,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(alpha: 0.4),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Retry — Reload Ads',
style: TextStyle(fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Skip (no reward)',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 13),
),
),
],
if (_retrying)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orangeAccent,
),
),
),
],
),
),
],
),
),
);
}
}
+348
View File
@@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// The lock screen shown when FocusGram is locked.
///
/// Supports PIN entry with optional scrambled keypad and biometric fallback.
/// [forAppWide] controls which PIN to verify: true = app-wide, false = messages.
/// [title] lets the screen show context (e.g. "Messages Locked").
class AppLockScreen extends StatefulWidget {
final bool forAppWide;
final String? title;
final String? subtitle;
const AppLockScreen({
super.key,
this.forAppWide = true,
this.title,
this.subtitle,
});
@override
State<AppLockScreen> createState() => _AppLockScreenState();
}
class _AppLockScreenState extends State<AppLockScreen> {
String _enteredPin = '';
bool _showError = false;
String _errorMsg = '';
bool _isVerifying = false;
List<int> _scrambledDigits = [];
@override
void initState() {
super.initState();
_refreshScrambled();
}
void _refreshScrambled() {
setState(() {
_scrambledDigits = context.read<AppLockService>().getScrambledDigits();
});
}
@override
Widget build(BuildContext context) {
final appLock = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.white,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue.withValues(alpha: 0.1),
),
child: const Icon(
Icons.lock_outline,
color: Colors.blueAccent,
size: 32,
),
),
const SizedBox(height: 20),
// Title
Text(
widget.title ?? 'FocusGram is Locked',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
widget.subtitle ?? 'Enter your PIN to unlock',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const SizedBox(height: 32),
// PIN dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final filled = i < _enteredPin.length;
return Container(
width: 16,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? Colors.blueAccent
: (isDark ? Colors.white24 : Colors.black12),
),
);
}),
),
// Error text
if (_showError)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
_errorMsg,
style: const TextStyle(color: Colors.redAccent, fontSize: 13),
),
),
if (_isVerifying)
const Padding(
padding: EdgeInsets.only(top: 16),
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Biometrics button
if (appLock.biometricsEnabled)
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: IconButton(
icon: Icon(
Icons.fingerprint,
color: Colors.blueAccent.withValues(alpha: 0.8),
size: 40,
),
onPressed: _authenticateBiometric,
tooltip: 'Use fingerprint',
),
),
// Keypad
_buildKeypad(appLock),
],
),
),
);
}
Widget _buildKeypad(AppLockService appLock) {
final useScrambled = appLock.scrambleKeypad;
// Build digit labels
final digitLabels = useScrambled
? _scrambledDigits.map((d) => d.toString()).toList()
: List.generate(10, (i) => i.toString());
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Column(
children: [
// Row 1: 1 2 3
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[1],
onTap: () => _onDigit(digitLabels[1]),
),
_KeypadButton(
label: digitLabels[2],
onTap: () => _onDigit(digitLabels[2]),
),
_KeypadButton(
label: digitLabels[3],
onTap: () => _onDigit(digitLabels[3]),
),
],
),
// Row 2: 4 5 6
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[4],
onTap: () => _onDigit(digitLabels[4]),
),
_KeypadButton(
label: digitLabels[5],
onTap: () => _onDigit(digitLabels[5]),
),
_KeypadButton(
label: digitLabels[6],
onTap: () => _onDigit(digitLabels[6]),
),
],
),
// Row 3: 7 8 9
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[7],
onTap: () => _onDigit(digitLabels[7]),
),
_KeypadButton(
label: digitLabels[8],
onTap: () => _onDigit(digitLabels[8]),
),
_KeypadButton(
label: digitLabels[9],
onTap: () => _onDigit(digitLabels[9]),
),
],
),
// Row 4: delete 0 scramble-refresh
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: '',
onTap: _onDelete,
isFunction: true,
),
_KeypadButton(
label: digitLabels[0],
onTap: () => _onDigit(digitLabels[0]),
),
if (useScrambled)
_KeypadButton(
label: '',
onTap: _refreshScrambled,
isFunction: true,
)
else
const SizedBox(width: 72), // Placeholder
],
),
],
),
);
}
void _onDigit(String digit) {
if (_enteredPin.length >= 4) return;
setState(() {
_enteredPin += digit;
_showError = false;
});
if (_enteredPin.length == 4) {
_verifyPin();
}
}
void _onDelete() {
if (_enteredPin.isEmpty) return;
setState(() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1));
}
Future<void> _verifyPin() async {
setState(() => _isVerifying = true);
final appLock = context.read<AppLockService>();
final valid = await appLock.verifyPin(_enteredPin, forAppWide: widget.forAppWide);
if (!mounted) return;
if (valid) {
HapticFeedback.heavyImpact();
appLock.onUnlocked();
Navigator.of(context).pop(true);
} else {
setState(() {
_showError = true;
_errorMsg = 'Wrong PIN. Try again.';
_enteredPin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
}
}
Future<void> _authenticateBiometric() async {
final appLock = context.read<AppLockService>();
final available = await appLock.isBiometricsAvailable();
if (!available) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometrics not available on this device')),
);
}
return;
}
final success = await appLock.authenticateWithBiometrics();
if (success && mounted) {
appLock.onUnlocked();
Navigator.of(context).pop(true);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric authentication failed')),
);
}
}
}
class _KeypadButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool isFunction;
const _KeypadButton({
required this.label,
required this.onTap,
this.isFunction = false,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return SizedBox(
width: 72,
height: 72,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(36),
onTap: onTap,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: isFunction ? 28 : 24,
fontWeight: FontWeight.w500,
color: isFunction
? Colors.blueAccent
: (isDark ? Colors.white : Colors.black87),
),
),
),
),
),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
import 'app_lock_setup_screen.dart';
/// App Lock settings — two independent lock modes (app-wide + messages tab),
/// each with their own toggle, all backed by a single PIN.
class AppLockSettingsPage extends StatefulWidget {
const AppLockSettingsPage({super.key});
@override
State<AppLockSettingsPage> createState() => _AppLockSettingsPageState();
}
class _AppLockSettingsPageState extends State<AppLockSettingsPage> {
Future<bool> _ensurePin() async {
final appLock = context.read<AppLockService>();
if (appLock.hasPin) return true;
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
return ok == true;
}
@override
Widget build(BuildContext context) {
final a = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final anythingOn = a.lockAppWide || a.lockMessages;
return Scaffold(
appBar: AppBar(
title: const Text('App Lock',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
// ── Status card ──────────────────────────────────────
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: anythingOn
? [Colors.blueAccent.withValues(alpha: 0.15), Colors.blue.withValues(alpha: 0.05)]
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: anythingOn
? Colors.blueAccent.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Column(
children: [
Icon(
anythingOn ? Icons.lock_rounded : Icons.lock_open_rounded,
color: anythingOn ? Colors.blueAccent : Colors.grey,
size: 48,
),
const SizedBox(height: 12),
Text(
anythingOn ? 'Lock Active' : 'No Lock',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold,
color: anythingOn ? Colors.blueAccent : Colors.grey,
),
),
const SizedBox(height: 6),
Text(
_statusText(a),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const _SectionHeader(title: 'LOCK MODES'),
// ── App-wide lock ────────────────────────────────────
SwitchListTile(
title: const Text('Lock Entire App'),
subtitle: const Text(
'Require PIN when opening FocusGram.',
),
value: a.lockAppWide,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockAppWide(v);
HapticFeedback.selectionClick();
},
),
// ── Messages tab lock ────────────────────────────────
SwitchListTile(
title: const Text('Lock Messages Tab'),
subtitle: const Text(
'Require PIN to open Instagram Direct Messages',
),
value: a.lockMessages,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockMessages(v);
HapticFeedback.selectionClick();
},
),
// ─── PIN & extras ────────────────────────────────────
if (a.hasPin) ...[
const _SectionHeader(title: 'PIN & SECURITY'),
ListTile(
title: const Text('Change PIN'),
subtitle: const Text('Set a new 4-digit code'),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
onTap: () async {
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
if (ok == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PIN updated')),
);
}
},
),
SwitchListTile(
title: const Text('Scrambled Keypad'),
subtitle: const Text('Shuffle digits on the lock screen'),
value: a.scrambleKeypad,
onChanged: (v) async {
await a.setScrambleKeypad(v);
HapticFeedback.selectionClick();
},
),
// Biometrics option removed
],
// ── Hint if no PIN ───────────────────────────────────
if (!a.hasPin)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.blueAccent),
SizedBox(width: 8),
Expanded(
child: Text(
'Enable any lock mode above to set up your PIN.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 40),
],
),
);
}
String _statusText(AppLockService a) {
if (!a.hasPin) return 'Set a PIN to enable any lock mode.';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages tab');
if (parts.isEmpty) return 'Both modes are off — enable one above.';
return '${parts.join(' + ')} lock is active.';
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(title,
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2)),
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// First-time setup screen for App Lock.
/// User enters PIN twice, then optionally enables biometrics.
class AppLockSetupScreen extends StatefulWidget {
const AppLockSetupScreen({super.key});
@override
State<AppLockSetupScreen> createState() => _AppLockSetupScreenState();
}
class _AppLockSetupScreenState extends State<AppLockSetupScreen> {
final _pinController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePin = true;
bool _obscureConfirm = true;
String? _error;
@override
void dispose() {
_pinController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Set App Lock PIN'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text(
'Choose a 4-digit PIN to lock FocusGram.',
style: TextStyle(fontSize: 15, height: 1.5),
),
const SizedBox(height: 32),
// PIN field
TextField(
controller: _pinController,
obscureText: _obscurePin,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Enter PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscurePin ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscurePin = !_obscurePin),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: 16),
// Confirm PIN field
TextField(
controller: _confirmController,
obscureText: _obscureConfirm,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Confirm PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
// Error
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_error!,
style: const TextStyle(color: Colors.redAccent),
),
),
const Spacer(),
// Save button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _savePin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Enable App Lock',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Future<void> _savePin() async {
final pin = _pinController.text.trim();
final confirm = _confirmController.text.trim();
if (pin.length != 4) {
setState(() => _error = 'PIN must be exactly 4 digits.');
return;
}
if (pin != confirm) {
setState(() => _error = 'PINs do not match.');
return;
}
if (pin == pin.split('').toSet().join('') && pin.length == 4) {
// Allow any 4-digit PIN
}
final appLock = context.read<AppLockService>();
// Set both PINs to the same value for simplicity
await appLock.setPin(pin, forAppWide: true);
await appLock.setPin(pin, forAppWide: false);
HapticFeedback.heavyImpact();
if (mounted) {
Navigator.pop(context, true);
}
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import '../services/session_manager.dart';
/// The Bait Me button widget.
///
/// Shows a gamble-themed button that triggers random outcomes.
/// Gated behind Level 3. Cooldown prevents spam.
class BaitMeButton extends StatefulWidget {
const BaitMeButton({super.key});
@override
State<BaitMeButton> createState() => _BaitMeButtonState();
}
class _BaitMeButtonState extends State<BaitMeButton>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final baitEngine = context.read<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) {
return const SizedBox.shrink();
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// The button
SizedBox(
width: 48,
height: 48,
child: Stack(
children: [
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning
? _spinAnimation.value * 2 * pi * 3
: 0,
child: child,
);
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: baitEngine.isOnCooldown
? Colors.grey.withValues(alpha: 0.3)
: Colors.purpleAccent.withValues(alpha: 0.2),
border: Border.all(
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
width: 2,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: baitEngine.isOnCooldown
? null
: _onBaitMe,
child: Center(
child: Icon(
Icons.casino_rounded,
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
size: 22,
),
),
),
),
),
),
// Cooldown badge
if (baitEngine.isOnCooldown)
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${baitEngine.cooldownRemainingMinutes}m',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
const SizedBox(height: 2),
Text(
'Bait Me',
style: TextStyle(
fontSize: 9,
color: isDark ? Colors.white60 : Colors.black54,
),
),
],
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() {
_isSpinning = true;
});
_spinController.forward(from: 0);
// Wait for spin animation
await Future.delayed(const Duration(milliseconds: 1200));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
// Wire callbacks
baitEngine.onAddMinutes = (minutes) {
creditStore.addBonusMinutes(minutes);
HapticFeedback.heavyImpact();
};
baitEngine.onResetSession = () {
creditStore.resetBalances();
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onReduceSessionTime = (minutes) {
// Deduct from reel credits
for (var i = 0; i < minutes; i++) {
creditStore.drainReelsMinute();
}
HapticFeedback.heavyImpact();
};
baitEngine.onIncreaseCooldown = (minutes) {
// Increase cooldown by adding to the last session end time
// Session manager handles cooldown via _lastSessionEnd
HapticFeedback.heavyImpact();
};
baitEngine.onEndReelSession = () {
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onEndAppSession = () {
sessionManager.endAppSession();
HapticFeedback.heavyImpact();
};
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
};
// Activate
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
});
// Show result dialog
_showOutcomeDialog(context, outcome);
}
void _showOutcomeDialog(BuildContext context, BaitOutcome outcome) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
BaitEngine.outcomeLabel(outcome),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: outcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 12),
Text(
BaitEngine.outcomeSubtext(outcome),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white70 : Colors.black87,
height: 1.4,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
}
}
+242
View File
@@ -0,0 +1,242 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
// import '../services/level_service.dart'; // unused
import '../services/session_manager.dart';
/// Full-screen Bait Me page with big spin animation.
class BaitMeFullScreen extends StatefulWidget {
const BaitMeFullScreen({super.key});
@override
State<BaitMeFullScreen> createState() => _BaitMeFullScreenState();
}
class _BaitMeFullScreenState extends State<BaitMeFullScreen>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
bool _done = false;
BaitOutcome? _lastOutcome;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Spacer(),
// Title
Text(
_done ? '🎲 Result!' : '🎲 Bait Me',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_done
? BaitEngine.outcomeSubtext(_lastOutcome ?? BaitOutcome.addTenMinutes)
: 'Tap the button to test your luck!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 15,
),
),
const Spacer(),
// Spinning icon
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning ? _spinAnimation.value * 2 * pi * 5 : 0,
child: child,
);
},
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _done
? Colors.green.withValues(alpha: 0.15)
: Colors.purpleAccent.withValues(alpha: 0.15),
border: Border.all(
color: _done ? Colors.greenAccent : Colors.purpleAccent,
width: 3,
),
),
child: Center(
child: Icon(
_done ? Icons.check_circle : Icons.casino_rounded,
color: _done ? Colors.greenAccent : Colors.purpleAccent,
size: 56,
),
),
),
),
const Spacer(),
// Outcome description
if (_done && _lastOutcome != null)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
BaitEngine.outcomeLabel(_lastOutcome!),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _lastOutcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 8),
Text(
BaitEngine.outcomeSubtext(_lastOutcome!),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 14,
height: 1.4,
),
),
],
),
),
const Spacer(flex: 2),
// Big button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isSpinning ? null : _onBaitMe,
style: ElevatedButton.styleFrom(
backgroundColor:
_done ? Colors.greenAccent : Colors.purpleAccent,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
),
icon: Icon(
_isSpinning
? Icons.hourglass_top
: _done
? Icons.check_circle
: Icons.casino_rounded,
size: 24,
),
label: Text(
_isSpinning
? 'Rolling…'
: _done
? 'Done — Close'
: '🎲 Spin the Wheel!',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
),
if (!_done)
Padding(
padding: const EdgeInsets.only(top: 12),
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Not now',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3))),
),
),
const Spacer(),
],
),
),
),
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() => _isSpinning = true);
_spinController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 1800));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
baitEngine.onAddMinutes = (m) => creditStore.addBonusMinutes(m);
baitEngine.onResetSession = () => creditStore.resetBalances();
baitEngine.onReduceSessionTime = (m) {
for (var i = 0; i < m; i++) creditStore.drainReelsMinute();
};
baitEngine.onEndReelSession = () => sessionManager.endSession();
baitEngine.onEndAppSession = () => sessionManager.endAppSession();
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) await launchUrl(uri, mode: LaunchMode.externalApplication);
};
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
_done = true;
_lastOutcome = outcome;
});
HapticFeedback.heavyImpact();
}
}
+342
View File
@@ -0,0 +1,342 @@
/*import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
/// A hidden debug menu for development & testing.
///
/// Access: tap the app version in settings 7 times.
/// Allows manually setting XP/level to test feature gating.
class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key});
@override
State<DebugMenuScreen> createState() => _DebugMenuScreenState();
}
class _DebugMenuScreenState extends State<DebugMenuScreen> {
int _customLevel = 1;
int _customXp = 0;
@override
void initState() {
super.initState();
final levelService = context.read<LevelService>();
_customLevel = levelService.level;
_customXp = levelService.xp;
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Debug Menu',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Current state
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.amber.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, color: Colors.amber, size: 20),
const SizedBox(width: 8),
const Text(
'Developer Tools',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const SizedBox(height: 12),
Text(
'Current: Level ${levelService.level} · ${levelService.xp} XP',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 4),
Text(
'Progress: ${(levelService.levelProgress * 100).toStringAsFixed(0)}% to next level',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const SizedBox(height: 24),
// Manual level setter
const Text(
'SET LEVEL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 12),
// Quick level buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(5, (i) {
final lvl = i + 1;
final selected = _customLevel == lvl;
return ElevatedButton(
onPressed: () => setState(() => _customLevel = lvl),
style: ElevatedButton.styleFrom(
backgroundColor: selected ? Colors.blueAccent : null,
foregroundColor: selected ? Colors.white : null,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
child: Text('Level $lvl'),
);
}),
),
const SizedBox(height: 16),
// Set XP field
const Text(
'SET XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'XP Amount',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
controller: TextEditingController(text: '$_customXp'),
onChanged: (v) {
_customXp = int.tryParse(v) ?? 0;
},
),
const SizedBox(height: 20),
// Apply button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _applyDebugSettings(levelService),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.warning_amber_rounded, size: 20),
label: const Text(
'Apply Debug Settings',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 32),
// Feature unlock preview
const Text(
'FEATURE UNLOCK STATUS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = _customLevel >= feature.requiredLevel;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 13,
color: unlocked ? null : Colors.grey,
),
),
),
Text(
'Lv ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 32),
const SizedBox(height: 40),
// Danger zone
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.dangerous_outlined, color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text(
'Danger Zone',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _resetAllData(levelService),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
icon: const Icon(Icons.delete_forever, size: 18),
label: const Text('Reset All Level Data'),
),
),
],
),
),
],
),
);
}
Future<void> _applyDebugSettings(LevelService levelService) async {
HapticFeedback.heavyImpact();
// Use reflection-like approach: set the private fields via a method
// Since LevelService doesn't expose a raw setter, we provide one here.
await _forceSetLevel(levelService, _customLevel, _customXp);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Set to Level $_customLevel with $_customXp XP'),
backgroundColor: Colors.amber.shade800,
),
);
}
}
Future<void> _forceSetLevel(LevelService levelService, int level, int xp) async {
// The LevelService stores data in Hive (local only).
// We bypass the normal XP system by writing directly to cache.
await levelService.debugSetLevel(level, xp);
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) setState(() {});
}
Future<void> _resetAllData(LevelService levelService) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset All Level Data?'),
content: const Text(
'This will reset your level, XP, and all history to defaults. '
'This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true && mounted) {
await levelService.debugReset();
if (mounted) {
setState(() {
_customLevel = 1;
_customXp = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Level data reset')),
);
}
}
}
}
*/
+329
View File
@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import 'timer_fallback_screen.dart';
import '../widgets/native_ad_banner.dart';
/// Shown before a reel or Instagram session when credits are zero
/// and Effort Friction Mode is enabled.
///
/// Fallback chain: Adsterra Social Bar (WebView) → Timer fallback.
class EffortFrictionGate extends StatefulWidget {
final String sessionType; // 'reels' or 'insta'
final VoidCallback onProceed;
final VoidCallback? onCancel;
const EffortFrictionGate({
super.key,
required this.sessionType,
required this.onProceed,
this.onCancel,
});
@override
State<EffortFrictionGate> createState() => _EffortFrictionGateState();
}
class _EffortFrictionGateState extends State<EffortFrictionGate> {
bool _isWorking = false;
String _status = '';
@override
Widget build(BuildContext context) {
final creditStore = context.watch<CreditStore>();
final isReels = widget.sessionType == 'reels';
final credits =
isReels ? creditStore.reelsMinutes : creditStore.instaMinutes;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Colors.orange.shade800,
Colors.orange.shade500,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.3),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 28),
Text(
isReels ? 'Earn Reels Time' : 'Earn Instagram Time',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Watch a short ad to earn ${CreditStore.minutesPerAd} minutes '
'of ${isReels ? 'reel' : 'Instagram'} time.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 32),
// Credit balance display
if (credits > 0)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.green.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.access_time,
color: Colors.greenAccent,
size: 20,
),
const SizedBox(width: 8),
Text(
'You have $credits min remaining',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 20),
// Status message
if (_status.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: Colors.blueAccent,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_status,
style: const TextStyle(
color: Colors.blueAccent,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 12),
// Watch ad button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: _isWorking ? null : _startFallbackChain,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: _isWorking
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.play_arrow_rounded, size: 22),
label: Text(
_isWorking
? 'Working…'
: 'Watch Ad (+${CreditStore.minutesPerAd} min)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
// Proceed button
if (credits > 0)
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: widget.onProceed,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Proceed with earned time'),
),
),
const SizedBox(height: 16),
// Cancel
TextButton(
onPressed: widget.onCancel ?? () => Navigator.pop(context),
child: Text(
credits > 0 ? 'Skip for now' : 'Not now',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.4),
),
),
),
const Spacer(flex: 1),
Text(
'Ads by Adsterra',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.15),
fontSize: 10,
),
),
const SizedBox(height: 4),
// Native banner ad at bottom
const NativeAdBanner(height: 50),
const SizedBox(height: 8),
],
),
),
),
);
}
// ── Fallback Chain ─────────────────────────────────────────
Future<void> _startFallbackChain() async {
setState(() => _isWorking = true);
// Tier 1: Adsterra ad (full-screen WebView)
setState(() => _status = '');
if (mounted) {
final adsterraResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => AdsterraAdScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (adsterraResult == true && mounted) {
_grantReward();
setState(() {
_isWorking = false;
_status = '';
});
return;
}
if (!mounted) return;
}
// Tier 2: Timer fallback (always works)
setState(() => _status = 'Using timer fallback…');
if (mounted) {
final timerResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => TimerFallbackScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (timerResult == true && mounted) {
_grantReward();
}
}
if (mounted) {
setState(() {
_isWorking = false;
_status = '';
});
}
}
void _grantReward() {
final creditStore = context.read<CreditStore>();
final levelService = context.read<LevelService>();
if (widget.sessionType == 'reels') {
creditStore.addReelsMinutes();
} else {
creditStore.addInstaMinutes();
}
levelService.addXpForAd();
HapticFeedback.heavyImpact();
}
}
+94 -60
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
import 'ghost_mode_submenu_page.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@@ -10,7 +11,6 @@ class ExtrasSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
appBar: AppBar(
title: const Text(
@@ -25,6 +25,10 @@ class ExtrasSettingsPage extends StatelessWidget {
),
body: ListView(
children: [
const _SectionHeader(title: 'STARTUP'),
_LaunchPagePicker(settings: settings),
const SizedBox(height: 8),
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Download Media (Feed + Reels)',
@@ -37,68 +41,34 @@ class ExtrasSettingsPage extends StatelessWidget {
),
const _SectionHeader(title: 'FOCUS'),
_SwitchTile(
title: 'GHOST MODE',
subtitle: 'Hide seen indicator / read receipts',
value: settings.ghostMode,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
color: settings.ghostMode
? Colors.purple.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 2),
child: Icon(
Icons.info_outline,
size: 14,
color: Colors.amber,
),
),
const Expanded(
child: Text(
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
style: TextStyle(fontSize: 11, color: Colors.amber),
),
),
],
child: Icon(
Icons.visibility_off_rounded,
color: settings.ghostMode ? Colors.purpleAccent : Colors.grey,
size: 20,
),
),
title: const Text('Ghost Mode', style: TextStyle(fontSize: 15)),
subtitle: Text(
_ghostSubtitle(settings),
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const GhostModeSubmenuPage()),
),
),
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
*/
const SizedBox(height: 40),
],
),
@@ -106,12 +76,77 @@ class ExtrasSettingsPage extends StatelessWidget {
}
}
String _ghostSubtitle(SettingsService s) {
if (s.ghostMode) return 'DM Ghost active';
return 'Tap to configure ghost modes';
}
class _LaunchPagePicker extends StatelessWidget {
final SettingsService settings;
const _LaunchPagePicker({required this.settings});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final options = ['home', 'following', 'favorites', 'direct'];
final labels = {
'home': 'Home Feed',
'following': 'Following',
'favorites': 'Favorites',
'direct': 'Direct Messages',
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: settings.startupPage,
decoration: const InputDecoration(
labelText: 'Launch Page',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: options
.map(
(p) => DropdownMenuItem(
value: p,
child: Text(
labels[p] ?? p,
style: const TextStyle(fontSize: 14),
),
),
)
.toList(),
onChanged: (v) {
if (v != null) settings.setStartupPage(v);
HapticFeedback.selectionClick();
},
),
const SizedBox(height: 6),
Text(
'Choose which page opens when you launch Focusgram.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white38 : Colors.black38,
),
),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
this.subtitle,
@@ -124,7 +159,7 @@ class _SwitchTile extends StatelessWidget {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
? Text(subtitle!, style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
@@ -135,7 +170,6 @@ class _SwitchTile extends StatelessWidget {
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
/// Ghost Mode submenu — tap "Ghost Mode" in Extras to open this.
/// Single mode: DM Ghost (comprehensive seen-signal blocking).
class GhostModeSubmenuPage extends StatelessWidget {
const GhostModeSubmenuPage({super.key});
@override
Widget build(BuildContext context) {
final s = context.watch<SettingsService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Ghost Mode',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── DM Ghost ──────────────────────────────────────
_GhostCard(
icon: Icons.visibility_off_rounded,
title: 'DM Ghost',
subtitle: 'Read messages without the person knowing',
value: s.ghostMode,
warning:
'When DM Ghost is enabled, you can\'t send messages or react to any, you can just receive messages. You can turn ghost mode off anytime from topbar button.',
onChanged: (v) => s.setGhostMode(v),
isDark: isDark,
danger: true,
),
const SizedBox(height: 24),
const SizedBox(height: 40),
],
),
);
}
}
class _GhostCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final String warning;
final ValueChanged<bool> onChanged;
final bool isDark;
final bool danger;
const _GhostCard({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
required this.warning,
required this.onChanged,
required this.isDark,
this.danger = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.08 : 0.03),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.25 : 0.1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: value
? (danger ? Colors.redAccent : Colors.blueAccent)
: Colors.grey,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: value
? (danger ? Colors.redAccent : null)
: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
Switch(
value: value,
activeColor: danger ? Colors.redAccent : null,
onChanged: onChanged,
),
],
),
if (value)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (danger ? Colors.red : Colors.amber).withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
danger ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14,
color: danger ? Colors.redAccent : Colors.amber,
),
const SizedBox(width: 6),
Expanded(
child: Text(
warning,
style: TextStyle(
fontSize: 11,
color: danger
? Colors.redAccent
: Colors.amber.shade800,
),
),
),
],
),
),
),
],
),
);
}
}
+94 -14
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import '../utils/discipline_challenge.dart';
class GuardrailsPage extends StatefulWidget {
@@ -113,20 +115,33 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
),
),
),
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Daily Reel Limit',
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
min: 5,
max: 120,
divisor: 5,
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()),
),
// If quota used up, show earn page instead of slider
if (sm.dailyRemainingSeconds <= 0)
_buildQuotaExhaustedTile(context, sm)
else
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Daily Reel Limit',
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
min: 5,
max: 120,
divisor: 5,
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) async {
// XP penalty for increasing limit
final increase = (v.toInt() - (sm.dailyLimitSeconds ~/ 60));
if (increase > 0) {
// context.read<LevelService>().grantDebugXp(
// -increase * 5, 'Penalty: increased reel limit',
// );
}
await sm.setDailyLimitMinutes(v.toInt());
},
),
_buildFrictionSliderTile(
context: context,
sm: sm,
@@ -225,6 +240,71 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
);
}
Widget _buildQuotaExhaustedTile(BuildContext context, SessionManager sm) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.orange.withValues(alpha: 0.2)),
),
child: Column(
children: [
const Icon(
Icons.hourglass_empty,
color: Colors.orangeAccent,
size: 36,
),
const SizedBox(height: 8),
const Text(
'Daily Reel Quota Used Up',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
const Text(
'Watch an ad to earn 3 more minutes of reel time.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _earnQuota(context, sm),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text('Watch Ad (+3 min reels)'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
);
}
Future<void> _earnQuota(BuildContext context, SessionManager sm) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (result == true && context.mounted) {
sm.increaseDailyLimit(3);
context.read<LevelService>().addXpForAd();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('+3 min reel quota earned!')),
);
}
}
Widget _buildFrictionSliderTile({
required BuildContext context,
required SessionManager sm,
+516
View File
@@ -0,0 +1,516 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
import '../services/settings_service.dart';
import '../services/credit_store.dart';
import 'adsterra_ad_screen.dart';
/// Displays current level, XP progress, and locked/preview features.
class LevelPanelScreen extends StatelessWidget {
const LevelPanelScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text(
'Your Journey',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Level Header Card ──────────────────────────────
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _levelColors(levelService.level, isDark),
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _levelColors(levelService.level, isDark)[0]
.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
// Level badge
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.2),
border: Border.all(
color: Colors.white.withValues(alpha: 0.4),
width: 3,
),
),
child: Center(
child: Text(
'${levelService.level}',
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
Text(
_levelTitle(levelService.level),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// XP progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: levelService.levelProgress,
minHeight: 8,
backgroundColor: Colors.white.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'${levelService.xp} / ${levelService.xpForNextLevel} XP',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
const SizedBox(height: 24),
// ── Next Unlock ────────────────────────────────────
if (levelService.nextLockedFeature != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.lock_outline,
color: Colors.amber,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next at Level ${levelService.nextLockedFeature!.requiredLevel}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
'Unlock ${levelService.nextLockedFeature!.name}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
],
// ── Feature Unlock Table ───────────────────────────
const Text(
'FEATURES',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = levelService.isFeatureUnlocked(feature);
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: unlocked ? 0.04 : 0.02),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: unlocked
? Colors.greenAccent.withValues(alpha: 0.2)
: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 14,
fontWeight:
unlocked ? FontWeight.w600 : FontWeight.normal,
color:
unlocked
? null
: Colors.grey,
),
),
),
Text(
unlocked ? 'Unlocked' : 'Level ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: unlocked ? Colors.greenAccent : Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 24),
// ── XP Rules ────────────────────────────────────────
const Text(
'HOW TO EARN XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
_XpRuleTile(
icon: Icons.play_circle_outline,
label: 'Watch a rewarded ad',
value: '+2 XP (up to 20/day)',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.trending_down,
label: 'Watch fewer reels than your weekly average',
value: '+10 XP per reel saved',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.check_circle_outline,
label: 'Stay under your daily reel limit',
value: '+15 XP per day',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.login,
label: 'Open the app and check in',
value: '+1 XP per day',
isDark: isDark,
),
const SizedBox(height: 16),
// ── Watch Ad to earn XP ─────────────────────────────
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _watchAdForXp(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text(
'Watch Ad to Earn +2 XP',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 16),
// ── XP History ──────────────────────────────────────
const Text(
'RECENT XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...levelService.recentXpLog.take(10).map((entry) {
final dt = DateTime.tryParse(entry['time'] as String? ?? '');
final timeStr = dt != null
? '${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'
: '';
final amount = entry['amount'] as int;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
amount > 0 ? Icons.add_circle : Icons.remove_circle,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entry['reason'] as String? ?? '',
style: const TextStyle(fontSize: 13),
),
),
Text(
amount > 0 ? '+$amount XP' : '$amount XP',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
),
),
const SizedBox(width: 8),
Text(
timeStr,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
);
}),
if (levelService.recentXpLog.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No XP earned yet — watch an ad above or reduce reel time!',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white38 : Colors.black38,
),
),
),
const SizedBox(height: 20),
const Text(
'DEGRADATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.15),
),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text(
'XP decays if you backslide',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
SizedBox(height: 6),
Text(
'• Watching more reels than your weekly average deducts XP\n'
'• Exceeding limits for 3 consecutive days drops a level\n'
'• Levels are preserved on monthly reset, but XP resets',
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
),
],
),
),
const SizedBox(height: 40),
],
),
);
}
Color _levelColor(int level) {
switch (level) {
case 1: return Colors.grey;
case 2: return Colors.blue;
case 3: return Colors.purple;
case 4: return Colors.orange;
case 5: return Colors.amber;
default: return Colors.grey;
}
}
List<Color> _levelColors(int level, bool isDark) {
final base = _levelColor(level);
// MaterialColor supports .shadeXXX; plain Color doesn't.
if (base is MaterialColor) {
return isDark
? [base.shade800, base.shade900]
: [base.shade400, base.shade700];
}
return [base, base];
}
/// Navigate to Adsterra ad -> grant XP on completion.
Future<void> _watchAdForXp(BuildContext context) async {
// Try Adsterra Social Bar first
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (adResult == true && context.mounted) {
context.read<LevelService>().addXpForAd();
context.read<CreditStore>().addReelsMinutes();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('+10 XP earned!'),
duration: Duration(seconds: 2),
),
);
}
}
String _levelTitle(int level) {
switch (level) {
case 1: return 'Beginner';
case 2: return 'Mindful Scroller';
case 3: return 'Disciplined';
case 4: return 'Focus Master';
case 5: return 'Digital Monk';
default: return 'Level $level';
}
}
}
class _XpRuleTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final bool isDark;
const _XpRuleTile({
required this.icon,
required this.label,
required this.value,
required this.isDark,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Colors.greenAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white70 : Colors.black87,
),
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
color: Colors.greenAccent,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
+625 -40
View File
@@ -13,11 +13,20 @@ import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/injection_manager.dart';
import '../scripts/native_feel.dart';
import '../scripts/grayscale.dart' as grayscale;
import '../services/screen_time_service.dart';
import '../services/navigation_guard.dart';
import '../services/focusgram_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../services/notification_service.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'bait_me_full_screen.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed — offline feature deleted
// reels_history_service import removed — feature deleted
import 'app_lock_screen.dart';
import '../features/update_checker/update_checker_service.dart';
import '../utils/discipline_challenge.dart';
import 'settings_page.dart';
@@ -26,9 +35,9 @@ import '../features/preloader/instagram_preloader.dart';
import '../v2_integration/script_engine_v2_overlay.dart';
import '../v2_integration/script_registry_v2_overlay.dart';
import '../scripts/focus_scripts.dart';
import 'adsterra_ad_screen.dart';
import '../focus_settings.dart';
import '../services/adblock/adblock_content_blocker_loader.dart';
/// Core validator/dispatcher for the JS → Flutter bridge:
@@ -100,10 +109,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
bool _isPreloaded = false;
bool _minimalModeBannerDismissed = false;
bool _isInDirectThread = false;
bool _dmThreadCdnBlockArmed = false;
DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0);
SkeletonType _skeletonType = SkeletonType.generic;
/// True when on the homepage and should block api/graphql + gateway.
/// Updated in onLoadStart / UrlChange before shouldInterceptRequest fires.
bool _blockHomepageGraphql = false;
/// Helper to determine if we are on a login/onboarding page.
bool get _isOnOnboardingPage {
final path = Uri.tryParse(_currentUrl)?.path ?? '';
@@ -227,6 +239,121 @@ class _MainWebViewPageState extends State<MainWebViewPage>
);
}
/// Show a full-screen lock gate when navigating to Instagram DMs.
void _showDmLockGate() {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.lock_outline,
color: Colors.white54,
size: 64,
),
const SizedBox(height: 24),
const Text(
'Messages Locked',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Enter your PIN to access Direct Messages',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
const SizedBox(height: 40),
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push<bool>(
ctx,
MaterialPageRoute(
builder: (_) => const AppLockScreen(
forAppWide: false,
title: 'Messages Locked',
subtitle:
'Enter your PIN to access Direct Messages',
),
),
);
if (!ctx.mounted) return;
if (result == true) {
_dmLockOverride = true;
Navigator.pop(ctx);
} else {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
}
},
icon: const Icon(Icons.lock_open_rounded),
label: const Text('Unlock'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
},
child: const Text(
'Cancel — Go to Home',
style: TextStyle(color: Colors.white38),
),
),
],
),
),
),
),
),
),
),
);
}
/// Set ghost mode flags in the WebView so the pre-injected scripts activate.
void _setGhostModeFlags(InAppWebViewController c, SettingsService s) {
c.evaluateJavascript(
source:
'''
window.__fgFullDmGhost = ${s.ghostMode};
''',
);
}
/// Re-inject grayscale on app resume (fixes cold-start persistence bug
/// where the preloader cache can bypass onLoadStop).
void _syncGrayscaleOnResume(SettingsService settings) {
if (_injectionManager == null || _controller == null) return;
if (settings.isGrayscaleActiveNow) {
_injectionManager!.runAllPostLoadInjections(_currentUrl);
} else {
// Explicitly remove grayscale
_controller?.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
}
}
void _onSessionChanged() {
if (!mounted) return;
final sm = context.read<SessionManager>();
@@ -360,6 +487,21 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_injectionManager!.runAllPostLoadInjections(_currentUrl);
}
// Ghost mode flags update + reload (scripts already injected by preloader,
// but need to reload so the fetch/XHR interceptors see the new flags from
// the start of page load).
if (_lastGhostMode != settings.ghostMode) {
_lastGhostMode = settings.ghostMode;
if (_controller != null) {
_setGhostModeFlags(_controller!, settings);
// Schedule a reload so the flags take effect on fresh page load
_reloadDebounce?.cancel();
_reloadDebounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _controller?.reload();
});
}
}
// 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state)
setState(() {});
@@ -425,6 +567,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
screenTime.startTracking();
// Cancel persistent notification when app comes to foreground
NotificationService().cancelPersistentNotification(id: 5001);
// Re-inject grayscale on resume — schedules may have changed
// while the app was backgrounded, and injection can be lost on cold
// start due to the preloader cache bypassing onLoadStop.
_syncGrayscaleOnResume(settings);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
@@ -535,10 +682,24 @@ class _MainWebViewPageState extends State<MainWebViewPage>
),
if (sm.canExtendAppSession)
ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.of(context, rootNavigator: true).pop();
sm.extendAppSession();
// Keep _extensionDialogShown = true while ad runs so the
// watchdog timer doesn't re-show the dialog over the ad screen.
if (!mounted) return;
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
_extensionDialogShown = false;
if (adResult == true && mounted) {
sm.extendAppSession();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
@@ -546,7 +707,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('+10 minutes'),
child: const Text('Watch Ad (+10 min)'),
),
],
),
@@ -673,21 +834,37 @@ class _MainWebViewPageState extends State<MainWebViewPage>
static bool _isDirectThreadUrl(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/[^/]+/?$').hasMatch(path);
// Match both /direct/inbox/ and /direct/t/{thread_id}
return RegExp(r'^/direct/').hasMatch(path);
}
/* unused after CDN block was removed
static bool _isFktmInstagramCdn(String url) {
final host = Uri.tryParse(url)?.host.toLowerCase() ?? '';
return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host);
}
*/
void _syncDirectThreadState(String url) {
final active = _isDirectThreadUrl(url);
if (_isInDirectThread == active) return;
_isInDirectThread = active;
_dmThreadCdnBlockArmed = false;
// Reset override when leaving DMs
if (!active) _dmLockOverride = false;
// If Messages Tab Lock is enabled and user navigated to DMs,
// show a lock overlay.
if (active && mounted) {
final appLock = context.read<AppLockService>();
if (appLock.messagesLockReady && !_dmLockOverride) {
_showDmLockGate();
}
}
}
bool _dmLockOverride = false;
Future<void> _showReelSessionPicker() async {
final settings = context.read<SettingsService>();
if (settings.requireWordChallenge) {
@@ -836,6 +1013,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_BrandedTopBar(
onFocusControlTap: () =>
_edgePanelKey.currentState?._toggleExpansion(),
onDmGhostToggle: () {
context.read<SettingsService>().setGhostMode(false);
_controller?.reload();
},
onReload: () => _controller?.reload(),
currentUrl: _currentUrl,
dmGhostActive: context.read<SettingsService>().ghostMode,
),
Expanded(
child: Consumer<SessionManager>(
@@ -1029,6 +1213,95 @@ class _MainWebViewPageState extends State<MainWebViewPage>
);
}
// ── DM Ghost: block ALL seen signals ────────────────
// Like Chrome DevTools "Block request URL" — catches all
// sources at the native WebView level.
//
// Rules:
// 1. Block specific seen endpoint patterns everywhere
// 2. Block /api/graphql on homepage (/) and DM threads
// (/direct/t/*). Allow on /direct/inbox/ so inbox loads.
if (settings.ghostMode) {
// — Seen endpoint patterns (always block) —
final seenBlocked = RegExp(
r'/api/v1/media/[\w-]+/seen/|'
r'/api/v1/stories/reel/seen/|'
r'/api/v1/direct_v2/threads/[\w-]+/seen/|'
r'/api/v1/direct_v2/visual_message/[\w-]+/seen/|'
r'/api/v1/live/[\w-]+/comment/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/|'
r'/api/v1/direct_v2/mark_item_seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/|'
r'/api/v1/direct_v2/visual_thread/[^/]+/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/|'
r'/api/v1/live/[^/]+/join/|'
r'/api/v1/live/[^/]+/get_join_requests/|'
r'/api/v1/media/seen/|'
r'/api/v1/feed/viewed_story/|'
r'/api/v1/feed/reels_tray/seen/|'
r'/api/v1/qe/|'
r'/api/v1/launcher/sync/|'
r'/api/v1/logging/|'
r'/api/v1/fb_onetap_logging/|'
r'/ajax/bz|'
r'/ajax/logging/|'
r'/api/v1/stats/|'
r'/api/v1/fbanalytics/',
).hasMatch(url);
if (seenBlocked) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
// — Block /api/graphql + gateway on homepage &
// DM thread pages. Allow on /direct/inbox/. —
final currentPath =
Uri.tryParse(_currentUrl)?.path ??
_currentUrl;
final isHomepage =
currentPath == '/' || currentPath == '';
final isDmThread = currentPath.startsWith(
'/direct/t/',
);
if (!currentPath.startsWith(
'/direct/inbox/',
) &&
(isHomepage || isDmThread) &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
}
// Legacy homepage graphql + gateway block
// (kept for safety — the ghost mode block above now covers it)
if (_blockHomepageGraphql &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
/* Strip ads from feed (JS handles it)
if (settings.noAds &&
url.contains(
@@ -1158,6 +1431,29 @@ class _MainWebViewPageState extends State<MainWebViewPage>
settingsService,
);
// Set ghost mode flags (scripts already injected by preloader)
_setGhostModeFlags(controller, settingsService);
// Navigate to startup page if not Home
if (settingsService.startupPage != 'home') {
await controller.loadUrl(
urlRequest: URLRequest(
url: WebUri(settingsService.startupUrl),
),
);
}
// Force-inject grayscale on initial WebView creation,
// because the preloader's keepAlive causes the main
// WebView to skip onLoadStop on cold start.
if (settingsService.isGrayscaleActiveNow) {
try {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} catch (_) {}
}
_registerJavaScriptHandlers(controller);
// ── FocusGram v2 overlay initial sync ───────────────
@@ -1223,6 +1519,14 @@ class _MainWebViewPageState extends State<MainWebViewPage>
if (!mounted) return;
final u = url?.toString() ?? '';
_syncDirectThreadState(u);
// Update homepage graphql block flag SYNCHRONOUSLY
// (before setState, so shouldInterceptRequest sees it)
final path = Uri.tryParse(u)?.path ?? u;
_blockHomepageGraphql =
settings.ghostMode &&
(path == '/' ||
path == '' ||
path == '/explore/');
final lower = u.toLowerCase();
final isOnboardingUrl =
lower.contains('/accounts/login') ||
@@ -1251,6 +1555,15 @@ class _MainWebViewPageState extends State<MainWebViewPage>
final current = url?.toString() ?? '';
_syncDirectThreadState(current);
// Re-set ghost mode flags on every page load.
// evaluateJavascript-set flags are destroyed when
// the JS context resets on navigation. The flags
// are also prepended to initialUserScripts, but
// this covers the toggle-off → reload case.
final s = context.read<SettingsService>();
_setGhostModeFlags(controller, s);
setState(() {
_isLoading = false;
_currentUrl = current;
@@ -1263,6 +1576,17 @@ class _MainWebViewPageState extends State<MainWebViewPage>
// Phase 1 V2 overlay DOM scripts
await _v2Engine?.injectDocumentEndScripts();
// Re-inject grayscale on every page load
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
await controller.evaluateJavascript(
source:
InjectionController.notificationBridgeJS,
@@ -1458,7 +1782,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
right: 0,
child: const _InstagramGradientProgressBar(),
),
_EdgePanel(key: _edgePanelKey),
_EdgePanel(key: _edgePanelKey, currentUrl: _currentUrl),
if (_exploreBlockedOverlay)
Positioned.fill(
@@ -1850,13 +2174,35 @@ class _MainWebViewPageState extends State<MainWebViewPage>
},
);
// ReelMetadata handler removed — reel history feature deleted
controller.addJavaScriptHandler(
handlerName: 'UrlChange',
callback: (args) async {
final url = (args.isNotEmpty ? args[0] : '') as String? ?? '';
_syncDirectThreadState(url);
final s = context.read<SettingsService>();
// Update homepage graphql block for SPA navigation
final path = Uri.tryParse(url)?.path ?? url;
_blockHomepageGraphql =
s.ghostMode && (path == '/' || path == '' || path == '/explore/');
// Re-set ghost mode flags on SPA navigation (no page reload).
_setGhostModeFlags(controller, s);
await _injectionManager?.runAllPostLoadInjections(url);
// Re-inject grayscale on SPA nav (no page reload)
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
// Phase 1 V2 overlay re-inject on SPA route changes
await _v2Engine?.injectDocumentEndScripts();
@@ -1876,7 +2222,6 @@ class _MainWebViewPageState extends State<MainWebViewPage>
.read<SettingsService>()
.disableExploreEntirely;
final path = Uri.tryParse(url)?.path ?? url;
final isReels = path.startsWith('/reels') || path.startsWith('/reel/');
final isExplore = path.startsWith('/explore');
@@ -1967,7 +2312,8 @@ class _MinimalModeBanner extends StatelessWidget {
}
class _EdgePanel extends StatefulWidget {
const _EdgePanel({super.key});
final String currentUrl;
const _EdgePanel({super.key, this.currentUrl = ''});
@override
State<_EdgePanel> createState() => _EdgePanelState();
}
@@ -2091,6 +2437,38 @@ class _EdgePanelState extends State<_EdgePanel> {
],
),
),
// Level badge
Consumer<LevelService>(
builder: (context, lv, _) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: lv.level >= 3
? Colors.purple.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: lv.level >= 3
? Colors.purpleAccent.withValues(alpha: 0.4)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Text(
'Lv ${lv.level}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: lv.level >= 3
? Colors.purpleAccent
: Colors.grey,
),
),
),
),
// Save current page — REMOVED
const SizedBox(width: 4),
IconButton(
tooltip: 'Close',
icon: Icon(
@@ -2102,6 +2480,8 @@ class _EdgePanelState extends State<_EdgePanel> {
),
],
),
// Bait Me button row
_BaitMeButtonRow(),
const SizedBox(height: 18),
Container(
width: double.infinity,
@@ -2189,6 +2569,7 @@ class _EdgePanelState extends State<_EdgePanel> {
color: reelsHardDisabled ? Colors.redAccent : textSub,
isDark: isDark,
),
const SizedBox(height: 16),
if (sm.isSessionActive)
SizedBox(
@@ -2226,17 +2607,86 @@ class _EdgePanelState extends State<_EdgePanel> {
),
const SizedBox(height: 8),
if (!canStart && !sm.isSessionActive)
Text(
reelsHardDisabled
? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
: sm.isCooldownActive
? 'A cooldown is active before the next Reel session.'
: 'Your daily Reel quota is used up.',
style: TextStyle(
color: textSub,
fontSize: 12,
height: 1.35,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reelsHardDisabled
? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
: sm.isCooldownActive
? 'A cooldown is active before the next Reel session.'
: 'Your daily Reel quota is used up.',
style: TextStyle(
color: textSub,
fontSize: 12,
height: 1.35,
),
),
if (sm.dailyRemainingSeconds <= 0 &&
!reelsHardDisabled &&
!sm.isCooldownActive)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Consumer<CreditStore>(
builder: (ctx, credits, _) {
if (!credits.canWatchAdToday) {
return Text(
'Ad limit reached (3/day)',
style: TextStyle(
color: textSub,
fontSize: 11,
),
);
}
return SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: () async {
final adResult =
await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) =>
const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
if (adResult == true && context.mounted) {
context
.read<CreditStore>()
.addReelsMinutes(amount: 2);
context
.read<SessionManager>()
.addBonusDailyMinutes(2);
HapticFeedback.heavyImpact();
}
},
icon: const Icon(Icons.videocam, size: 16),
label: Text(
'Watch Ad (+2 min) '
'(${CreditStore.maxDailyAds - credits.adsWatchedToday}/3 today)',
style: const TextStyle(fontSize: 12),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(
alpha: 0.4,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
},
),
),
],
),
const SizedBox(height: 10),
Divider(color: border),
@@ -2345,16 +2795,88 @@ class _EdgePanelState extends State<_EdgePanel> {
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
const _BrandedTopBar({this.onFocusControlTap});
/// Small row showing the Bait Me button and daily XP for the edge panel.
class _BaitMeButtonRow extends StatelessWidget {
const _BaitMeButtonRow();
@override
Widget build(BuildContext context) {
final isDark = context.watch<SettingsService>().isDarkMode;
final levelService = context.watch<LevelService>();
final baitEngine = context.watch<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: baitEngine.isOnCooldown
? null
: () => _openBaitMe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purpleAccent.withValues(alpha: 0.2),
foregroundColor: Colors.purpleAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.casino_rounded, size: 20),
label: Text(
baitEngine.isOnCooldown
? 'Bait Me (${baitEngine.cooldownRemainingMinutes}m cooldown)'
: '🎲 Bait Me — Feel Lucky?',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
);
}
void _openBaitMe(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BaitMeFullScreen()),
);
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
final VoidCallback? onDmGhostToggle;
final VoidCallback? onReload;
final String currentUrl;
final bool dmGhostActive;
const _BrandedTopBar({
this.onFocusControlTap,
this.onDmGhostToggle,
this.onReload,
this.currentUrl = '',
this.dmGhostActive = false,
});
static bool _isDirectInbox(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return path == '/direct/inbox/' || path == '/direct/inbox';
}
static bool _isDirectThread(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/').hasMatch(path);
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final barBg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black;
final iconColor = isDark ? Colors.white70 : Colors.black54;
final border = isDark ? Colors.white12 : Colors.black12;
final showDmGhostBtn = _isDirectThread(currentUrl) && dmGhostActive;
final showReloadBtn = _isDirectInbox(currentUrl);
return Container(
height: 60,
@@ -2363,10 +2885,11 @@ class _BrandedTopBar extends StatelessWidget {
border: Border(bottom: BorderSide(color: border, width: 0.5)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: settings icon
IconButton(
icon: Icon(Icons.settings_outlined, color: iconColor, size: 22),
onPressed: () => Navigator.push(
@@ -2374,21 +2897,83 @@ class _BrandedTopBar extends StatelessWidget {
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
// Center: FocusGram logo (or DM ghost badge)
if (showDmGhostBtn)
GestureDetector(
onTap: onDmGhostToggle,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off,
color: Colors.redAccent,
size: 16,
),
const SizedBox(width: 4),
Text(
'DM Ghost ON',
style: TextStyle(
color: Colors.redAccent,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.close,
color: Colors.redAccent.withValues(alpha: 0.6),
size: 14,
),
],
),
),
)
else
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
),
),
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
// Right: reload button + timer icon
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showReloadBtn)
IconButton(
icon: Icon(
Icons.refresh_rounded,
color: iconColor,
size: 22,
),
onPressed: onReload,
tooltip: 'Reload page',
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
),
],
),
],
),
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
/// Opens a saved page offline. Uses saved HTML content when available,
/// falls back to WebView cache.
class OfflineFeedViewer extends StatelessWidget {
final String url;
final String? pageId;
const OfflineFeedViewer({super.key, required this.url, this.pageId});
@override
Widget build(BuildContext context) {
// Find the saved page with HTML content
SavedPage? page;
if (pageId != null) {
final ss = context.read<SnapshotService>();
final matches = ss.savedPages.where((p) => p.id == pageId);
if (matches.isNotEmpty) page = matches.first;
}
return Scaffold(
appBar: AppBar(
title: const Text('Offline View',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: Colors.blue.withValues(alpha: 0.1),
child: const Row(
children: [
Icon(Icons.wifi_off_rounded, size: 14,
color: Colors.blueAccent),
SizedBox(width: 6),
Text('Offline — saved content shown',
style: TextStyle(fontSize: 11, color: Colors.blueAccent)),
],
),
),
Expanded(
child: page?.htmlContent != null
? InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: page!.htmlContent!,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri(url),
);
},
)
: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: InAppWebViewSettings(
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
javaScriptEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
),
),
],
),
);
}
}
+122 -40
View File
@@ -6,8 +6,18 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/level_service.dart';
import '../services/credit_store.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed — offline feature deleted
import '../services/focusgram_router.dart';
import 'app_lock_settings_page.dart';
// snapshot_manager_screen import removed — offline feature deleted
import 'level_panel_screen.dart';
//import 'debug_menu_screen.dart';
import '../widgets/native_ad_banner.dart';
import '../features/screen_time/screen_time_screen.dart';
// reels_history_screen import removed — feature deleted
import 'guardrails_page.dart';
import 'extras_settings_page.dart';
@@ -37,7 +47,7 @@ class SettingsPage extends StatelessWidget {
body: ListView(
children: [
const _DonateTile(),
_buildStatsRow(sm),
_buildStatsRow(sm, context),
const _SectionHeader(title: 'FOCUS & BLOCKING'),
_SubmoduleTile(
@@ -71,13 +81,14 @@ class SettingsPage extends StatelessWidget {
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Download media, Ghost Mode',
subtitle: 'Startup Page, Download media, Ghost Mode',
enabled: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
),
),
const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile(
@@ -88,13 +99,25 @@ class SettingsPage extends StatelessWidget {
? 'Grayscale on'
: settings.grayscaleSchedules.isNotEmpty
? 'Grayscale scheduled (${settings.grayscaleSchedules.length} schedules)'
: 'Theme, grayscale',
: 'Grayscale and schedules',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppearancePage()),
),
),
const _SectionHeader(title: 'SECURITY'),
_SubmoduleTile(
icon: Icons.lock_rounded,
iconColor: Colors.blueAccent,
title: 'App Lock',
subtitle: _appLockSubtitle(context.watch<AppLockService>()),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppLockSettingsPage()),
),
),
const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'),
_SubmoduleTile(
icon: Icons.lock_outline,
@@ -120,18 +143,22 @@ class SettingsPage extends StatelessWidget {
MaterialPageRoute(builder: (_) => const ScreenTimeScreen()),
),
),
const _SectionHeader(title: 'ABOUT'),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
_SubmoduleTile(
icon: Icons.trending_up_rounded,
iconColor: Colors.amber,
title: 'Your Journey',
subtitle: 'Level ${context.watch<LevelService>().level} · ${context.watch<LevelService>().xp} XP',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LevelPanelScreen()),
),
),
// Quick XP debug grant (visible in settings for testing)
// _XpDebugTile(),
// Reels History removed
const _SectionHeader(title: 'ABOUT'),
_VersionTile(),
ListTile(
title: const Text('GitHub'),
trailing: const Icon(Icons.open_in_new, size: 14),
@@ -173,6 +200,8 @@ class SettingsPage extends StatelessWidget {
'https://www.instagram.com/accounts/settings/?entrypoint=profile';
},
),
const SizedBox(height: 20),
const NativeAdBanner(height: 60),
const SizedBox(height: 40),
Center(
child: Text(
@@ -189,7 +218,35 @@ class SettingsPage extends StatelessWidget {
);
}
Widget _buildStatsRow(SessionManager sm) {
Widget _buildStatsRow(SessionManager sm, BuildContext context) {
final creditStore = context.watch<CreditStore>();
final cells = <Widget>[
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
];
if (true) { // ad counter always shown
cells.addAll([
_dividerCell(),
_statCell(
'XP Ads Watched',
'${creditStore.adsWatchedToday}',
Colors.purpleAccent,
),
]);
}
return Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16),
@@ -200,21 +257,7 @@ class SettingsPage extends StatelessWidget {
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
],
children: cells,
),
);
}
@@ -240,6 +283,16 @@ class SettingsPage extends StatelessWidget {
color: Colors.blue.withValues(alpha: 0.1),
);
String _appLockSubtitle(AppLockService a) {
if (!a.anyLockEnabled) return 'Protect FocusGram with a PIN';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages');
return '${parts.join(' + ')} lock active';
}
void _showLegalDisclaimer(BuildContext context) {
showDialog(
context: context,
@@ -341,6 +394,8 @@ class FocusSettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 8),
const _SectionHeader(title: 'FRICTION'),
_SwitchTile(
title: 'Mindfulness Gate',
@@ -378,17 +433,24 @@ class FocusSettingsPage extends StatelessWidget {
onSelected: (v) => settings.setWordChallengeCount(v),
),
const _SectionHeader(title: 'MEDIA'),
/*
( I TRIED SO HARD, AND GOT SO FAR, BUT IN THE END...
IT DOESNT EVEN MATTER ..... (didnt work))
_SwitchTile(
title: 'Block Autoplay Videos',
subtitle: 'Videos won\'t play until you tap them',
value: settings.blockAutoplay,
onChanged: (v) => settings.setBlockAutoplay(v),
),*/
title: 'Effort Friction Mode',
subtitle: 'Watch ads to earn reel quota minutes',
value: settings.effortFrictionEnabled,
onChanged: (v) async {
if (v && !context.read<LevelService>().isFeatureUnlocked(AppFeature.effortFriction)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unlocks at Level 2')),
);
return;
}
await settings.setEffortFrictionEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'MEDIA'),
// Block Autoplay removed — was unreliable
_SwitchTile(
title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped',
@@ -412,7 +474,7 @@ class FocusSettingsPage extends StatelessWidget {
_SwitchTile(
title: 'Hide Feed Posts',
subtitle:
'Hides home feed posts (stories tray, posts, suggested content)',
'Hides home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
@@ -1321,6 +1383,26 @@ class _NumberEditTile extends StatelessWidget {
}
}
class _VersionTile extends StatelessWidget {
const _VersionTile();
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
+312
View File
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
import '../services/level_service.dart';
import 'offline_feed_viewer.dart';
/// Manages saved pages for offline viewing via WebView cache.
/// Gated behind Level 5.
class SnapshotManagerScreen extends StatelessWidget {
const SnapshotManagerScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isUnlocked = levelService.level >= 5; // offline pages at L5
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Offline Pages',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: isUnlocked
? const _SavedPageList()
: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 64,
color: Colors.grey.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
const Text(
'Unlocks at Level 5',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Earn XP to unlock offline browsing.\n'
'Watch ads and reduce reel time to level up.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
height: 1.5,
),
),
],
),
),
),
);
}
}
class _SavedPageList extends StatelessWidget {
const _SavedPageList();
@override
Widget build(BuildContext context) {
final snapshotService = context.watch<SnapshotService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// Info card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withValues(alpha: 0.12)),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blueAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
'The WebView already caches pages you visit. '
'Save bookmarks here to easily reopen them when offline.\n'
'No API needed — the cache handles everything.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white60 : Colors.black54,
height: 1.4,
),
),
),
],
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
'${snapshotService.totalSaved} saved page${snapshotService.totalSaved == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const Spacer(),
if (snapshotService.totalSaved > 0)
GestureDetector(
onTap: () => _confirmClearAll(context, snapshotService),
child: Text(
'Clear all',
style: TextStyle(
fontSize: 12,
color: Colors.redAccent.withValues(alpha: 0.7),
),
),
),
],
),
),
// Page list
Expanded(
child: snapshotService.savedPages.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border_rounded,
size: 48,
color: Colors.grey.withValues(alpha: 0.3),
),
const SizedBox(height: 12),
Text(
'No saved pages yet',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black38,
),
),
const SizedBox(height: 4),
Text(
'Visit Instagram pages online, then save them here\nto browse offline later.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white24 : Colors.black26,
height: 1.4,
),
),
],
),
)
: ListView.builder(
itemCount: snapshotService.savedPages.length,
itemBuilder: (context, index) {
final page = snapshotService.savedPages[index];
return ListTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.web_rounded,
color: Colors.blueAccent,
size: 22,
),
),
title: Text(
page.title,
style: const TextStyle(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatDate(page.savedAt),
style: const TextStyle(fontSize: 12),
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDelete(context, snapshotService, page.id);
} else if (value == 'open') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(url: page.url, pageId: page.id),
),
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'open',
child: Row(
children: [
Icon(Icons.open_in_browser, size: 18),
SizedBox(width: 8),
Text('Open Offline'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text('Remove',
style: TextStyle(color: Colors.redAccent)),
],
),
),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(url: page.url),
),
);
},
);
},
),
),
],
);
}
void _confirmDelete(
BuildContext context,
SnapshotService service,
String id,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove page?'),
content:
const Text('Removes the bookmark. Cache is preserved automatically.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deletePage(id);
Navigator.pop(ctx);
},
child:
const Text('Remove', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
void _confirmClearAll(BuildContext context, SnapshotService service) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all saved pages?'),
content: const Text('This removes all bookmarks.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deleteAll();
Navigator.pop(ctx);
},
child:
const Text('Clear', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}';
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A 15-second timer that acts as the last-resort fallback
/// when both AdMob and Adsterra fail to serve an ad.
///
/// Shows a digital wellness quote while the user waits.
/// After the timer, they earn the same reward.
class TimerFallbackScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const TimerFallbackScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 15,
});
@override
State<TimerFallbackScreen> createState() => _TimerFallbackScreenState();
}
class _TimerFallbackScreenState extends State<TimerFallbackScreen> {
int _remaining = 0;
Timer? _timer;
int _quoteIndex = 0;
static const _quotes = [
'"The secret of getting ahead is getting started." — Mark Twain',
'"Focus on being productive instead of busy." — Tim Ferriss',
'"Almost everything will work if you unplug it for a few minutes." — Ann Lamott',
'"The key is not to prioritize what\'s on your schedule, but to schedule your priorities." — Stephen Covey',
'"Your mind is for having ideas, not holding them." — David Allen',
'"Simplicity is the ultimate sophistication." — Leonardo da Vinci',
'"The ability to simplify means to eliminate the unnecessary." — Hans Hofmann',
'"In the midst of chaos, there is also opportunity." — Sun Tzu',
];
@override
void initState() {
super.initState();
_remaining = widget.requiredSeconds;
_quoteIndex = DateTime.now().millisecondsSinceEpoch % _quotes.length;
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
if (_remaining > 0) {
_remaining--;
} else {
_timer?.cancel();
HapticFeedback.heavyImpact();
}
});
});
}
@override
Widget build(BuildContext context) {
final done = _remaining <= 0;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withValues(alpha: 0.1),
border: Border.all(
color: Colors.green.withValues(alpha: 0.3),
width: 2,
),
),
child: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
color: done ? Colors.greenAccent : Colors.green,
size: 36,
),
),
const SizedBox(height: 28),
// Timer
Text(
done ? 'Done!' : '$_remaining',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white,
fontSize: 56,
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 8),
Text(
done
? 'You earned ${widget.sessionType == 'reels' ? 'reel' : 'Instagram'} time'
: 'Please wait while we prepare your reward',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
const SizedBox(height: 40),
// Quote
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
_quotes[_quoteIndex],
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
fontStyle: FontStyle.italic,
),
),
),
const Spacer(flex: 1),
// Continue button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: done
? () => Navigator.pop(context, true)
: null,
style: ElevatedButton.styleFrom(
backgroundColor:
done ? Colors.greenAccent : Colors.grey,
foregroundColor:
done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: Icon(
done ? Icons.check_circle : Icons.hourglass_empty,
size: 22,
),
label: Text(
done
? 'Continue & Earn Reward'
: 'Wait $_remaining seconds',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
const SizedBox(height: 16),
Text(
'No ad available — timer reward instead',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.2),
fontSize: 11,
),
),
const Spacer(flex: 1),
],
),
),
),
);
}
}
+33
View File
@@ -223,3 +223,36 @@ const String kAutoplayBlockerJS = r'''
}, true);
})();
''';
// Reinforcement observer catches videos that Instagram creates after the
// prototype override (e.g. React re-renders). Runs a MutationObserver that
// pauses any <video> that tries to autoplay.
const String kAutoplayObserverJS = r'''
(function fgAutoplayObserver() {
if (window.__fgAutoplayObserverRunning) return;
window.__fgAutoplayObserverRunning = true;
function pauseIfBlocked(v) {
try {
if (window.__fgBlockAutoplay === false) return;
if (window.__focusgramSessionActive) return;
const url = window.location.href || '';
if (url.includes('/reels/') || url.includes('/reel/')) return;
if (v.paused) return;
if (v.getAttribute('data-fg-user-played') === '1') return;
v.pause();
} catch (_) {}
}
// Check all existing videos periodically
setInterval(function() {
document.querySelectorAll('video').forEach(pauseIfBlocked);
}, 500);
// Mark video as user-played on click
document.addEventListener('click', function(e) {
var v = e.target && e.target.closest ? e.target.closest('video') : null;
if (v) v.setAttribute('data-fg-user-played', '1');
}, true);
})();
''';
+20 -4
View File
@@ -40,8 +40,11 @@ const String kBlurHomeFeedAndExploreCSS = '''
transition: filter 0.15s ease !important;
}
/* Per-post unblur override (set by kTapToUnblurJS) */
[data-fg-unblurred="1"] img,
[data-fg-unblurred="1"] video {
/* Must match the blur selector's specificity (body[path="/"] article img = 0,0,1,3) */
body[path="/"] [data-fg-unblurred="1"] img,
body[path="/"] [data-fg-unblurred="1"] video,
body[path^="/explore"] [data-fg-unblurred="1"] img,
body[path^="/explore"] [data-fg-unblurred="1"] video {
filter: none !important;
-webkit-filter: none !important;
}
@@ -149,6 +152,15 @@ const String kTapToUnblurJS = r'''
}
}
function unblurAllMediaInHost(host) {
try {
host.querySelectorAll('img,video').forEach(function(el) {
el.style.setProperty('filter', 'none', 'important');
el.style.setProperty('-webkit-filter', 'none', 'important');
});
} catch (_) {}
}
function unblurMedia(media) {
try {
media.style.setProperty('filter', 'none', 'important');
@@ -164,11 +176,15 @@ const String kTapToUnblurJS = r'''
if (!media) return;
const host = getHost(media);
if (!host) return;
if (isUnblurred(host)) return; // allow normal Instagram behaviour
// ALWAYS re-unblur media Instagram swaps DOM elements in carousels,
// so the inline style applied on first tap is lost on subsequent pages.
unblurMedia(media);
if (isUnblurred(host)) return; // allow normal Instagram click-through
// First tap: unblur and swallow click so it doesn't open the post.
markUnblurred(host);
unblurMedia(media);
if (e.cancelable) e.preventDefault();
e.stopPropagation();
} catch (_) {}
+410 -54
View File
@@ -1,77 +1,413 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart';
// Ghost Mode
const String ghostModeJS = '''
const _WS = window.WebSocket;
window.WebSocket = function(url, protocols) {
if (url.includes('edge-chat.instagram.com') ||
url.includes('gateway.instagram.com')) {
return {
send: ()=>{}, close: ()=>{},
readyState: 1,
addEventListener: ()=>{},
removeEventListener: ()=>{},
};
/// Flutter sets these flags after settings load to enable ghost modes.
/// Must be called from onWebViewCreated or on settings change.
const String kSetGhostFlagsJS = '''
(function(){
// Placeholder Flutter replaces these with actual setting values:
// window.__fgPartialGhost = true/false;
// window.__fgFullDmGhost = true/false;
// window.__fgStoryGhost = true/false;
// window.__fgGhostReady = true; // signals scripts can proceed
})();
''';
//
// PARTIAL GHOST MODE existing behavior
// Blocks seen API patterns, WebSocket chat gateways, and uses
// first-click gate for api/graphql on /direct/* (inbox loads, then block).
//
const String kPartialGhostJS = r'''
(function() {
if (window.__fgPartialGhostPatched) return;
window.__fgPartialGhostPatched = true;
// Seen API patterns
var SEEN = [/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//];
function isSeen(u) { for(var i=0;i<SEEN.length;i++){if(SEEN[i].test(u))return true;}return false; }
// First-click gate for api/graphql on /direct/*
window.__fgDirectApiBlocked = false;
document.addEventListener('click',function(){
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
},true);
document.addEventListener('touchstart',function(){
if(window.location.pathname.indexOf('/direct/')===0) window.__fgDirectApiBlocked=true;
},true);
var _prevD=window.location.pathname.indexOf('/direct/')===0;
setInterval(function(){
var n=window.location.pathname.indexOf('/direct/')===0;
if(n!==_prevD){_prevD=n;window.__fgDirectApiBlocked=false;}
},300);
function partialEnabled() { return window.__fgPartialGhost===true; }
function shouldBlock(u) {
if (!partialEnabled()) return false;
return window.location.pathname.indexOf('/direct/')===0 &&
window.__fgDirectApiBlocked &&
u.indexOf('/api/graphql')!==-1;
}
return new _WS(url, protocols);
};
window.WebSocket.prototype = _WS.prototype;
// Fetch override (chain with previous fetch)
var _prevFetch = window.fetch;
window.fetch=function(i,init){
var u=(typeof i==='string')?i:(i&&i.url)?i.url:'';
if(partialEnabled()&&(isSeen(u)||shouldBlock(u))) return Promise.resolve(new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}));
return _prevFetch.call(window,i,init);
};
// XHR override (chain)
var _prevOpen=XMLHttpRequest.prototype.open,_prevSend=XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open=function(m,u){this.__fgU=u||'';return _prevOpen.apply(this,arguments);};
XMLHttpRequest.prototype.send=function(b){
if(partialEnabled()&&(isSeen(this.__fgU||'')||shouldBlock(this.__fgU||''))){
var self=this;setTimeout(function(){
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
},5);return;
}
return _prevSend.apply(this,arguments);
};
// Selective WS seen-message filter (no gouger)
(function() {
var _WS = window.WebSocket;
function PartialWS(url, protocols) {
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
var _send = ws.send.bind(ws);
ws.send = function(data) {
if (typeof data === 'string') {
try {
var parsed = JSON.parse(data);
if (parsed && (parsed.op === '4' || parsed.op === 'seen')) return;
} catch(e) {}
if (data.indexOf('"seen"') !== -1 && data.indexOf('"thread_id"') !== -1) return;
}
return _send(data);
};
return ws;
}
PartialWS.prototype = _WS.prototype;
PartialWS.CONNECTING = _WS.CONNECTING;
PartialWS.OPEN = _WS.OPEN;
PartialWS.CLOSING = _WS.CLOSING;
PartialWS.CLOSED = _WS.CLOSED;
window.WebSocket = PartialWS;
})();
})();
''';
// No Story Tray
const String hideStoryTrayJS = '''
const style = document.createElement('style');
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
document.head.appendChild(style);
''';
//
// FULL DM GHOST blocks ALL api/graphql on /direct/* immediately
// (inbox won't load, messages can't be sent)
//
const String kFullDmGhostJS = r'''
(function() {
if (window.__fgFullDmGhostPatched) return;
window.__fgFullDmGhostPatched = true;
// No Autoplay
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') {
e.target.pause();
// Smart path-based blocking
// /direct/inbox/ allow (inbox loads)
// /direct/t/* block ALL api/graphql immediately
// any /direct/* block except /direct/inbox/
function shouldBlockDmPath() {
if (window.__fgFullDmGhost !== true) return false;
var p = window.location.pathname;
if (p.indexOf('/direct/') !== 0) return false;
if (p === '/direct/inbox/' || p === '/direct/inbox') return false;
return true;
}
}, true);
// DM URL blocklist
var DM_URLS = [
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/mark_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/mark_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_visual_item_seen\\//,
/\\/api\\/v1\\/direct_v2\\/visual_thread\\/[^/]+\\/seen\\//,
/\\/api\\/v1\\/direct_v2\\/threads\\/[^/]+\\/items\\/[^/]+\\/mark_audio_seen\\//,
/\\/api\\/v1\\/live\\/[^/]+\\/join\\//,
/\\/api\\/v1\\/live\\/[^/]+\\/get_join_requests\\//,
/\\/api\\/v1\\/media\\/seen\\//,
/\\/api\\/v1\\/feed\\/viewed_story\\//,
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
/\\/api\\/v1\\/direct_v2\\/threads\\/[\\w-]+\\/seen\\//,
/\\/api\\/v1\\/direct_v2\\/visual_message\\/[\\w-]+\\/seen\\//,
/\\/api\\/v1\\/live\\/[\\w-]+\\/comment\\/seen\\//,
/\\/api\\/v1\\/qe\\//,
/\\/api\\/v1\\/launcher\\/sync\\//,
/\\/api\\/v1\\/logging\\//,
/\\/api\\/v1\\/fb_onetap_logging\\//,
/\\/ajax\\/bz/,
/\\/ajax\\/logging\\//,
/\\/api\\/v1\\/stats\\//,
/\\/api\\/v1\\/fbanalytics\\//,
];
function matchUrl(url) {
if (!url) return false;
for (var i = 0; i < DM_URLS.length; i++) { if (DM_URLS[i].test(url)) return true; }
return false;
}
// DM GraphQL operations
var DM_OPS = [
'MarkDirectThreadItemSeen','markDirectThreadItemSeen',
'DirectMarkItemSeen','DirectThreadMarkSeen',
'MarkVisualMessageSeen','DirectMarkVisualItemSeen',
'MarkAudioMessageSeen','AudioSeenMutation',
'LiveJoinBroadcast','JoinLiveBroadcast','MarkLiveViewer',
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
'LogImpression','LogClick','FeedbackSeenMutation',
];
function matchGraphQL(body) {
if (!body) return false;
var str = typeof body === 'string' ? body : String(body);
for (var i = 0; i < DM_OPS.length; i++) { if (str.indexOf(DM_OPS[i]) !== -1) return true; }
return false;
}
function isGraphql(url) {
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
}
function shouldBlock(url, init) {
// 1. Path-based: on /direct/t/* block ALL graphql
if (shouldBlockDmPath() && isGraphql(url)) return true;
// 2. URL blocklist match
if (matchUrl(url)) return true;
// 3. GraphQL body op-name match
if (isGraphql(url) && init) {
var bs = '';
if (typeof init.body === 'string') bs = init.body;
else if (init.body && init.body.toString) bs = init.body.toString();
if (matchGraphQL(bs)) return true;
}
return false;
}
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
// Fetch override (chain)
var _prevFetch = window.fetch;
window.fetch = function(i, init) {
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
return _prevFetch.apply(this, arguments);
};
// XHR override (chain)
var _prevOpen = XMLHttpRequest.prototype.open;
var _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this.__fgDU = u || ''; return _prevOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(b) {
var u = this.__fgDU || '';
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockDmPath())) {
var self = this;
setTimeout(function() {
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
}, 5);
return;
}
return _prevSend.apply(this, arguments);
};
// SW killer
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
}
// Beacon blocker
if (navigator.sendBeacon) {
navigator.sendBeacon = function(url) { return true; };
}
// MQTT WS intercept (typing / live viewer)
(function() {
var _WS = window.WebSocket;
function DmGhostWS(url, protocols) {
var ws = protocols ? new _WS(url, protocols) : new _WS(url);
var _send = ws.send.bind(ws);
ws.send = function(data) {
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
var bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
var packetType = bytes[0] & 0xF0;
if (packetType === 0x30) {
try {
var decoded = new TextDecoder('utf-8').decode(bytes);
if (decoded.indexOf('/t_fs') !== -1 || decoded.indexOf('activity_indicator') !== -1 ||
decoded.indexOf('is_typing') !== -1 || decoded.indexOf('direct_typing') !== -1 ||
decoded.indexOf('/live/viewer') !== -1 || decoded.indexOf('live_viewer_list') !== -1) {
return;
}
} catch(e) {}
}
} else if (typeof data === 'string') {
if (data.indexOf('typing') !== -1 || data.indexOf('live_viewer') !== -1 || data.indexOf('is_typing') !== -1) return;
}
return _send(data);
};
return ws;
}
DmGhostWS.prototype = _WS.prototype;
DmGhostWS.CONNECTING = _WS.CONNECTING;
DmGhostWS.OPEN = _WS.OPEN;
DmGhostWS.CLOSING = _WS.CLOSING;
DmGhostWS.CLOSED = _WS.CLOSED;
window.WebSocket = DmGhostWS;
})();
})();
''';
// No Reels / Explore
const String hideReelsJS = '''
const hideReels = () => {
// nav bar reels icon
document.querySelectorAll('a[href="/reels/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
// explore page
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
};
//
// STORY GHOST blocks api/graphql on homepage (/) and /stories/*
// Allows viewing stories without sending seen indicators.
//
const String kStoryGhostJS = r'''
(function() {
if (window.__fgStoryGhostPatched) return;
window.__fgStoryGhostPatched = true;
new MutationObserver(hideReels).observe(document.body, {
childList: true,
subtree: true
});
// Smart path-based blocking
// On /, /stories/*, /story/* block ALL api/graphql
// On /direct/inbox/ allow (DMs need graphql to load messages)
function shouldBlockByPath() {
if (window.__fgStoryGhost !== true) return false;
var p = window.location.pathname;
// Don't block on DM pages
if (p.indexOf('/direct/') === 0) return false;
var isStory = p.indexOf('/stories/') === 0 || p.indexOf('/story/') === 0;
var isHome = p === '/' || p === '';
return isHome || isStory;
}
hideReels();
''';
// No DMs
const String hideDMsJS = '''
const style = document.createElement('style');
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
document.head.appendChild(style);
// Story URL blocklist
var STORY_URLS = [
/\\/api\\/v1\\/media\\/[\\w-]+\\/seen\\//,
/\\/api\\/v1\\/stories\\/reel\\/seen\\//,
/\\/api\\/v1\\/feed\\/viewed_story\\//,
/\\/api\\/v1\\/feed\\/reels_tray\\/seen\\//,
/\\/api\\/v1\\/media\\/seen\\//,
];
function matchUrl(url) {
if (!url) return false;
for (var i = 0; i < STORY_URLS.length; i++) { if (STORY_URLS[i].test(url)) return true; }
return false;
}
// Story GraphQL operations
var STORY_OPS = [
'MarkStorySeen','markStorySeen','ReelSeenMutation','reel_seen','IgFeedSeen',
'FeedbackSeenMutation',
];
function matchGraphQL(body) {
if (!body) return false;
var str = typeof body === 'string' ? body : String(body);
for (var i = 0; i < STORY_OPS.length; i++) { if (str.indexOf(STORY_OPS[i]) !== -1) return true; }
return false;
}
function isGraphql(url) {
return url.indexOf('/api/graphql') !== -1 || url.indexOf('/graphql') !== -1;
}
function shouldBlock(url, init) {
// 1. Path-based: on story pages block ALL graphql
if (shouldBlockByPath() && isGraphql(url)) return true;
// 2. URL blocklist match
if (matchUrl(url)) return true;
// 3. GraphQL body op-name match
if (isGraphql(url) && init) {
var bs = '';
if (typeof init.body === 'string') bs = init.body;
else if (init.body && init.body.toString) bs = init.body.toString();
if (matchGraphQL(bs)) return true;
}
return false;
}
function fakeOk() { return new Response(JSON.stringify({status:'ok'}),{status:200,headers:{'Content-Type':'application/json'}}); }
// Fetch override (chain)
var _prevFetch = window.fetch;
window.fetch = function(i, init) {
var u = (typeof i === 'string') ? i : (i && i.url) ? i.url : String(i);
if (shouldBlock(u, init)) return Promise.resolve(fakeOk());
return _prevFetch.apply(this, arguments);
};
// XHR override (chain)
var _prevOpen = XMLHttpRequest.prototype.open;
var _prevSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this.__fgSU = u || ''; return _prevOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(b) {
var u = this.__fgSU || '';
if (shouldBlock(u, {body: b}) || (isGraphql(u) && shouldBlockByPath())) {
var self = this;
setTimeout(function() {
Object.defineProperty(self,'readyState',{get:function(){return 4}});
Object.defineProperty(self,'status',{get:function(){return 200}});
Object.defineProperty(self,'responseText',{get:function(){return '{"status":"ok"}'}});
Object.defineProperty(self,'response',{get:function(){return '{"status":"ok"}'}});
try{self.onreadystatechange&&self.onreadystatechange();}catch(e){}
try{self.onload&&self.onload();}catch(e){}
['readystatechange','load'].forEach(function(t){try{self.dispatchEvent(new Event(t));}catch(e){}});
}, 5);
return;
}
return _prevSend.apply(this, arguments);
};
// SW killer
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register = function() { return Promise.reject(new Error('blocked')); };
navigator.serviceWorker.getRegistrations().then(function(regs) { regs.forEach(function(r) { r.unregister(); }); }).catch(function(){});
}
// Beacon blocker
if (navigator.sendBeacon) {
navigator.sendBeacon = function(url) { return true; };
}
})();
''';
//
// Builder injects the right scripts based on settings
//
List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[];
final endScripts = <String>[];
// AT_DOCUMENT_START scripts
if (settings.ghostMode) startScripts.add(ghostModeJS);
// Prepend flag values directly into the script so they survive page navigation.
// (evaluateJavascript-set flags are destroyed when the JS context resets on load.)
// DM Ghost uses the comprehensive Full DM approach (URL blocklist, GraphQL ops, SW killer, beacon, WS).
// it should have worked, but sadly it didnt
if (settings.ghostMode)
startScripts.add('window.__fgFullDmGhost=true;' + kFullDmGhostJS);
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END scripts
// AT_DOCUMENT_END
if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS);
@@ -97,3 +433,23 @@ List<UserScript> buildUserScripts(FocusSettings settings) {
}
return scripts;
}
// Existing non-ghost helpers (unchanged)
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') e.target.pause();
}, true);
''';
const String hideStoryTrayJS = '''
(function(){var s=document.createElement('style');s.textContent='[data-pagelet="story_tray"]{display:none!important}';document.head.appendChild(s);})();
''';
const String hideReelsJS = '''
(function(){new MutationObserver(function(){document.querySelectorAll('a[href="/reels/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')});document.querySelectorAll('a[href="/explore/"]').forEach(function(e){var p=e.closest('div');if(p)p.style.setProperty('display','none','important')})}).observe(document.body,{childList:true,subtree:true});})();
''';
const String hideDMsJS = '''
(function(){var s=document.createElement('style');s.textContent='a[href="/direct/inbox/"]{display:none!important}';document.head.appendChild(s);})();
''';
+2 -2
View File
@@ -15,8 +15,8 @@ const String kReelMetadataExtractorScript = r'''
return;
}
// Check if this is a reel page
if (!currentUrl.includes('/reel/')) {
// Check if this is a reel page (Instagram uses /reels/ not /reel/)
if (!currentUrl.includes('/reels/') && !currentUrl.includes('/reel/')) {
return;
}
+184
View File
@@ -0,0 +1,184 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages app lock: PIN, biometrics, and two independent lock modes.
///
/// Modes (both can be on at the same time):
/// - **App-wide lock** shown on cold start (before WebView) and after
/// background timeout.
/// - **Messages tab lock** shown when navigating to Instagram DMs.
///
/// Both use the same PIN (stored in secure storage).
class AppLockService extends ChangeNotifier {
static const _pinAppWideKey = 'app_lock_pin_app_wide';
static const _pinMessagesKey = 'app_lock_pin_messages';
static const _prefAppWide = 'app_lock_app_wide';
static const _prefLockMessages = 'app_lock_lock_messages';
static const _prefScramble = 'app_lock_scramble_keypad';
static const _prefBio = 'app_lock_biometrics_enabled';
static const _prefTimeout = 'app_lock_timeout_ms';
final _secure = const FlutterSecureStorage();
final _auth = LocalAuthentication();
// Mode toggles
bool _lockAppWide = false; // locks the whole app on start / bg timeout
bool _lockMessages = false; // locks only the DMs tab
// Settings
bool _scramble = false;
bool _bioEnabled = true;
int _timeoutMs = 120000; // 2 min
bool _hasPin = false;
// Runtime state
bool _isShowingLock = false; // true while lock screen is displayed
DateTime? _bgAt;
// Getters
bool get lockAppWide => _lockAppWide;
bool get lockMessages => _lockMessages;
bool get isShowingLock => _isShowingLock;
bool get scrambleKeypad => _scramble;
bool get biometricsEnabled => _bioEnabled;
bool get hasPin => _hasPin;
bool get anyLockEnabled => _lockAppWide || _lockMessages;
/// Whether the app-wide lock screen should show on cold start.
bool get needsUnlockOnStart => _lockAppWide && _hasPin;
/// Whether the messages tab lock is enabled and can function.
bool get messagesLockReady => _lockMessages && _hasPin;
// Init
Future<void> init() async {
final p = await SharedPreferences.getInstance();
_lockAppWide = p.getBool(_prefAppWide) ?? false;
_lockMessages = p.getBool(_prefLockMessages) ?? false;
_scramble = p.getBool(_prefScramble) ?? false;
_bioEnabled = p.getBool(_prefBio) ?? true;
_timeoutMs = p.getInt(_prefTimeout) ?? 120000;
// Check if either PIN exists
final hashA = await _secure.read(key: _pinAppWideKey);
final hashM = await _secure.read(key: _pinMessagesKey);
_hasPin = (hashA != null && hashA.isNotEmpty) ||
(hashM != null && hashM.isNotEmpty);
}
// PIN management
String _hash(String pin) =>
utf8.encode('fg_${pin}_salt26')
.map((x) => x.toRadixString(16).padLeft(2, '0'))
.join();
/// Set PIN for a specific lock mode.
Future<void> setPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
await _secure.write(key: key, value: _hash(pin));
_hasPin = true;
notifyListeners();
}
/// Verify PIN for the given mode.
Future<bool> verifyPin(String pin, {required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final stored = await _secure.read(key: key);
return stored != null && stored == _hash(pin);
}
/// Check whether a specific mode has a PIN set.
Future<bool> hasPinFor({required bool forAppWide}) async {
final key = forAppWide ? _pinAppWideKey : _pinMessagesKey;
final hash = await _secure.read(key: key);
return hash != null && hash.isNotEmpty;
}
// Toggles
Future<void> setLockAppWide(bool v) async {
_lockAppWide = v;
(await SharedPreferences.getInstance()).setBool(_prefAppWide, v);
if (!v && !_isShowingLock) _isShowingLock = false;
notifyListeners();
}
Future<void> setLockMessages(bool v) async {
_lockMessages = v;
(await SharedPreferences.getInstance()).setBool(_prefLockMessages, v);
notifyListeners();
}
Future<void> setScrambleKeypad(bool v) async {
_scramble = v;
(await SharedPreferences.getInstance()).setBool(_prefScramble, v);
notifyListeners();
}
Future<void> setBiometricsEnabled(bool v) async {
_bioEnabled = v;
(await SharedPreferences.getInstance()).setBool(_prefBio, v);
notifyListeners();
}
// Lock / Unlock lifecycle
/// Call when app-wide lock screen is opened.
void onLockScreenShown() {
_isShowingLock = true;
notifyListeners();
}
/// Call after successful unlock (PIN or biometric).
void onUnlocked() {
_isShowingLock = false;
_bgAt = null;
notifyListeners();
}
/// Call when app goes to background.
void onBackgrounded() {
_bgAt = DateTime.now();
}
/// Whether the app-wide lock should trigger on resume.
bool get shouldLockOnResume {
if (!_lockAppWide || !_hasPin || _bgAt == null) return false;
return DateTime.now().difference(_bgAt!).inMilliseconds >= _timeoutMs;
}
// Biometrics
Future<bool> isBiometricsAvailable() async {
try {
return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
} catch (_) {
return false;
}
}
Future<bool> authenticateWithBiometrics() async {
if (!_bioEnabled) return false;
try {
return await _auth.authenticate(
localizedReason: 'Unlock FocusGram',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
),
);
} catch (_) {
return false;
}
}
// Scrambled keypad
List<int> getScrambledDigits() {
final d = List<int>.generate(10, (i) => i);
d.shuffle(Random());
return d;
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Outcome of a Bait Me activation.
enum BaitOutcome {
/// Opens your ad website and resets the reels session.
openAdSiteAndReset,
/// Adds 10 minutes to the session credit balance.
addTenMinutes,
/// Opens an external ad URL and ends the session.
openExternalAdAndEnd,
/// Randomly reduces session time (1-5 min).
reduceSessionTime,
/// Increases cooldown by 10 min.
increaseCooldown,
/// Ends the current reel session.
endReelSession,
/// Ends the current app session.
endAppSession,
}
/// Weighted random outcome engine for the Bait Me button.
class BaitEngine extends ChangeNotifier {
static const String _boxName = 'bait_engine';
late Box _box;
final Random _random = Random();
// Hardcoded ad URLs
String _adWebsiteUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
String _externalAdUrl =
'https://www.effectivecpmnetwork.com/qbwsaqj5?key=e547ad0035c9e857ba0ee18506a45f13';
// Cooldown
static const int _cooldownMinutes = 30;
DateTime? _lastActivation;
// Callbacks
void Function(int minutes)? onAddMinutes;
void Function()? onResetSession;
void Function()? onEndReelSession;
void Function()? onEndAppSession;
void Function(String url)? onOpenUrl;
void Function(int minutes)? onReduceSessionTime;
void Function(int minutes)? onIncreaseCooldown;
// Getters
String get adWebsiteUrl => _adWebsiteUrl;
String get externalAdUrl => _externalAdUrl;
bool get isOnCooldown {
if (_lastActivation == null) return false;
return DateTime.now().difference(_lastActivation!).inMinutes <
_cooldownMinutes;
}
int get cooldownRemainingMinutes {
if (_lastActivation == null) return 0;
final elapsed = DateTime.now().difference(_lastActivation!).inMinutes;
return (_cooldownMinutes - elapsed).clamp(0, _cooldownMinutes);
}
// Init
Future<void> init() async {
_box = await Hive.openBox(_boxName);
final lastMs = _box.get('last_activation_ms', defaultValue: 0) as int;
if (lastMs > 0) {
_lastActivation = DateTime.fromMillisecondsSinceEpoch(lastMs);
}
}
// Activation
BaitOutcome roll() {
final r = _random.nextInt(100);
// 30% open ad site + reset (permanent always happens when rolled)
// 20% add 10 min
// 15% reduce session time
// 15% increase cooldown
// 10% end reel session
// 10% end app session
if (r < 30) return BaitOutcome.openAdSiteAndReset;
if (r < 50) return BaitOutcome.addTenMinutes;
if (r < 65) return BaitOutcome.reduceSessionTime;
if (r < 80) return BaitOutcome.increaseCooldown;
if (r < 90) return BaitOutcome.endReelSession;
return BaitOutcome.endAppSession;
}
Future<BaitOutcome> activate() async {
final outcome = roll();
_lastActivation = DateTime.now();
await _box.put(
'last_activation_ms', _lastActivation!.millisecondsSinceEpoch);
notifyListeners();
switch (outcome) {
case BaitOutcome.openAdSiteAndReset:
onResetSession?.call();
onOpenUrl?.call(_adWebsiteUrl);
break;
case BaitOutcome.addTenMinutes:
onAddMinutes?.call(10);
break;
case BaitOutcome.openExternalAdAndEnd:
onOpenUrl?.call(_externalAdUrl);
onResetSession?.call();
break;
case BaitOutcome.reduceSessionTime:
final min = 1 + _random.nextInt(5); // 1-5 min
onReduceSessionTime?.call(min);
break;
case BaitOutcome.increaseCooldown:
onIncreaseCooldown?.call(10);
break;
case BaitOutcome.endReelSession:
onEndReelSession?.call();
break;
case BaitOutcome.endAppSession:
onEndAppSession?.call();
break;
}
return outcome;
}
static String outcomeLabel(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return '💸 Session Reset!';
case BaitOutcome.addTenMinutes:
return '⏰ +10 Minutes!';
case BaitOutcome.openExternalAdAndEnd:
return '🚫 Session Ended!';
case BaitOutcome.reduceSessionTime:
return '⏳ Time Deducted!';
case BaitOutcome.increaseCooldown:
return '🧊 Cooldown Increased!';
case BaitOutcome.endReelSession:
return '🎬 Reel Session Ended!';
case BaitOutcome.endAppSession:
return '📱 App Session Ended!';
}
}
static String outcomeSubtext(BaitOutcome o) {
switch (o) {
case BaitOutcome.openAdSiteAndReset:
return 'All session credits have been reset. Better luck next time.';
case BaitOutcome.addTenMinutes:
return 'You earned 10 extra minutes. Use them wisely!';
case BaitOutcome.openExternalAdAndEnd:
return 'Session forcefully ended. Time for a break.';
case BaitOutcome.reduceSessionTime:
return 'The Bait Me took some time away!';
case BaitOutcome.increaseCooldown:
return 'Cooldown period extended by 10 minutes.';
case BaitOutcome.endReelSession:
return 'Your reel session has been cut short.';
case BaitOutcome.endAppSession:
return 'Your Instagram session has been ended.';
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// Manages time credit balances earned by watching rewarded ads.
///
/// Two balances: [reelsMinutesRemaining] for reel sessions and
/// [instaMinutesRemaining] for Instagram app sessions.
///
/// Also tracks ad watch counts for the Ad Counter dashboard (Phase 5).
class CreditStore extends ChangeNotifier {
static const String _boxName = 'credit_store';
late Box _box;
// Balances
int _reelsMinutes = 0;
int _instaMinutes = 0;
// Ad counters
int _adsWatchedToday = 0;
int _adsWatchedAllTime = 0;
String _todayKey = '';
// Gettters
int get reelsMinutes => _reelsMinutes;
int get instaMinutes => _instaMinutes;
int get adsWatchedToday => _adsWatchedToday;
int get adsWatchedAllTime => _adsWatchedAllTime;
int get timeEarnedViaAds => (_adsWatchedAllTime * minutesPerAd);
bool get hasReelsCredits => _reelsMinutes > 0;
bool get hasInstaCredits => _instaMinutes > 0;
bool get canWatchAdToday => _adsWatchedToday < maxDailyAds;
/// Minutes earned per rewarded ad watch.
static const int minutesPerAd = 2;
static const int maxDailyAds = 5;
// Init
Future<void> init() async {
_box = await Hive.openBox(_boxName);
_reelsMinutes = (_box.get('reels_min', defaultValue: 0) as num).toInt();
_instaMinutes = (_box.get('insta_min', defaultValue: 0) as num).toInt();
_adsWatchedAllTime = (_box.get('ads_all_time', defaultValue: 0) as num)
.toInt();
_todayKey = _dayKey();
// Restore today's count, reset if date changed
final savedDate = _box.get('ads_today_date', defaultValue: '') as String;
if (savedDate == _todayKey) {
_adsWatchedToday = (_box.get('ads_today_count', defaultValue: 0) as num)
.toInt();
} else {
_adsWatchedToday = 0;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', 0);
}
}
// Credit operations
/// Add minutes earned from watching an ad.
Future<void> addReelsMinutes({int amount = minutesPerAd}) async {
_reelsMinutes += amount;
await _box.put('reels_min', _reelsMinutes);
_incrementAdCounters();
notifyListeners();
}
Future<void> addInstaMinutes({int amount = minutesPerAd}) async {
_instaMinutes += amount;
await _box.put('insta_min', _instaMinutes);
_incrementAdCounters();
notifyListeners();
}
/// Drain 1 minute from the reel balance (called every minute during a session).
Future<void> drainReelsMinute() async {
if (_reelsMinutes <= 0) return;
_reelsMinutes--;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
/// Drain 1 minute from the Instagram balance.
Future<void> drainInstaMinute() async {
if (_instaMinutes <= 0) return;
_instaMinutes--;
await _box.put('insta_min', _instaMinutes);
notifyListeners();
}
/// Reset all balances (e.g. on settings toggle off).
Future<void> resetBalances() async {
_reelsMinutes = 0;
_instaMinutes = 0;
await _box.put('reels_min', 0);
await _box.put('insta_min', 0);
notifyListeners();
}
/// Add minutes directly from the Bait Me feature.
Future<void> addBonusMinutes(int minutes) async {
// Add to reels balance (bait me rewards are for reels)
_reelsMinutes += minutes;
await _box.put('reels_min', _reelsMinutes);
notifyListeners();
}
// Ad counter helpers
void _incrementAdCounters() {
_adsWatchedToday++;
_adsWatchedAllTime++;
_box.put('ads_today_date', _todayKey);
_box.put('ads_today_count', _adsWatchedToday);
_box.put('ads_all_time', _adsWatchedAllTime);
}
/// Reset daily ad counter (call on day change).
Future<void> resetDailyIfNeeded() async {
final newKey = _dayKey();
if (newKey != _todayKey) {
_todayKey = newKey;
_adsWatchedToday = 0;
await _box.put('ads_today_date', _todayKey);
await _box.put('ads_today_count', 0);
notifyListeners();
}
}
String _dayKey() {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
}
+421
View File
@@ -0,0 +1,421 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
/// Feature identifiers for level gating.
/// Every gated feature checks [LevelService.isFeatureUnlocked].
class AppFeature {
final String id;
final String name;
final int requiredLevel;
const AppFeature._(this.id, this.name, this.requiredLevel);
static const effortFriction = AppFeature._(
'effort_friction',
'Effort Friction Mode',
2,
);
static const reelsHistory = AppFeature._('reels_history', 'Reels History', 2);
static const downloadMedia = AppFeature._(
'download_media',
'Download Media',
2,
);
static const fullDmGhost = AppFeature._('full_dm_ghost', 'Full DM Ghost', 1);
static const ghostMode = AppFeature._('ghost_mode', 'Ghost Mode', 2);
static const baitMe = AppFeature._('bait_me', 'Bait Me Button', 3);
static const appLock = AppFeature._('app_lock', 'App Lock', 3);
static const customFriction = AppFeature._(
'custom_friction',
'Custom Friction Rules',
4,
);
static const List<AppFeature> all = [
effortFriction,
downloadMedia,
ghostMode,
baitMe,
appLock,
];
}
/// XP thresholds for each level.
/// Level 1 = 0 XP (always start here).
const Map<int, int> levelThresholds = {1: 0, 2: 100, 3: 250, 4: 450, 5: 700};
const int maxLevel = 5;
/// A single XP event logged for the XP history view.
class _XpEvent {
final int amount;
final String reason;
final DateTime time;
_XpEvent(this.amount, this.reason, this.time);
}
/// Tracks XP, level progression, degradation, and monthly resets.
///
/// Always-on (not toggleable). All new features are gated behind levels.
///
/// **Storage:** Hive box `level_cache` (persistent local storage).
class LevelService extends ChangeNotifier {
// Hive box
static const String _hiveBox = 'level_cache';
late Box _cache;
// Runtime state
int _level = 1;
int _xp = 0;
DateTime? _lastResetDate;
List<int> _dailyReelCounts = []; // last 30 days
int _totalReelsAllTime = 0;
int _adsWatchedTotal = 0;
// Track today for daily reel logging
int _todayReelCount = 0;
String _todayKey = '';
// Getters
int get level => _level;
int get xp => _xp;
int get totalReelsAllTime => _totalReelsAllTime;
int get adsWatchedTotal => _adsWatchedTotal;
/// XP needed for the current level (cumulative threshold for this level).
int get xpForCurrentLevel => levelThresholds[_level] ?? 0;
/// XP needed to reach the next level (or current if at max).
int get xpForNextLevel {
if (_level >= maxLevel) return levelThresholds[maxLevel]!;
return levelThresholds[_level + 1] ?? xpForCurrentLevel;
}
/// Progress 0.01.0 within the current level.
double get levelProgress {
final current = _xp - xpForCurrentLevel;
final needed = xpForNextLevel - xpForCurrentLevel;
if (needed <= 0) return 1.0;
return (current / needed).clamp(0.0, 1.0);
}
/// Whether the user has reached (or exceeded) the required level.
bool isFeatureUnlocked(AppFeature feature) => _level >= feature.requiredLevel;
/// The next locked feature with level requirement for "What's next?" display.
AppFeature? get nextLockedFeature {
for (final f in AppFeature.all) {
if (!isFeatureUnlocked(f)) return f;
}
return null;
}
// Initialization
Future<void> init() async {
// 1. Open Hive cache box
_cache = await Hive.openBox(_hiveBox);
_loadFromCache();
// 2. Set up today tracking
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
// 3. Check monthly reset
await _checkMonthlyReset();
// 4. Check daily degradation
await _checkDailyDegradation();
notifyListeners();
}
void _loadFromCache() {
try {
_level = (_cache.get('level') ?? 1) as int;
_xp = (_cache.get('xp') ?? 0) as int;
final lastReset = _cache.get('lastResetDate') as String?;
if (lastReset != null) {
_lastResetDate = DateTime.tryParse(lastReset);
}
final countsRaw = _cache.get('dailyReelCounts') as String?;
if (countsRaw != null) {
_dailyReelCounts = (jsonDecode(countsRaw) as List).cast<int>();
}
_totalReelsAllTime = (_cache.get('totalReelsAllTime') ?? 0) as int;
_adsWatchedTotal = (_cache.get('adsWatchedTotal') ?? 0) as int;
} catch (_) {
// Fall back to defaults
}
}
Future<void> _saveToCache() async {
await _cache.put('level', _level);
await _cache.put('xp', _xp);
await _cache.put('lastResetDate', _lastResetDate?.toIso8601String());
await _cache.put('dailyReelCounts', jsonEncode(_dailyReelCounts));
await _cache.put('totalReelsAllTime', _totalReelsAllTime);
await _cache.put('adsWatchedTotal', _adsWatchedTotal);
}
// XP History
final List<_XpEvent> _xpHistory = [];
List<_XpEvent> get xpHistory => List.unmodifiable(_xpHistory);
/// Human-readable recent XP log for "Your Journey".
List<Map<String, dynamic>> get recentXpLog {
return _xpHistory.reversed
.take(50)
.map(
(e) => {
'amount': e.amount,
'reason': e.reason,
'time': e.time.toIso8601String(),
},
)
.toList();
}
// XP Earning
static const int _dailyAdXpCap = 20;
int _adsWatchedToday = 0;
/// Call when a rewarded ad is completed.
Future<void> addXpForAd() async {
if (_adsWatchedToday >= _dailyAdXpCap) return; // Cap reached
_adsWatchedToday++;
_adsWatchedTotal++;
await _awardXp(10, reason: 'Watched an ad');
}
/// Call when a session ends awards XP for self-control.
/// [reelsWatchedToday] = total reels watched so far today.
Future<void> evaluateDailyReelControl(int reelsWatchedToday) async {
// Calculate 7-day average
final avg7 = _sevenDayAverage();
if (avg7 <= 0) return; // Not enough data yet
if (reelsWatchedToday < avg7) {
// User watched fewer reels than average award XP
final reelsSaved = (avg7 - reelsWatchedToday).floor();
final xpGain = min(reelsSaved * 10, 50); // Max +50 XP per day
await _awardXp(xpGain, reason: 'Reduced reel count');
}
// Log today's count
await _logDailyReelCount(reelsWatchedToday);
}
/// Call once per day when the user opens the app.
Future<void> addDailyCheckinXp() async {
final prefs = await SharedPreferences.getInstance();
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
final lastCheckin = prefs.getString('level_last_checkin') ?? '';
if (lastCheckin == today) return; // Already checked in today
await prefs.setString('level_last_checkin', today);
await _awardXp(1, reason: 'Daily check-in');
}
/// Complete a full day under the daily reel limit.
Future<void> awardDayUnderLimit() async {
await _awardXp(15, reason: 'Day under limit');
}
Future<void> _awardXp(int amount, {String reason = 'general'}) async {
_xp += amount;
_xp = max(0, min(_xp, levelThresholds[maxLevel]!));
// Log to history
_xpHistory.add(_XpEvent(amount, reason, DateTime.now()));
// Keep last 200 entries
if (_xpHistory.length > 200) {
_xpHistory.removeRange(0, _xpHistory.length - 200);
}
await _checkLevelUp();
await _saveToCache();
notifyListeners();
}
Future<void> _checkLevelUp() async {
while (_level < maxLevel) {
final nextThreshold = levelThresholds[_level + 1]!;
if (_xp >= nextThreshold) {
_level++;
//debugPrint('🎉 Level up! Now Level $_level');
} else {
break;
}
}
}
// XP Decay / Degradation
Future<void> _checkDailyDegradation() async {
if (_dailyReelCounts.isEmpty) return;
final avg7 = _sevenDayAverage();
final allTimeAvg = _allTimeAverage();
// Check if today's count (from yesterday, since this runs at startup)
// exceeds both averages
final yesterdayCount = _dailyReelCounts.isNotEmpty
? _dailyReelCounts.last
: 0;
if (yesterdayCount > avg7 && yesterdayCount > allTimeAvg && avg7 > 0) {
// Deduct XP
_xp = max(0, _xp - 20);
notifyListeners();
}
// Check for level drop: exceeded app time limit 3 days in a row
// (We check via a streak counter stored in prefs)
await _checkLevelDropStreak();
}
Future<void> _checkLevelDropStreak() async {
final prefs = await SharedPreferences.getInstance();
final streakKey = 'level_drop_streak';
int streak = prefs.getInt(streakKey) ?? 0;
if (_dailyReelCounts.length >= 3) {
final last3 = _dailyReelCounts.sublist(_dailyReelCounts.length - 3);
final avg7 = _sevenDayAverage();
final allExceeded = last3.every((c) => c > avg7 && avg7 > 0);
if (allExceeded) {
streak++;
await prefs.setInt(streakKey, streak);
} else {
// Reset streak
await prefs.setInt(streakKey, 0);
}
if (streak >= 3 && _level > 1) {
// Drop one full level
_level = max(1, _level - 1);
// Also reduce XP to the threshold of the new level
_xp = levelThresholds[_level]!;
await prefs.setInt(streakKey, 0);
//debugPrint('⚠️ Level dropped to $_level due to 3-day streak');
}
}
await _saveToCache();
}
// Monthly Reset
Future<void> _checkMonthlyReset() async {
if (_lastResetDate == null) {
_lastResetDate = DateTime.now();
return;
}
final daysSinceReset = DateTime.now().difference(_lastResetDate!).inDays;
if (daysSinceReset >= 30) {
_xp = 0; // Reset XP to 0
// Level is preserved (loss aversion)
_lastResetDate = DateTime.now();
_dailyReelCounts = []; // Clear daily history
_dailyReelCountsAddedToday = false;
await _saveToCache();
notifyListeners();
// Show monthly summary (handled by the UI layer by checking a flag)
_showMonthlySummary = true;
}
}
/// Flag consumed by UI to show "New month, fresh start" screen.
bool _showMonthlySummary = false;
bool get showMonthlySummary => _showMonthlySummary;
void dismissMonthlySummary() {
_showMonthlySummary = false;
notifyListeners();
}
// Daily Reel Logging
bool _dailyReelCountsAddedToday = false;
Future<void> _logDailyReelCount(int reelCount) async {
if (_dailyReelCountsAddedToday) return;
_dailyReelCounts.add(reelCount);
_totalReelsAllTime += reelCount;
// Keep only last 30 days
if (_dailyReelCounts.length > 30) {
_dailyReelCounts.removeRange(0, _dailyReelCounts.length - 30);
}
_dailyReelCountsAddedToday = true;
await _saveToCache();
}
double _sevenDayAverage() {
if (_dailyReelCounts.isEmpty) return 0;
final recent = _dailyReelCounts.length >= 7
? _dailyReelCounts.sublist(_dailyReelCounts.length - 7)
: _dailyReelCounts;
final sum = recent.fold<int>(0, (a, b) => a + b);
return sum / recent.length;
}
double _allTimeAverage() {
if (_dailyReelCounts.isEmpty) return 0;
final sum = _dailyReelCounts.fold<int>(0, (a, b) => a + b);
return sum / _dailyReelCounts.length;
}
/// Call this at the end of each day to award "day under limit" XP.
Future<void> finalizeDay(
int reelsWatchedToday,
int dailyReelLimitMinutes,
) async {
final dailyReelCount = reelsWatchedToday; // in minutes
if (dailyReelCount <= dailyReelLimitMinutes) {
await awardDayUnderLimit();
}
}
/// Reset the daily ad counter (call at midnight).
void resetDailyAdCounter() {
_adsWatchedToday = 0;
}
/*/// Grant XP with a custom reason (used from the debug section in settings).
Future<void> grantDebugXp(int amount, String reason) async {
await _awardXp(amount, reason: reason);
}
// Debug Methods
/// Force-set level and XP (debug only).
Future<void> debugSetLevel(int level, int xp) async {
_level = level.clamp(1, maxLevel);
_xp = xp.clamp(0, levelThresholds[maxLevel]!);
await _saveToCache();
notifyListeners();
}
/// Reset all level data (debug only).
Future<void> debugReset() async {
_level = 1;
_xp = 0;
_dailyReelCounts = [];
_totalReelsAllTime = 0;
_adsWatchedTotal = 0;
_adsWatchedToday = 0;
_lastResetDate = DateTime.now();
_dailyReelCountsAddedToday = false;
await _saveToCache();
notifyListeners();
}*/
}
+6 -7
View File
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class NotificationService {
@@ -55,7 +54,7 @@ class NotificationService {
>()
?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) {
debugPrint('iOS permission request error: $e');
// debugPrint('iOS permission request error: $e');
}
}
@@ -67,7 +66,7 @@ class NotificationService {
>()
?.requestNotificationsPermission();
} catch (e) {
debugPrint('Android permission request error: $e');
// debugPrint('Android permission request error: $e');
}
}
@@ -105,7 +104,7 @@ class NotificationService {
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Notification error: $e');
// debugPrint('Notification error: $e');
}
}
@@ -149,7 +148,7 @@ class NotificationService {
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Persistent notification error: $e');
// debugPrint('Persistent notification error: $e');
}
}
@@ -158,7 +157,7 @@ class NotificationService {
try {
await _notificationsPlugin.cancel(id: id);
} catch (e) {
debugPrint('Cancel persistent notification error: $e');
// debugPrint('Cancel persistent notification error: $e');
}
}
@@ -167,7 +166,7 @@ class NotificationService {
try {
await _notificationsPlugin.cancelAll();
} catch (e) {
debugPrint('Cancel all notifications error: $e');
// debugPrint('Cancel all notifications error: $e');
}
}
}
+26
View File
@@ -462,6 +462,13 @@ class SessionManager extends ChangeNotifier {
return true;
}
/// Temporarily increase the daily limit by [minutes] (for ad rewards).
void addBonusDailyMinutes(int minutes) {
_dailyLimitSeconds += minutes * 60;
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
notifyListeners();
}
void endSession() {
if (!_isSessionActive) return;
// Don't show notification when user manually ends the session
@@ -482,6 +489,13 @@ class SessionManager extends ChangeNotifier {
notifyListeners();
}
/// Whether the user needs to go through the Effort Friction gate
/// before starting a reel session.
bool needsEffortFrictionGate(bool effortModeEnabled, int creditBalance) {
if (!effortModeEnabled) return false;
return creditBalance <= 0;
}
// App session API
/// Start an app session of [minutes] (160).
@@ -498,6 +512,18 @@ class SessionManager extends ChangeNotifier {
}
/// Extend the app session by 10 minutes. Only works once.
/// Increase daily limit by [minutes] and return whether it succeeded.
bool increaseDailyLimit(int minutes) {
final current = _dailyLimitSeconds;
final added = minutes * 60;
_dailyLimitSeconds = (current + added).clamp(0, 7200); // max 2 hours
_prefs?.setInt(_keyDailyLimitSec, _dailyLimitSeconds);
_dailyUsedSeconds = 0; // reset used counter so they can use the new quota
_prefs?.setInt(_keyDailyUsedSeconds, 0);
notifyListeners();
return true;
}
bool extendAppSession() {
if (_appExtensionUsed) return false;
final base = _appSessionEnd ?? DateTime.now();
+68 -4
View File
@@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
@@ -63,6 +63,16 @@ class SettingsService extends ChangeNotifier {
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
// Adsterra fallback
static const _keyAdsterraZoneUrl = 'adsterra_zone_url';
static const _keyAdsterraAdCode = 'adsterra_ad_code';
// Startup page
static const _keyStartupPage = 'startup_page';
// Effort Friction Mode
static const _keyEffortFrictionEnabled = 'effort_friction_enabled';
// Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms';
@@ -139,6 +149,10 @@ class SettingsService extends ChangeNotifier {
bool _notifyPersistent = false;
// Focus mode settings
bool _effortFrictionEnabled = false;
String _startupPage = 'home'; // home, following, favorites, direct
String _adsterraZoneUrl = '';
String _adsterraAdCode = '';
bool _ghostMode = false;
bool _noAds = false;
bool _noStories = false;
@@ -196,6 +210,23 @@ class SettingsService extends ChangeNotifier {
bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get effortFrictionEnabled => _effortFrictionEnabled;
String get startupPage => _startupPage;
String get startupUrl {
switch (_startupPage) {
case 'following':
return 'https://www.instagram.com/?variant=following';
case 'favorites':
return 'https://www.instagram.com/?variant=favorites';
case 'direct':
return 'https://www.instagram.com/direct/inbox/';
default:
return 'https://www.instagram.com/';
}
}
String get adsterraZoneUrl => _adsterraZoneUrl;
String get adsterraAdCode => _adsterraAdCode;
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
@@ -290,7 +321,8 @@ class SettingsService extends ChangeNotifier {
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules
// Load grayscale toggle + schedules
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
@@ -330,6 +362,11 @@ class SettingsService extends ChangeNotifier {
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_effortFrictionEnabled =
_prefs!.getBool(_keyEffortFrictionEnabled) ?? false;
_startupPage = _prefs!.getString(_keyStartupPage) ?? 'home';
_adsterraZoneUrl = _prefs!.getString(_keyAdsterraZoneUrl) ?? '';
_adsterraAdCode = _prefs!.getString(_keyAdsterraAdCode) ?? '';
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
@@ -411,8 +448,9 @@ class SettingsService extends ChangeNotifier {
final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
// Defer notifyListeners to next microtask to avoid rebuild conflicts
Future.microtask(notifyListeners);
// Defer notifyListeners to after the current frame to avoid
// Flutter's 'Dependents.isEmpty' assertion error.
WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners());
}
Future<void> setWordChallengeCount(int count) async {
@@ -771,7 +809,33 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
// Startup page
Future<void> setStartupPage(String page) async {
_startupPage = page;
await _prefs?.setString(_keyStartupPage, page);
notifyListeners();
}
// Adsterra zone config
Future<void> setAdsterraZoneUrl(String url) async {
_adsterraZoneUrl = url;
await _prefs?.setString(_keyAdsterraZoneUrl, url);
notifyListeners();
}
Future<void> setAdsterraAdCode(String code) async {
_adsterraAdCode = code;
await _prefs?.setString(_keyAdsterraAdCode, code);
notifyListeners();
}
// Focus mode settings
Future<void> setEffortFrictionEnabled(bool v) async {
_effortFrictionEnabled = v;
await _prefs?.setBool(_keyEffortFrictionEnabled, v);
notifyListeners();
}
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
/// A saved page that can be viewed offline via WebView cache.
/// No API calls just bookmarks URLs you've already visited
/// so the WebView's built-in cache (`LOAD_CACHE_ELSE_NETWORK`)
/// can serve them when offline.
class SavedPage {
final String id;
final String url;
final String title;
final DateTime savedAt;
final String? htmlContent; // captured page HTML for offline viewing
const SavedPage({
required this.id,
required this.url,
required this.title,
required this.savedAt,
this.htmlContent,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'savedAt': savedAt.toIso8601String(),
if (htmlContent != null) 'html': htmlContent,
};
factory SavedPage.fromJson(Map<String, dynamic> json) => SavedPage(
id: json['id'] as String? ?? '',
url: json['url'] as String? ?? '',
title: json['title'] as String? ?? 'Instagram',
savedAt: DateTime.tryParse(json['savedAt'] as String? ?? '') ??
DateTime.now(),
htmlContent: json['html'] as String?,
);
}
/// Manages saved pages for offline viewing.
///
/// How it works:
/// 1. The WebView already has `cacheMode: LOAD_CACHE_ELSE_NETWORK`
/// 2. When you visit a page online, the WebView caches it automatically
/// 3. This service just bookmarks URLs so you can navigate to them offline
/// 4. The WebView serves the cached version when there's no internet
///
/// No Instagram API needed. No content downloading. Just cache + bookmarks.
class SnapshotService extends ChangeNotifier {
static const String _hiveBox = 'saved_pages';
late Box _box;
List<SavedPage> _savedPages = [];
List<SavedPage> get savedPages => List.unmodifiable(_savedPages);
int get totalSaved => _savedPages.length;
Future<void> init() async {
_box = await Hive.openBox(_hiveBox);
_loadFromCache();
}
void _loadFromCache() {
try {
final raw = _box.get('page_list') as String?;
if (raw != null) {
final decoded = jsonDecode(raw) as List;
_savedPages = decoded
.map((e) => SavedPage.fromJson(e as Map<String, dynamic>))
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
}
} catch (_) {}
}
Future<void> _saveToCache() async {
final json = jsonEncode(_savedPages.map((e) => e.toJson()).toList());
await _box.put('page_list', json);
}
/// Save a page. Optionally pass [htmlContent] captured from the WebView.
Future<void> savePage(String url, {String title = 'Instagram', String? htmlContent}) async {
if (url.isEmpty) return;
// Avoid duplicates
if (_savedPages.any((p) => p.url == url)) return;
final page = SavedPage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
url: url,
title: title,
savedAt: DateTime.now(),
htmlContent: htmlContent,
);
_savedPages.insert(0, page);
await _saveToCache();
notifyListeners();
}
/// Remove a saved page.
Future<void> deletePage(String id) async {
_savedPages.removeWhere((p) => p.id == id);
await _saveToCache();
notifyListeners();
}
/// Remove all saved pages.
Future<void> deleteAll() async {
_savedPages.clear();
await _saveToCache();
notifyListeners();
}
/// Get the total count.
int get count => _savedPages.length;
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Adsterra 300×250 medium rectangle banner.
/// Native-looking container, no "AD" label.
/// Best for in-content placements (settings page, panel).
const String _kMediumRectCode = '''
<script>
atOptions = {
'key' : '99233324430f9128f2b01c30b6eebc20',
'format' : 'iframe',
'height' : 250,
'width' : 300,
'params' : {}
};
</script>
<script src="https://www.highperformanceformat.com/99233324430f9128f2b01c30b6eebc20/invoke.js"></script>
''';
class MediumRectBanner extends StatelessWidget {
const MediumRectBanner({super.key});
String get _html => '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
background:transparent;
display:flex; align-items:center; justify-content:center;
}
iframe { border:none; max-width:100%; }
</style>
</head>
<body>$_kMediumRectCode</body>
</html>
''';
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 270),
decoration: BoxDecoration(
color: Colors.transparent,
border: Border(
top: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
),
),
child: SizedBox(
height: 250,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: _html,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
// Adsterra banner codes
// 320×50 standard mobile banner, used at bottom of screens
const String _kBanner320x50 = '''
<script>
atOptions = {
'key' : 'd00c3602dafbd199f16d4a6426156cd6',
'format' : 'iframe',
'height' : 50,
'width' : 320,
'params' : {}
};
</script>
<script src="https://www.highperformanceformat.com/d00c3602dafbd199f16d4a6426156cd6/invoke.js"></script>
''';
/// A small 320×50 banner that loads natively inside the app.
/// Place at the bottom of screens.
class NativeAdBanner extends StatelessWidget {
final double height;
final String? customCode;
const NativeAdBanner({super.key, this.height = 60, this.customCode});
String get _html {
final code = customCode ?? _kBanner320x50;
return '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
width:100%; height:100%;
background:transparent;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
}
iframe { border:none; max-width:100%; }
</style>
</head>
<body>$code</body>
</html>
''';
}
@override
Widget build(BuildContext context) {
return Container(
// Subtle native look barely visible border, no "AD" label
decoration: BoxDecoration(
color: Colors.transparent,
border: Border(
top: BorderSide(color: Colors.grey.withValues(alpha: 0.08)),
),
),
child: SizedBox(
height: height,
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: _html,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
),
),
);
}
}