android_sleep_tracker 0.0.1
android_sleep_tracker: ^0.0.1 copied to clipboard
Flutter API over Android's native sleep tracking (Google Play Services Activity Recognition Sleep API). Android-only, no UI, no health scoring.
import 'dart:async';
import 'package:android_sleep_tracker/android_sleep_tracker.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorSchemeSeed: const Color(0xFF5B4FE9),
useMaterial3: true,
),
home: const SleepTrackerPage(),
);
}
}
/// What the UI shows for the last start/stop/refresh attempt.
enum _Status { idle, unsupported, permissionDenied, error }
class SleepTrackerPage extends StatefulWidget {
const SleepTrackerPage({super.key});
@override
State<SleepTrackerPage> createState() => _SleepTrackerPageState();
}
class _SleepTrackerPageState extends State<SleepTrackerPage> {
final _tracker = AndroidSleepTracker.instance;
bool _isTracking = false;
_Status _status = _Status.idle;
String? _errorDetail;
bool _busy = false;
final List<SleepApiEvent> _liveEvents = [];
StreamSubscription<SleepApiEvent>? _eventSubscription;
static const _maxLiveEvents = 20;
@override
void initState() {
super.initState();
_refresh();
// No polling: startTracking() (below) registers with the Sleep API,
// and this stream just relays whatever it pushes, live.
_eventSubscription = _tracker.sleepDataStream.listen(
_onSleepEvent,
onError: (Object error) {
debugPrint('[android_sleep_tracker] sleepDataStream error: $error');
},
);
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
void _onSleepEvent(SleepApiEvent event) {
debugPrint('[android_sleep_tracker] sleepDataStream event: $event');
if (!mounted) return;
setState(() {
_liveEvents.insert(0, event);
if (_liveEvents.length > _maxLiveEvents) {
_liveEvents.removeRange(_maxLiveEvents, _liveEvents.length);
}
});
}
Future<void> _refresh() async {
final tracking = await _tracker.isTracking();
debugPrint('[android_sleep_tracker] isTracking=$tracking');
if (!mounted) return;
setState(() => _isTracking = tracking);
}
Future<void> _start() async {
setState(() {
_busy = true;
_status = _Status.idle;
_errorDetail = null;
});
try {
await _tracker.startTracking();
} on SleepTrackerUnsupportedException {
setState(() => _status = _Status.unsupported);
} on SleepTrackerPermissionDeniedException {
setState(() => _status = _Status.permissionDenied);
} on SleepTrackerPlatformException catch (e) {
setState(() {
_status = _Status.error;
_errorDetail = '${e.code}${e.message != null ? ': ${e.message}' : ''}';
});
} finally {
setState(() => _busy = false);
await _refresh();
}
}
Future<void> _stop() async {
setState(() => _busy = true);
try {
await _tracker.stopTracking();
} finally {
setState(() => _busy = false);
await _refresh();
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: scheme.surfaceContainerLowest,
appBar: AppBar(title: const Text('android_sleep_tracker example')),
body: RefreshIndicator(
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_TrackingStatusCard(isTracking: _isTracking),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy || _isTracking ? null : _start,
icon: const Icon(Icons.bedtime_outlined),
label: const Text('Start tracking'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy || !_isTracking ? null : _stop,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Stop tracking'),
),
),
],
),
_buildStatusBanner(),
const SizedBox(height: 28),
Row(
children: [
Text(
'Live events',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(width: 8),
if (_liveEvents.isNotEmpty)
Text(
'(${_liveEvents.length})',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.outline),
),
],
),
const SizedBox(height: 12),
_liveEvents.isEmpty
? const _EmptyStateCard(
icon: Icons.sensors_outlined,
message: 'Waiting for live Sleep API events…',
)
: Column(
children: _liveEvents
.map((event) => _LiveEventTile(event: event))
.toList(),
),
],
),
),
);
}
Widget _buildStatusBanner() {
final (icon, message, color) = switch (_status) {
_Status.idle => (null, null, null),
_Status.unsupported => (
Icons.error_outline,
'Sleep tracking is unsupported on this device '
'(missing/outdated Google Play Services, or non-Android host).',
Colors.orange,
),
_Status.permissionDenied => (
Icons.block,
'ACTIVITY_RECOGNITION permission was denied. '
'Grant it in system settings to start tracking.',
Colors.red,
),
_Status.error => (
Icons.error_outline,
'Platform error: $_errorDetail',
Colors.red,
),
};
if (message == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(message, style: TextStyle(color: color)),
),
],
),
);
}
}
class _TrackingStatusCard extends StatelessWidget {
const _TrackingStatusCard({required this.isTracking});
final bool isTracking;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = isTracking ? scheme.primary : scheme.outline;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isTracking
? scheme.primaryContainer
: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
isTracking ? Icons.bedtime : Icons.bedtime_off_outlined,
color: color,
),
const SizedBox(width: 12),
Text(
isTracking ? 'Tracking sleep' : 'Not tracking',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600, color: color),
),
const Spacer(),
_StatusDot(active: isTracking, color: color),
],
),
);
}
}
class _StatusDot extends StatelessWidget {
const _StatusDot({required this.active, required this.color});
final bool active;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? color : Colors.transparent,
border: Border.all(color: color, width: 1.5),
),
);
}
}
/// Shared surface for every card below — same fill color and rounded
/// corners, just padding/radius vary.
class _SurfaceCard extends StatelessWidget {
const _SurfaceCard({
required this.child,
this.padding = const EdgeInsets.all(18),
this.borderRadius = 16,
});
final Widget child;
final EdgeInsetsGeometry padding;
final double borderRadius;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(borderRadius),
),
child: child,
);
}
}
/// Centered icon + message placeholder for the "no live events yet" state.
class _EmptyStateCard extends StatelessWidget {
const _EmptyStateCard({required this.icon, required this.message});
final IconData icon;
final String message;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return _SurfaceCard(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16),
child: Column(
children: [
Icon(icon, size: 28, color: scheme.outline),
const SizedBox(height: 10),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: scheme.outline),
),
],
),
);
}
}
class _LiveEventTile extends StatelessWidget {
const _LiveEventTile({required this.event});
final SleepApiEvent event;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
// Bind into a local so the switch patterns below can promote it — Dart
// doesn't promote a field/property access (`this.event`), only locals.
final e = event;
final (icon, title, subtitle) = switch (e) {
SleepSegmentReceived() => (
Icons.bedtime_outlined,
'Segment · ${e.status.name}',
e.status == SleepSegmentStatus.notDetected
? 'No sleep detected in the past day'
: '${_formatDateTime(e.startTime)} → ${_formatDateTime(e.endTime)}',
),
SleepClassifyReceived() => (
Icons.insights_outlined,
'Classify · confidence ${e.confidence}',
'${_formatDateTime(e.timestamp)} · motion ${e.motion} · light ${e.light}',
),
};
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
borderRadius: 12,
child: Row(
children: [
Icon(icon, size: 18, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyMedium),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: scheme.outline),
),
],
),
),
],
),
),
);
}
}
final _dateTimeFormat = DateFormat('MMM d, h:mm a');
String _formatDateTime(DateTime dt) => _dateTimeFormat.format(dt);