What's new

- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
This commit is contained in:
Ujwal
2026-03-04 10:48:14 +05:45
commit 7bb472d212
92 changed files with 14740 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
// test/services/focusgram_router_test.dart
//
// Tests for FocusGramRouter — the lightweight cross-widget URL notifier.
//
// Run with: flutter test test/services/focusgram_router_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/focusgram_router.dart';
void main() {
// Reset between tests so state does not bleed across
tearDown(() {
FocusGramRouter.pendingUrl.value = null;
});
group('FocusGramRouter.pendingUrl', () {
test('initial value is null', () {
expect(FocusGramRouter.pendingUrl.value, isNull);
});
test('can be set to a URL string', () {
FocusGramRouter.pendingUrl.value =
'https://www.instagram.com/accounts/settings/';
expect(
FocusGramRouter.pendingUrl.value,
'https://www.instagram.com/accounts/settings/',
);
});
test('can be cleared back to null', () {
FocusGramRouter.pendingUrl.value = 'https://www.instagram.com/';
FocusGramRouter.pendingUrl.value = null;
expect(FocusGramRouter.pendingUrl.value, isNull);
});
test('notifies listeners when value changes', () {
bool notified = false;
void listener() => notified = true;
FocusGramRouter.pendingUrl.addListener(listener);
FocusGramRouter.pendingUrl.value = 'https://www.instagram.com/';
expect(notified, isTrue);
FocusGramRouter.pendingUrl.removeListener(listener);
});
test('does NOT notify when value is set to same value', () {
FocusGramRouter.pendingUrl.value = 'https://x.com/';
int callCount = 0;
void listener() => callCount++;
FocusGramRouter.pendingUrl.addListener(listener);
// Setting to the exact same value should not notify
FocusGramRouter.pendingUrl.value = 'https://x.com/';
expect(callCount, 0);
FocusGramRouter.pendingUrl.removeListener(listener);
});
test('is a singleton — same instance across multiple accesses', () {
final a = FocusGramRouter.pendingUrl;
final b = FocusGramRouter.pendingUrl;
expect(identical(a, b), isTrue);
});
});
}
+193
View File
@@ -0,0 +1,193 @@
// test/services/navigation_guard_test.dart
//
// Tests for NavigationGuard — the URL allow/block logic.
//
// Run with: flutter test test/services/navigation_guard_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/navigation_guard.dart';
void main() {
group('NavigationGuard.evaluate', () {
// ── Instagram domain — allowed ──────────────────────────────────────────
test('allows root instagram.com', () {
final d = NavigationGuard.evaluate(url: 'https://www.instagram.com/');
expect(d.blocked, isFalse);
});
test('allows profile pages', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/someuser/',
);
expect(d.blocked, isFalse);
});
test('allows DM inbox', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/direct/inbox/',
);
expect(d.blocked, isFalse);
});
test('allows Explore page', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/explore/',
);
expect(d.blocked, isFalse);
});
test('allows instagram.com without www', () {
final d = NavigationGuard.evaluate(
url: 'https://instagram.com/accounts/login/',
);
expect(d.blocked, isFalse);
});
test('allows a specific reel URL from a DM share', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/reel/ABC123xyz/',
);
expect(d.blocked, isFalse);
});
test('allows login page', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/accounts/login/',
);
expect(d.blocked, isFalse);
});
// ── Reels FEED tab — blocked ────────────────────────────────────────────
test('blocks the reels feed tab /reels/', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/reels/',
);
expect(d.blocked, isTrue);
expect(d.reason, isNotNull);
});
test('blocks the reels feed tab /reels (no trailing slash)', () {
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/reels',
);
expect(d.blocked, isTrue);
});
test('blocks the reels feed with our FocusGram query param', () {
// NavigationDelegate fires this URL from _strictReelsBlockJS
final d = NavigationGuard.evaluate(
url: 'https://www.instagram.com/reels/?fg=blocked',
);
// NOTE: The guard blocks /reels/ root; query params don't change outcome
// This is expected to be blocked since it matches _reelsFeedRegex
expect(d.blocked, isTrue);
});
// ── External domains — blocked ──────────────────────────────────────────
test('blocks external HTTP domains', () {
final d = NavigationGuard.evaluate(url: 'https://evil.com/phish');
expect(d.blocked, isTrue);
expect(d.reason, contains('External domain'));
});
test('blocks Facebook redirects', () {
final d = NavigationGuard.evaluate(
url: 'https://facebook.com/redirect?url=...',
);
expect(d.blocked, isTrue);
});
test('blocks l.instagram.com tracking redirect', () {
final d = NavigationGuard.evaluate(
url: 'https://l.instagram.com/?u=https%3A%2F%2Fexample.com',
);
// l.instagram.com is not in _allowedHosts → blocked
expect(d.blocked, isTrue);
});
// ── Non-HTTP schemes — allowed ──────────────────────────────────────────
test('allows about:blank', () {
final d = NavigationGuard.evaluate(url: 'about:blank');
expect(d.blocked, isFalse);
});
test('allows data: URIs', () {
final d = NavigationGuard.evaluate(url: 'data:text/html,hello');
expect(d.blocked, isFalse);
});
// ── Invalid URL — safe fallback ─────────────────────────────────────────
test('does not throw on empty string — returns not blocked', () {
final d = NavigationGuard.evaluate(url: '');
expect(d.blocked, isFalse);
});
test('does not throw on malformed URL', () {
final d = NavigationGuard.evaluate(url: ':::bad:::');
expect(d.blocked, isFalse);
});
});
group('NavigationGuard.isSpecificReel', () {
test('returns true for a real reel URL', () {
expect(
NavigationGuard.isSpecificReel(
'https://www.instagram.com/reel/ABC123/',
),
isTrue,
);
});
test('returns true for reel URL without trailing slash', () {
expect(
NavigationGuard.isSpecificReel('https://www.instagram.com/reel/XYZ'),
isTrue,
);
});
test('returns false for the reels FEED root', () {
expect(
NavigationGuard.isSpecificReel('https://www.instagram.com/reels/'),
isFalse,
);
});
test('returns false for profile URL', () {
expect(
NavigationGuard.isSpecificReel('https://www.instagram.com/someuser/'),
isFalse,
);
});
test('returns false for DM inbox', () {
expect(
NavigationGuard.isSpecificReel(
'https://www.instagram.com/direct/inbox/',
),
isFalse,
);
});
test('returns false for empty string', () {
expect(NavigationGuard.isSpecificReel(''), isFalse);
});
});
group('BlockDecision', () {
test('const constructor fields are accessible', () {
const d = BlockDecision(blocked: true, reason: 'test');
expect(d.blocked, isTrue);
expect(d.reason, 'test');
});
test('reason can be null', () {
const d = BlockDecision(blocked: false, reason: null);
expect(d.reason, isNull);
});
});
}
+293
View File
@@ -0,0 +1,293 @@
// test/services/session_manager_test.dart
//
// Tests for SessionManager — reel session logic, daily quotas, cooldowns,
// app session tracking, and scheduled blocking.
//
// SessionManager uses SharedPreferences and Timer internally.
// We use SharedPreferences.setMockInitialValues({}) for isolation.
// Timer-based tests use FakeAsync where needed.
//
// Run with: flutter test test/services/session_manager_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/session_manager.dart';
// Helper: initialise a SessionManager with a clean SharedPreferences slate.
Future<SessionManager> _freshManager() async {
SharedPreferences.setMockInitialValues({});
final sm = SessionManager();
await sm.init();
return sm;
}
void main() {
// ── Initial state ────────────────────────────────────────────────────────
group('SessionManager — initial state', () {
test('no reel session active on first init', () async {
final sm = await _freshManager();
expect(sm.isSessionActive, isFalse);
});
test('remainingSessionSeconds is 0 when no session', () async {
final sm = await _freshManager();
expect(sm.remainingSessionSeconds, 0);
});
test('dailyUsedSeconds starts at 0', () async {
final sm = await _freshManager();
expect(sm.dailyUsedSeconds, 0);
});
test('dailyRemainingSeconds matches default limit', () async {
final sm = await _freshManager();
expect(sm.dailyRemainingSeconds, sm.dailyLimitSeconds);
});
test('isDailyLimitExhausted is false initially', () async {
final sm = await _freshManager();
expect(sm.isDailyLimitExhausted, isFalse);
});
test('isCooldownActive is false initially', () async {
final sm = await _freshManager();
expect(sm.isCooldownActive, isFalse);
});
test('cooldownRemainingSeconds is 0 when no cooldown', () async {
final sm = await _freshManager();
expect(sm.cooldownRemainingSeconds, 0);
});
test('daily open count is incremented on init', () async {
final sm = await _freshManager();
// init() calls _incrementOpenCount once
expect(sm.dailyOpenCount, 1);
});
});
// ── Reel session — startSession ──────────────────────────────────────────
group('SessionManager.startSession', () {
test('returns true and activates session within daily quota', () async {
final sm = await _freshManager();
final ok = sm.startSession(5);
expect(ok, isTrue);
expect(sm.isSessionActive, isTrue);
});
test('session expires after requested minutes (approx)', () async {
final sm = await _freshManager();
sm.startSession(5);
// Remaining should be <= 5 min = 300 s
expect(sm.remainingSessionSeconds, lessThanOrEqualTo(300));
expect(sm.remainingSessionSeconds, greaterThan(290));
});
test('returns false when daily limit is exhausted', () async {
SharedPreferences.setMockInitialValues({
'sessn_daily_limit_sec': 300, // 5 min daily limit
'sessn_daily_used_sec': 300, // already used all of it
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
final ok = sm.startSession(5);
expect(ok, isFalse);
expect(sm.isSessionActive, isFalse);
});
test('returns false during cooldown', () async {
// Last session ended 5 minutes ago; cooldown is 15 min
final lastEnd = DateTime.now().subtract(const Duration(minutes: 5));
SharedPreferences.setMockInitialValues({
'sessn_last_end_ts': lastEnd.millisecondsSinceEpoch,
'sessn_cooldown_sec': 900, // 15 min cooldown
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.isCooldownActive, isTrue);
final ok = sm.startSession(5);
expect(ok, isFalse);
});
test('notifies listeners when session starts', () async {
final sm = await _freshManager();
bool notified = false;
sm.addListener(() => notified = true);
sm.startSession(5);
expect(notified, isTrue);
});
});
// ── Reel session — endSession ────────────────────────────────────────────
group('SessionManager.endSession', () {
test('deactivates an active session', () async {
final sm = await _freshManager();
sm.startSession(5);
expect(sm.isSessionActive, isTrue);
sm.endSession();
expect(sm.isSessionActive, isFalse);
});
test('remainingSessionSeconds becomes 0 after end', () async {
final sm = await _freshManager();
sm.startSession(5);
sm.endSession();
expect(sm.remainingSessionSeconds, 0);
});
test('cooldown becomes active after ending a session', () async {
final sm = await _freshManager();
sm.startSession(5);
sm.endSession();
// Only active if cooldownSeconds > 0
if (sm.cooldownSeconds > 0) {
expect(sm.isCooldownActive, isTrue);
}
});
test('notifies listeners on end', () async {
final sm = await _freshManager();
sm.startSession(5);
bool notified = false;
sm.addListener(() => notified = true);
sm.endSession();
expect(notified, isTrue);
});
});
// ── Daily quota ──────────────────────────────────────────────────────────
group('SessionManager — daily quota', () {
test('dailyRemainingSeconds is capped at 0 when exhausted', () async {
SharedPreferences.setMockInitialValues({
'sessn_daily_limit_sec': 300,
'sessn_daily_used_sec': 400, // over limit
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.dailyRemainingSeconds, 0);
expect(sm.isDailyLimitExhausted, isTrue);
});
test('dailyRemainingSeconds decrements correctly', () async {
SharedPreferences.setMockInitialValues({
'sessn_daily_limit_sec': 600,
'sessn_daily_used_sec': 100,
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.dailyRemainingSeconds, 500);
});
});
// ── App session ──────────────────────────────────────────────────────────
group('SessionManager — app session', () {
test('app session is active when end is in the future', () async {
final future = DateTime.now().add(const Duration(minutes: 30));
SharedPreferences.setMockInitialValues({
'app_sess_end_ts': future.millisecondsSinceEpoch,
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.isAppSessionActive, isTrue);
});
test('app session is NOT active when end is in the past', () async {
final past = DateTime.now().subtract(const Duration(minutes: 10));
SharedPreferences.setMockInitialValues({
'app_sess_end_ts': past.millisecondsSinceEpoch,
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.isAppSessionActive, isFalse);
});
test('appSessionRemainingSeconds is > 0 for a future session', () async {
final future = DateTime.now().add(const Duration(minutes: 30));
SharedPreferences.setMockInitialValues({
'app_sess_end_ts': future.millisecondsSinceEpoch,
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.appSessionRemainingSeconds, greaterThan(0));
});
test('canExtendAppSession is true by default', () async {
final sm = await _freshManager();
expect(sm.canExtendAppSession, isTrue);
});
test('canExtendAppSession is false when extension used', () async {
SharedPreferences.setMockInitialValues({
'app_sess_ext_used': true,
'sessn_daily_date': _today(),
});
final sm = SessionManager();
await sm.init();
expect(sm.canExtendAppSession, isFalse);
});
});
// ── Scheduled blocking ───────────────────────────────────────────────────
group('SessionManager — scheduled blocking', () {
test('isScheduledBlockActive is false when schedule disabled', () async {
final sm = await _freshManager();
expect(sm.scheduleEnabled, isFalse);
expect(sm.isScheduledBlockActive, isFalse);
});
test('simple daytime range (9:0017:00) blocks at noon', () async {
final sm = await _freshManager();
// We can't control DateTime.now() but we CAN test the logic
// by verifying the method doesn't throw and returns a bool.
expect(sm.isScheduledBlockActive, isA<bool>());
});
});
// ── setAppForeground ─────────────────────────────────────────────────────
group('SessionManager.setAppForeground', () {
test('does nothing when value is unchanged', () async {
final sm = await _freshManager();
bool notified = false;
sm.addListener(() => notified = true);
sm.setAppForeground(true); // already true by default (in foreground)
expect(notified, isFalse);
});
test('notifies when transitioning to background', () async {
final sm = await _freshManager();
bool notified = false;
sm.addListener(() => notified = true);
sm.setAppForeground(false);
expect(notified, isTrue);
});
test('notifies when returning to foreground', () async {
final sm = await _freshManager();
sm.setAppForeground(false); // go to background first
bool notified = false;
sm.addListener(() => notified = true);
sm.setAppForeground(true);
expect(notified, isTrue);
});
});
}
/// Returns today's date formatted as 'yyyy-MM-dd' (same format as SessionManager).
String _today() {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}