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
+532
View File
@@ -0,0 +1,532 @@
import 'package:flutter/material.dart';
enum SkeletonType { feed, reels, explore, messages, profile, generic }
class SkeletonScreen extends StatefulWidget {
final SkeletonType skeletonType;
const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic});
@override
State<SkeletonScreen> createState() => _SkeletonScreenState();
}
class _SkeletonScreenState extends State<SkeletonScreen>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(_shimmerController);
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = theme.colorScheme.surfaceContainerHighest.withValues(
alpha: theme.brightness == Brightness.dark ? 0.25 : 0.4,
);
final highlightColor = theme.colorScheme.onSurface.withValues(alpha: 0.08);
return Container(
color: theme.scaffoldBackgroundColor,
width: double.infinity,
height: double.infinity,
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: const [0.1, 0.3, 0.6],
transform: _SlidingGradientTransform(
slidePercent: _shimmerAnimation.value,
),
).createShader(rect);
},
blendMode: BlendMode.srcATop,
child: _buildSkeletonContent(context),
);
},
),
);
}
Widget _buildSkeletonContent(BuildContext context) {
switch (widget.skeletonType) {
case SkeletonType.feed:
return _buildFeedSkeleton(context);
case SkeletonType.reels:
return _buildReelsSkeleton(context);
case SkeletonType.explore:
return _buildExploreSkeleton(context);
case SkeletonType.messages:
return _buildMessagesSkeleton(context);
case SkeletonType.profile:
return _buildProfileSkeleton(context);
case SkeletonType.generic:
return _buildGenericSkeleton(context);
}
}
Widget _buildFeedSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 6,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(height: 4),
Container(
width: 32,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 0),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(width: 8),
Container(
width: 80,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
Container(
width: double.infinity,
height: width,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: List.generate(
3,
(i) => Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
),
),
],
);
}
Widget _buildReelsSkeleton(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 24),
Container(
width: 150,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 12),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
}
Widget _buildExploreSkeleton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(2),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 15,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
);
}
Widget _buildMessagesSkeleton(BuildContext context) {
return ListView.builder(
itemCount: 8,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: 150,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildProfileSkeleton(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 24),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(48),
),
),
const SizedBox(height: 16),
Container(
width: 120,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
3,
(index) => Column(
children: [
Container(
width: 40,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
const SizedBox(height: 24),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 9,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
],
),
);
}
Widget _buildGenericSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
),
const SizedBox(height: 8),
Container(
width: 40,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: width * 0.4,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.25,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: width * 1.1,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 12),
Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.5,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
},
),
),
],
);
}
}
class _SlidingGradientTransform extends GradientTransform {
final double slidePercent;
const _SlidingGradientTransform({required this.slidePercent});
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}
SkeletonType getSkeletonTypeFromUrl(String url) {
final parsed = Uri.tryParse(url);
if (parsed == null) return SkeletonType.generic;
final path = parsed.path.toLowerCase();
if (path.startsWith('/reels') || path.startsWith('/reel/')) {
return SkeletonType.reels;
} else if (path.startsWith('/explore')) {
return SkeletonType.explore;
} else if (path.startsWith('/messages') || path.startsWith('/inbox')) {
return SkeletonType.messages;
} else if (path.startsWith('/') && !path.startsWith('/accounts')) {
if (path.split('/').length <= 2) {
return SkeletonType.feed;
}
return SkeletonType.profile;
}
return SkeletonType.generic;
}
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
class NativeBottomNav extends StatelessWidget {
final String currentUrl;
final bool reelsEnabled;
final bool exploreEnabled;
final bool minimalMode;
final Function(String path) onNavigate;
const NativeBottomNav({
super.key,
required this.currentUrl,
required this.reelsEnabled,
required this.exploreEnabled,
required this.minimalMode,
required this.onNavigate,
});
String get _path {
final parsed = Uri.tryParse(currentUrl);
if (parsed != null && parsed.path.isNotEmpty) return parsed.path;
return currentUrl; // may already be a path from SPA callbacks
}
bool get _onHome => _path == '/' || _path.isEmpty;
bool get _onExplore => _path.startsWith('/explore');
bool get _onReels => _path.startsWith('/reels') || _path.startsWith('/reel/');
bool get _onProfile =>
_path.startsWith('/accounts') ||
_path.contains('/profile') ||
_path.split('/').where((p) => p.isNotEmpty).length == 1;
@override
Widget build(BuildContext context) {
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 tabs = <_NavItem>[
_NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: 'Home',
path: '/',
active: _onHome,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.search_outlined,
activeIcon: Icons.search,
label: 'Search',
path: '/explore/',
active: _onExplore,
enabled: exploreEnabled,
),
_NavItem(
icon: Icons.add_box_outlined,
activeIcon: Icons.add_box,
label: 'New',
path: '/create/select/',
active: false,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.play_circle_outline,
activeIcon: Icons.play_circle,
label: 'Reels',
path: '/reels/',
active: _onReels,
enabled: reelsEnabled,
),
_NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: 'Profile',
path: '/accounts/edit/',
active: _onProfile,
enabled: true,
),
];
return SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
width: 0.5,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) {
final color =
item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35;
return Expanded(
child: Opacity(
opacity: opacity,
child: InkWell(
onTap: item.enabled ? () => onNavigate(item.path) : null,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.active ? item.activeIcon : item.icon,
size: 24,
color: color,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
color: color,
),
),
],
),
),
),
),
);
}).toList(),
),
),
);
}
}
class _NavItem {
final IconData icon;
final IconData activeIcon;
final String label;
final String path;
final bool active;
final bool enabled;
_NavItem({
required this.icon,
required this.activeIcon,
required this.label,
required this.path,
required this.active,
required this.enabled,
});
}
@@ -0,0 +1,72 @@
import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart';
class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView;
static InAppWebViewController? controller;
static final InAppWebViewKeepAlive keepAlive = InAppWebViewKeepAlive();
static bool isReady = false;
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/'),
),
initialSettings: InAppWebViewSettings(
userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
),
initialUserScripts: UnmodifiableListView([
UserScript(
source: 'window.__fgBlockAutoplay = true;',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kSpaNavigationMonitorScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
]),
onWebViewCreated: (c) {
controller = c;
},
onLoadStop: (c, url) async {
isReady = true;
await c.evaluateJavascript(source: kNativeFeelingPostLoadScript);
},
);
await _headlessWebView!.run();
}
static void dispose() {
_headlessWebView?.dispose();
_headlessWebView = null;
controller = null;
isReady = false;
}
}
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'reels_history_service.dart';
class ReelsHistoryScreen extends StatefulWidget {
const ReelsHistoryScreen({super.key});
@override
State<ReelsHistoryScreen> createState() => _ReelsHistoryScreenState();
}
class _ReelsHistoryScreenState extends State<ReelsHistoryScreen> {
final _service = ReelsHistoryService();
late Future<List<ReelsHistoryEntry>> _future;
@override
void initState() {
super.initState();
_future = _service.getEntries();
}
Future<void> _refresh() async {
setState(() => _future = _service.getEntries());
}
String _formatTimestamp(DateTime dt) =>
DateFormat('EEE, MMM d • h:mm a').format(dt.toLocal());
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt.toLocal());
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 _formatTimestamp(dt);
}
Future<void> _confirmClearAll() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear Reels History?'),
content: const Text(
'This removes all history entries stored locally on this device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Clear All'),
),
],
),
);
if (ok != true || !mounted) return;
await _service.clearAll();
await _refresh();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Reels History',
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),
),
actions: [
IconButton(
tooltip: 'Clear All',
onPressed: _confirmClearAll,
icon: const Icon(Icons.delete_outline),
),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<List<ReelsHistoryEntry>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final entries = snapshot.data ?? const <ReelsHistoryEntry>[];
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(
Icons.lock_outline,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 6),
Text(
'${entries.length} reels stored locally on device only',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
const SizedBox(height: 4),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.all(48),
child: Center(
child: Column(
children: [
Icon(
Icons.play_circle_outline,
size: 48,
color: Colors.grey,
),
SizedBox(height: 12),
Text(
'No Reels history yet.\nWatch a Reel and it will appear here.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
)
else
...entries.map((entry) {
return Dismissible(
key: ValueKey(entry.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.redAccent.withValues(alpha: 0.15),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
),
),
onDismissed: (_) async {
await _service.deleteEntry(entry.id);
// Don't call _refresh() on dismiss — removes the entry from
// the live list already via Dismissible, avoids double setState
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
leading: _ReelThumbnail(url: entry.thumbnailUrl),
title: Text(
entry.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
_relativeTime(entry.visitedAt),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
trailing: const Icon(
Icons.play_circle_outline,
color: Colors.blue,
size: 20,
),
onTap: () => Navigator.pop(context, entry.url),
),
);
}),
const SizedBox(height: 40),
],
);
},
),
),
);
}
}
/// Thumbnail widget that correctly sends Referer + User-Agent headers
/// required by Instagram's CDN. Without these the CDN returns 403.
class _ReelThumbnail extends StatelessWidget {
final String url;
const _ReelThumbnail({required this.url});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: url.isEmpty
? _placeholder()
: Image.network(
url,
width: 60,
height: 60,
fit: BoxFit.cover,
headers: const {
// Instagram CDN requires a valid Referer header
'Referer': 'https://www.instagram.com/',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86',
},
errorBuilder: (_, _, _) => _placeholder(),
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return Container(
color: Colors.white10,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
);
},
),
),
);
}
Widget _placeholder() => Container(
color: Colors.white10,
child: const Icon(
Icons.play_circle_outline,
color: Colors.white30,
size: 28,
),
);
}
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ReelsHistoryEntry {
final String id;
final String url;
final String title;
final String thumbnailUrl;
final DateTime visitedAt;
const ReelsHistoryEntry({
required this.id,
required this.url,
required this.title,
required this.thumbnailUrl,
required this.visitedAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
};
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
return ReelsHistoryEntry(
id: (json['id'] as String?) ?? '',
url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
);
}
}
class ReelsHistoryService {
static const String _prefsKey = 'reels_history';
static const int _maxEntries = 200;
SharedPreferences? _prefs;
Future<SharedPreferences> _getPrefs() async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
}
Future<List<ReelsHistoryEntry>> getEntries() async {
final prefs = await _getPrefs();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw) as List<dynamic>;
final entries = decoded
.whereType<Map>()
.map((e) => ReelsHistoryEntry.fromJson(e.cast<String, dynamic>()))
.where((e) => e.url.isNotEmpty)
.toList();
entries.sort((a, b) => b.visitedAt.compareTo(a.visitedAt));
return entries;
} catch (_) {
return [];
}
}
Future<void> addEntry({
required String url,
required String title,
required String thumbnailUrl,
}) async {
if (url.isEmpty) return;
final now = DateTime.now().toUtc();
final entries = await getEntries();
final recentDuplicate = entries.any((e) {
if (e.url != url) return false;
final diff = now.difference(e.visitedAt).inSeconds.abs();
return diff <= 60;
});
if (recentDuplicate) return;
final entry = ReelsHistoryEntry(
id: DateTime.now().microsecondsSinceEpoch.toString(),
url: url,
title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl,
visitedAt: now,
);
final updated = [entry, ...entries];
if (updated.length > _maxEntries) {
updated.removeRange(_maxEntries, updated.length);
}
await _save(updated);
}
Future<void> deleteEntry(String id) async {
final entries = await getEntries();
entries.removeWhere((e) => e.id == id);
await _save(entries);
}
Future<void> clearAll() async {
final prefs = await _getPrefs();
await prefs.remove(_prefsKey);
}
Future<void> _save(List<ReelsHistoryEntry> entries) async {
final prefs = await _getPrefs();
final jsonList = entries.map((e) => e.toJson()).toList();
await prefs.setString(_prefsKey, jsonEncode(jsonList));
}
}
@@ -0,0 +1,307 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/screen_time_service.dart';
class ScreenTimeScreen extends StatelessWidget {
const ScreenTimeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Screen Time',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Consumer<ScreenTimeService>(
builder: (context, service, _) {
final data = service.secondsByDate;
final todayKey = _todayKey();
final todaySeconds = data[todayKey] ?? 0;
final last7 = _lastNDays(7);
final barSpots = <BarChartGroupData>[];
int totalSeconds = 0;
for (var i = 0; i < last7.length; i++) {
final key = last7[i];
final sec = data[key] ?? 0;
totalSeconds += sec;
barSpots.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: sec / 60.0,
width: 10,
borderRadius: BorderRadius.circular(4),
color: Colors.blueAccent,
),
],
),
);
}
final daysWithData = data.values.isEmpty ? 0 : data.length;
final weeklyAvgMinutes = last7.isEmpty
? 0.0
: totalSeconds / 60.0 / last7.length;
final allTimeMinutes = totalSeconds / 60.0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildStatCard(
title: 'Today',
value: _formatDuration(todaySeconds),
),
const SizedBox(height: 16),
_buildChartCard(barSpots, last7),
const SizedBox(height: 16),
_buildInlineStats(
weeklyAvgMinutes: weeklyAvgMinutes,
allTimeMinutes: allTimeMinutes,
daysWithData: daysWithData,
),
const SizedBox(height: 24),
const Text(
'All data stored locally on your device only',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 24),
Center(
child: OutlinedButton.icon(
onPressed: () => _confirmReset(context, service),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Reset all data'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
),
),
],
);
},
),
);
}
static String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
static List<String> _lastNDays(int n) {
final now = DateTime.now();
return List.generate(n, (i) {
final d = now.subtract(Duration(days: n - 1 - i));
return '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
});
}
Widget _buildStatCard({required String title, required String value}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildChartCard(List<BarChartGroupData> bars, List<String> last7Keys) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white12),
),
height: 220,
child: BarChart(
BarChartData(
barGroups: bars,
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= last7Keys.length) {
return const SizedBox.shrink();
}
final label = last7Keys[index].substring(
last7Keys[index].length - 2,
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
label,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
},
),
),
),
),
),
);
}
Widget _buildInlineStats({
required double weeklyAvgMinutes,
required double allTimeMinutes,
required int daysWithData,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.02),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_inlineStat(
label: '7-day avg',
value: '${weeklyAvgMinutes.toStringAsFixed(1)} min',
),
_inlineDivider(),
_inlineStat(
label: 'All-time total',
value: '${allTimeMinutes.toStringAsFixed(0)} min',
),
_inlineDivider(),
_inlineStat(label: 'Tracked days', value: '$daysWithData'),
],
),
);
}
Widget _inlineStat({required String label, required String value}) {
return Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
}
Widget _inlineDivider() {
return Container(
width: 1,
height: 40,
color: Colors.white.withValues(alpha: 0.08),
);
}
static String _formatDuration(int seconds) {
if (seconds < 60) {
return '0:${seconds.toString().padLeft(2, '0')}';
}
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h > 0) {
return '${h}h ${m.toString().padLeft(2, '0')}m';
}
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _confirmReset(
BuildContext context,
ScreenTimeService service,
) async {
final first = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset screen time?'),
content: const Text(
'This will clear all locally stored screen time data for the last 30 days.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Continue',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (first != true) return;
if (!context.mounted) return;
final second = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirm reset'),
content: const Text(
'Are you sure you want to permanently delete all screen time data?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Yes, delete',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (!context.mounted) return;
if (second == true) {
await service.resetAll();
}
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'update_checker_service.dart';
class UpdateBanner extends StatefulWidget {
final UpdateInfo updateInfo;
final VoidCallback onDismiss;
const UpdateBanner({
super.key,
required this.updateInfo,
required this.onDismiss,
});
@override
State<UpdateBanner> createState() => _UpdateBannerState();
}
class _UpdateBannerState extends State<UpdateBanner> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
const Text('🎉', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
'FocusGram ${widget.updateInfo.latestVersion} available',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
HapticFeedback.lightImpact();
setState(() => _isExpanded = !_isExpanded);
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
HapticFeedback.lightImpact();
widget.onDismiss();
},
),
],
),
),
if (_isExpanded) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"What's new",
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_formatReleaseNotes(widget.updateInfo.whatsNew),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () async {
final uri = Uri.parse(widget.updateInfo.releaseUrl);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
},
child: const Text('Download on GitHub'),
),
),
],
),
),
],
],
),
);
}
String _formatReleaseNotes(String raw) {
var text = raw;
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');
return text.trim();
}
}
@@ -0,0 +1,105 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class UpdateInfo {
final String latestVersion; // e.g. "1.0.0"
final String releaseUrl; // html_url
final String whatsNew; // trimmed body
final bool isUpdateAvailable;
const UpdateInfo({
required this.latestVersion,
required this.releaseUrl,
required this.whatsNew,
required this.isUpdateAvailable,
});
}
class UpdateCheckerService extends ChangeNotifier {
static const String _lastDismissedKey = 'last_dismissed_update_version';
static const String _githubUrl =
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest';
UpdateInfo? _updateInfo;
bool _isDismissed = false;
bool get hasUpdate => _updateInfo != null && !_isDismissed;
UpdateInfo? get updateInfo => hasUpdate ? _updateInfo : null;
Future<void> checkForUpdates() async {
try {
final response = await http
.get(Uri.parse(_githubUrl))
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return;
final data = json.decode(response.body);
final String gitVersionTag =
data['tag_name'] ?? ''; // e.g. "v0.9.8-beta.2"
final String htmlUrl = data['html_url'] ?? '';
final String body = (data['body'] as String?) ?? '';
if (gitVersionTag.isEmpty || htmlUrl.isEmpty) return;
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version; // e.g. "0.9.8-beta.2"
if (!_isNewerVersion(gitVersionTag, currentVersion)) return;
final prefs = await SharedPreferences.getInstance();
final dismissedVersion = prefs.getString(_lastDismissedKey);
if (dismissedVersion == gitVersionTag) {
_isDismissed = true;
return;
}
final cleanVersion =
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
var trimmed = body.trim();
if (trimmed.length > 1500) {
trimmed = trimmed.substring(0, 1500).trim();
}
_updateInfo = UpdateInfo(
latestVersion: cleanVersion,
releaseUrl: htmlUrl,
whatsNew: trimmed,
isUpdateAvailable: true,
);
_isDismissed = false;
notifyListeners();
} catch (e) {
debugPrint('Update check failed: $e');
}
}
Future<void> dismissUpdate() async {
if (_updateInfo == null) return;
_isDismissed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastDismissedKey, _updateInfo!.latestVersion);
notifyListeners();
}
bool _isNewerVersion(String gitTag, String current) {
// Clean versions: strip 'v' and everything after '-' (beta/rc)
String cleanGit = gitTag.startsWith('v') ? gitTag.substring(1) : gitTag;
String cleanCurrent = current;
List<String> gitParts = cleanGit.split('-')[0].split('.');
List<String> currentParts = cleanCurrent.split('-')[0].split('.');
for (int i = 0; i < gitParts.length && i < currentParts.length; i++) {
int gitNum = int.tryParse(gitParts[i]) ?? 0;
int curNum = int.tryParse(currentParts[i]) ?? 0;
if (gitNum > curNum) return true;
if (gitNum < curNum) return false;
}
return false;
}
}