UPDATES: updated UI from sidebar to topbar(again)

fixed external redirect on instagram m's settings.
fixed bug where it opened app session instead of reel session.
hided vertical scroll bar.
removed custom bottom bar.
fixed bug where it wasnt showing searchbar in /explore.

FIXED/ADDED/IMPROVED A LOT MORE THINGS.

Ready for Release
This commit is contained in:
Ujwal
2026-02-24 00:04:23 +05:45
parent 878e625f0e
commit 5232b8b0a9
48 changed files with 5258 additions and 1127 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);
});
});
}
@@ -0,0 +1,372 @@
// test/services/injection_controller_test.dart
//
// Tests for InjectionController — JS/CSS builder, Ghost Mode keyword resolver,
// and JS string generation.
//
// Run with: flutter test test/services/injection_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/injection_controller.dart';
void main() {
// ── resolveBlockedKeywords ───────────────────────────────────────────────
group('InjectionController.resolveBlockedKeywords', () {
test('returns empty list when all flags are false', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(kws, isEmpty);
});
test('includes seen keywords when seenStatus is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(
kws,
containsAll(['/seen', 'media/seen', 'reel/seen', '/mark_seen']),
);
});
test('includes typing keywords when typingIndicator is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(kws, containsAll(['set_typing_status', '/typing']));
});
test('includes live keywords when stories is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: true,
dmPhotos: false,
);
expect(kws, contains('/live/'));
});
test('includes visual_item_seen when dmPhotos is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: true,
);
expect(kws, contains('visual_item_seen'));
});
test('all flags true — returns all groups combined', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
// Must contain at least one keyword from every group
expect(
kws,
containsAll([
'/seen',
'set_typing_status',
'/live/',
'visual_item_seen',
]),
);
});
test('no duplicates in result (seen + typing + stories + dmPhotos)', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
final unique = kws.toSet();
expect(kws.length, unique.length);
});
});
// ── resolveWsBlockedKeywords ─────────────────────────────────────────────
group('InjectionController.resolveWsBlockedKeywords', () {
test('returns empty list when typingIndicator is false', () {
expect(
InjectionController.resolveWsBlockedKeywords(typingIndicator: false),
isEmpty,
);
});
test('returns non-empty list when typingIndicator is true', () {
final kws = InjectionController.resolveWsBlockedKeywords(
typingIndicator: true,
);
expect(kws, isNotEmpty);
expect(kws, contains('activity_status'));
});
});
// ── buildGhostModeJS ─────────────────────────────────────────────────────
group('InjectionController.buildGhostModeJS', () {
test('returns empty string when all flags are false', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js.trim(), isEmpty);
});
test('generated JS contains seen keywords when seenStatus=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(js, contains('/seen'));
expect(js, contains('media/seen'));
});
test('generated JS contains typing keywords when typingIndicator=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js, contains('set_typing_status'));
});
test('generated JS contains live keyword when stories=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: false,
stories: true,
dmPhotos: false,
);
expect(js, contains('/live/'));
});
test('generated JS contains BLOCKED array and shouldBlock function', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(js, contains('BLOCKED'));
expect(js, contains('shouldBlock'));
});
test('generated JS wraps XHR and fetch', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
expect(js, contains('window.fetch'));
expect(js, contains('XMLHttpRequest.prototype.open'));
expect(js, contains('XMLHttpRequest.prototype.send'));
});
test('WS patch is included when typingIndicator=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js, contains('WebSocket'));
});
test('WS patch is NOT included when typingIndicator=false', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
// WS_KEYS will be empty array; the `if (WS_KEYS.length > 0)` guard
// prevents the WS override from running — but the string may still be present.
// At minimum, WS_KEYS should be empty in the output.
expect(js, contains('WS_KEYS = []'));
});
});
// ── buildSessionStateJS ──────────────────────────────────────────────────
group('InjectionController.buildSessionStateJS', () {
test('returns true assignment when active', () {
expect(
InjectionController.buildSessionStateJS(true),
contains('__focusgramSessionActive = true'),
);
});
test('returns false assignment when inactive', () {
expect(
InjectionController.buildSessionStateJS(false),
contains('__focusgramSessionActive = false'),
);
});
});
// ── softNavigateJS ───────────────────────────────────────────────────────
group('InjectionController.softNavigateJS', () {
test('contains the target path', () {
final js = InjectionController.softNavigateJS('/direct/inbox/');
expect(js, contains('/direct/inbox/'));
});
test('contains location.href assignment', () {
final js = InjectionController.softNavigateJS('/explore/');
expect(js, contains('location.href'));
});
});
// ── buildInjectionJS ─────────────────────────────────────────────────────
group('InjectionController.buildInjectionJS', () {
InjectionController.buildInjectionJS; // reference check
test('contains session state flag', () {
final js = _buildFull();
expect(js, contains('__focusgramSessionActive'));
});
test('contains path tracker when assembled', () {
final js = _buildFull();
expect(js, contains('fgTrackPath'));
});
test('includes reels block JS when session is not active', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
expect(js, contains('fgReelsBlock'));
});
test('does NOT include reels block JS when session is active', () {
final js = InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
expect(js, isNot(contains('fgReelsBlock')));
});
test('always includes link sanitizer', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
// linkSanitizationJS is now always injected (not togglable)
expect(js, contains('fgSanitize'));
});
test('returns non-empty string in all cases', () {
expect(_buildFull().trim(), isNotEmpty);
});
});
// ── iOSUserAgent sanity ──────────────────────────────────────────────────
group('InjectionController.iOSUserAgent', () {
test('contains iPhone identifier', () {
expect(InjectionController.iOSUserAgent, contains('iPhone'));
});
test('contains FBAN (Instagram app identifier)', () {
expect(InjectionController.iOSUserAgent, contains('FBAN'));
});
test('is non-empty', () {
expect(InjectionController.iOSUserAgent, isNotEmpty);
});
});
// ── notificationBridgeJS ─────────────────────────────────────────────────
group('InjectionController.notificationBridgeJS', () {
test('contains Notification bridge guard', () {
expect(
InjectionController.notificationBridgeJS,
contains('fgNotifBridged'),
);
});
test('patches window.Notification', () {
expect(
InjectionController.notificationBridgeJS,
contains('window.Notification'),
);
});
});
// ── linkSanitizationJS ───────────────────────────────────────────────────
group('InjectionController.linkSanitizationJS', () {
test('strips igsh param', () {
expect(InjectionController.linkSanitizationJS, contains('igsh'));
});
test('strips utm params', () {
expect(InjectionController.linkSanitizationJS, contains('utm_source'));
});
test('strips fbclid', () {
expect(InjectionController.linkSanitizationJS, contains('fbclid'));
});
test('patches navigator.share', () {
expect(
InjectionController.linkSanitizationJS,
contains('navigator.share'),
);
});
});
}
/// Helper to create a fully-featured injection JS for common assertions.
String _buildFull() => InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: true,
blurReels: true,
ghostTyping: true,
ghostSeen: true,
ghostStories: true,
ghostDmPhotos: true,
enableTextSelection: false,
);
+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')}';
}
+250
View File
@@ -0,0 +1,250 @@
// test/services/settings_service_test.dart
//
// Tests for SettingsService — default values, setters, tab management,
// and the legacy GhostMode key migration.
//
// Note: SettingsService requires SharedPreferences. We use
// SharedPreferences.setMockInitialValues({}) to avoid platform channel calls.
//
// Run with: flutter test test/services/settings_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/settings_service.dart';
/// Helper: create an initialised SettingsService with a clean prefs slate.
Future<SettingsService> makeService() async {
SharedPreferences.setMockInitialValues({});
final svc = SettingsService();
await svc.init();
return svc;
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
// ── Default values ──────────────────────────────────────────────────────
group('SettingsService — defaults', () {
test('blurExplore defaults to true', () async {
expect((await makeService()).blurExplore, isTrue);
});
test('blurReels defaults to false', () async {
expect((await makeService()).blurReels, isFalse);
});
test('requireLongPress defaults to true', () async {
expect((await makeService()).requireLongPress, isTrue);
});
test('showBreathGate defaults to true', () async {
expect((await makeService()).showBreathGate, isTrue);
});
test('requireWordChallenge defaults to true', () async {
expect((await makeService()).requireWordChallenge, isTrue);
});
test('enableTextSelection defaults to false', () async {
expect((await makeService()).enableTextSelection, isFalse);
});
test('ghostTyping defaults to true', () async {
expect((await makeService()).ghostTyping, isTrue);
});
test('ghostSeen defaults to true', () async {
expect((await makeService()).ghostSeen, isTrue);
});
test('ghostStories defaults to true', () async {
expect((await makeService()).ghostStories, isTrue);
});
test('ghostDmPhotos defaults to true', () async {
expect((await makeService()).ghostDmPhotos, isTrue);
});
test('sanitizeLinks defaults to true', () async {
expect((await makeService()).sanitizeLinks, isTrue);
});
test('isFirstRun defaults to true', () async {
expect((await makeService()).isFirstRun, isTrue);
});
test(
'anyGhostModeEnabled is true when all ghost settings are true',
() async {
expect((await makeService()).anyGhostModeEnabled, isTrue);
},
);
});
// ── Setters ─────────────────────────────────────────────────────────────
group('SettingsService — setters persist and notify', () {
test('setBlurExplore changes value and notifies', () async {
final svc = await makeService();
bool notified = false;
svc.addListener(() => notified = true);
await svc.setBlurExplore(false);
expect(svc.blurExplore, isFalse);
expect(notified, isTrue);
});
test('setBlurReels persists', () async {
final svc = await makeService();
await svc.setBlurReels(true);
expect(svc.blurReels, isTrue);
});
test('setRequireLongPress persists', () async {
final svc = await makeService();
await svc.setRequireLongPress(false);
expect(svc.requireLongPress, isFalse);
});
test('setGhostTyping turns off ghost typing', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
expect(svc.ghostTyping, isFalse);
});
test('setGhostSeen turns off ghost seen', () async {
final svc = await makeService();
await svc.setGhostSeen(false);
expect(svc.ghostSeen, isFalse);
});
test('setGhostStories turns off ghost stories', () async {
final svc = await makeService();
await svc.setGhostStories(false);
expect(svc.ghostStories, isFalse);
});
test('setGhostDmPhotos turns off ghost dm photos', () async {
final svc = await makeService();
await svc.setGhostDmPhotos(false);
expect(svc.ghostDmPhotos, isFalse);
});
test('setSanitizeLinks persists', () async {
final svc = await makeService();
await svc.setSanitizeLinks(false);
expect(svc.sanitizeLinks, isFalse);
});
test('setFirstRunCompleted sets isFirstRun to false', () async {
final svc = await makeService();
await svc.setFirstRunCompleted();
expect(svc.isFirstRun, isFalse);
});
test('setEnableTextSelection persists', () async {
final svc = await makeService();
await svc.setEnableTextSelection(true);
expect(svc.enableTextSelection, isTrue);
});
});
// ── anyGhostModeEnabled ──────────────────────────────────────────────────
group('SettingsService.anyGhostModeEnabled', () {
test('is false when all ghost flags are off', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
await svc.setGhostSeen(false);
await svc.setGhostStories(false);
await svc.setGhostDmPhotos(false);
expect(svc.anyGhostModeEnabled, isFalse);
});
test('is true when only one ghost flag is on', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
await svc.setGhostSeen(false);
await svc.setGhostStories(false);
await svc.setGhostDmPhotos(true); // only dmPhotos on
expect(svc.anyGhostModeEnabled, isTrue);
});
});
// ── Tab management ───────────────────────────────────────────────────────
group('SettingsService — tab management', () {
test('default tabs include Home, Reels, Messages, Profile', () async {
final svc = await makeService();
expect(
svc.enabledTabs,
containsAll(['Home', 'Reels', 'Messages', 'Profile']),
);
});
test('toggleTab removes an enabled tab', () async {
final svc = await makeService();
final before = List<String>.from(svc.enabledTabs);
await svc.toggleTab('Reels');
expect(svc.enabledTabs, isNot(contains('Reels')));
expect(svc.enabledTabs.length, before.length - 1);
});
test('toggleTab adds a tab back when toggled again', () async {
final svc = await makeService();
await svc.toggleTab('Reels');
await svc.toggleTab('Reels');
expect(svc.enabledTabs, contains('Reels'));
});
test('toggleTab does not remove the last remaining tab', () async {
final svc = await makeService();
final tabs = List<String>.from(svc.enabledTabs);
for (final t in tabs.sublist(0, tabs.length - 1)) {
await svc.toggleTab(t);
}
final last = svc.enabledTabs.first;
await svc.toggleTab(last); // try to remove the last one
expect(svc.enabledTabs.length, 1); // still 1
});
test('reorderTab moves item correctly — no tabs are lost', () async {
final svc = await makeService();
final original = List<String>.from(svc.enabledTabs);
await svc.reorderTab(0, 1);
expect(svc.enabledTabs.toSet(), original.toSet());
});
});
// ── Legacy Ghost Mode migration ──────────────────────────────────────────
group('SettingsService — legacy ghost mode migration', () {
test(
'migrates legacy ghost_mode=true to all four granular flags',
() async {
SharedPreferences.setMockInitialValues({'set_ghost_mode': true});
final svc = SettingsService();
await svc.init();
expect(svc.ghostTyping, isTrue);
expect(svc.ghostSeen, isTrue);
expect(svc.ghostStories, isTrue);
expect(svc.ghostDmPhotos, isTrue);
},
);
test(
'migrates legacy ghost_mode=false to all four granular flags off',
() async {
SharedPreferences.setMockInitialValues({'set_ghost_mode': false});
final svc = SettingsService();
await svc.init();
expect(svc.ghostTyping, isFalse);
expect(svc.ghostSeen, isFalse);
expect(svc.ghostStories, isFalse);
expect(svc.ghostDmPhotos, isFalse);
},
);
});
}