feat: enhance history page with lifecycle management and background loading
Some checks failed
/ mirror (push) Failing after 11s
/ build-stealth (push) Successful in 10m50s
/ build (push) Successful in 10m56s

This commit is contained in:
AlexisDanlos 2025-06-27 15:08:00 +02:00
parent dd12b523cc
commit c9ebd7b92f
2 changed files with 172 additions and 21 deletions

View File

@ -33,16 +33,18 @@ class HistoryPage extends StatefulWidget {
const HistoryPage({Key? key}) : super(key: key);
@override
_HistoryPageState createState() => _HistoryPageState();
HistoryPageState createState() => HistoryPageState();
}
class _HistoryPageState extends State<HistoryPage>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
class HistoryPageState extends State<HistoryPage>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
List<History> histories = [];
bool loading = true;
bool _isInitialLoad = true;
bool _isBackgroundLoading = false;
int? _expandedIndex;
final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService();
Timer? _debounceTimer;
// Create a MethodChannel instance.
static const MethodChannel _channel = MethodChannel('com.example.calllog');
@ -50,12 +52,52 @@ class _HistoryPageState extends State<HistoryPage>
@override
bool get wantKeepAlive => true; // Preserve state when switching pages
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Load initial data
_buildHistories();
}
/// Public method to trigger reload when page becomes visible
void triggerReload() {
if (!_isInitialLoad && !_isBackgroundLoading) {
_debouncedReload();
}
}
@override
void dispose() {
_debounceTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// Reload data when app comes back to foreground
if (state == AppLifecycleState.resumed && !_isInitialLoad && !_isBackgroundLoading) {
_debouncedReload();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (loading && histories.isEmpty) {
_buildHistories();
}
// didChangeDependencies is not reliable for TabBarView changes
// We'll use a different approach with RouteAware or manual detection
}
/// Debounced reload to prevent multiple rapid reloads
void _debouncedReload() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted && !_isBackgroundLoading) {
_reloadHistoriesInBackground();
}
});
}
Future<void> _refreshContacts() async {
@ -130,10 +172,12 @@ class _HistoryPageState extends State<HistoryPage>
// Request permission.
bool hasPermission = await _requestCallLogPermission();
if (!hasPermission) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Call log permission not granted')));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Call log permission not granted')));
}
setState(() {
loading = false;
_isInitialLoad = false;
});
return;
}
@ -212,10 +256,112 @@ class _HistoryPageState extends State<HistoryPage>
// Sort histories by most recent.
callHistories.sort((a, b) => b.date.compareTo(a.date));
setState(() {
histories = callHistories;
loading = false;
});
if (mounted) {
setState(() {
histories = callHistories;
_isInitialLoad = false;
});
}
}
/// Reload histories in the background without showing loading indicators
Future<void> _reloadHistoriesInBackground() async {
if (_isBackgroundLoading) return;
_isBackgroundLoading = true;
try {
// Request permission.
bool hasPermission = await _requestCallLogPermission();
if (!hasPermission) {
_isBackgroundLoading = false;
return;
}
// Retrieve call logs from native code.
List<dynamic> nativeLogs = [];
try {
nativeLogs = await _channel.invokeMethod('getCallLogs');
} on PlatformException catch (e) {
print("Error fetching call logs: ${e.message}");
_isBackgroundLoading = false;
return;
}
// Ensure contacts are loaded.
final contactState = ContactState.of(context);
if (contactState.loading) {
await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 100));
return contactState.loading;
});
}
List<Contact> contacts = contactState.contacts;
List<History> callHistories = [];
// Process each log entry with intermittent yields to avoid freezing.
for (int i = 0; i < nativeLogs.length; i++) {
final entry = nativeLogs[i];
final String number = entry['number'] ?? '';
if (number.isEmpty) continue;
// Convert timestamp to DateTime.
DateTime callDate =
DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0);
int typeInt = entry['type'] ?? 0;
int duration = entry['duration'] ?? 0;
String callType;
String callStatus;
// Map integer values to call type/status.
// Commonly: 1 = incoming, 2 = outgoing, 3 = missed.
switch (typeInt) {
case 1:
callType = "incoming";
callStatus = (duration == 0) ? "missed" : "answered";
break;
case 2:
callType = "outgoing";
callStatus = "answered";
break;
case 3:
callType = "incoming";
callStatus = "missed";
break;
default:
callType = "unknown";
callStatus = "unknown";
}
// Try to find a matching contact.
Contact? matchedContact = findContactForNumber(number, contacts);
if (matchedContact == null) {
// Create a dummy contact if not found.
matchedContact = Contact(
id: "dummy-$number",
displayName: number,
phones: [Phone(number)],
);
}
callHistories
.add(History(matchedContact, callDate, callType, callStatus, 1));
// Yield every 10 iterations to avoid blocking the UI.
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
}
// Sort histories by most recent.
callHistories.sort((a, b) => b.date.compareTo(a.date));
if (mounted) {
setState(() {
histories = callHistories;
});
}
} finally {
_isBackgroundLoading = false;
}
}
List _buildGroupedList(List<History> historyList) {
@ -283,9 +429,9 @@ class _HistoryPageState extends State<HistoryPage>
@override
Widget build(BuildContext context) {
super.build(context); // required due to AutomaticKeepAliveClientMixin
final contactState = ContactState.of(context);
if (loading || contactState.loading) {
// Show loading only on initial load and if no data is available yet
if (_isInitialLoad && histories.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: const Center(child: CircularProgressIndicator()),

View File

@ -19,6 +19,7 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
final TextEditingController _searchController = TextEditingController();
late SearchController _searchBarController;
String _rawSearchInput = '';
final GlobalKey<HistoryPageState> _historyPageKey = GlobalKey<HistoryPageState>();
@override
void initState() {
@ -93,6 +94,10 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
void _handleTabIndex() {
setState(() {});
// Trigger history page reload when switching to history tab (index 1)
if (_tabController.index == 1) {
_historyPageKey.currentState?.triggerReload();
}
}
void _toggleFavorite(Contact contact) async {
@ -270,11 +275,11 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
children: [
TabBarView(
controller: _tabController,
children: const [
FavoritesPage(),
HistoryPage(),
ContactPage(),
VoicemailPage(),
children: [
const FavoritesPage(),
HistoryPage(key: _historyPageKey),
const ContactPage(),
const VoicemailPage(),
],
),
Positioned(