pedometer_pro 0.2.0
pedometer_pro: ^0.2.0 copied to clipboard
Pedometer & Step Detection. Get step count in a timeRange & stream live steps.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:pedometer_pro/pedometer_pro.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const PedometerProExampleApp());
}
class PedometerProExampleApp extends StatelessWidget {
const PedometerProExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pedometer Pro',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const PedometerHomePage(),
);
}
}
class _DaySteps {
const _DaySteps(this.day, this.result);
final DateTime day;
final StepCountResult result;
}
class PedometerHomePage extends StatefulWidget {
const PedometerHomePage({super.key});
@override
State<PedometerHomePage> createState() => _PedometerHomePageState();
}
class _PedometerHomePageState extends State<PedometerHomePage> {
final Pedometer _pedometer = Pedometer();
/// iOS stores about 7 days; Android Recording API stores about 10.
int get _historyDays =>
(defaultTargetPlatform == TargetPlatform.iOS) ? 7 : 10;
PermissionStatus? _permission;
int? _todaySteps;
int? _historyTotal;
List<_DaySteps> _daily = const [];
String? _historySource;
bool _startingStreams = false;
String? _rangeError;
bool _loadingRange = false;
int? _streamSteps;
int? _streamFromToday;
PedestrianStatus? _status;
String? _streamError;
String? _statusError;
StreamSubscription<int>? _stepSub;
StreamSubscription<int>? _stepFromSub;
StreamSubscription<PedestrianStatus>? _statusSub;
@override
void dispose() {
_stepSub?.cancel();
_stepFromSub?.cancel();
_statusSub?.cancel();
super.dispose();
}
Future<void> _requestPermission() async {
final status = (defaultTargetPlatform == TargetPlatform.android)
? await Permission.activityRecognition.request()
: await Permission.sensors.request();
if (mounted) setState(() => _permission = status);
}
bool get _granted {
final status = _permission;
return status == PermissionStatus.granted ||
status == PermissionStatus.limited;
}
Future<void> _loadHistory() async {
if (_loadingRange) return;
setState(() {
_loadingRange = true;
_rangeError = null;
_todaySteps = null;
_historyTotal = null;
_daily = const [];
_historySource = null;
});
try {
final now = DateTime.now();
final todayStart = DateTime(now.year, now.month, now.day);
final daily = <_DaySteps>[];
for (var i = _historyDays - 1; i >= 0; i--) {
if (!mounted) return;
final day = DateTime(now.year, now.month, now.day - i);
final next = DateTime(day.year, day.month, day.day + 1);
final end = next.isAfter(now) ? now : next;
if (!day.isBefore(end)) continue;
final result = await _pedometer.getStepCountDetailed(
from: day,
to: end,
);
daily.add(_DaySteps(day, result));
}
if (!mounted) return;
final sources = daily.map((d) => d.result.source).toSet();
setState(() {
_daily = daily;
_todaySteps = daily
.where((d) => d.day == todayStart)
.firstOrNull
?.result
.steps;
// A total is meaningful only when every day came from the same provider.
_historyTotal = sources.length == 1
? daily.fold<int>(0, (sum, d) => sum + d.result.steps)
: null;
_historySource = sources.map((s) => s.name).join(', ');
if (sources.length > 1) {
_rangeError =
'History source changed. Reload to get a consistent total.';
}
});
} catch (e) {
if (mounted) setState(() => _rangeError = e.toString());
} finally {
if (mounted) setState(() => _loadingRange = false);
}
}
Future<void> _listenStreams() async {
if (_startingStreams) return;
setState(() => _startingStreams = true);
try {
await Future.wait([
if (_stepSub != null) _stepSub!.cancel(),
if (_stepFromSub != null) _stepFromSub!.cancel(),
if (_statusSub != null) _statusSub!.cancel(),
]);
if (!mounted) return;
setState(() {
_streamError = null;
_statusError = null;
_streamSteps = null;
_status = null;
_streamFromToday = null;
});
_stepSub = _pedometer.stepCountStream().listen(
(steps) {
if (mounted) setState(() => _streamSteps = steps);
},
onError: (Object e) {
if (mounted) setState(() => _streamError = e.toString());
},
);
_statusSub = _pedometer.pedestrianStatusStream().listen(
(status) {
if (mounted) setState(() => _status = status);
},
onError: (Object e) {
if (mounted) setState(() => _statusError = e.toString());
},
);
if ((defaultTargetPlatform == TargetPlatform.iOS)) {
final now = DateTime.now();
final todayStart = DateTime(now.year, now.month, now.day);
_stepFromSub = _pedometer
.stepCountStreamFrom(from: todayStart)
.listen(
(steps) {
if (mounted) setState(() => _streamFromToday = steps);
},
onError: (Object e) {
if (mounted) setState(() => _streamError = e.toString());
},
);
}
} catch (e) {
if (mounted) setState(() => _streamError = e.toString());
} finally {
if (mounted) setState(() => _startingStreams = false);
}
}
String _formatDay(DateTime day) {
final m = day.month.toString().padLeft(2, '0');
final d = day.day.toString().padLeft(2, '0');
return '${day.year}-$m-$d';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Pedometer Pro')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
(defaultTargetPlatform == TargetPlatform.android)
? 'Android history comes from Health Connect (Samsung Health / Google Fit / other apps). '
'Galaxy phones: install Health Connect, open Samsung Health → Settings → Health Connect, '
'and allow this app to read steps. '
'play-services-fitness Local Recording is only this app\'s own log after subscribe, '
'not Samsung Health. If Health Connect is empty, today falls back to TYPE_STEP_COUNTER '
'(steps since last boot).'
: 'iOS: getStepCount reads CoreMotion history (about 7 days).',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _requestPermission,
child: const Text('Request permission'),
),
const SizedBox(height: 8),
Text('Permission: ${_permission?.name ?? 'not requested'}'),
const Divider(height: 32),
Text(
'History (getStepCount)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed:
!_loadingRange &&
((defaultTargetPlatform == TargetPlatform.android) ||
_granted)
? _loadHistory
: null,
child: Text('Load history (today + last $_historyDays days)'),
),
const SizedBox(height: 8),
if (_loadingRange) const LinearProgressIndicator(),
if (_rangeError != null)
Text(_rangeError!, style: const TextStyle(color: Colors.red))
else ...[
Text('Today: ${_todaySteps ?? '-'}'),
Text('Last $_historyDays days: ${_historyTotal ?? '-'}'),
if (_historySource != null) Text('Source: $_historySource'),
if (_daily.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Per day', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
for (final entry in _daily)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(width: 120, child: Text(_formatDay(entry.day))),
Text(
'${entry.result.steps} (${entry.result.source.name})',
),
],
),
),
],
],
const Divider(height: 32),
Text('Live streams', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _granted && !_startingStreams ? _listenStreams : null,
child: const Text('Start live streams'),
),
const SizedBox(height: 8),
Text(_streamError ?? 'Steps since boot: ${_streamSteps ?? '-'}'),
if ((defaultTargetPlatform == TargetPlatform.iOS))
Text('Steps from today (stream): ${_streamFromToday ?? '-'}'),
Text(_statusError ?? 'Pedestrian status: ${_status?.name ?? '-'}'),
if (_permission == PermissionStatus.denied ||
_permission == PermissionStatus.permanentlyDenied)
TextButton(
onPressed: openAppSettings,
child: const Text('Open app settings'),
),
],
),
);
}
}