Flutter Inactive Timer

CI

A pure Dart package for detecting user inactivity on desktop (Windows and macOS). It reads the idle duration straight from the OS through dart:ffi โ€” no native code to build, and no Flutter required, so it works in a Flutter app and in a Dart CLI or server program. It provides customizable timeout and notification thresholds, making it ideal for implementing security features like automatic logout or session timeouts.

Features

  • ๐Ÿ–ฅ๏ธ Supports Windows and macOS platforms
  • โฑ๏ธ Customizable inactivity timeout duration
  • ๐Ÿ”” Notification either at a percentage of the timeout or a fixed lead time before it
  • โณ remaining() for driving a live countdown ("logs out in 04:59")
  • ๐Ÿ” onActive callback for reacting when the user returns from inactivity
  • ๐Ÿ”„ Easy-to-use API to start, stop, and dispose monitoring
  • ๐Ÿงน dispose() for deterministic teardown (no leaked timers)
  • ๐Ÿ›ก๏ธ Option to require explicit user confirmation to continue session

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  flutter_inactive_timer: ^4.0.0

Upgrading from 3.x? See Migrating to 4.0.0 below โ€” no code changes for typical use, but do a clean build.

Usage

Basic Setup

import 'package:flutter_inactive_timer/flutter_inactive_timer.dart';

// Create an instance with custom settings
final inactivityTimer = FlutterInactiveTimer(
  timeoutDuration: Duration(minutes: 5),
  notification: NotifyAtPercent(80), // notify at 80% of the timeout (i.e. 4 min)
  onInactiveDetected: () {
    // Handle inactive timeout, e.g., log out the user
    print('User inactive for too long. Session expired.');
  },
  onNotification: () {
    // Handle pre-timeout notification, e.g., show a warning dialog
    print('Warning: Session will expire soon due to inactivity.');
  },
  onActive: () {
    // Optional: handle the user returning after a notification was shown
    // (e.g., dismiss the warning dialog/snackbar)
    print('User is active again.');
  },
);

// Start monitoring
await inactivityTimer.startMonitoring();

// Pause monitoring (can be resumed later with startMonitoring())
inactivityTimer.stopMonitoring();

// Permanently tear down when you're done with the instance
inactivityTimer.dispose();

stopMonitoring() is a pause โ€” you can call startMonitoring() again. dispose() is permanent โ€” it cancels the timer and releases the instance so it can be garbage collected; a disposed timer cannot be restarted. Always dispose() from your widget's State.dispose.

Notification Timing

The notification parameter decides when the pre-timeout warning fires. It is a NotificationTrigger, one of:

// At a percentage of the timeout โ€” here 80% of 5 minutes = 4 minutes.
notification: NotifyAtPercent(80),

// A fixed lead time before the timeout, independent of its length โ€”
// here always 30 seconds before the timeout, whatever the timeout is.
notification: NotifyBefore(Duration(seconds: 30)),

// No notification at all โ€” only onInactiveDetected fires, at the timeout.
notification: null, // (or omit it entirely; null is the default)

Use NotifyBefore when the warning should give the user a constant amount of time to react (e.g. "always warn 1 minute before logout"), regardless of how long the timeout is. Use NotifyAtPercent when the warning should scale with the timeout.

NotifyBefore(before) requires before to be >= 0 and shorter than timeoutDuration (asserted in debug builds). A lead time equal to or longer than the timeout has no valid firing point, so in release it safely fires at the moment monitoring starts.

Advanced Usage

Reacting to User Return (onActive)

onActive fires once when the user becomes active again after a notification has already been delivered. It lets you clean up any UI state that was put in place by onNotification (a warning banner, a dimmed overlay, a status label, etc.) without having to track it yourself.

final inactivityTimer = FlutterInactiveTimer(
  timeoutDuration: Duration(minutes: 5),
  notification: NotifyAtPercent(80),
  onNotification: () => setState(() => _status = 'Almost inactive'),
  onActive: () => setState(() => _status = 'Active'),
  onInactiveDetected: () => setState(() => _status = 'Session expired'),
);

When does it fire?

  • requireExplicitContinue: false (default): fires automatically as soon as the timer detects fresh input (bounded to a ~500ms detection latency).
  • requireExplicitContinue: true: fires when you call continueSession().

It does not fire if no notification was pending (for example, resetting the timer during normal activity will not call onActive).

Explicit Continue Mode

You can require users to explicitly confirm they want to continue their session after receiving a notification:

final inactivityTimer = FlutterInactiveTimer(
  timeoutDuration: Duration(minutes: 5),
  notification: NotifyAtPercent(80),
  onInactiveDetected: handleTimeout,
  onNotification: showWarningDialog,
  requireExplicitContinue: true, // Require explicit user action to continue
);

When using requireExplicitContinue: true, you'll need to call continueSession() method when the user confirms they want to continue:

// Example using a dialog with a continue button
void showWarningDialog() {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: Text('Session Expiring Soon'),
      content: Text('Your session will expire due to inactivity. Do you want to continue?'),
      actions: [
        TextButton(
          onPressed: () {
            Navigator.pop(context);
            inactivityTimer.continueSession(); // Explicitly continue the session
          },
          child: Text('Continue Session'),
        ),
      ],
    ),
  );
}

Live Countdown (remaining())

remaining() returns how much time is left before the timeout โ€” use it to show a live "logs out in 04:59" countdown. It reads the current idle duration, so the countdown resets when the user is active, and stays correct in requireExplicitContinue lock (where computing timeout - idle yourself would not). It returns Duration.zero when not monitoring.

It is a pull API: the timer keeps no ticker of its own, so drive it from your own periodic timer and repaint at whatever cadence you like.

Timer? _ticker;
Duration _remaining = Duration.zero;

