-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Description
Describe the bug
When using flutter_local_notifications on Windows desktop, calling cancel(id) in a loop to cancel multiple notifications causes the UI to completely freeze. The freeze duration is proportional to the number of notifications being cancelled.
The issue is specifically with individual cancel(id) calls - using cancelAll() does not cause the freeze, but cancelAll() is not always a viable solution because it cancels ALL notifications including ones that should be preserved.
This issue does not occur on Android/iOS where the same cancel operations run without blocking the UI.
To Reproduce
- Create a Flutter desktop app targeting Windows
- Schedule 200+ notifications using
zonedSchedule() - Minimize the app, then restore it
- On resume, loop through notifications calling
cancel(id)for each - Try to scroll or interact with the UI during the cancel loop
- Observe: UI is completely frozen for 2-5+ seconds
Expected behavior
Individual cancel(id) calls should not block the UI thread. They should execute asynchronously like they do on Android/iOS.
Sample code to reproduce the problem
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz;
import 'dart:io';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Windows Notification Freeze Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
final ScrollController _scrollController = ScrollController();
bool _isInitialized = false;
String _lifecycleState = 'Active';
final List<String> _scrollItems = List.generate(
100,
(i) => 'Scrollable Item #${i + 1}',
);
final List<String> _logMessages = [];
int _pendingCount = 0;
bool _isProcessing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_initializeNotifications();
_addLog('App started - Click "Schedule 200 Notifications" button to begin');
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
setState(() {
_lifecycleState = state.name;
});
_addLog('==================================================');
_addLog('Lifecycle changed to: ${state.name.toUpperCase()}');
if (state == AppLifecycleState.resumed) {
_addLog('APP RESUMED - FREEZE WILL START NOW!');
_addLog('Try scrolling the list on the left...');
_onAppResumed();
} else if (state == AppLifecycleState.paused) {
_addLog('App minimized');
}
}
Future<void> _initializeNotifications() async {
_addLog('Initializing notifications...');
tz.initializeTimeZones();
tz.setLocalLocation(tz.getLocation('UTC'));
_addLog('Timezone initialized to UTC');
if (!Platform.isWindows) {
_addLog(
'NOT ON WINDOWS - This demo requires Windows to show the freeze!',
);
setState(() {
_isInitialized = false;
});
return;
}
const initSettings = InitializationSettings(
windows: WindowsInitializationSettings(
appName: "My Flutter App",
appUserModelId: "com.example.myflutterapp",
guid: "12345678-1234-5678-9012-ABCDEFABCDEF",
),
);
try {
final result = await _notifications.initialize(initSettings);
setState(() {
_isInitialized = result ?? false;
});
_addLog('Initialization result: $_isInitialized');
} catch (e) {
_addLog('Initialization error: $e');
}
}
Future<void> _scheduleNotifications() async {
_addLog('Scheduling 200 notifications...');
final now = tz.TZDateTime.now(tz.local);
for (int i = 1000; i < 1200; i++) {
try {
final scheduledTime = now.add(Duration(minutes: i - 999));
await _notifications.zonedSchedule(
i,
'Test Notification $i',
'This is scheduled test notification number $i at $scheduledTime',
scheduledTime,
const NotificationDetails(
windows: WindowsNotificationDetails(subtitle: 'Test'),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (e) {
_addLog('Error scheduling notification $i: $e');
}
}
_addLog('Scheduled 200 test notifications for future times');
}
Future<void> _onAppResumed() async {
if (!Platform.isWindows) {
_addLog('Not on Windows - skipping');
return;
}
if (_isProcessing) {
_addLog('Already processing - skipping');
return;
}
setState(() {
_isProcessing = true;
});
try {
_addLog('==================================================');
_addLog('APP RESUMED - CANCELING AND RESCHEDULING NOTIFICATIONS');
_addLog('==================================================');
_addLog('Calling pendingNotificationRequests()...');
_addLog('FREEZE STARTS NOW - Try scrolling!');
final startCheck = DateTime.now();
final pending = await _notifications.pendingNotificationRequests();
final checkDuration = DateTime.now().difference(startCheck);
_addLog('==================================================');
_addLog('FREEZE ENDED - UI should be responsive now');
_addLog(
'pendingNotificationRequests() took: ${checkDuration.inMilliseconds}ms',
);
_addLog('Found ${pending.length} pending notifications');
setState(() {
_pendingCount = pending.length;
});
_addLog('Starting cancel and reschedule process...');
await _cancelAndRescheduleNotifications();
_addLog('==================================================');
_addLog('RESUME PROCESS COMPLETED');
_addLog('==================================================');
} catch (e) {
_addLog('Error in resume check: $e');
} finally {
setState(() {
_isProcessing = false;
});
}
}
Future<void> _cancelAndRescheduleNotifications() async {
_addLog('==================================================');
_addLog('CANCELING AND RESCHEDULING ALL NOTIFICATIONS');
_addLog('Canceling existing notifications one by one...');
_addLog('FREEZE STARTS - Cancel operations are blocking!');
final startCancel = DateTime.now();
// THIS IS THE PROBLEM - Each cancel() call blocks the UI
for (int i = 1000; i < 1200; i++) {
await _notifications.cancel(i);
_addLog(' Canceled notification $i');
}
final cancelDuration = DateTime.now().difference(startCancel);
_addLog('Cancel completed in ${cancelDuration.inMilliseconds}ms');
_addLog('Scheduling new notifications...');
_addLog('FINAL FREEZE - Scheduling is also blocking!');
final startSchedule = DateTime.now();
final now = tz.TZDateTime.now(tz.local);
for (int i = 1000; i < 1200; i++) {
final scheduledTime = now.add(Duration(minutes: (i - 999) * 2));
await _notifications.zonedSchedule(
i,
'Rescheduled Notification $i',
'This notification was rescheduled after resume for $scheduledTime',
scheduledTime,
const NotificationDetails(
windows: WindowsNotificationDetails(subtitle: 'Rescheduled'),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
_addLog(' Scheduled notification $i');
}
final scheduleDuration = DateTime.now().difference(startSchedule);
_addLog('Scheduling completed in ${scheduleDuration.inMilliseconds}ms');
_addLog('==================================================');
_addLog('CANCEL AND RESCHEDULE COMPLETE');
_addLog('==================================================');
}
void _addLog(String message) {
final timestamp = DateTime.now().toString().substring(11, 23);
setState(() {
_logMessages.insert(0, '[$timestamp] $message');
if (_logMessages.length > 200) {
_logMessages.removeLast();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
}
});
}
void _clearLogs() {
setState(() {
_logMessages.clear();
});
}
Future<void> _manualTrigger() async {
_addLog('MANUAL TRIGGER - Simulating app resume...');
await _onAppResumed();
}
@override
Widget build(BuildContext context) {
final isWindows = Platform.isWindows;
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Windows Notification Freeze Demo'),
),
body: Row(
children: [
Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(color: Colors.grey.shade300, width: 2),
),
color: Colors.grey.shade50,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade700,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SCROLL TEST AREA',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Try scrolling when you restore the window.\nIf the list is frozen = bug reproduced!',
style: TextStyle(
fontSize: 13,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _scrollItems.length,
itemBuilder: (context, index) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
color: index % 2 == 0
? Colors.white
: Colors.grey.shade50,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.notifications_active,
color: Colors.blue.shade700,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_scrollItems[index],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
Text(
'Item index: $index',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
);
},
),
),
],
),
),
),
Expanded(
flex: 3,
child: Column(
children: [
if (!isWindows)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.shade100,
border: Border.all(
color: Colors.orange.shade700,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.warning,
color: Colors.orange.shade700,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'NOT RUNNING ON WINDOWS!\nThis demo requires Windows to reproduce the freeze bug.',
style: TextStyle(
color: Colors.orange.shade900,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
Card(
margin: const EdgeInsets.all(16),
color: _lifecycleState == 'resumed'
? Colors.green.shade50
: Colors.grey.shade50,
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _lifecycleState == 'resumed'
? Colors.green
: Colors.orange,
shape: BoxShape.circle,
),
child: Icon(
_lifecycleState == 'resumed'
? Icons.play_arrow
: Icons.pause,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 12),
Text(
'Lifecycle: ${_lifecycleState.toUpperCase()}',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const Divider(height: 24),
_buildStatusRow(
'Platform',
Platform.operatingSystem,
isWindows ? Colors.green : Colors.red,
),
_buildStatusRow(
'Initialized',
'$_isInitialized',
_isInitialized ? Colors.green : Colors.red,
),
_buildStatusRow(
'Pending notifications',
'$_pendingCount',
Colors.blue,
),
_buildStatusRow(
'Processing',
'$_isProcessing',
_isProcessing ? Colors.orange : Colors.grey,
),
],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isProcessing || !_isInitialized
? null
: _scheduleNotifications,
icon: const Icon(Icons.schedule),
label: const Text('SCHEDULE 200 NOTIFICATIONS'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isProcessing ? null : _manualTrigger,
icon: const Icon(Icons.play_arrow),
label: const Text('MANUAL TRIGGER (Simulate Resume)'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border.all(color: Colors.red.shade300, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bug_report,
color: Colors.red.shade700,
size: 28,
),
const SizedBox(width: 8),
Text(
'HOW TO REPRODUCE:',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.red.shade700,
),
),
],
),
const SizedBox(height: 12),
const Text(
'1. Click "SCHEDULE 200 NOTIFICATIONS" button\n'
'2. MINIMIZE this window (use Windows key + D)\n'
'3. Wait 3 seconds\n'
'4. RESTORE the window (cancels & re-schedules notifications)\n'
'5. IMMEDIATELY try scrolling the left panel\n'
'6. Notice: UI FROZEN for 2-5 seconds!\n'
'7. Check log below for freeze duration\n\n'
'OR click the red button above to trigger manually',
style: TextStyle(fontSize: 13, height: 1.5),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: Card(
margin: const EdgeInsets.all(16),
elevation: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Activity Log',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
TextButton.icon(
onPressed: _clearLogs,
icon: const Icon(Icons.clear_all, size: 16),
label: const Text('Clear'),
),
],
),
),
Expanded(
child: _logMessages.isEmpty
? const Center(child: Text('No activity yet'))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _logMessages.length,
itemBuilder: (context, index) {
final log = _logMessages[index];
final isSeparator = log.contains('===');
final isWarning =
log.contains('NOT ON WINDOWS') ||
log.contains('FREEZE');
final isError = log.contains('Error');
final isSuccess =
log.contains('completed') ||
log.contains('result: true') ||
log.contains('Scheduled');
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
log,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: isSeparator
? Colors.grey.shade400
: isError
? Colors.red.shade700
: isWarning
? Colors.orange.shade700
: isSuccess
? Colors.green.shade700
: Colors.black87,
fontWeight:
(isWarning || isError) &&
!isSeparator
? FontWeight.bold
: FontWeight.normal,
),
),
);
},
),
),
],
),
),
),
],
),
),
],
),
);
}
Widget _buildStatusRow(String label, String value, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$label:', style: const TextStyle(fontWeight: FontWeight.w500)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
value,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
),
],
),
);
}
}Environment
- Flutter version: 3.38.4
- flutter_local_notifications version: ^19.5.0
- Platform: Windows 11 (x64)
- Dart version: 3.10.3
Additional context
cancelAll()does NOT freeze the UI, but it's not a viable workaround when you need to cancel only specific notifications- The freeze duration scales with the number of
cancel(id)calls - This appears to be a Windows-specific issue in the platform channel implementation
- Android and iOS handle the same operations without UI blocking
Suggested fix
Consider implementing batch cancellation or making cancel(id) truly asynchronous on Windows by executing the platform channel calls off the main thread.