Feature Pack with bug fixes for V2

This commit is contained in:
Ujwal223
2026-06-09 23:39:43 +05:45
parent f1bd12f0bd
commit 39b6545e4a
53 changed files with 7314 additions and 328 deletions
+302
View File
@@ -0,0 +1,302 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
/// Full-screen ad page. User MUST click the ad to earn the reward.
///
/// Flow:
/// 1. Ad loads in WebView for 20s
/// 2. User taps the ad → opens in external browser via url_launcher
/// 3. Timer continues counting to 20s regardless
/// 4. After 20s, "Continue & Earn Reward" button unlocks if BOTH ads clicked
/// 5. If ads not clicked within time, a Retry button appears to reload
const String _kAdHtml = '''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; background:#111; display:flex; flex-direction:column; align-items:center; justify-content:space-around; }
.ad-slot { width:100%; text-align:center; }
</style>
</head>
<body>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 1</div>
<script async="async" data-cfasync="false" src="https://pl18364273.effectivecpmnetwork.com/e8a9b107824c939fb63d96c218c1336a/invoke.js"></script>
<div id="container-e8a9b107824c939fb63d96c218c1336a"></div>
</div>
<div class="ad-slot">
<div style="color:#666;font-size:10px;margin-bottom:4px;">Ad 2</div>
<script>
atOptions = {'key':'99233324430f9128f2b01c30b6eebc20','format':'iframe','height':250,'width':300,'params':{}};
</script>
<script src="https://www.highperformanceformat.com/99233324430f9128f2b01c30b6eebc20/invoke.js"></script>
</div>
</body>
</html>
''';
class AdsterraAdScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const AdsterraAdScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 20,
});
@override
State<AdsterraAdScreen> createState() => _AdsterraAdScreenState();
}
class _AdsterraAdScreenState extends State<AdsterraAdScreen> {
int _elapsed = 0;
Timer? _timer;
int _adsClicked = 0; // count of ad clicks (need 2 for reward)
bool _retrying = false;
InAppWebViewController? _webController;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _elapsed++);
});
}
Future<void> _retry() async {
setState(() {
_retrying = true;
_elapsed = 0;
_adsClicked = 0;
});
_startTimer();
try {
await _webController?.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
} catch (_) {}
if (mounted) setState(() => _retrying = false);
}
@override
Widget build(BuildContext context) {
final timerDone = _elapsed >= widget.requiredSeconds;
final bothClicked = _adsClicked >= 2;
final done = timerDone && bothClicked;
// When timer expired but ads not clicked, wait a bit then allow skip
final canSkip = timerDone && !bothClicked;
String statusText;
Color statusColor;
if (bothClicked && timerDone) {
statusText = 'Ready!';
statusColor = Colors.greenAccent;
} else if (bothClicked) {
statusText = 'Both ads clicked! Waiting for timer…';
statusColor = Colors.greenAccent;
} else {
statusText = 'Tap BOTH ads below to earn XP ($_adsClicked/2)';
statusColor = Colors.white.withValues(alpha: 0.4);
}
String buttonText;
bool buttonEnabled;
VoidCallback? buttonAction;
if (done) {
buttonText = 'Continue & Earn Reward';
buttonEnabled = true;
buttonAction = () => Navigator.pop(context, true);
} else if (timerDone && !bothClicked) {
buttonText = 'Tap both ads to continue';
buttonEnabled = false;
buttonAction = null;
} else {
final remaining = widget.requiredSeconds - _elapsed;
buttonText = 'Wait ${remaining > 0 ? remaining : 0}s';
buttonEnabled = false;
buttonAction = null;
}
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Top bar
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.videocam, color: Colors.white54, size: 18),
const SizedBox(width: 8),
const Text('Sponsored',
style: TextStyle(color: Colors.white54, fontSize: 13)),
const Spacer(),
Text('${_elapsed.clamp(0, widget.requiredSeconds)}s / ${widget.requiredSeconds}s',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white54,
fontSize: 13,
fontWeight: FontWeight.w600)),
],
),
),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (_elapsed / widget.requiredSeconds).clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
done ? Colors.greenAccent : Colors.blueAccent),
),
),
// Hint text
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
statusText,
style: TextStyle(color: statusColor, fontSize: 11),
),
),
// Ad WebView
Expanded(
child: InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
useHybridComposition: true,
transparentBackground: true,
cacheEnabled: false,
safeBrowsingEnabled: false,
),
onWebViewCreated: (c) async {
_webController = c;
await c.loadData(
data: _kAdHtml,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri('https://adsterra.com'),
);
},
onLoadStop: (_, url) {
// ad loaded
},
shouldOverrideUrlLoading: (controller, nav) async {
final url = nav.request.url?.toString() ?? '';
if (url.isNotEmpty &&
!url.contains('adsterra.com') &&
!url.startsWith('about:')) {
if (_adsClicked < 2) _adsClicked++;
if (mounted) setState(() {});
await launchUrl(Uri.parse(url),
mode: LaunchMode.externalApplication);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
),
// Button area
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: buttonEnabled ? buttonAction : null,
style: ElevatedButton.styleFrom(
backgroundColor: done ? Colors.greenAccent : Colors.grey,
foregroundColor: done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
icon: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
size: 22),
label: Text(
buttonText,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
// Retry / Skip buttons when timer done but ads not clicked
if (canSkip && !_retrying) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: _retry,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(alpha: 0.4),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Retry — Reload Ads',
style: TextStyle(fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 4),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Skip (no reward)',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3),
fontSize: 13),
),
),
],
if (_retrying)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orangeAccent,
),
),
),
],
),
),
],
),
),
);
}
}
+348
View File
@@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// The lock screen shown when FocusGram is locked.
///
/// Supports PIN entry with optional scrambled keypad and biometric fallback.
/// [forAppWide] controls which PIN to verify: true = app-wide, false = messages.
/// [title] lets the screen show context (e.g. "Messages Locked").
class AppLockScreen extends StatefulWidget {
final bool forAppWide;
final String? title;
final String? subtitle;
const AppLockScreen({
super.key,
this.forAppWide = true,
this.title,
this.subtitle,
});
@override
State<AppLockScreen> createState() => _AppLockScreenState();
}
class _AppLockScreenState extends State<AppLockScreen> {
String _enteredPin = '';
bool _showError = false;
String _errorMsg = '';
bool _isVerifying = false;
List<int> _scrambledDigits = [];
@override
void initState() {
super.initState();
_refreshScrambled();
}
void _refreshScrambled() {
setState(() {
_scrambledDigits = context.read<AppLockService>().getScrambledDigits();
});
}
@override
Widget build(BuildContext context) {
final appLock = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.white,
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue.withValues(alpha: 0.1),
),
child: const Icon(
Icons.lock_outline,
color: Colors.blueAccent,
size: 32,
),
),
const SizedBox(height: 20),
// Title
Text(
widget.title ?? 'FocusGram is Locked',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
widget.subtitle ?? 'Enter your PIN to unlock',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const SizedBox(height: 32),
// PIN dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final filled = i < _enteredPin.length;
return Container(
width: 16,
height: 16,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled
? Colors.blueAccent
: (isDark ? Colors.white24 : Colors.black12),
),
);
}),
),
// Error text
if (_showError)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
_errorMsg,
style: const TextStyle(color: Colors.redAccent, fontSize: 13),
),
),
if (_isVerifying)
const Padding(
padding: EdgeInsets.only(top: 16),
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Biometrics button
if (appLock.biometricsEnabled)
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: IconButton(
icon: Icon(
Icons.fingerprint,
color: Colors.blueAccent.withValues(alpha: 0.8),
size: 40,
),
onPressed: _authenticateBiometric,
tooltip: 'Use fingerprint',
),
),
// Keypad
_buildKeypad(appLock),
],
),
),
);
}
Widget _buildKeypad(AppLockService appLock) {
final useScrambled = appLock.scrambleKeypad;
// Build digit labels
final digitLabels = useScrambled
? _scrambledDigits.map((d) => d.toString()).toList()
: List.generate(10, (i) => i.toString());
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
child: Column(
children: [
// Row 1: 1 2 3
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[1],
onTap: () => _onDigit(digitLabels[1]),
),
_KeypadButton(
label: digitLabels[2],
onTap: () => _onDigit(digitLabels[2]),
),
_KeypadButton(
label: digitLabels[3],
onTap: () => _onDigit(digitLabels[3]),
),
],
),
// Row 2: 4 5 6
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[4],
onTap: () => _onDigit(digitLabels[4]),
),
_KeypadButton(
label: digitLabels[5],
onTap: () => _onDigit(digitLabels[5]),
),
_KeypadButton(
label: digitLabels[6],
onTap: () => _onDigit(digitLabels[6]),
),
],
),
// Row 3: 7 8 9
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: digitLabels[7],
onTap: () => _onDigit(digitLabels[7]),
),
_KeypadButton(
label: digitLabels[8],
onTap: () => _onDigit(digitLabels[8]),
),
_KeypadButton(
label: digitLabels[9],
onTap: () => _onDigit(digitLabels[9]),
),
],
),
// Row 4: delete 0 scramble-refresh
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_KeypadButton(
label: '',
onTap: _onDelete,
isFunction: true,
),
_KeypadButton(
label: digitLabels[0],
onTap: () => _onDigit(digitLabels[0]),
),
if (useScrambled)
_KeypadButton(
label: '',
onTap: _refreshScrambled,
isFunction: true,
)
else
const SizedBox(width: 72), // Placeholder
],
),
],
),
);
}
void _onDigit(String digit) {
if (_enteredPin.length >= 4) return;
setState(() {
_enteredPin += digit;
_showError = false;
});
if (_enteredPin.length == 4) {
_verifyPin();
}
}
void _onDelete() {
if (_enteredPin.isEmpty) return;
setState(() => _enteredPin = _enteredPin.substring(0, _enteredPin.length - 1));
}
Future<void> _verifyPin() async {
setState(() => _isVerifying = true);
final appLock = context.read<AppLockService>();
final valid = await appLock.verifyPin(_enteredPin, forAppWide: widget.forAppWide);
if (!mounted) return;
if (valid) {
HapticFeedback.heavyImpact();
appLock.onUnlocked();
Navigator.of(context).pop(true);
} else {
setState(() {
_showError = true;
_errorMsg = 'Wrong PIN. Try again.';
_enteredPin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
}
}
Future<void> _authenticateBiometric() async {
final appLock = context.read<AppLockService>();
final available = await appLock.isBiometricsAvailable();
if (!available) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometrics not available on this device')),
);
}
return;
}
final success = await appLock.authenticateWithBiometrics();
if (success && mounted) {
appLock.onUnlocked();
Navigator.of(context).pop(true);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric authentication failed')),
);
}
}
}
class _KeypadButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool isFunction;
const _KeypadButton({
required this.label,
required this.onTap,
this.isFunction = false,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return SizedBox(
width: 72,
height: 72,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(36),
onTap: onTap,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: isFunction ? 28 : 24,
fontWeight: FontWeight.w500,
color: isFunction
? Colors.blueAccent
: (isDark ? Colors.white : Colors.black87),
),
),
),
),
),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
import 'app_lock_setup_screen.dart';
/// App Lock settings — two independent lock modes (app-wide + messages tab),
/// each with their own toggle, all backed by a single PIN.
class AppLockSettingsPage extends StatefulWidget {
const AppLockSettingsPage({super.key});
@override
State<AppLockSettingsPage> createState() => _AppLockSettingsPageState();
}
class _AppLockSettingsPageState extends State<AppLockSettingsPage> {
Future<bool> _ensurePin() async {
final appLock = context.read<AppLockService>();
if (appLock.hasPin) return true;
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
return ok == true;
}
@override
Widget build(BuildContext context) {
final a = context.watch<AppLockService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
final anythingOn = a.lockAppWide || a.lockMessages;
return Scaffold(
appBar: AppBar(
title: const Text('App Lock',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
// ── Status card ──────────────────────────────────────
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: anythingOn
? [Colors.blueAccent.withValues(alpha: 0.15), Colors.blue.withValues(alpha: 0.05)]
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: anythingOn
? Colors.blueAccent.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Column(
children: [
Icon(
anythingOn ? Icons.lock_rounded : Icons.lock_open_rounded,
color: anythingOn ? Colors.blueAccent : Colors.grey,
size: 48,
),
const SizedBox(height: 12),
Text(
anythingOn ? 'Lock Active' : 'No Lock',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold,
color: anythingOn ? Colors.blueAccent : Colors.grey,
),
),
const SizedBox(height: 6),
Text(
_statusText(a),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const _SectionHeader(title: 'LOCK MODES'),
// ── App-wide lock ────────────────────────────────────
SwitchListTile(
title: const Text('Lock Entire App'),
subtitle: const Text(
'Require PIN when opening FocusGram.',
),
value: a.lockAppWide,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockAppWide(v);
HapticFeedback.selectionClick();
},
),
// ── Messages tab lock ────────────────────────────────
SwitchListTile(
title: const Text('Lock Messages Tab'),
subtitle: const Text(
'Require PIN to open Instagram Direct Messages',
),
value: a.lockMessages,
onChanged: (v) async {
if (v && !a.hasPin) {
if (!await _ensurePin()) return;
}
await a.setLockMessages(v);
HapticFeedback.selectionClick();
},
),
// ─── PIN & extras ────────────────────────────────────
if (a.hasPin) ...[
const _SectionHeader(title: 'PIN & SECURITY'),
ListTile(
title: const Text('Change PIN'),
subtitle: const Text('Set a new 4-digit code'),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
onTap: () async {
final ok = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const AppLockSetupScreen()),
);
if (ok == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PIN updated')),
);
}
},
),
SwitchListTile(
title: const Text('Scrambled Keypad'),
subtitle: const Text('Shuffle digits on the lock screen'),
value: a.scrambleKeypad,
onChanged: (v) async {
await a.setScrambleKeypad(v);
HapticFeedback.selectionClick();
},
),
// Biometrics option removed
],
// ── Hint if no PIN ───────────────────────────────────
if (!a.hasPin)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.blueAccent),
SizedBox(width: 8),
Expanded(
child: Text(
'Enable any lock mode above to set up your PIN.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 40),
],
),
);
}
String _statusText(AppLockService a) {
if (!a.hasPin) return 'Set a PIN to enable any lock mode.';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages tab');
if (parts.isEmpty) return 'Both modes are off — enable one above.';
return '${parts.join(' + ')} lock is active.';
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(title,
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2)),
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_lock_service.dart';
/// First-time setup screen for App Lock.
/// User enters PIN twice, then optionally enables biometrics.
class AppLockSetupScreen extends StatefulWidget {
const AppLockSetupScreen({super.key});
@override
State<AppLockSetupScreen> createState() => _AppLockSetupScreenState();
}
class _AppLockSetupScreenState extends State<AppLockSetupScreen> {
final _pinController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePin = true;
bool _obscureConfirm = true;
String? _error;
@override
void dispose() {
_pinController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Set App Lock PIN'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
const Text(
'Choose a 4-digit PIN to lock FocusGram.',
style: TextStyle(fontSize: 15, height: 1.5),
),
const SizedBox(height: 32),
// PIN field
TextField(
controller: _pinController,
obscureText: _obscurePin,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Enter PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscurePin ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscurePin = !_obscurePin),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
const SizedBox(height: 16),
// Confirm PIN field
TextField(
controller: _confirmController,
obscureText: _obscureConfirm,
maxLength: 4,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Confirm PIN',
counterText: '',
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm ? Icons.visibility_off : Icons.visibility,
),
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
),
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() => _error = null),
),
// Error
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_error!,
style: const TextStyle(color: Colors.redAccent),
),
),
const Spacer(),
// Save button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _savePin,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Enable App Lock',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Future<void> _savePin() async {
final pin = _pinController.text.trim();
final confirm = _confirmController.text.trim();
if (pin.length != 4) {
setState(() => _error = 'PIN must be exactly 4 digits.');
return;
}
if (pin != confirm) {
setState(() => _error = 'PINs do not match.');
return;
}
if (pin == pin.split('').toSet().join('') && pin.length == 4) {
// Allow any 4-digit PIN
}
final appLock = context.read<AppLockService>();
// Set both PINs to the same value for simplicity
await appLock.setPin(pin, forAppWide: true);
await appLock.setPin(pin, forAppWide: false);
HapticFeedback.heavyImpact();
if (mounted) {
Navigator.pop(context, true);
}
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import '../services/session_manager.dart';
/// The Bait Me button widget.
///
/// Shows a gamble-themed button that triggers random outcomes.
/// Gated behind Level 3. Cooldown prevents spam.
class BaitMeButton extends StatefulWidget {
const BaitMeButton({super.key});
@override
State<BaitMeButton> createState() => _BaitMeButtonState();
}
class _BaitMeButtonState extends State<BaitMeButton>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final baitEngine = context.read<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) {
return const SizedBox.shrink();
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// The button
SizedBox(
width: 48,
height: 48,
child: Stack(
children: [
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning
? _spinAnimation.value * 2 * pi * 3
: 0,
child: child,
);
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: baitEngine.isOnCooldown
? Colors.grey.withValues(alpha: 0.3)
: Colors.purpleAccent.withValues(alpha: 0.2),
border: Border.all(
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
width: 2,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: baitEngine.isOnCooldown
? null
: _onBaitMe,
child: Center(
child: Icon(
Icons.casino_rounded,
color: baitEngine.isOnCooldown
? Colors.grey
: Colors.purpleAccent,
size: 22,
),
),
),
),
),
),
// Cooldown badge
if (baitEngine.isOnCooldown)
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${baitEngine.cooldownRemainingMinutes}m',
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
const SizedBox(height: 2),
Text(
'Bait Me',
style: TextStyle(
fontSize: 9,
color: isDark ? Colors.white60 : Colors.black54,
),
),
],
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() {
_isSpinning = true;
});
_spinController.forward(from: 0);
// Wait for spin animation
await Future.delayed(const Duration(milliseconds: 1200));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
// Wire callbacks
baitEngine.onAddMinutes = (minutes) {
creditStore.addBonusMinutes(minutes);
HapticFeedback.heavyImpact();
};
baitEngine.onResetSession = () {
creditStore.resetBalances();
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onReduceSessionTime = (minutes) {
// Deduct from reel credits
for (var i = 0; i < minutes; i++) {
creditStore.drainReelsMinute();
}
HapticFeedback.heavyImpact();
};
baitEngine.onIncreaseCooldown = (minutes) {
// Increase cooldown by adding to the last session end time
// Session manager handles cooldown via _lastSessionEnd
HapticFeedback.heavyImpact();
};
baitEngine.onEndReelSession = () {
sessionManager.endSession();
HapticFeedback.heavyImpact();
};
baitEngine.onEndAppSession = () {
sessionManager.endAppSession();
HapticFeedback.heavyImpact();
};
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
};
// Activate
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
});
// Show result dialog
_showOutcomeDialog(context, outcome);
}
void _showOutcomeDialog(BuildContext context, BaitOutcome outcome) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
BaitEngine.outcomeLabel(outcome),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: outcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 12),
Text(
BaitEngine.outcomeSubtext(outcome),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white70 : Colors.black87,
height: 1.4,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
}
}
+242
View File
@@ -0,0 +1,242 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
// import '../services/level_service.dart'; // unused
import '../services/session_manager.dart';
/// Full-screen Bait Me page with big spin animation.
class BaitMeFullScreen extends StatefulWidget {
const BaitMeFullScreen({super.key});
@override
State<BaitMeFullScreen> createState() => _BaitMeFullScreenState();
}
class _BaitMeFullScreenState extends State<BaitMeFullScreen>
with SingleTickerProviderStateMixin {
bool _isSpinning = false;
bool _done = false;
BaitOutcome? _lastOutcome;
late AnimationController _spinController;
late Animation<double> _spinAnimation;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
_spinAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _spinController, curve: Curves.easeOutBack),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Spacer(),
// Title
Text(
_done ? '🎲 Result!' : '🎲 Bait Me',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_done
? BaitEngine.outcomeSubtext(_lastOutcome ?? BaitOutcome.addTenMinutes)
: 'Tap the button to test your luck!',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 15,
),
),
const Spacer(),
// Spinning icon
AnimatedBuilder(
animation: _spinAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _isSpinning ? _spinAnimation.value * 2 * pi * 5 : 0,
child: child,
);
},
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _done
? Colors.green.withValues(alpha: 0.15)
: Colors.purpleAccent.withValues(alpha: 0.15),
border: Border.all(
color: _done ? Colors.greenAccent : Colors.purpleAccent,
width: 3,
),
),
child: Center(
child: Icon(
_done ? Icons.check_circle : Icons.casino_rounded,
color: _done ? Colors.greenAccent : Colors.purpleAccent,
size: 56,
),
),
),
),
const Spacer(),
// Outcome description
if (_done && _lastOutcome != null)
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
BaitEngine.outcomeLabel(_lastOutcome!),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _lastOutcome == BaitOutcome.addTenMinutes
? Colors.greenAccent
: Colors.redAccent,
),
),
const SizedBox(height: 8),
Text(
BaitEngine.outcomeSubtext(_lastOutcome!),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 14,
height: 1.4,
),
),
],
),
),
const Spacer(flex: 2),
// Big button
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isSpinning ? null : _onBaitMe,
style: ElevatedButton.styleFrom(
backgroundColor:
_done ? Colors.greenAccent : Colors.purpleAccent,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
),
icon: Icon(
_isSpinning
? Icons.hourglass_top
: _done
? Icons.check_circle
: Icons.casino_rounded,
size: 24,
),
label: Text(
_isSpinning
? 'Rolling…'
: _done
? 'Done — Close'
: '🎲 Spin the Wheel!',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
),
if (!_done)
Padding(
padding: const EdgeInsets.only(top: 12),
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Not now',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.3))),
),
),
const Spacer(),
],
),
),
),
),
);
}
Future<void> _onBaitMe() async {
HapticFeedback.mediumImpact();
setState(() => _isSpinning = true);
_spinController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 1800));
if (!mounted) return;
final baitEngine = context.read<BaitEngine>();
final creditStore = context.read<CreditStore>();
final sessionManager = context.read<SessionManager>();
baitEngine.onAddMinutes = (m) => creditStore.addBonusMinutes(m);
baitEngine.onResetSession = () => creditStore.resetBalances();
baitEngine.onReduceSessionTime = (m) {
for (var i = 0; i < m; i++) creditStore.drainReelsMinute();
};
baitEngine.onEndReelSession = () => sessionManager.endSession();
baitEngine.onEndAppSession = () => sessionManager.endAppSession();
baitEngine.onOpenUrl = (url) async {
final uri = Uri.tryParse(url);
if (uri != null) await launchUrl(uri, mode: LaunchMode.externalApplication);
};
final outcome = await baitEngine.activate();
if (!mounted) return;
setState(() {
_isSpinning = false;
_done = true;
_lastOutcome = outcome;
});
HapticFeedback.heavyImpact();
}
}
+342
View File
@@ -0,0 +1,342 @@
/*import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
/// A hidden debug menu for development & testing.
///
/// Access: tap the app version in settings 7 times.
/// Allows manually setting XP/level to test feature gating.
class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key});
@override
State<DebugMenuScreen> createState() => _DebugMenuScreenState();
}
class _DebugMenuScreenState extends State<DebugMenuScreen> {
int _customLevel = 1;
int _customXp = 0;
@override
void initState() {
super.initState();
final levelService = context.read<LevelService>();
_customLevel = levelService.level;
_customXp = levelService.xp;
}
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Debug Menu',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Current state
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.amber.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, color: Colors.amber, size: 20),
const SizedBox(width: 8),
const Text(
'Developer Tools',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const SizedBox(height: 12),
Text(
'Current: Level ${levelService.level} · ${levelService.xp} XP',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 4),
Text(
'Progress: ${(levelService.levelProgress * 100).toStringAsFixed(0)}% to next level',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
const SizedBox(height: 24),
// Manual level setter
const Text(
'SET LEVEL',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 12),
// Quick level buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(5, (i) {
final lvl = i + 1;
final selected = _customLevel == lvl;
return ElevatedButton(
onPressed: () => setState(() => _customLevel = lvl),
style: ElevatedButton.styleFrom(
backgroundColor: selected ? Colors.blueAccent : null,
foregroundColor: selected ? Colors.white : null,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
child: Text('Level $lvl'),
);
}),
),
const SizedBox(height: 16),
// Set XP field
const Text(
'SET XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'XP Amount',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
controller: TextEditingController(text: '$_customXp'),
onChanged: (v) {
_customXp = int.tryParse(v) ?? 0;
},
),
const SizedBox(height: 20),
// Apply button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _applyDebugSettings(levelService),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.warning_amber_rounded, size: 20),
label: const Text(
'Apply Debug Settings',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 32),
// Feature unlock preview
const Text(
'FEATURE UNLOCK STATUS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = _customLevel >= feature.requiredLevel;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 13,
color: unlocked ? null : Colors.grey,
),
),
),
Text(
'Lv ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 32),
const SizedBox(height: 40),
// Danger zone
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.dangerous_outlined, color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text(
'Danger Zone',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _resetAllData(levelService),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
icon: const Icon(Icons.delete_forever, size: 18),
label: const Text('Reset All Level Data'),
),
),
],
),
),
],
),
);
}
Future<void> _applyDebugSettings(LevelService levelService) async {
HapticFeedback.heavyImpact();
// Use reflection-like approach: set the private fields via a method
// Since LevelService doesn't expose a raw setter, we provide one here.
await _forceSetLevel(levelService, _customLevel, _customXp);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Set to Level $_customLevel with $_customXp XP'),
backgroundColor: Colors.amber.shade800,
),
);
}
}
Future<void> _forceSetLevel(LevelService levelService, int level, int xp) async {
// The LevelService stores data in Hive (local only).
// We bypass the normal XP system by writing directly to cache.
await levelService.debugSetLevel(level, xp);
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) setState(() {});
}
Future<void> _resetAllData(LevelService levelService) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset All Level Data?'),
content: const Text(
'This will reset your level, XP, and all history to defaults. '
'This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true && mounted) {
await levelService.debugReset();
if (mounted) {
setState(() {
_customLevel = 1;
_customXp = 0;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Level data reset')),
);
}
}
}
}
*/
+329
View File
@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import 'timer_fallback_screen.dart';
import '../widgets/native_ad_banner.dart';
/// Shown before a reel or Instagram session when credits are zero
/// and Effort Friction Mode is enabled.
///
/// Fallback chain: Adsterra Social Bar (WebView) → Timer fallback.
class EffortFrictionGate extends StatefulWidget {
final String sessionType; // 'reels' or 'insta'
final VoidCallback onProceed;
final VoidCallback? onCancel;
const EffortFrictionGate({
super.key,
required this.sessionType,
required this.onProceed,
this.onCancel,
});
@override
State<EffortFrictionGate> createState() => _EffortFrictionGateState();
}
class _EffortFrictionGateState extends State<EffortFrictionGate> {
bool _isWorking = false;
String _status = '';
@override
Widget build(BuildContext context) {
final creditStore = context.watch<CreditStore>();
final isReels = widget.sessionType == 'reels';
final credits =
isReels ? creditStore.reelsMinutes : creditStore.instaMinutes;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Colors.orange.shade800,
Colors.orange.shade500,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.3),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: const Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 40,
),
),
const SizedBox(height: 28),
Text(
isReels ? 'Earn Reels Time' : 'Earn Instagram Time',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Watch a short ad to earn ${CreditStore.minutesPerAd} minutes '
'of ${isReels ? 'reel' : 'Instagram'} time.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
),
),
const SizedBox(height: 32),
// Credit balance display
if (credits > 0)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.green.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.access_time,
color: Colors.greenAccent,
size: 20,
),
const SizedBox(width: 8),
Text(
'You have $credits min remaining',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 20),
// Status message
if (_status.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: Colors.blueAccent,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_status,
style: const TextStyle(
color: Colors.blueAccent,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 12),
// Watch ad button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: _isWorking ? null : _startFallbackChain,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: _isWorking
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.play_arrow_rounded, size: 22),
label: Text(
_isWorking
? 'Working…'
: 'Watch Ad (+${CreditStore.minutesPerAd} min)',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
// Proceed button
if (credits > 0)
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: widget.onProceed,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70,
side: BorderSide(
color: Colors.white.withValues(alpha: 0.2),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Proceed with earned time'),
),
),
const SizedBox(height: 16),
// Cancel
TextButton(
onPressed: widget.onCancel ?? () => Navigator.pop(context),
child: Text(
credits > 0 ? 'Skip for now' : 'Not now',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.4),
),
),
),
const Spacer(flex: 1),
Text(
'Ads by Adsterra',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.15),
fontSize: 10,
),
),
const SizedBox(height: 4),
// Native banner ad at bottom
const NativeAdBanner(height: 50),
const SizedBox(height: 8),
],
),
),
),
);
}
// ── Fallback Chain ─────────────────────────────────────────
Future<void> _startFallbackChain() async {
setState(() => _isWorking = true);
// Tier 1: Adsterra ad (full-screen WebView)
setState(() => _status = '');
if (mounted) {
final adsterraResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => AdsterraAdScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (adsterraResult == true && mounted) {
_grantReward();
setState(() {
_isWorking = false;
_status = '';
});
return;
}
if (!mounted) return;
}
// Tier 2: Timer fallback (always works)
setState(() => _status = 'Using timer fallback…');
if (mounted) {
final timerResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => TimerFallbackScreen(
sessionType: widget.sessionType,
requiredSeconds: 15,
),
),
);
if (timerResult == true && mounted) {
_grantReward();
}
}
if (mounted) {
setState(() {
_isWorking = false;
_status = '';
});
}
}
void _grantReward() {
final creditStore = context.read<CreditStore>();
final levelService = context.read<LevelService>();
if (widget.sessionType == 'reels') {
creditStore.addReelsMinutes();
} else {
creditStore.addInstaMinutes();
}
levelService.addXpForAd();
HapticFeedback.heavyImpact();
}
}
+94 -60
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
import 'ghost_mode_submenu_page.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@@ -10,7 +11,6 @@ class ExtrasSettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
appBar: AppBar(
title: const Text(
@@ -25,6 +25,10 @@ class ExtrasSettingsPage extends StatelessWidget {
),
body: ListView(
children: [
const _SectionHeader(title: 'STARTUP'),
_LaunchPagePicker(settings: settings),
const SizedBox(height: 8),
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Download Media (Feed + Reels)',
@@ -37,68 +41,34 @@ class ExtrasSettingsPage extends StatelessWidget {
),
const _SectionHeader(title: 'FOCUS'),
_SwitchTile(
title: 'GHOST MODE',
subtitle: 'Hide seen indicator / read receipts',
value: settings.ghostMode,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
color: settings.ghostMode
? Colors.purple.withValues(alpha: 0.15)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 2),
child: Icon(
Icons.info_outline,
size: 14,
color: Colors.amber,
),
),
const Expanded(
child: Text(
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
style: TextStyle(fontSize: 11, color: Colors.amber),
),
),
],
child: Icon(
Icons.visibility_off_rounded,
color: settings.ghostMode ? Colors.purpleAccent : Colors.grey,
size: 20,
),
),
title: const Text('Ghost Mode', style: TextStyle(fontSize: 15)),
subtitle: Text(
_ghostSubtitle(settings),
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const GhostModeSubmenuPage()),
),
),
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
*/
const SizedBox(height: 40),
],
),
@@ -106,12 +76,77 @@ class ExtrasSettingsPage extends StatelessWidget {
}
}
String _ghostSubtitle(SettingsService s) {
if (s.ghostMode) return 'DM Ghost active';
return 'Tap to configure ghost modes';
}
class _LaunchPagePicker extends StatelessWidget {
final SettingsService settings;
const _LaunchPagePicker({required this.settings});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final options = ['home', 'following', 'favorites', 'direct'];
final labels = {
'home': 'Home Feed',
'following': 'Following',
'favorites': 'Favorites',
'direct': 'Direct Messages',
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: settings.startupPage,
decoration: const InputDecoration(
labelText: 'Launch Page',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: options
.map(
(p) => DropdownMenuItem(
value: p,
child: Text(
labels[p] ?? p,
style: const TextStyle(fontSize: 14),
),
),
)
.toList(),
onChanged: (v) {
if (v != null) settings.setStartupPage(v);
HapticFeedback.selectionClick();
},
),
const SizedBox(height: 6),
Text(
'Choose which page opens when you launch Focusgram.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white38 : Colors.black38,
),
),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
this.subtitle,
@@ -124,7 +159,7 @@ class _SwitchTile extends StatelessWidget {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
? Text(subtitle!, style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
@@ -135,7 +170,6 @@ class _SwitchTile extends StatelessWidget {
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
/// Ghost Mode submenu — tap "Ghost Mode" in Extras to open this.
/// Single mode: DM Ghost (comprehensive seen-signal blocking).
class GhostModeSubmenuPage extends StatelessWidget {
const GhostModeSubmenuPage({super.key});
@override
Widget build(BuildContext context) {
final s = context.watch<SettingsService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Ghost Mode',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── DM Ghost ──────────────────────────────────────
_GhostCard(
icon: Icons.visibility_off_rounded,
title: 'DM Ghost',
subtitle: 'Read messages without the person knowing',
value: s.ghostMode,
warning:
'When DM Ghost is enabled, you can\'t send messages or react to any, you can just receive messages. You can turn ghost mode off anytime from topbar button.',
onChanged: (v) => s.setGhostMode(v),
isDark: isDark,
danger: true,
),
const SizedBox(height: 24),
const SizedBox(height: 40),
],
),
);
}
}
class _GhostCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final String warning;
final ValueChanged<bool> onChanged;
final bool isDark;
final bool danger;
const _GhostCard({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
required this.warning,
required this.onChanged,
required this.isDark,
this.danger = false,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.08 : 0.03),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (value ? (danger ? Colors.red : Colors.blue) : Colors.grey)
.withValues(alpha: value ? 0.25 : 0.1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: value
? (danger ? Colors.redAccent : Colors.blueAccent)
: Colors.grey,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: value
? (danger ? Colors.redAccent : null)
: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
],
),
),
Switch(
value: value,
activeColor: danger ? Colors.redAccent : null,
onChanged: onChanged,
),
],
),
if (value)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (danger ? Colors.red : Colors.amber).withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
danger ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14,
color: danger ? Colors.redAccent : Colors.amber,
),
const SizedBox(width: 6),
Expanded(
child: Text(
warning,
style: TextStyle(
fontSize: 11,
color: danger
? Colors.redAccent
: Colors.amber.shade800,
),
),
),
],
),
),
),
],
),
);
}
}
+94 -14
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/level_service.dart';
import 'adsterra_ad_screen.dart';
import '../utils/discipline_challenge.dart';
class GuardrailsPage extends StatefulWidget {
@@ -113,20 +115,33 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
),
),
),
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Daily Reel Limit',
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
min: 5,
max: 120,
divisor: 5,
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) => sm.setDailyLimitMinutes(v.toInt()),
),
// If quota used up, show earn page instead of slider
if (sm.dailyRemainingSeconds <= 0)
_buildQuotaExhaustedTile(context, sm)
else
_buildFrictionSliderTile(
context: context,
sm: sm,
title: 'Daily Reel Limit',
subtitle: '${sm.dailyLimitSeconds ~/ 60} min / day',
value: (sm.dailyLimitSeconds ~/ 60).toDouble(),
min: 5,
max: 120,
divisor: 5,
isMorePermissive: (v) => v > (sm.dailyLimitSeconds ~/ 60),
warningText:
'Increasing your limit makes it easier to scroll. Are you sure?',
onConfirmed: (v) async {
// XP penalty for increasing limit
final increase = (v.toInt() - (sm.dailyLimitSeconds ~/ 60));
if (increase > 0) {
// context.read<LevelService>().grantDebugXp(
// -increase * 5, 'Penalty: increased reel limit',
// );
}
await sm.setDailyLimitMinutes(v.toInt());
},
),
_buildFrictionSliderTile(
context: context,
sm: sm,
@@ -225,6 +240,71 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
);
}
Widget _buildQuotaExhaustedTile(BuildContext context, SessionManager sm) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.orange.withValues(alpha: 0.2)),
),
child: Column(
children: [
const Icon(
Icons.hourglass_empty,
color: Colors.orangeAccent,
size: 36,
),
const SizedBox(height: 8),
const Text(
'Daily Reel Quota Used Up',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
const Text(
'Watch an ad to earn 3 more minutes of reel time.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _earnQuota(context, sm),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text('Watch Ad (+3 min reels)'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
);
}
Future<void> _earnQuota(BuildContext context, SessionManager sm) async {
final result = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (result == true && context.mounted) {
sm.increaseDailyLimit(3);
context.read<LevelService>().addXpForAd();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('+3 min reel quota earned!')),
);
}
}
Widget _buildFrictionSliderTile({
required BuildContext context,
required SessionManager sm,
+516
View File
@@ -0,0 +1,516 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/level_service.dart';
import '../services/settings_service.dart';
import '../services/credit_store.dart';
import 'adsterra_ad_screen.dart';
/// Displays current level, XP progress, and locked/preview features.
class LevelPanelScreen extends StatelessWidget {
const LevelPanelScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
return Scaffold(
appBar: AppBar(
title: const Text(
'Your Journey',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Level Header Card ──────────────────────────────
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _levelColors(levelService.level, isDark),
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _levelColors(levelService.level, isDark)[0]
.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
// Level badge
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.2),
border: Border.all(
color: Colors.white.withValues(alpha: 0.4),
width: 3,
),
),
child: Center(
child: Text(
'${levelService.level}',
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
Text(
_levelTitle(levelService.level),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// XP progress bar
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: levelService.levelProgress,
minHeight: 8,
backgroundColor: Colors.white.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'${levelService.xp} / ${levelService.xpForNextLevel} XP',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
),
),
],
),
),
const SizedBox(height: 24),
// ── Next Unlock ────────────────────────────────────
if (levelService.nextLockedFeature != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.lock_outline,
color: Colors.amber,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next at Level ${levelService.nextLockedFeature!.requiredLevel}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
'Unlock ${levelService.nextLockedFeature!.name}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
],
// ── Feature Unlock Table ───────────────────────────
const Text(
'FEATURES',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...AppFeature.all.map((feature) {
final unlocked = levelService.isFeatureUnlocked(feature);
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: unlocked ? 0.04 : 0.02),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: unlocked
? Colors.greenAccent.withValues(alpha: 0.2)
: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
),
),
child: Row(
children: [
Icon(
unlocked ? Icons.check_circle : Icons.lock_outline,
color: unlocked ? Colors.greenAccent : Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
feature.name,
style: TextStyle(
fontSize: 14,
fontWeight:
unlocked ? FontWeight.w600 : FontWeight.normal,
color:
unlocked
? null
: Colors.grey,
),
),
),
Text(
unlocked ? 'Unlocked' : 'Level ${feature.requiredLevel}',
style: TextStyle(
fontSize: 12,
color: unlocked ? Colors.greenAccent : Colors.grey,
),
),
],
),
);
}),
const SizedBox(height: 24),
// ── XP Rules ────────────────────────────────────────
const Text(
'HOW TO EARN XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
_XpRuleTile(
icon: Icons.play_circle_outline,
label: 'Watch a rewarded ad',
value: '+2 XP (up to 20/day)',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.trending_down,
label: 'Watch fewer reels than your weekly average',
value: '+10 XP per reel saved',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.check_circle_outline,
label: 'Stay under your daily reel limit',
value: '+15 XP per day',
isDark: isDark,
),
_XpRuleTile(
icon: Icons.login,
label: 'Open the app and check in',
value: '+1 XP per day',
isDark: isDark,
),
const SizedBox(height: 16),
// ── Watch Ad to earn XP ─────────────────────────────
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: () => _watchAdForXp(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
label: const Text(
'Watch Ad to Earn +2 XP',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 16),
// ── XP History ──────────────────────────────────────
const Text(
'RECENT XP',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
...levelService.recentXpLog.take(10).map((entry) {
final dt = DateTime.tryParse(entry['time'] as String? ?? '');
final timeStr = dt != null
? '${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'
: '';
final amount = entry['amount'] as int;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
amount > 0 ? Icons.add_circle : Icons.remove_circle,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entry['reason'] as String? ?? '',
style: const TextStyle(fontSize: 13),
),
),
Text(
amount > 0 ? '+$amount XP' : '$amount XP',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: amount > 0 ? Colors.greenAccent : Colors.redAccent,
),
),
const SizedBox(width: 8),
Text(
timeStr,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
);
}),
if (levelService.recentXpLog.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'No XP earned yet — watch an ad above or reduce reel time!',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white38 : Colors.black38,
),
),
),
const SizedBox(height: 20),
const Text(
'DEGRADATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.15),
),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text(
'XP decays if you backslide',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
SizedBox(height: 6),
Text(
'• Watching more reels than your weekly average deducts XP\n'
'• Exceeding limits for 3 consecutive days drops a level\n'
'• Levels are preserved on monthly reset, but XP resets',
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
),
],
),
),
const SizedBox(height: 40),
],
),
);
}
Color _levelColor(int level) {
switch (level) {
case 1: return Colors.grey;
case 2: return Colors.blue;
case 3: return Colors.purple;
case 4: return Colors.orange;
case 5: return Colors.amber;
default: return Colors.grey;
}
}
List<Color> _levelColors(int level, bool isDark) {
final base = _levelColor(level);
// MaterialColor supports .shadeXXX; plain Color doesn't.
if (base is MaterialColor) {
return isDark
? [base.shade800, base.shade900]
: [base.shade400, base.shade700];
}
return [base, base];
}
/// Navigate to Adsterra ad -> grant XP on completion.
Future<void> _watchAdForXp(BuildContext context) async {
// Try Adsterra Social Bar first
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(sessionType: 'reels'),
),
);
if (adResult == true && context.mounted) {
context.read<LevelService>().addXpForAd();
context.read<CreditStore>().addReelsMinutes();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('+10 XP earned!'),
duration: Duration(seconds: 2),
),
);
}
}
String _levelTitle(int level) {
switch (level) {
case 1: return 'Beginner';
case 2: return 'Mindful Scroller';
case 3: return 'Disciplined';
case 4: return 'Focus Master';
case 5: return 'Digital Monk';
default: return 'Level $level';
}
}
}
class _XpRuleTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final bool isDark;
const _XpRuleTile({
required this.icon,
required this.label,
required this.value,
required this.isDark,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Colors.greenAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white70 : Colors.black87,
),
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
color: Colors.greenAccent,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
+625 -40
View File
@@ -13,11 +13,20 @@ import '../services/settings_service.dart';
import '../services/injection_controller.dart';
import '../services/injection_manager.dart';
import '../scripts/native_feel.dart';
import '../scripts/grayscale.dart' as grayscale;
import '../services/screen_time_service.dart';
import '../services/navigation_guard.dart';
import '../services/focusgram_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../services/notification_service.dart';
import '../services/bait_engine.dart';
import '../services/credit_store.dart';
import '../services/level_service.dart';
import 'bait_me_full_screen.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed — offline feature deleted
// reels_history_service import removed — feature deleted
import 'app_lock_screen.dart';
import '../features/update_checker/update_checker_service.dart';
import '../utils/discipline_challenge.dart';
import 'settings_page.dart';
@@ -26,9 +35,9 @@ import '../features/preloader/instagram_preloader.dart';
import '../v2_integration/script_engine_v2_overlay.dart';
import '../v2_integration/script_registry_v2_overlay.dart';
import '../scripts/focus_scripts.dart';
import 'adsterra_ad_screen.dart';
import '../focus_settings.dart';
import '../services/adblock/adblock_content_blocker_loader.dart';
/// Core validator/dispatcher for the JS → Flutter bridge:
@@ -100,10 +109,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
bool _isPreloaded = false;
bool _minimalModeBannerDismissed = false;
bool _isInDirectThread = false;
bool _dmThreadCdnBlockArmed = false;
DateTime _lastMainFrameLoadStartedAt = DateTime.fromMillisecondsSinceEpoch(0);
SkeletonType _skeletonType = SkeletonType.generic;
/// True when on the homepage and should block api/graphql + gateway.
/// Updated in onLoadStart / UrlChange before shouldInterceptRequest fires.
bool _blockHomepageGraphql = false;
/// Helper to determine if we are on a login/onboarding page.
bool get _isOnOnboardingPage {
final path = Uri.tryParse(_currentUrl)?.path ?? '';
@@ -227,6 +239,121 @@ class _MainWebViewPageState extends State<MainWebViewPage>
);
}
/// Show a full-screen lock gate when navigating to Instagram DMs.
void _showDmLockGate() {
Navigator.push(
context,
MaterialPageRoute(
builder: (ctx) => Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.lock_outline,
color: Colors.white54,
size: 64,
),
const SizedBox(height: 24),
const Text(
'Messages Locked',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'Enter your PIN to access Direct Messages',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
const SizedBox(height: 40),
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push<bool>(
ctx,
MaterialPageRoute(
builder: (_) => const AppLockScreen(
forAppWide: false,
title: 'Messages Locked',
subtitle:
'Enter your PIN to access Direct Messages',
),
),
);
if (!ctx.mounted) return;
if (result == true) {
_dmLockOverride = true;
Navigator.pop(ctx);
} else {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
}
},
icon: const Icon(Icons.lock_open_rounded),
label: const Text('Unlock'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
_controller?.evaluateJavascript(
source: 'window.location.href = "/";',
);
Navigator.pop(ctx);
},
child: const Text(
'Cancel — Go to Home',
style: TextStyle(color: Colors.white38),
),
),
],
),
),
),
),
),
),
),
);
}
/// Set ghost mode flags in the WebView so the pre-injected scripts activate.
void _setGhostModeFlags(InAppWebViewController c, SettingsService s) {
c.evaluateJavascript(
source:
'''
window.__fgFullDmGhost = ${s.ghostMode};
''',
);
}
/// Re-inject grayscale on app resume (fixes cold-start persistence bug
/// where the preloader cache can bypass onLoadStop).
void _syncGrayscaleOnResume(SettingsService settings) {
if (_injectionManager == null || _controller == null) return;
if (settings.isGrayscaleActiveNow) {
_injectionManager!.runAllPostLoadInjections(_currentUrl);
} else {
// Explicitly remove grayscale
_controller?.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
}
}
void _onSessionChanged() {
if (!mounted) return;
final sm = context.read<SessionManager>();
@@ -360,6 +487,21 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_injectionManager!.runAllPostLoadInjections(_currentUrl);
}
// Ghost mode flags update + reload (scripts already injected by preloader,
// but need to reload so the fetch/XHR interceptors see the new flags from
// the start of page load).
if (_lastGhostMode != settings.ghostMode) {
_lastGhostMode = settings.ghostMode;
if (_controller != null) {
_setGhostModeFlags(_controller!, settings);
// Schedule a reload so the flags take effect on fresh page load
_reloadDebounce?.cancel();
_reloadDebounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _controller?.reload();
});
}
}
// 2. Rebuild Flutter widget tree (e.g. overlay conditions, banner state)
setState(() {});
@@ -425,6 +567,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
screenTime.startTracking();
// Cancel persistent notification when app comes to foreground
NotificationService().cancelPersistentNotification(id: 5001);
// Re-inject grayscale on resume — schedules may have changed
// while the app was backgrounded, and injection can be lost on cold
// start due to the preloader cache bypassing onLoadStop.
_syncGrayscaleOnResume(settings);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
@@ -535,10 +682,24 @@ class _MainWebViewPageState extends State<MainWebViewPage>
),
if (sm.canExtendAppSession)
ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.of(context, rootNavigator: true).pop();
sm.extendAppSession();
// Keep _extensionDialogShown = true while ad runs so the
// watchdog timer doesn't re-show the dialog over the ad screen.
if (!mounted) return;
final adResult = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
_extensionDialogShown = false;
if (adResult == true && mounted) {
sm.extendAppSession();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
@@ -546,7 +707,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('+10 minutes'),
child: const Text('Watch Ad (+10 min)'),
),
],
),
@@ -673,21 +834,37 @@ class _MainWebViewPageState extends State<MainWebViewPage>
static bool _isDirectThreadUrl(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/[^/]+/?$').hasMatch(path);
// Match both /direct/inbox/ and /direct/t/{thread_id}
return RegExp(r'^/direct/').hasMatch(path);
}
/* unused after CDN block was removed
static bool _isFktmInstagramCdn(String url) {
final host = Uri.tryParse(url)?.host.toLowerCase() ?? '';
return RegExp(r'^instagram\.fktm\d+-\d+\.fna\.fbcdn\.net$').hasMatch(host);
}
*/
void _syncDirectThreadState(String url) {
final active = _isDirectThreadUrl(url);
if (_isInDirectThread == active) return;
_isInDirectThread = active;
_dmThreadCdnBlockArmed = false;
// Reset override when leaving DMs
if (!active) _dmLockOverride = false;
// If Messages Tab Lock is enabled and user navigated to DMs,
// show a lock overlay.
if (active && mounted) {
final appLock = context.read<AppLockService>();
if (appLock.messagesLockReady && !_dmLockOverride) {
_showDmLockGate();
}
}
}
bool _dmLockOverride = false;
Future<void> _showReelSessionPicker() async {
final settings = context.read<SettingsService>();
if (settings.requireWordChallenge) {
@@ -836,6 +1013,13 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_BrandedTopBar(
onFocusControlTap: () =>
_edgePanelKey.currentState?._toggleExpansion(),
onDmGhostToggle: () {
context.read<SettingsService>().setGhostMode(false);
_controller?.reload();
},
onReload: () => _controller?.reload(),
currentUrl: _currentUrl,
dmGhostActive: context.read<SettingsService>().ghostMode,
),
Expanded(
child: Consumer<SessionManager>(
@@ -1029,6 +1213,95 @@ class _MainWebViewPageState extends State<MainWebViewPage>
);
}
// ── DM Ghost: block ALL seen signals ────────────────
// Like Chrome DevTools "Block request URL" — catches all
// sources at the native WebView level.
//
// Rules:
// 1. Block specific seen endpoint patterns everywhere
// 2. Block /api/graphql on homepage (/) and DM threads
// (/direct/t/*). Allow on /direct/inbox/ so inbox loads.
if (settings.ghostMode) {
// — Seen endpoint patterns (always block) —
final seenBlocked = RegExp(
r'/api/v1/media/[\w-]+/seen/|'
r'/api/v1/stories/reel/seen/|'
r'/api/v1/direct_v2/threads/[\w-]+/seen/|'
r'/api/v1/direct_v2/visual_message/[\w-]+/seen/|'
r'/api/v1/live/[\w-]+/comment/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/|'
r'/api/v1/direct_v2/mark_item_seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/|'
r'/api/v1/direct_v2/visual_thread/[^/]+/seen/|'
r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/|'
r'/api/v1/live/[^/]+/join/|'
r'/api/v1/live/[^/]+/get_join_requests/|'
r'/api/v1/media/seen/|'
r'/api/v1/feed/viewed_story/|'
r'/api/v1/feed/reels_tray/seen/|'
r'/api/v1/qe/|'
r'/api/v1/launcher/sync/|'
r'/api/v1/logging/|'
r'/api/v1/fb_onetap_logging/|'
r'/ajax/bz|'
r'/ajax/logging/|'
r'/api/v1/stats/|'
r'/api/v1/fbanalytics/',
).hasMatch(url);
if (seenBlocked) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
// — Block /api/graphql + gateway on homepage &
// DM thread pages. Allow on /direct/inbox/. —
final currentPath =
Uri.tryParse(_currentUrl)?.path ??
_currentUrl;
final isHomepage =
currentPath == '/' || currentPath == '';
final isDmThread = currentPath.startsWith(
'/direct/t/',
);
if (!currentPath.startsWith(
'/direct/inbox/',
) &&
(isHomepage || isDmThread) &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
}
// Legacy homepage graphql + gateway block
// (kept for safety — the ghost mode block above now covers it)
if (_blockHomepageGraphql &&
(url.contains('/api/graphql') ||
url.contains(
'gateway.instagram.com',
))) {
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode('{"status":"ok"}'),
),
statusCode: 200,
contentType: 'application/json',
);
}
/* Strip ads from feed (JS handles it)
if (settings.noAds &&
url.contains(
@@ -1158,6 +1431,29 @@ class _MainWebViewPageState extends State<MainWebViewPage>
settingsService,
);
// Set ghost mode flags (scripts already injected by preloader)
_setGhostModeFlags(controller, settingsService);
// Navigate to startup page if not Home
if (settingsService.startupPage != 'home') {
await controller.loadUrl(
urlRequest: URLRequest(
url: WebUri(settingsService.startupUrl),
),
);
}
// Force-inject grayscale on initial WebView creation,
// because the preloader's keepAlive causes the main
// WebView to skip onLoadStop on cold start.
if (settingsService.isGrayscaleActiveNow) {
try {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} catch (_) {}
}
_registerJavaScriptHandlers(controller);
// ── FocusGram v2 overlay initial sync ───────────────
@@ -1223,6 +1519,14 @@ class _MainWebViewPageState extends State<MainWebViewPage>
if (!mounted) return;
final u = url?.toString() ?? '';
_syncDirectThreadState(u);
// Update homepage graphql block flag SYNCHRONOUSLY
// (before setState, so shouldInterceptRequest sees it)
final path = Uri.tryParse(u)?.path ?? u;
_blockHomepageGraphql =
settings.ghostMode &&
(path == '/' ||
path == '' ||
path == '/explore/');
final lower = u.toLowerCase();
final isOnboardingUrl =
lower.contains('/accounts/login') ||
@@ -1251,6 +1555,15 @@ class _MainWebViewPageState extends State<MainWebViewPage>
final current = url?.toString() ?? '';
_syncDirectThreadState(current);
// Re-set ghost mode flags on every page load.
// evaluateJavascript-set flags are destroyed when
// the JS context resets on navigation. The flags
// are also prepended to initialUserScripts, but
// this covers the toggle-off → reload case.
final s = context.read<SettingsService>();
_setGhostModeFlags(controller, s);
setState(() {
_isLoading = false;
_currentUrl = current;
@@ -1263,6 +1576,17 @@ class _MainWebViewPageState extends State<MainWebViewPage>
// Phase 1 V2 overlay DOM scripts
await _v2Engine?.injectDocumentEndScripts();
// Re-inject grayscale on every page load
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleJS,
);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
await controller.evaluateJavascript(
source:
InjectionController.notificationBridgeJS,
@@ -1458,7 +1782,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
right: 0,
child: const _InstagramGradientProgressBar(),
),
_EdgePanel(key: _edgePanelKey),
_EdgePanel(key: _edgePanelKey, currentUrl: _currentUrl),
if (_exploreBlockedOverlay)
Positioned.fill(
@@ -1850,13 +2174,35 @@ class _MainWebViewPageState extends State<MainWebViewPage>
},
);
// ReelMetadata handler removed — reel history feature deleted
controller.addJavaScriptHandler(
handlerName: 'UrlChange',
callback: (args) async {
final url = (args.isNotEmpty ? args[0] : '') as String? ?? '';
_syncDirectThreadState(url);
final s = context.read<SettingsService>();
// Update homepage graphql block for SPA navigation
final path = Uri.tryParse(url)?.path ?? url;
_blockHomepageGraphql =
s.ghostMode && (path == '/' || path == '' || path == '/explore/');
// Re-set ghost mode flags on SPA navigation (no page reload).
_setGhostModeFlags(controller, s);
await _injectionManager?.runAllPostLoadInjections(url);
// Re-inject grayscale on SPA nav (no page reload)
if (s.isGrayscaleActiveNow) {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
} else {
await controller.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
}
// Phase 1 V2 overlay re-inject on SPA route changes
await _v2Engine?.injectDocumentEndScripts();
@@ -1876,7 +2222,6 @@ class _MainWebViewPageState extends State<MainWebViewPage>
.read<SettingsService>()
.disableExploreEntirely;
final path = Uri.tryParse(url)?.path ?? url;
final isReels = path.startsWith('/reels') || path.startsWith('/reel/');
final isExplore = path.startsWith('/explore');
@@ -1967,7 +2312,8 @@ class _MinimalModeBanner extends StatelessWidget {
}
class _EdgePanel extends StatefulWidget {
const _EdgePanel({super.key});
final String currentUrl;
const _EdgePanel({super.key, this.currentUrl = ''});
@override
State<_EdgePanel> createState() => _EdgePanelState();
}
@@ -2091,6 +2437,38 @@ class _EdgePanelState extends State<_EdgePanel> {
],
),
),
// Level badge
Consumer<LevelService>(
builder: (context, lv, _) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: lv.level >= 3
? Colors.purple.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: lv.level >= 3
? Colors.purpleAccent.withValues(alpha: 0.4)
: Colors.grey.withValues(alpha: 0.2),
),
),
child: Text(
'Lv ${lv.level}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: lv.level >= 3
? Colors.purpleAccent
: Colors.grey,
),
),
),
),
// Save current page — REMOVED
const SizedBox(width: 4),
IconButton(
tooltip: 'Close',
icon: Icon(
@@ -2102,6 +2480,8 @@ class _EdgePanelState extends State<_EdgePanel> {
),
],
),
// Bait Me button row
_BaitMeButtonRow(),
const SizedBox(height: 18),
Container(
width: double.infinity,
@@ -2189,6 +2569,7 @@ class _EdgePanelState extends State<_EdgePanel> {
color: reelsHardDisabled ? Colors.redAccent : textSub,
isDark: isDark,
),
const SizedBox(height: 16),
if (sm.isSessionActive)
SizedBox(
@@ -2226,17 +2607,86 @@ class _EdgePanelState extends State<_EdgePanel> {
),
const SizedBox(height: 8),
if (!canStart && !sm.isSessionActive)
Text(
reelsHardDisabled
? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
: sm.isCooldownActive
? 'A cooldown is active before the next Reel session.'
: 'Your daily Reel quota is used up.',
style: TextStyle(
color: textSub,
fontSize: 12,
height: 1.35,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
reelsHardDisabled
? 'Turn off hard Reels blocking in Focus Mode to use timed sessions.'
: sm.isCooldownActive
? 'A cooldown is active before the next Reel session.'
: 'Your daily Reel quota is used up.',
style: TextStyle(
color: textSub,
fontSize: 12,
height: 1.35,
),
),
if (sm.dailyRemainingSeconds <= 0 &&
!reelsHardDisabled &&
!sm.isCooldownActive)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Consumer<CreditStore>(
builder: (ctx, credits, _) {
if (!credits.canWatchAdToday) {
return Text(
'Ad limit reached (3/day)',
style: TextStyle(
color: textSub,
fontSize: 11,
),
);
}
return SizedBox(
width: double.infinity,
height: 40,
child: OutlinedButton.icon(
onPressed: () async {
final adResult =
await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) =>
const AdsterraAdScreen(
sessionType: 'reels',
requiredSeconds: 20,
),
),
);
if (adResult == true && context.mounted) {
context
.read<CreditStore>()
.addReelsMinutes(amount: 2);
context
.read<SessionManager>()
.addBonusDailyMinutes(2);
HapticFeedback.heavyImpact();
}
},
icon: const Icon(Icons.videocam, size: 16),
label: Text(
'Watch Ad (+2 min) '
'(${CreditStore.maxDailyAds - credits.adsWatchedToday}/3 today)',
style: const TextStyle(fontSize: 12),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orangeAccent,
side: BorderSide(
color: Colors.orangeAccent.withValues(
alpha: 0.4,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
},
),
),
],
),
const SizedBox(height: 10),
Divider(color: border),
@@ -2345,16 +2795,88 @@ class _EdgePanelState extends State<_EdgePanel> {
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
const _BrandedTopBar({this.onFocusControlTap});
/// Small row showing the Bait Me button and daily XP for the edge panel.
class _BaitMeButtonRow extends StatelessWidget {
const _BaitMeButtonRow();
@override
Widget build(BuildContext context) {
final isDark = context.watch<SettingsService>().isDarkMode;
final levelService = context.watch<LevelService>();
final baitEngine = context.watch<BaitEngine>();
final isUnlocked = levelService.isFeatureUnlocked(AppFeature.baitMe);
if (!isUnlocked) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: baitEngine.isOnCooldown
? null
: () => _openBaitMe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purpleAccent.withValues(alpha: 0.2),
foregroundColor: Colors.purpleAccent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.casino_rounded, size: 20),
label: Text(
baitEngine.isOnCooldown
? 'Bait Me (${baitEngine.cooldownRemainingMinutes}m cooldown)'
: '🎲 Bait Me — Feel Lucky?',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
),
);
}
void _openBaitMe(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BaitMeFullScreen()),
);
}
}
class _BrandedTopBar extends StatelessWidget {
final VoidCallback? onFocusControlTap;
final VoidCallback? onDmGhostToggle;
final VoidCallback? onReload;
final String currentUrl;
final bool dmGhostActive;
const _BrandedTopBar({
this.onFocusControlTap,
this.onDmGhostToggle,
this.onReload,
this.currentUrl = '',
this.dmGhostActive = false,
});
static bool _isDirectInbox(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return path == '/direct/inbox/' || path == '/direct/inbox';
}
static bool _isDirectThread(String url) {
final path = Uri.tryParse(url)?.path ?? url;
return RegExp(r'^/direct/t/').hasMatch(path);
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final isDark = settings.isDarkMode;
final barBg = isDark ? Colors.black : Colors.white;
final textMain = isDark ? Colors.white : Colors.black;
final iconColor = isDark ? Colors.white70 : Colors.black54;
final border = isDark ? Colors.white12 : Colors.black12;
final showDmGhostBtn = _isDirectThread(currentUrl) && dmGhostActive;
final showReloadBtn = _isDirectInbox(currentUrl);
return Container(
height: 60,
@@ -2363,10 +2885,11 @@ class _BrandedTopBar extends StatelessWidget {
border: Border(bottom: BorderSide(color: border, width: 0.5)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: settings icon
IconButton(
icon: Icon(Icons.settings_outlined, color: iconColor, size: 22),
onPressed: () => Navigator.push(
@@ -2374,21 +2897,83 @@ class _BrandedTopBar extends StatelessWidget {
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
// Center: FocusGram logo (or DM ghost badge)
if (showDmGhostBtn)
GestureDetector(
onTap: onDmGhostToggle,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.redAccent.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off,
color: Colors.redAccent,
size: 16,
),
const SizedBox(width: 4),
Text(
'DM Ghost ON',
style: TextStyle(
color: Colors.redAccent,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.close,
color: Colors.redAccent.withValues(alpha: 0.6),
size: 14,
),
],
),
),
)
else
Text(
'FocusGram',
style: GoogleFonts.grandHotel(
color: textMain,
fontSize: 32,
letterSpacing: 0.5,
),
),
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
// Right: reload button + timer icon
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showReloadBtn)
IconButton(
icon: Icon(
Icons.refresh_rounded,
color: iconColor,
size: 22,
),
onPressed: onReload,
tooltip: 'Reload page',
),
IconButton(
icon: const Icon(
Icons.timer_outlined,
color: Colors.blueAccent,
size: 22,
),
onPressed: onFocusControlTap,
),
],
),
],
),
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
/// Opens a saved page offline. Uses saved HTML content when available,
/// falls back to WebView cache.
class OfflineFeedViewer extends StatelessWidget {
final String url;
final String? pageId;
const OfflineFeedViewer({super.key, required this.url, this.pageId});
@override
Widget build(BuildContext context) {
// Find the saved page with HTML content
SavedPage? page;
if (pageId != null) {
final ss = context.read<SnapshotService>();
final matches = ss.savedPages.where((p) => p.id == pageId);
if (matches.isNotEmpty) page = matches.first;
}
return Scaffold(
appBar: AppBar(
title: const Text('Offline View',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: Colors.blue.withValues(alpha: 0.1),
child: const Row(
children: [
Icon(Icons.wifi_off_rounded, size: 14,
color: Colors.blueAccent),
SizedBox(width: 6),
Text('Offline — saved content shown',
style: TextStyle(fontSize: 11, color: Colors.blueAccent)),
],
),
),
Expanded(
child: page?.htmlContent != null
? InAppWebView(
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
domStorageEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
onWebViewCreated: (c) async {
await c.loadData(
data: page!.htmlContent!,
mimeType: 'text/html',
encoding: 'utf-8',
baseUrl: WebUri(url),
);
},
)
: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: InAppWebViewSettings(
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
javaScriptEnabled: true,
transparentBackground: false,
useHybridComposition: true,
),
),
),
],
),
);
}
}
+122 -40
View File
@@ -6,8 +6,18 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../services/level_service.dart';
import '../services/credit_store.dart';
import '../services/app_lock_service.dart';
// snapshot_service import removed — offline feature deleted
import '../services/focusgram_router.dart';
import 'app_lock_settings_page.dart';
// snapshot_manager_screen import removed — offline feature deleted
import 'level_panel_screen.dart';
//import 'debug_menu_screen.dart';
import '../widgets/native_ad_banner.dart';
import '../features/screen_time/screen_time_screen.dart';
// reels_history_screen import removed — feature deleted
import 'guardrails_page.dart';
import 'extras_settings_page.dart';
@@ -37,7 +47,7 @@ class SettingsPage extends StatelessWidget {
body: ListView(
children: [
const _DonateTile(),
_buildStatsRow(sm),
_buildStatsRow(sm, context),
const _SectionHeader(title: 'FOCUS & BLOCKING'),
_SubmoduleTile(
@@ -71,13 +81,14 @@ class SettingsPage extends StatelessWidget {
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Download media, Ghost Mode',
subtitle: 'Startup Page, Download media, Ghost Mode',
enabled: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
),
),
const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile(
@@ -88,13 +99,25 @@ class SettingsPage extends StatelessWidget {
? 'Grayscale on'
: settings.grayscaleSchedules.isNotEmpty
? 'Grayscale scheduled (${settings.grayscaleSchedules.length} schedules)'
: 'Theme, grayscale',
: 'Grayscale and schedules',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppearancePage()),
),
),
const _SectionHeader(title: 'SECURITY'),
_SubmoduleTile(
icon: Icons.lock_rounded,
iconColor: Colors.blueAccent,
title: 'App Lock',
subtitle: _appLockSubtitle(context.watch<AppLockService>()),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AppLockSettingsPage()),
),
),
const _SectionHeader(title: 'PRIVACY & NOTIFICATIONS'),
_SubmoduleTile(
icon: Icons.lock_outline,
@@ -120,18 +143,22 @@ class SettingsPage extends StatelessWidget {
MaterialPageRoute(builder: (_) => const ScreenTimeScreen()),
),
),
const _SectionHeader(title: 'ABOUT'),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
_SubmoduleTile(
icon: Icons.trending_up_rounded,
iconColor: Colors.amber,
title: 'Your Journey',
subtitle: 'Level ${context.watch<LevelService>().level} · ${context.watch<LevelService>().xp} XP',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const LevelPanelScreen()),
),
),
// Quick XP debug grant (visible in settings for testing)
// _XpDebugTile(),
// Reels History removed
const _SectionHeader(title: 'ABOUT'),
_VersionTile(),
ListTile(
title: const Text('GitHub'),
trailing: const Icon(Icons.open_in_new, size: 14),
@@ -173,6 +200,8 @@ class SettingsPage extends StatelessWidget {
'https://www.instagram.com/accounts/settings/?entrypoint=profile';
},
),
const SizedBox(height: 20),
const NativeAdBanner(height: 60),
const SizedBox(height: 40),
Center(
child: Text(
@@ -189,7 +218,35 @@ class SettingsPage extends StatelessWidget {
);
}
Widget _buildStatsRow(SessionManager sm) {
Widget _buildStatsRow(SessionManager sm, BuildContext context) {
final creditStore = context.watch<CreditStore>();
final cells = <Widget>[
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
];
if (true) { // ad counter always shown
cells.addAll([
_dividerCell(),
_statCell(
'XP Ads Watched',
'${creditStore.adsWatchedToday}',
Colors.purpleAccent,
),
]);
}
return Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16),
@@ -200,21 +257,7 @@ class SettingsPage extends StatelessWidget {
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_statCell('Opens Today', '${sm.dailyOpenCount}×', Colors.blue),
_dividerCell(),
_statCell(
'Reels Used',
'${sm.dailyUsedSeconds ~/ 60}m',
Colors.orangeAccent,
),
_dividerCell(),
_statCell(
'Remaining',
'${sm.dailyRemainingSeconds ~/ 60}m',
Colors.greenAccent,
),
],
children: cells,
),
);
}
@@ -240,6 +283,16 @@ class SettingsPage extends StatelessWidget {
color: Colors.blue.withValues(alpha: 0.1),
);
String _appLockSubtitle(AppLockService a) {
if (!a.anyLockEnabled) return 'Protect FocusGram with a PIN';
final parts = <String>[];
if (a.lockAppWide) parts.add('App-wide');
if (a.lockMessages) parts.add('Messages');
return '${parts.join(' + ')} lock active';
}
void _showLegalDisclaimer(BuildContext context) {
showDialog(
context: context,
@@ -341,6 +394,8 @@ class FocusSettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 8),
const _SectionHeader(title: 'FRICTION'),
_SwitchTile(
title: 'Mindfulness Gate',
@@ -378,17 +433,24 @@ class FocusSettingsPage extends StatelessWidget {
onSelected: (v) => settings.setWordChallengeCount(v),
),
const _SectionHeader(title: 'MEDIA'),
/*
( I TRIED SO HARD, AND GOT SO FAR, BUT IN THE END...
IT DOESNT EVEN MATTER ..... (didnt work))
_SwitchTile(
title: 'Block Autoplay Videos',
subtitle: 'Videos won\'t play until you tap them',
value: settings.blockAutoplay,
onChanged: (v) => settings.setBlockAutoplay(v),
),*/
title: 'Effort Friction Mode',
subtitle: 'Watch ads to earn reel quota minutes',
value: settings.effortFrictionEnabled,
onChanged: (v) async {
if (v && !context.read<LevelService>().isFeatureUnlocked(AppFeature.effortFriction)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unlocks at Level 2')),
);
return;
}
await settings.setEffortFrictionEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'MEDIA'),
// Block Autoplay removed — was unreliable
_SwitchTile(
title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped',
@@ -412,7 +474,7 @@ class FocusSettingsPage extends StatelessWidget {
_SwitchTile(
title: 'Hide Feed Posts',
subtitle:
'Hides home feed posts (stories tray, posts, suggested content)',
'Hides home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
@@ -1321,6 +1383,26 @@ class _NumberEditTile extends StatelessWidget {
}
}
class _VersionTile extends StatelessWidget {
const _VersionTile();
@override
Widget build(BuildContext context) {
return FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) => ListTile(
title: const Text('Version'),
trailing: Text(
snapshot.data?.version ?? '',
style: const TextStyle(color: Colors.grey),
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
+312
View File
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/snapshot_service.dart';
import '../services/level_service.dart';
import 'offline_feed_viewer.dart';
/// Manages saved pages for offline viewing via WebView cache.
/// Gated behind Level 5.
class SnapshotManagerScreen extends StatelessWidget {
const SnapshotManagerScreen({super.key});
@override
Widget build(BuildContext context) {
final levelService = context.watch<LevelService>();
final isUnlocked = levelService.level >= 5; // offline pages at L5
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text(
'Offline Pages',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: isUnlocked
? const _SavedPageList()
: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 64,
color: Colors.grey.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
const Text(
'Unlocks at Level 5',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Earn XP to unlock offline browsing.\n'
'Watch ads and reduce reel time to level up.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white54 : Colors.black54,
height: 1.5,
),
),
],
),
),
),
);
}
}
class _SavedPageList extends StatelessWidget {
const _SavedPageList();
@override
Widget build(BuildContext context) {
final snapshotService = context.watch<SnapshotService>();
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// Info card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withValues(alpha: 0.12)),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blueAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
'The WebView already caches pages you visit. '
'Save bookmarks here to easily reopen them when offline.\n'
'No API needed — the cache handles everything.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white60 : Colors.black54,
height: 1.4,
),
),
),
],
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Text(
'${snapshotService.totalSaved} saved page${snapshotService.totalSaved == 1 ? '' : 's'}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black54,
),
),
const Spacer(),
if (snapshotService.totalSaved > 0)
GestureDetector(
onTap: () => _confirmClearAll(context, snapshotService),
child: Text(
'Clear all',
style: TextStyle(
fontSize: 12,
color: Colors.redAccent.withValues(alpha: 0.7),
),
),
),
],
),
),
// Page list
Expanded(
child: snapshotService.savedPages.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border_rounded,
size: 48,
color: Colors.grey.withValues(alpha: 0.3),
),
const SizedBox(height: 12),
Text(
'No saved pages yet',
style: TextStyle(
color: isDark ? Colors.white38 : Colors.black38,
),
),
const SizedBox(height: 4),
Text(
'Visit Instagram pages online, then save them here\nto browse offline later.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white24 : Colors.black26,
height: 1.4,
),
),
],
),
)
: ListView.builder(
itemCount: snapshotService.savedPages.length,
itemBuilder: (context, index) {
final page = snapshotService.savedPages[index];
return ListTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.web_rounded,
color: Colors.blueAccent,
size: 22,
),
),
title: Text(
page.title,
style: const TextStyle(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
_formatDate(page.savedAt),
style: const TextStyle(fontSize: 12),
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDelete(context, snapshotService, page.id);
} else if (value == 'open') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(url: page.url, pageId: page.id),
),
);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'open',
child: Row(
children: [
Icon(Icons.open_in_browser, size: 18),
SizedBox(width: 8),
Text('Open Offline'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete_outline,
color: Colors.redAccent, size: 18),
SizedBox(width: 8),
Text('Remove',
style: TextStyle(color: Colors.redAccent)),
],
),
),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OfflineFeedViewer(url: page.url),
),
);
},
);
},
),
),
],
);
}
void _confirmDelete(
BuildContext context,
SnapshotService service,
String id,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove page?'),
content:
const Text('Removes the bookmark. Cache is preserved automatically.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deletePage(id);
Navigator.pop(ctx);
},
child:
const Text('Remove', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
void _confirmClearAll(BuildContext context, SnapshotService service) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all saved pages?'),
content: const Text('This removes all bookmarks.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
service.deleteAll();
Navigator.pop(ctx);
},
child:
const Text('Clear', style: TextStyle(color: Colors.redAccent)),
),
],
),
);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}';
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A 15-second timer that acts as the last-resort fallback
/// when both AdMob and Adsterra fail to serve an ad.
///
/// Shows a digital wellness quote while the user waits.
/// After the timer, they earn the same reward.
class TimerFallbackScreen extends StatefulWidget {
final String sessionType;
final int requiredSeconds;
const TimerFallbackScreen({
super.key,
required this.sessionType,
this.requiredSeconds = 15,
});
@override
State<TimerFallbackScreen> createState() => _TimerFallbackScreenState();
}
class _TimerFallbackScreenState extends State<TimerFallbackScreen> {
int _remaining = 0;
Timer? _timer;
int _quoteIndex = 0;
static const _quotes = [
'"The secret of getting ahead is getting started." — Mark Twain',
'"Focus on being productive instead of busy." — Tim Ferriss',
'"Almost everything will work if you unplug it for a few minutes." — Ann Lamott',
'"The key is not to prioritize what\'s on your schedule, but to schedule your priorities." — Stephen Covey',
'"Your mind is for having ideas, not holding them." — David Allen',
'"Simplicity is the ultimate sophistication." — Leonardo da Vinci',
'"The ability to simplify means to eliminate the unnecessary." — Hans Hofmann',
'"In the midst of chaos, there is also opportunity." — Sun Tzu',
];
@override
void initState() {
super.initState();
_remaining = widget.requiredSeconds;
_quoteIndex = DateTime.now().millisecondsSinceEpoch % _quotes.length;
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
if (_remaining > 0) {
_remaining--;
} else {
_timer?.cancel();
HapticFeedback.heavyImpact();
}
});
});
}
@override
Widget build(BuildContext context) {
final done = _remaining <= 0;
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withValues(alpha: 0.1),
border: Border.all(
color: Colors.green.withValues(alpha: 0.3),
width: 2,
),
),
child: Icon(
done ? Icons.check_circle : Icons.timer_outlined,
color: done ? Colors.greenAccent : Colors.green,
size: 36,
),
),
const SizedBox(height: 28),
// Timer
Text(
done ? 'Done!' : '$_remaining',
style: TextStyle(
color: done ? Colors.greenAccent : Colors.white,
fontSize: 56,
fontWeight: FontWeight.bold,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 8),
Text(
done
? 'You earned ${widget.sessionType == 'reels' ? 'reel' : 'Instagram'} time'
: 'Please wait while we prepare your reward',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
),
),
const SizedBox(height: 40),
// Quote
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
child: Text(
_quotes[_quoteIndex],
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 15,
height: 1.5,
fontStyle: FontStyle.italic,
),
),
),
const Spacer(flex: 1),
// Continue button
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton.icon(
onPressed: done
? () => Navigator.pop(context, true)
: null,
style: ElevatedButton.styleFrom(
backgroundColor:
done ? Colors.greenAccent : Colors.grey,
foregroundColor:
done ? Colors.black : Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
icon: Icon(
done ? Icons.check_circle : Icons.hourglass_empty,
size: 22,
),
label: Text(
done
? 'Continue & Earn Reward'
: 'Wait $_remaining seconds',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
const SizedBox(height: 16),
Text(
'No ad available — timer reward instead',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.2),
fontSize: 11,
),
),
const Spacer(flex: 1),
],
),
),
),
);
}
}