void _startCountdown() {
  _ticker = Timer.periodic(const Duration(seconds: 1), (_) async {
    final left = await inactivityTimer.remaining();
    if (mounted) setState(() => _remaining = left);
  });
}

@override
void dispose() {
  _ticker?.cancel(); // always cancel your ticker
  inactivityTimer.dispose();
  super.dispose();
}

Prefer a Stream (e.g. for a StreamBuilder)? Wrap the same call โ€” no extra API needed:

final countdown = Stream.periodic(
  const Duration(seconds: 1),
  (_) => inactivityTimer.remaining(),
).asyncMap((future) => future);

Lifecycle Management

Tie the timer to your widget's lifecycle. Use dispose() (not just stopMonitoring()) in State.dispose so the recurring timer doesn't keep the instance โ€” and any state its callbacks capture โ€” alive:

@override
void initState() {
  super.initState();
  inactivityTimer = FlutterInactiveTimer(/* ... */);
  inactivityTimer.startMonitoring();
}

@override
void dispose() {
  inactivityTimer.dispose(); // permanent teardown; releases the timer
  super.dispose();
}

If you rebuild the timer with new settings, dispose() the old instance before replacing it so its timer callback stops holding the previous object:

void reconfigure() {
  inactivityTimer.dispose();
  inactivityTimer = FlutterInactiveTimer(/* new settings */);
  inactivityTimer.startMonitoring();
}

Example

Check the /example directory for a complete example application demonstrating all features. The example includes:

  • Single mode demo showing basic timeout functionality
  • Multi-mode demo showing how to use multiple independent timers
  • Complete UI for configuring and testing timer settings
  • Examples for both Windows and macOS platforms

Platform Support

Platform Support
Windows โœ…
macOS โœ…
Linux โŒ
Web โŒ
Android โŒ
iOS โŒ

How It Works

Everything is derived from one number: the idle duration, the milliseconds since the user's last keyboard or mouse input. Dart reads it straight from the operating system through dart:ffi โ€” there is no native plugin code and nothing for your build to compile:

  • On Windows, from the Win32 GetLastInputInfo and GetTickCount64 APIs.
  • On macOS, from IOKit's HIDIdleTime.

Dart never subtracts two separate clock readings, which avoids a class of wraparound bugs (see ADR-0001). Until 4.0.0 these calls lived in Swift and C++ behind a method channel; both implementations were run side by side on real machines and shown to report the same value before the native code was deleted (see ADR-0004).

The scheduling and notification rules live in a pure, unit-tested InactivityPolicy.

Roadmap

  • Improve configuration options
  • Add support for additional platforms in the future

Troubleshooting

Common Issues

  1. Timer not triggering: Make sure your app is running on a supported platform (Windows or macOS).
  2. Inconsistent behavior: Make sure startMonitoring() is called before expecting the timer to work.
  3. macOS permission issues: Some macOS environments might require additional permissions for input monitoring.

Migrating to 3.0.0

3.0.0 changes the constructor's public API:

  • notificationPer (int) โ†’ notification (NotificationTrigger?). Wrap the old percentage in NotifyAtPercent, and replace notificationPer: 0 (the old "no notification" value) with null or by omitting the parameter.
  • timeoutDuration (int seconds) โ†’ Duration.
// 2.x
FlutterInactiveTimer(
  timeoutDuration: 300,
  notificationPer: 80,
  onInactiveDetected: ...,
  onNotification: ...,
);

// 3.0.0
FlutterInactiveTimer(
  timeoutDuration: Duration(seconds: 300),
  notification: NotifyAtPercent(80),
  onInactiveDetected: ...,
  onNotification: ...,
);

You can also now schedule the notification a fixed time before the timeout with NotifyBefore(Duration(...)) โ€” see Notification Timing. See ADR-0002 for the design rationale.

Migrating to 4.0.0

No code changes for typical use. The FlutterInactiveTimer constructor, its callbacks and remaining() are all unchanged, the values you get back are the same, and the package keeps its name.

What changed is underneath. The idle duration is read through dart:ffi instead of a method channel, so the package ships no Swift or C++ and no longer depends on Flutter at all โ€” it is a plain Dart package now, which means the same timer also runs in a Dart CLI or server program. See example_cli/, ADR-0004 and ADR-0005.

Two things to do:

  • Do a clean build (flutter clean, then rebuild). A stale build directory can still hold the old plugin registration, which now has nothing to register.

  • Only if you wrote your own platform implementation: MethodChannelFlutterInactiveTimer is gone, and FlutterInactiveTimerPlatform is a plain abstract class:

    // 3.x
    class MyPlatform with MockPlatformInterfaceMixin
        implements FlutterInactiveTimerPlatform { ... }
    // 4.0.0
    class MyPlatform extends FlutterInactiveTimerPlatform { ... }
    

    The seam is otherwise unchanged โ€” implement getIdleDuration(). Extend the class, do not implement it: getIdleDuration() has a default body, so an extends subclass keeps compiling if the interface ever gains a member. The runtime check that enforced this went with plugin_platform_interface; breaking the rule is now a compile error instead of a silent one.

Migrating to 2.0.0

2.0.0 is a behavior- and API-compatible upgrade for typical app usage โ€” the FlutterInactiveTimer constructor and its callbacks are unchanged. Two things to know:

  • Recommended: switch State.dispose from stopMonitoring() to dispose() (see Lifecycle Management). dispose() is the new, permanent teardown that prevents leaked timers.
  • Only if you wrote a custom platform implementation: the method channel contract changed. Implement getIdleDuration() (milliseconds since last input) instead of the old getSystemTickCount() + getLastInputTime() pair.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the MIT License - see the LICENSE file for details.