flutter_app_usage_kit 0.1.0
flutter_app_usage_kit: ^0.1.0 copied to clipboard
Android UsageStatsManager plugin for querying per-app screen time, foreground events, and installed app metadata.
example/lib/main.dart
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_app_usage_kit/flutter_app_usage_kit.dart';
void main() {
runApp(const AppUsageKitExampleApp());
}
class AppUsageKitExampleApp extends StatelessWidget {
const AppUsageKitExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'App Usage Kit',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const _HomeShell(),
);
}
}
// ---------------------------------------------------------------------------
// Shell with bottom nav
// ---------------------------------------------------------------------------
class _HomeShell extends StatefulWidget {
const _HomeShell();
@override
State<_HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<_HomeShell> {
int _tab = 0;
static const _tabs = [_TodayUsageTab(), _RecentEventsTab(), _SettingsTab()];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _tabs[_tab],
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.bar_chart),
label: "Today's Usage",
),
NavigationDestination(
icon: Icon(Icons.list_alt),
label: 'Recent Events',
),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Tab 1 — Today's Usage
// ---------------------------------------------------------------------------
class _TodayUsageTab extends StatefulWidget {
const _TodayUsageTab();
@override
State<_TodayUsageTab> createState() => _TodayUsageTabState();
}
class _TodayUsageTabState extends State<_TodayUsageTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
List<UsageInfo>? _stats;
Map<String, AppInfo> _appInfoCache = {};
String? _error;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (_loading) return;
setState(() {
_loading = true;
_error = null;
});
try {
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day);
final stats = await AppUsageKit.queryUsageStats(start: start, end: now);
stats.sort(
(a, b) => b.totalForegroundTime.compareTo(a.totalForegroundTime),
);
final nonZero = stats
.where((s) => s.totalForegroundTime.inSeconds > 0)
.toList();
// Batch fetch app names (no icons — fast)
final cache = <String, AppInfo>{};
for (final s in nonZero) {
final info = await AppUsageKit.getAppInfo(s.packageName);
if (info != null) cache[s.packageName] = info;
}
if (mounted) {
setState(() {
_stats = nonZero;
_appInfoCache = cache;
});
}
} on PermissionDeniedException {
if (mounted) setState(() => _error = 'permission_denied');
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text("Today's Usage"),
actions: [
if (!_loading)
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
],
),
if (_loading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_error == 'permission_denied')
SliverFillRemaining(child: _PermissionBanner(onGranted: _load))
else if (_error != null)
SliverFillRemaining(
child: _ErrorBanner(message: _error!, onRetry: _load),
)
else if (_stats == null || _stats!.isEmpty)
const SliverFillRemaining(
child: Center(child: Text('No usage data for today yet.')),
)
else
SliverList.builder(
itemCount: _stats!.length,
itemBuilder: (ctx, i) {
final s = _stats![i];
final info = _appInfoCache[s.packageName];
return ListTile(
leading: _AppIcon(
iconPng: info?.iconPng,
packageName: s.packageName,
),
title: Text(
info?.appName ?? s.packageName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
s.packageName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatDuration(s.totalForegroundTime),
style: Theme.of(ctx).textTheme.titleMedium,
),
if (s.launchCount > 0)
Text(
'${s.launchCount}× launched',
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
);
},
),
],
);
}
}
// ---------------------------------------------------------------------------
// Tab 2 — Recent Events (last 1 hour)
// ---------------------------------------------------------------------------
class _RecentEventsTab extends StatefulWidget {
const _RecentEventsTab();
@override
State<_RecentEventsTab> createState() => _RecentEventsTabState();
}
class _RecentEventsTabState extends State<_RecentEventsTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
List<UsageEvent>? _events;
String? _error;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (_loading) return;
setState(() {
_loading = true;
_error = null;
});
try {
final now = DateTime.now();
final start = now.subtract(const Duration(hours: 1));
final events = await AppUsageKit.queryEvents(start: start, end: now);
events.sort((a, b) => b.timestamp.compareTo(a.timestamp));
if (mounted) setState(() => _events = events);
} on PermissionDeniedException {
if (mounted) setState(() => _error = 'permission_denied');
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text('Recent Events'),
actions: [
if (!_loading)
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
],
),
if (_loading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (_error == 'permission_denied')
SliverFillRemaining(child: _PermissionBanner(onGranted: _load))
else if (_error != null)
SliverFillRemaining(
child: _ErrorBanner(message: _error!, onRetry: _load),
)
else if (_events == null || _events!.isEmpty)
const SliverFillRemaining(
child: Center(child: Text('No events in the last hour.')),
)
else
SliverList.builder(
itemCount: _events!.length,
itemBuilder: (ctx, i) {
final e = _events![i];
return ListTile(
leading: _EventTypeIcon(type: e.type),
title: Text(
e.packageName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
e.className ?? e.type.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: Text(
_formatTime(e.timestamp),
style: Theme.of(ctx).textTheme.bodySmall,
),
);
},
),
],
);
}
}
// ---------------------------------------------------------------------------
// Tab 3 — Settings
// ---------------------------------------------------------------------------
class _SettingsTab extends StatefulWidget {
const _SettingsTab();
@override
State<_SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends State<_SettingsTab>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
bool? _hasPermission;
int? _installedAppCount;
bool _includeSystemApps = false;
bool _loadingApps = false;
@override
void initState() {
super.initState();
_checkPermission();
}
Future<void> _checkPermission() async {
final granted = await AppUsageKit.hasPermission();
if (mounted) setState(() => _hasPermission = granted);
}
Future<void> _openSettings() async {
await AppUsageKit.openPermissionSettings();
await Future<void>.delayed(const Duration(seconds: 1));
await _checkPermission();
}
Future<void> _loadAppCount() async {
setState(() => _loadingApps = true);
final apps = await AppUsageKit.getInstalledApps(
includeSystemApps: _includeSystemApps,
);
if (mounted) {
setState(() {
_installedAppCount = apps.length;
_loadingApps = false;
});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final cs = Theme.of(context).colorScheme;
return CustomScrollView(
slivers: [
const SliverAppBar.large(title: Text('Settings')),
SliverList(
delegate: SliverChildListDelegate([
// Permission card
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Usage Access Permission',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Icon(
_hasPermission == true
? Icons.check_circle
: Icons.cancel,
color: _hasPermission == true ? cs.primary : cs.error,
),
const SizedBox(width: 8),
Text(
_hasPermission == null
? 'Checking…'
: _hasPermission!
? 'Granted'
: 'Not granted',
),
],
),
if (_hasPermission == false) ...[
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: const Text('Open Usage Access Settings'),
onPressed: _openSettings,
),
],
const SizedBox(height: 8),
TextButton(
onPressed: _checkPermission,
child: const Text('Re-check permission'),
),
],
),
),
),
// Installed apps card
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Installed Apps',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include system apps'),
value: _includeSystemApps,
onChanged: (v) => setState(() {
_includeSystemApps = v;
_installedAppCount = null;
}),
),
if (_installedAppCount != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Found $_installedAppCount apps',
style: Theme.of(context).textTheme.bodyLarge,
),
),
FilledButton.tonal(
onPressed: _loadingApps ? null : _loadAppCount,
child: _loadingApps
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Count installed apps'),
),
],
),
),
),
// Plugin info card
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Plugin Info',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const _InfoRow(
label: 'Package',
value: 'flutter_app_usage_kit',
),
const _InfoRow(label: 'Platform', value: 'Android only'),
const _InfoRow(
label: 'Min SDK',
value: 'API 21 (Android 5.0)',
),
const _InfoRow(
label: 'Data source',
value: 'UsageStatsManager',
),
],
),
),
),
]),
),
],
);
}
}
// ---------------------------------------------------------------------------
// Shared widgets
// ---------------------------------------------------------------------------
class _PermissionBanner extends StatelessWidget {
const _PermissionBanner({required this.onGranted});
final VoidCallback onGranted;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 64,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 16),
Text(
'Usage Access Required',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Grant "Usage Access" permission in Settings to see app usage data.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(Icons.open_in_new),
label: const Text('Open Settings'),
onPressed: () async {
await AppUsageKit.openPermissionSettings();
await Future<void>.delayed(const Duration(seconds: 1));
onGranted();
},
),
],
),
),
);
}
}
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Retry')),
],
),
),
);
}
}
class _AppIcon extends StatelessWidget {
const _AppIcon({required this.iconPng, required this.packageName});
final Uint8List? iconPng;
final String packageName;
@override
Widget build(BuildContext context) {
if (iconPng != null) {
return CircleAvatar(
backgroundImage: MemoryImage(iconPng!),
backgroundColor: Colors.transparent,
);
}
return CircleAvatar(
child: Text(packageName.isNotEmpty ? packageName[0].toUpperCase() : '?'),
);
}
}
class _EventTypeIcon extends StatelessWidget {
const _EventTypeIcon({required this.type});
final UsageEventType type;
@override
Widget build(BuildContext context) {
final (icon, color) = switch (type) {
UsageEventType.foreground => (Icons.play_arrow, Colors.green),
UsageEventType.background => (Icons.pause, Colors.orange),
UsageEventType.screenInteractive => (Icons.brightness_high, Colors.blue),
UsageEventType.screenNonInteractive => (
Icons.brightness_3,
Colors.blueGrey,
),
UsageEventType.keyguardShown => (Icons.lock, Colors.purple),
UsageEventType.keyguardHidden => (Icons.lock_open, Colors.teal),
UsageEventType.unknown => (Icons.help_outline, Colors.grey),
};
return Icon(icon, color: color);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
Expanded(child: Text(value)),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
String _formatDuration(Duration d) {
if (d.inHours >= 1) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
return '${h}h ${m}m';
}
if (d.inMinutes >= 1) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
return '${d.inSeconds}s';
}
String _formatTime(DateTime dt) {
final h = dt.hour.toString().padLeft(2, '0');
final m = dt.minute.toString().padLeft(2, '0');
final s = dt.second.toString().padLeft(2, '0');
return '$h:$m:$s';
}