V2 Release

This commit is contained in:
Ujwal223
2026-05-25 22:12:38 +05:45
parent 2d33dcb889
commit 842dc70829
38 changed files with 642 additions and 334 deletions
+6 -6
View File
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
SizedBox(
height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: 6,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56,
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Container(
width: 32,
height: 8,
height: 6,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(3),
),
),
],
+7 -13
View File
@@ -35,12 +35,11 @@ class NativeBottomNav extends StatelessWidget {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bgColor =
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
final iconColorInactive =
isDark ? Colors.white70 : Colors.black54;
final iconColorActive =
theme.colorScheme.primary;
final bgColor = theme.colorScheme.surface.withValues(
alpha: isDark ? 0.95 : 0.98,
);
final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
final iconColorActive = theme.colorScheme.primary;
final tabs = <_NavItem>[
_NavItem(
@@ -103,8 +102,7 @@ class NativeBottomNav extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) {
final color =
item.active ? iconColorActive : iconColorInactive;
final color = item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35;
return Expanded(
@@ -129,10 +127,7 @@ class NativeBottomNav extends StatelessWidget {
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
color: color,
),
style: TextStyle(fontSize: 10, color: color),
),
],
),
@@ -164,4 +159,3 @@ class _NavItem {
required this.enabled,
});
}
@@ -14,12 +14,10 @@ class InstagramPreloader {
static Future<void> start(String userAgent) async {
if (_headlessWebView != null) return; // don't start twice
_headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive,
initialUrlRequest: URLRequest(
url: WebUri('https://www.instagram.com/'),
),
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
initialSettings: InAppWebViewSettings(
userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true,
@@ -69,4 +67,3 @@ class InstagramPreloader {
isReady = false;
}
}
@@ -18,12 +18,12 @@ class ReelsHistoryEntry {
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
};
'id': id,
'url': url,
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
};
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
return ReelsHistoryEntry(
@@ -31,7 +31,8 @@ class ReelsHistoryEntry {
url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
);
}
@@ -114,4 +115,3 @@ class ReelsHistoryService {
await prefs.setString(_prefsKey, jsonEncode(jsonList));
}
}
@@ -32,10 +32,7 @@ class _UpdateBannerState extends State<UpdateBanner> {
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 0.5,
),
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
),
),
child: Column(
@@ -121,10 +118,11 @@ class _UpdateBannerState extends State<UpdateBanner> {
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
text =
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
text = text.replaceAll(
RegExp(r'\[([^\]]+)\]\([^)]+\)'),
r'\1',
); // links -> text
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
return text.trim();
}
}
@@ -56,8 +56,9 @@ class UpdateCheckerService extends ChangeNotifier {
return;
}
final cleanVersion =
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
final cleanVersion = gitVersionTag.startsWith('v')
? gitVersionTag.substring(1)
: gitVersionTag;
var trimmed = body.trim();
if (trimmed.length > 1500) {
+8 -8
View File
@@ -1,17 +1,17 @@
class FocusSettings {
final bool ghostMode; // hide read receipts
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
final bool noAutoplay; // stop videos autoplaying
final bool noDMs; // block direct messages
final bool ghostMode; // hide read receipts
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
final bool noAutoplay; // stop videos autoplaying
final bool noDMs; // block direct messages
const FocusSettings({
this.ghostMode = false,
this.noAds = false,
this.noAds = true,
this.noStories = false,
this.noReels = false,
this.noAutoplay = false,
this.noDMs = false,
});
}
}
+6 -1
View File
@@ -17,6 +17,7 @@ import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart';
import 'features/update_checker/update_checker_service.dart';
import 'features/preloader/instagram_preloader.dart';
import 'widgets/remote_popup_handler.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -88,7 +89,7 @@ class FocusGramApp extends StatelessWidget {
/// 2. Cooldown Gate (if app-open cooldown active)
/// 3. Breath Gate (if enabled in settings)
/// 4. If an app session is already active, resume it
/// otherwise show App Session Picker
/// otherwise show App Session Picker
/// 5. Main WebView
class InitialRouteHandler extends StatefulWidget {
const InitialRouteHandler({super.key});
@@ -108,6 +109,10 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
super.initState();
_appLinks = AppLinks();
_initDeepLinks();
WidgetsBinding.instance.addPostFrameCallback((_) {
RemotePopupHandler.checkAndShow(context);
});
}
Future<void> _initDeepLinks() async {
+33 -1
View File
@@ -46,7 +46,39 @@ class ExtrasSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick();
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
),
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),
),
),
],
),
),
),
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
@@ -66,7 +98,7 @@ class ExtrasSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick();
},
),
*/
const SizedBox(height: 40),
],
),
+123 -11
View File
@@ -27,7 +27,7 @@ import '../v2_integration/script_engine_v2_overlay.dart';
import '../v2_integration/script_registry_v2_overlay.dart';
import '../scripts/focus_scripts.dart';
import '../focus_settings.dart';
import 'package:http/http.dart' as http;
import '../services/adblock/adblock_content_blocker_loader.dart';
@@ -127,7 +127,10 @@ class _MainWebViewPageState extends State<MainWebViewPage>
// Check for updates on launch
context.read<UpdateCheckerService>().checkForUpdates();
unawaited(_loadAdblockerData());
// Load adblock data early. If adblock is enabled, we wait for initial data
// to be loaded so the WebView can apply contentBlockers on first render.
// This prevents ads from loading before filters are applied.
unawaited(_loadAdblockerDataEarly());
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SessionManager>().addListener(_onSessionChanged);
@@ -155,6 +158,9 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_lastV2AutoplayBlockerEnabled = settings.blockAutoplay;
_lastAdblockToggleValue = settings.v2AdBlockerDomEnabled;
_onScreenTimeChanged();
// Load full adblock data with longer timeout after UI is initialized
unawaited(_loadAdblockerData());
});
FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged);
@@ -581,6 +587,28 @@ class _MainWebViewPageState extends State<MainWebViewPage>
return data;
}
Future<void> _loadAdblockerDataEarly() async {
final settings = context.read<SettingsService>();
if (!settings.v2AdBlockerDomEnabled) return;
try {
final prefs = await SharedPreferences.getInstance();
final loader = AdblockContentBlockerLoader();
final data = await loader.loadOrUpdateIfNeeded(
enabled: true,
prefs: prefs,
timeoutMs: 5000, // Short timeout for early load
);
if (mounted) {
setState(() => _adblockData = data);
}
} catch (_) {
// If loading fails, continue without blocking app startup
// AdblockData will be retried in _loadAdblockerData()
}
}
bool _isBlockedByAdblockHostList(WebUri uri, Set<String>? blockedHosts) {
if (blockedHosts == null || blockedHosts.isEmpty) return false;
@@ -911,6 +939,24 @@ class _MainWebViewPageState extends State<MainWebViewPage>
pullToRefreshController: _pullToRefreshController,
shouldInterceptRequest: (controller, request) async {
final url = request.url.toString();
const adDomains = [
'an.facebook.com',
'connect.facebook.net',
'pixel.facebook.com',
'graph.facebook.com/logging',
'www.instagram.com/ajax/bz',
'www.instagram.com/api/v1/web/comet/logcalls',
'doubleclick.net',
'googletagmanager.com',
'scorecardresearch.com',
];
if (adDomains.any(url.contains)) {
return WebResourceResponse(
data: Uint8List(0),
);
}
final referrer =
request.headers?['Referer'] ??
request.headers?['referer'];
@@ -919,7 +965,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
_syncDirectThreadState(referrer);
}
if (_isInDirectThread &&
/*if (_isInDirectThread &&
_isFktmInstagramCdn(url)) {
if (_dmThreadCdnBlockArmed) {
return WebResourceResponse(
@@ -928,7 +974,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
}
_dmThreadCdnBlockArmed = true;
}
*/
// Strict/high-priority domain blocking from uBlock-style lists.
final adblockHosts = _adblockData?.blockedHosts;
if (_isBlockedByAdblockHostList(
@@ -983,11 +1029,11 @@ class _MainWebViewPageState extends State<MainWebViewPage>
);
}
// Strip ads from feed
/* Strip ads from feed (JS handles it)
if (settings.noAds &&
url.contains(
'instagram.com/graphql/query',
)) {
)) {/
try {
final res = await http.post(
Uri.parse(url),
@@ -1006,10 +1052,76 @@ class _MainWebViewPageState extends State<MainWebViewPage>
edges.removeWhere((e) {
final node = e['node'];
if (node == null) return false;
return node['ad'] != null ||
node['explore_story'] != null ||
node['media']?['inventory_source'] ==
'mixed_unconnected';
// Strip ads from feed
if (settings.noAds &&
url.contains(
'instagram.com/graphql',
)) {
try {
final res = await http.post(
Uri.parse(url),
headers: Map<String, String>.from(
request.headers ?? {},
),
);
final json = jsonDecode(res.body);
void filterEdges(dynamic obj) {
if (obj == null) return;
if (obj is Map) {
if (obj['edges'] is List) {
(obj['edges'] as List).removeWhere((
e,
) {
final node = e is Map
? e['node']
: null;
if (node == null ||
node is! Map)
return false;
return node['is_ad'] ==
true ||
node['ad_id'] != null ||
node['ad_action_links'] !=
null ||
node['is_paid_partnership'] ==
true ||
node['sponsor_tags'] !=
null ||
node['commerciality_status'] ==
'ad' ||
node['commerciality_status'] ==
'shoppable_feed_ad' ||
(node['__typename']
?.toString()
.toLowerCase()
.contains(
'ad',
) ??
false);
});
}
obj.values.forEach(filterEdges);
} else if (obj is List) {
obj.forEach(filterEdges);
}
}
filterEdges(json);
return WebResourceResponse(
data: Uint8List.fromList(
utf8.encode(jsonEncode(json)),
),
headers: res.headers,
statusCode: 200,
contentType: 'application/json',
);
} catch (_) {
return null;
}
}
});
}
@@ -1025,7 +1137,7 @@ class _MainWebViewPageState extends State<MainWebViewPage>
// if anything fails, pass through original request unmodified
return null;
}
}
}*/
return null;
},
+14 -42
View File
@@ -46,7 +46,7 @@ class SettingsPage extends StatelessWidget {
title: 'Focus Mode',
subtitle: settings.minimalModeEnabled
? 'Minimal mode on'
: 'Blocking, friction, media',
: 'Blocking, Content Hider, Feed Blur and more',
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const FocusSettingsPage()),
@@ -71,7 +71,7 @@ class SettingsPage extends StatelessWidget {
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Download media, Ghost Mode, Ad Blocker',
subtitle: 'Download media, Ghost Mode',
enabled: true,
onTap: () => Navigator.push(
context,
@@ -100,7 +100,7 @@ class SettingsPage extends StatelessWidget {
icon: Icons.lock_outline,
iconColor: Colors.tealAccent,
title: 'Privacy & Notifications',
subtitle: 'Session end alerts',
subtitle: 'Manage Your Notifications',
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@@ -379,12 +379,16 @@ class FocusSettingsPage extends StatelessWidget {
),
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),
),
),*/
_SwitchTile(
title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped',
@@ -403,48 +407,16 @@ class FocusSettingsPage extends StatelessWidget {
),
),
const _SectionHeader(title: 'FOCUSGRAM V2 OVERLAY'),
const _SectionHeader(title: 'CONTENT HIDER'),
_SwitchTile(
title: 'Content Hider',
subtitle: 'Hide stories tray, feed posts, reels, suggested content',
value: settings.v2ContentHiderEnabled,
onChanged: (v) => settings.setV2ContentHiderEnabled(v),
title: 'Hide Feed Posts',
subtitle:
'Hides home feed posts (stories tray, posts, suggested content)',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
if (settings.v2ContentHiderEnabled)
Padding(
padding: const EdgeInsets.only(left: 32),
child: Column(
children: [
_SwitchTile(
title: 'Hide Stories Tray',
subtitle: 'Story bubbles row',
value: settings.contentStories,
onChanged: (v) => settings.setContentStoriesEnabled(v),
),
_SwitchTile(
title: 'Hide Feed Posts',
subtitle: 'Home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
_SwitchTile(
title: 'Hide Reels (Feed)',
subtitle: 'Reels shown in the feed',
value: settings.contentReels,
onChanged: (v) => settings.setContentReelsEnabled(v),
),
_SwitchTile(
title: 'Hide Suggested Content',
subtitle: 'Suggested posts and recommendation units',
value: settings.contentSuggested,
onChanged: (v) => settings.setContentSuggestedEnabled(v),
),
],
),
),
const SizedBox(height: 40),
],
),
+45 -8
View File
@@ -451,18 +451,40 @@ const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
// Target text patterns that indicate suggested content
const suggestedPatterns = [
'Suggested for you',
'Suggested posts',
"You're all caught up",
'Suggested',
'Recommendations',
'Discover more',
'Suggested Accounts',
];
// Find and hide all elements with suggested content text
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
const matched = suggestedPatterns.some(pattern =>
text === pattern || text.includes(pattern)
);
if (matched) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
// Traverse up to find the container section/article
for (let i = 0; i < 12 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
const classList = parent.className || '';
// Hide articles, sections, lists, and common suggestion containers
if (
tag === 'article' ||
tag === 'section' ||
tag === 'li' ||
classList.includes('xjx87jv0') || // Instagram suggestion container
classList.includes('x1a8lsjc') // Reel suggestion container
) {
parent.style.setProperty('display', 'none', 'important');
break;
}
@@ -471,6 +493,21 @@ const String kHideSuggestedPostsJS = r'''
}
} catch(_) {}
});
// Also hide by attribute patterns
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
try {
let parent = el;
for (let i = 0; i < 12 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
} catch(_) {}
});
} catch(_) {}
}
-1
View File
@@ -13,4 +13,3 @@ const String kDmKeyboardFixJS = r'''
} catch (_) {}
});
''';
+15 -11
View File
@@ -78,18 +78,22 @@ List<UserScript> buildUserScripts(FocusSettings settings) {
final scripts = <UserScript>[];
if (startScripts.isNotEmpty) {
scripts.add(UserScript(
source: startScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
));
scripts.add(
UserScript(
source: startScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
),
);
}
if (endScripts.isNotEmpty) {
scripts.add(UserScript(
source: endScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
forMainFrameOnly: true,
));
scripts.add(
UserScript(
source: endScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
forMainFrameOnly: true,
),
);
}
return scripts;
}
}
-1
View File
@@ -9,4 +9,3 @@ const String kHapticBridgeScript = '''
}, true);
})();
''';
-1
View File
@@ -10,4 +10,3 @@ const String kScrollSmoothingJS = r'''
} catch (_) {}
})();
''';
-1
View File
@@ -29,4 +29,3 @@ const String kSpaNavigationMonitorScript = '''
window.addEventListener('popstate', () => notifyUrlChange());
})();
''';
+1 -1
View File
@@ -172,7 +172,7 @@ const String kVideoDownloadJS = r'''
btn.innerHTML = icon();
btn.style.cssText = [
'position:absolute',
'z-index:2147483647',
'z-index:999',
'width:34px',
'height:34px',
'border-radius:10px',
+84
View File
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class RemotePopupData {
final bool show;
final String id;
final String title;
final String body;
final int maxShows;
final String buttonText;
RemotePopupData({
required this.show,
required this.id,
required this.title,
required this.body,
required this.maxShows,
required this.buttonText,
});
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
return RemotePopupData(
show: json['show'] ?? false,
id: json['id']?.toString() ?? '',
title: json['header']?.toString() ?? 'Notice',
body: json['body']?.toString() ?? '',
maxShows: json['max_shows'] ?? 1,
buttonText: json['button_text']?.toString() ?? 'OK',
);
}
}
class RemotePopupService {
// Keep placeholder value until you replace it.
static const String popupUrl =
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
static Future<RemotePopupData?> fetchPopup() async {
try {
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
final uri = Uri.parse(
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
);
final response = await http.get(
uri,
headers: const {
'Cache-Control': 'no-cache',
},
);
if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return null;
return RemotePopupData.fromJson(decoded);
} catch (_) {
return null;
}
}
static Future<bool> shouldShow(RemotePopupData data) async {
if (!data.show) return false;
if (data.id.isEmpty) return false;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final shownCount = prefs.getInt(key) ?? 0;
return shownCount < data.maxShows;
}
static Future<void> markShown(RemotePopupData data) async {
if (data.id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final current = prefs.getInt(key) ?? 0;
await prefs.setInt(key, current + 1);
}
}
+5 -2
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -407,9 +408,11 @@ class SettingsService extends ChangeNotifier {
}
Future<void> setBreathGateSeconds(int seconds) async {
_breathGateSeconds = seconds.clamp(3, 60).toInt();
final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
notifyListeners();
// Defer notifyListeners to next microtask to avoid rebuild conflicts
Future.microtask(notifyListeners);
}
Future<void> setWordChallengeCount(int count) async {
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../services/remote_popup_service.dart';
class RemotePopupHandler {
static Future<void> checkAndShow(BuildContext context) async {
final popup = await RemotePopupService.fetchPopup();
if (popup == null) return;
final shouldShow = await RemotePopupService.shouldShow(popup);
if (!shouldShow) return;
await RemotePopupService.markShown(popup);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: true,
builder: (_) {
return AlertDialog(
title: Text(popup.title),
content: Text(popup.body),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(popup.buttonText),
),
],
);
},
);
}
}