diff --git a/.gitignore b/.gitignore index faca3f5..949198e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,9 @@ .swiftpm/ migrate_working_dir/ PRD.md +.reasonix/ .agents/ -TODO.md -v2/FOCUSGRAM_V2_PLAN.md -v2/FocusGram_Feed_Filtering_Reference.docx + # IntelliJ related diff --git a/CHANGELOG.md b/CHANGELOG.md index 035a394..3510bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,23 @@ -## FocusGram 2.0.0 +## FocusGram 2.1.0 ### What's new -- NEW: Added Media Downloader for downloading images and videos -- NEW: Added Ghost Mode -- NEW: Added a toggle for scroll lock in minimal mode -- NEW: Added Option to Choose Duration of Mindfulness Gate -- NEW: Added ability to customize number of words in typing challenge -- UPDATED: Redesigned Focus Control Flyout -- UPDATED: Settings and Reordered items -- UPDATED: Added more time Choices for reels session -- UPDATED: Improved Permission Request invocation in onboarding page. -- UPDATED: Improved Notification Alerts - +- NEW: Startup Page - choose which page to launch on app launch. +- NEW: App lock and DM's Lock. +- NEW: Bait me button in Focus Control. +- NEW: Interactive Level based system for unlocking features. +- NEW: Effort Friction Mode. +- NEW: Strict and fully working Ghost Mode. +- NEW: REDUCES the amount of ads in your feed (NO Toggles for this, mighn't work on some devices). ### Bug fixes -- Fixed: back button on homepage didnt exit the app. -- Fixed: Only First image of multiple imaged posts was blurred. -- FIxed: Couldn't scroll the home feed after enabling minimal mode + +- Fixed: Greyscale mode used to turn off when app was restarted. +- Fixed: Images in posts containing multiple images werent getting unblurred when tapped. +- Fixed: Ghost mode didn't work properly. +- Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs. +- Fixed: Download media button (rarely) opened random media rather than desired one. +- Fixed: Reel Session could be started despite quota being finished. - Perfomance Optimizations -- A lof of other Minor fixes . \ No newline at end of file +- A lof of other Minor fixes . diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ae5e115..a24ed93 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -63,11 +63,9 @@ android { } } + // Narrow exclusions to only the specific modules that cause conflicts, + // not entire Google/Firebase groups (which would block AdMob & Firebase). configurations.all { - exclude(group = "com.google.android.gms") - exclude(group = "com.google.firebase") - exclude(group = "com.google.android.datatransport") - exclude(group = "com.google.android.play") exclude(group = "com.google.android.play", module = "core") exclude(group = "com.google.android.play", module = "core-common") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4a38ec5..fcde977 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -61,6 +61,8 @@ + + diff --git a/android/gradle.properties b/android/gradle.properties index b3764f0..b7532de 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/assets/scripts/ghost_mode.js b/assets/scripts/ghost_mode.js index 2b78da6..df10132 100644 --- a/assets/scripts/ghost_mode.js +++ b/assets/scripts/ghost_mode.js @@ -1,12 +1,36 @@ /** - * FocusGram Ghost Mode + * FocusGram Ghost Mode (V2 Overlay) * Injected at DOCUMENT_START — before Instagram's JS loads. * Blocks story-seen, message-seen, and online-presence signals. + * + * Uses _prev chain pattern: each section saves the PREVIOUS fetch/XHR + * before overriding, so they compose rather than conflict. */ (function () { 'use strict'; - // ─── Seen API patterns ──────────────────────────────────────────────────── + // ─── First-interaction DM gate ────────────────────────────────────────── + // On /direct/*, first click blocks all api/graphql (inbox loads first). + 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 now = window.location.pathname.indexOf('/direct/') === 0; + if (now !== _prevD) { _prevD = now; window.__fgDirectApiBlocked = false; } + }, 300); + + function _blockIfNeeded(url) { + return window.__fgDirectApiBlocked && + window.location.pathname.indexOf('/direct/') === 0 && + url.indexOf('/api/graphql') !== -1; + } + + // ─── SEEN + ACTIVITY patterns ─────────────────────────────────────────── const SEEN_PATTERNS = [ /\/api\/v1\/media\/[\w-]+\/seen\//, /\/api\/v1\/stories\/reel\/seen\//, @@ -15,7 +39,6 @@ /\/api\/v1\/live\/[\w-]+\/comment\/seen\//, ]; - // ─── Activity patterns (like, comment) — intercepted for local history ──── const ACTIVITY_PATTERNS = [ /\/api\/v1\/web\/likes\/[\w-]+\/like\//, /\/api\/v1\/web\/comments\/add\//, @@ -25,16 +48,9 @@ const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url)); const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url)); - const fakeOkResponse = () => - new Response(JSON.stringify({ status: 'ok' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - - // ─── Fetch override ─────────────────────────────────────────────────────── - const _fetch = window.fetch.bind(window); - - const patchedFetch = async function (input, init) { + // ─── Fetch override — chains with whatever was there ────────────────────── + const _prevFetch = window.fetch; + window.fetch = async function (input, init) { const url = typeof input === 'string' ? input @@ -42,17 +58,24 @@ ? input.href : input?.url ?? ''; - // Block seen - if (isSeen(url)) { - if (window.GhostChannel) { - window.GhostChannel.postMessage( - JSON.stringify({ type: 'seen_blocked', url }) - ); - } - return fakeOkResponse(); + // DM first-interaction gate + if (_blockIfNeeded(url)) { + return new Response(JSON.stringify({ status: 'ok' }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); } - // Intercept activity for local history + // Seen pattern block + if (isSeen(url)) { + if (window.GhostChannel) { + window.GhostChannel.postMessage(JSON.stringify({ type: 'seen_blocked', url })); + } + return new Response(JSON.stringify({ status: 'ok' }), { + status: 200, headers: { 'Content-Type': 'application/json' } + }); + } + + // Activity interceptor for local history if (isActivity(url) && window.ActivityChannel) { const body = init?.body; const bodyText = @@ -66,51 +89,57 @@ ); } - return _fetch(input, init); + return _prevFetch(input, init); }; - - // Disguise as native - Object.defineProperty(window, 'fetch', { - value: patchedFetch, - writable: true, - configurable: true, - enumerable: true, - }); window.fetch.toString = () => 'function fetch() { [native code] }'; - window.fetch[Symbol.toStringTag] = 'fetch'; - // ─── XMLHttpRequest override ────────────────────────────────────────────── - const _XHROpen = XMLHttpRequest.prototype.open; - const _XHRSend = XMLHttpRequest.prototype.send; + // ─── XHR override — chains ────────────────────────────────────────────── + const _prevOpen = XMLHttpRequest.prototype.open; + const _prevSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...args) { this._fg_url = url ?? ''; this._fg_method = (method ?? '').toUpperCase(); - return _XHROpen.call(this, method, url, ...args); + return _prevOpen.call(this, method, url, ...args); }; XMLHttpRequest.prototype.send = function (body) { - if (this._fg_url && isSeen(this._fg_url)) { - // Fire readyState 4 with fake success without actually sending + const url = this._fg_url || ''; + + // DM first-interaction gate + if (_blockIfNeeded(url)) { const self = this; setTimeout(() => { Object.defineProperty(self, 'readyState', { get: () => 4 }); Object.defineProperty(self, 'status', { get: () => 200 }); - Object.defineProperty(self, 'responseText', { - get: () => '{"status":"ok"}', + Object.defineProperty(self, 'responseText', { get: () => '{"status":"ok"}' }); + Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' }); + ['readystatechange', 'load'].forEach(function(t) { + try { self.dispatchEvent(new Event(t)); } catch(e) {} }); - Object.defineProperty(self, 'response', { - get: () => '{"status":"ok"}', - }); - self.dispatchEvent(new Event('readystatechange')); - self.dispatchEvent(new Event('load')); - }, 10); + }, 5); return; } - return _XHRSend.call(this, body); + + // Seen pattern block + if (url && isSeen(url)) { + const self = this; + setTimeout(() => { + Object.defineProperty(self, 'readyState', { get: () => 4 }); + Object.defineProperty(self, 'status', { get: () => 200 }); + Object.defineProperty(self, 'responseText', { get: () => '{"status":"ok"}' }); + Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' }); + ['readystatechange', 'load'].forEach(function(t) { + try { self.dispatchEvent(new Event(t)); } catch(e) {} + }); + }, 5); + return; + } + + return _prevSend.call(this, body); }; - // ─── WebSocket intercept (message-seen via WS) ──────────────────────────── + // ─── WebSocket intercept (message-seen via WS) ────────────────────────── const _WS = window.WebSocket; function PatchedWebSocket(url, protocols) { @@ -119,7 +148,6 @@ ws.send = function (data) { if (typeof data === 'string') { - // IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version try { const parsed = JSON.parse(data); if ( @@ -130,7 +158,6 @@ return; // drop } } catch (_) {} - // Text-based seen signal check if (data.includes('"seen"') && data.includes('"thread_id"')) { return; } @@ -141,7 +168,6 @@ return ws; } - // Preserve WebSocket prototype chain so IG's ws checks pass PatchedWebSocket.prototype = _WS.prototype; PatchedWebSocket.CONNECTING = _WS.CONNECTING; PatchedWebSocket.OPEN = _WS.OPEN; @@ -149,24 +175,18 @@ PatchedWebSocket.CLOSED = _WS.CLOSED; window.WebSocket = PatchedWebSocket; - // ─── Visibility trick — hide "Active Now" ──────────────────────────────── - // Only applied if user enables online-status hiding - // Wrapped in a named fn so Flutter can call it: - // controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()') + // ─── Visibility trick — hide "Active Now" ────────────────────────────── window.__fgEnableOnlineHide = function () { Object.defineProperty(document, 'visibilityState', { - get: () => 'hidden', - configurable: true, + get: () => 'hidden', configurable: true, }); Object.defineProperty(document, 'hidden', { - get: () => true, - configurable: true, + get: () => true, configurable: true, }); document.dispatchEvent(new Event('visibilitychange')); }; window.__fgDisableOnlineHide = function () { - // Restore by deleting the overrides (falls back to native getter) delete document.visibilityState; delete document.hidden; document.dispatchEvent(new Event('visibilitychange')); diff --git a/decoded.jks b/decoded.jks deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/preloader/instagram_preloader.dart b/lib/features/preloader/instagram_preloader.dart index 741976f..13918ff 100644 --- a/lib/features/preloader/instagram_preloader.dart +++ b/lib/features/preloader/instagram_preloader.dart @@ -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 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; diff --git a/lib/features/reels_history/reels_history_service.dart b/lib/features/reels_history/reels_history_service.dart index 6364902..92ee133 100644 --- a/lib/features/reels_history/reels_history_service.dart +++ b/lib/features/reels_history/reels_history_service.dart @@ -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 toJson() => { @@ -23,6 +27,8 @@ class ReelsHistoryEntry { 'title': title, 'thumbnailUrl': thumbnailUrl, 'visitedAt': visitedAt.toUtc().toIso8601String(), + 'durationSeconds': durationSeconds, + 'adsWatchedInSession': adsWatchedInSession, }; static ReelsHistoryEntry fromJson(Map 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 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> 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 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 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 clearAll() async { final prefs = await _getPrefs(); await prefs.remove(_prefsKey); diff --git a/lib/features/update_checker/update_checker_service.dart b/lib/features/update_checker/update_checker_service.dart index 077d973..2ffd2ff 100644 --- a/lib/features/update_checker/update_checker_service.dart +++ b/lib/features/update_checker/update_checker_service.dart @@ -74,7 +74,7 @@ class UpdateCheckerService extends ChangeNotifier { _isDismissed = false; notifyListeners(); } catch (e) { - debugPrint('Update check failed: $e'); + // debugPrint('Update check failed: $e'); } } diff --git a/lib/focus_settings.dart b/lib/focus_settings.dart index 75d7692..7a4b454 100644 --- a/lib/focus_settings.dart +++ b/lib/focus_settings.dart @@ -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 diff --git a/lib/main.dart b/lib/main.dart index db01e20..084667b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _InitialRouteHandlerState(); } -class _InitialRouteHandlerState extends State { +class _InitialRouteHandlerState extends State + with WidgetsBindingObserver { bool _breathCompleted = false; bool _appSessionStarted = false; bool _onboardingCompleted = false; @@ -107,6 +133,7 @@ class _InitialRouteHandlerState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _appLinks = AppLinks(); _initDeepLinks(); @@ -115,17 +142,44 @@ class _InitialRouteHandlerState extends State { }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final appLock = context.read(); + 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 _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 { Widget build(BuildContext context) { final sm = context.watch(); final settings = context.watch(); + final appLock = context.watch(); + + // 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) { diff --git a/lib/screens/adsterra_ad_screen.dart b/lib/screens/adsterra_ad_screen.dart new file mode 100644 index 0000000..f7b9ee4 --- /dev/null +++ b/lib/screens/adsterra_ad_screen.dart @@ -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 = ''' + + + + + + + +
+
Ad 1
+ +
+
+
+
Ad 2
+ + +
+ + +'''; + +class AdsterraAdScreen extends StatefulWidget { + final String sessionType; + final int requiredSeconds; + + const AdsterraAdScreen({ + super.key, + required this.sessionType, + this.requiredSeconds = 20, + }); + + @override + State createState() => _AdsterraAdScreenState(); +} + +class _AdsterraAdScreenState extends State { + 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 _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( + 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, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart new file mode 100644 index 0000000..de82c37 --- /dev/null +++ b/lib/screens/app_lock_screen.dart @@ -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 createState() => _AppLockScreenState(); +} + +class _AppLockScreenState extends State { + String _enteredPin = ''; + bool _showError = false; + String _errorMsg = ''; + bool _isVerifying = false; + List _scrambledDigits = []; + + @override + void initState() { + super.initState(); + _refreshScrambled(); + } + + void _refreshScrambled() { + setState(() { + _scrambledDigits = context.read().getScrambledDigits(); + }); + } + + @override + Widget build(BuildContext context) { + final appLock = context.watch(); + 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 _verifyPin() async { + setState(() => _isVerifying = true); + + final appLock = context.read(); + 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 _authenticateBiometric() async { + final appLock = context.read(); + 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), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/app_lock_settings_page.dart b/lib/screens/app_lock_settings_page.dart new file mode 100644 index 0000000..77d6575 --- /dev/null +++ b/lib/screens/app_lock_settings_page.dart @@ -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 createState() => _AppLockSettingsPageState(); +} + +class _AppLockSettingsPageState extends State { + Future _ensurePin() async { + final appLock = context.read(); + if (appLock.hasPin) return true; + final ok = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AppLockSetupScreen()), + ); + return ok == true; + } + + @override + Widget build(BuildContext context) { + final a = context.watch(); + 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( + 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 = []; + 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)), + ); + } +} diff --git a/lib/screens/app_lock_setup_screen.dart b/lib/screens/app_lock_setup_screen.dart new file mode 100644 index 0000000..2eea0e8 --- /dev/null +++ b/lib/screens/app_lock_setup_screen.dart @@ -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 createState() => _AppLockSetupScreenState(); +} + +class _AppLockSetupScreenState extends State { + 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 _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(); + // 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); + } + } +} diff --git a/lib/screens/bait_me_button.dart b/lib/screens/bait_me_button.dart new file mode 100644 index 0000000..37982a1 --- /dev/null +++ b/lib/screens/bait_me_button.dart @@ -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 createState() => _BaitMeButtonState(); +} + +class _BaitMeButtonState extends State + with SingleTickerProviderStateMixin { + bool _isSpinning = false; + late AnimationController _spinController; + late Animation _spinAnimation; + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _spinAnimation = Tween(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(); + final baitEngine = context.read(); + 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 _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(); + final creditStore = context.read(); + final sessionManager = context.read(); + + // 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'), + ), + ], + ), + ); + } +} diff --git a/lib/screens/bait_me_full_screen.dart b/lib/screens/bait_me_full_screen.dart new file mode 100644 index 0000000..d047d3d --- /dev/null +++ b/lib/screens/bait_me_full_screen.dart @@ -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 createState() => _BaitMeFullScreenState(); +} + +class _BaitMeFullScreenState extends State + with SingleTickerProviderStateMixin { + bool _isSpinning = false; + bool _done = false; + BaitOutcome? _lastOutcome; + late AnimationController _spinController; + late Animation _spinAnimation; + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + _spinAnimation = Tween(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 _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(); + final creditStore = context.read(); + final sessionManager = context.read(); + + 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(); + } +} diff --git a/lib/screens/debug_menu_screen.dart b/lib/screens/debug_menu_screen.dart new file mode 100644 index 0000000..e4d1813 --- /dev/null +++ b/lib/screens/debug_menu_screen.dart @@ -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 createState() => _DebugMenuScreenState(); +} + +class _DebugMenuScreenState extends State { + int _customLevel = 1; + int _customXp = 0; + + @override + void initState() { + super.initState(); + final levelService = context.read(); + _customLevel = levelService.level; + _customXp = levelService.xp; + } + + @override + Widget build(BuildContext context) { + final levelService = context.watch(); + 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 _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 _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 _resetAllData(LevelService levelService) async { + final confirmed = await showDialog( + 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')), + ); + } + } + } +} +*/ \ No newline at end of file diff --git a/lib/screens/effort_friction_gate.dart b/lib/screens/effort_friction_gate.dart new file mode 100644 index 0000000..6a99653 --- /dev/null +++ b/lib/screens/effort_friction_gate.dart @@ -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 createState() => _EffortFrictionGateState(); +} + +class _EffortFrictionGateState extends State { + bool _isWorking = false; + String _status = ''; + + @override + Widget build(BuildContext context) { + final creditStore = context.watch(); + 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 _startFallbackChain() async { + setState(() => _isWorking = true); + + // Tier 1: Adsterra ad (full-screen WebView) + setState(() => _status = ''); + + if (mounted) { + final adsterraResult = await Navigator.push( + 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( + 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(); + final levelService = context.read(); + + if (widget.sessionType == 'reels') { + creditStore.addReelsMinutes(); + } else { + creditStore.addInstaMinutes(); + } + levelService.addXpForAd(); + HapticFeedback.heavyImpact(); + } +} diff --git a/lib/screens/extras_settings_page.dart b/lib/screens/extras_settings_page.dart index 209ae78..24cd00e 100644 --- a/lib/screens/extras_settings_page.dart +++ b/lib/screens/extras_settings_page.dart @@ -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(); - 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( + 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 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( diff --git a/lib/screens/ghost_mode_submenu_page.dart b/lib/screens/ghost_mode_submenu_page.dart new file mode 100644 index 0000000..ea0a39b --- /dev/null +++ b/lib/screens/ghost_mode_submenu_page.dart @@ -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(); + 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 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, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/guardrails_page.dart b/lib/screens/guardrails_page.dart index 947a883..de92f02 100644 --- a/lib/screens/guardrails_page.dart +++ b/lib/screens/guardrails_page.dart @@ -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 { ), ), ), - _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().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 { ); } + 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 _earnQuota(BuildContext context, SessionManager sm) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AdsterraAdScreen(sessionType: 'reels'), + ), + ); + if (result == true && context.mounted) { + sm.increaseDailyLimit(3); + context.read().addXpForAd(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('+3 min reel quota earned!')), + ); + } + } + Widget _buildFrictionSliderTile({ required BuildContext context, required SessionManager sm, diff --git a/lib/screens/level_panel_screen.dart b/lib/screens/level_panel_screen.dart new file mode 100644 index 0000000..225019c --- /dev/null +++ b/lib/screens/level_panel_screen.dart @@ -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(); + final settings = context.watch(); + 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( + 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 _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 _watchAdForXp(BuildContext context) async { + // Try Adsterra Social Bar first + final adResult = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AdsterraAdScreen(sessionType: 'reels'), + ), + ); + + if (adResult == true && context.mounted) { + context.read().addXpForAd(); + context.read().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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index a77ea53..8a9f797 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -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 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 ); } + /// 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( + 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(); @@ -360,6 +487,21 @@ class _MainWebViewPageState extends State _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 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 ), 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( + 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 borderRadius: BorderRadius.circular(10), ), ), - child: const Text('+10 minutes'), + child: const Text('Watch Ad (+10 min)'), ), ], ), @@ -673,21 +834,37 @@ class _MainWebViewPageState extends State 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(); + if (appLock.messagesLockReady && !_dmLockOverride) { + _showDmLockGate(); + } + } } + bool _dmLockOverride = false; + Future _showReelSessionPicker() async { final settings = context.read(); if (settings.requireWordChallenge) { @@ -836,6 +1013,13 @@ class _MainWebViewPageState extends State _BrandedTopBar( onFocusControlTap: () => _edgePanelKey.currentState?._toggleExpansion(), + onDmGhostToggle: () { + context.read().setGhostMode(false); + _controller?.reload(); + }, + onReload: () => _controller?.reload(), + currentUrl: _currentUrl, + dmGhostActive: context.read().ghostMode, ), Expanded( child: Consumer( @@ -1029,6 +1213,95 @@ class _MainWebViewPageState extends State ); } + // ── 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 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 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 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(); + _setGhostModeFlags(controller, s); + setState(() { _isLoading = false; _currentUrl = current; @@ -1263,6 +1576,17 @@ class _MainWebViewPageState extends State // 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 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 }, ); + // 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(); + + // 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 .read() .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( + 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( + 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( + context, + MaterialPageRoute( + builder: (_) => + const AdsterraAdScreen( + sessionType: 'reels', + requiredSeconds: 20, + ), + ), + ); + if (adResult == true && context.mounted) { + context + .read() + .addReelsMinutes(amount: 2); + context + .read() + .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().isDarkMode; + final levelService = context.watch(); + final baitEngine = context.watch(); + 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(); + 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, + ), + ], ), ], ), diff --git a/lib/screens/offline_feed_viewer.dart b/lib/screens/offline_feed_viewer.dart new file mode 100644 index 0000000..d265857 --- /dev/null +++ b/lib/screens/offline_feed_viewer.dart @@ -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(); + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index dbbb7b7..1c6fc91 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -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()), + 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( - 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().level} · ${context.watch().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(); + final cells = [ + _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 = []; + 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().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( + 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}); diff --git a/lib/screens/snapshot_manager_screen.dart b/lib/screens/snapshot_manager_screen.dart new file mode 100644 index 0000000..c836544 --- /dev/null +++ b/lib/screens/snapshot_manager_screen.dart @@ -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(); + 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(); + 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( + 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')}'; + } +} diff --git a/lib/screens/timer_fallback_screen.dart b/lib/screens/timer_fallback_screen.dart new file mode 100644 index 0000000..38c725d --- /dev/null +++ b/lib/screens/timer_fallback_screen.dart @@ -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 createState() => _TimerFallbackScreenState(); +} + +class _TimerFallbackScreenState extends State { + 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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/scripts/autoplay_blocker.dart b/lib/scripts/autoplay_blocker.dart index 2f5e294..0a6c133 100644 --- a/lib/scripts/autoplay_blocker.dart +++ b/lib/scripts/autoplay_blocker.dart @@ -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