mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-02 09:35:31 +02:00
Feature Pack with bug fixes for V2
This commit is contained in:
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
@@ -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});
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
|
||||
@@ -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
@@ -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);})();
|
||||
''';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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();
|
||||
}*/
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] (1–60).
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user