Hi everyone, I’m a relative newbie to flutter, and I am really struggling with getting my Live Activities to update on device. They display ok on the Lock Screen.
Part of my app has GPS activity tracking - ie a user can select Walk, Run or Cycle and Start a session which records/tracks their location, distance and time elapsed.
On the clock screen after I start the session, it just shows 0.00 for distance and time elapsed, and they never update.
I’m using live_activities: ^2.4.7, and have the App groups added to both the Live Activity widget and Runner, and both Info.plist have the NSSupportsLiveActivities key set to true.
I’m unsure if the issue is on the dart side or widget side.
My swift files are as follows:
LiveActivityBundle.swift
import WidgetKit
import SwiftUI
@main
struct LiveActivityBundle: WidgetBundle {
var body: some Widget {
LiveActivityLiveActivity()
LiveActivityDebugWidget()
}
}
LiveActivitiesAppAttributes.swift
import Foundation
import ActivityKit
// Must be EXACTLY this name for the plugin.
struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable {
public typealias LiveDeliveryData = ContentState
public struct ContentState: Codable, Hashable { }
var id = UUID()
}
extension LiveActivitiesAppAttributes {
func prefixedKey(_ key: String) -> String {
return "\(id)_\(key)"
}
}
LiveActivityLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI
// Use the SAME app group as Runner + Widget Extension capabilities.
private let sharedDefault = UserDefaults(suiteName: "group.com.stefanosai.pebbl")!
struct LiveActivityLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in
LockScreenLiveActivityView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
ExpandedLeadingView(context: context)
}
DynamicIslandExpandedRegion(.trailing) {
ExpandedTrailingView(context: context)
}
DynamicIslandExpandedRegion(.bottom) {
ExpandedBottomView(context: context)
}
} compactLeading: {
CompactLeadingView(context: context)
} compactTrailing: {
CompactTrailingView(context: context)
} minimal: {
MinimalView()
}
}
}
}
// MARK: - Shared reading helpers
private struct SharedState {
let activityType: String
let elapsedSec: Int
let distanceKm: Double
let speedKmh: Double
}
private func readSharedState(
_ context: ActivityViewContext<LiveActivitiesAppAttributes>
) -> SharedState {
let type = sharedDefault.string(
forKey: context.attributes.prefixedKey("activityType")
) ?? "GPS"
let elapsedStr = sharedDefault.string(
forKey: context.attributes.prefixedKey("elapsedSec")
) ?? "0"
let distanceStr = sharedDefault.string(
forKey: context.attributes.prefixedKey("distanceKm")
) ?? "0"
let speedStr = sharedDefault.string(
forKey: context.attributes.prefixedKey("speedKmh")
) ?? "0"
return SharedState(
activityType: type,
elapsedSec: Int(elapsedStr) ?? 0,
distanceKm: Double(distanceStr) ?? 0.0,
speedKmh: Double(speedStr) ?? 0.0
)
}
private func formatElapsed(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
return "\(mins):" + String(format: "%02d", secs)
}
// MARK: - Main lock screen view
private struct LockScreenLiveActivityView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
VStack(alignment: .leading, spacing: 8) {
Text("Pebblmed • \(state.activityType)")
.font(.headline)
HStack {
Text("Time: \(formatElapsed(state.elapsedSec))")
Spacer()
Text(String(format: "%.2f km", state.distanceKm))
}
.font(.subheadline.monospacedDigit())
Text(String(format: "Speed: %.1f km/h", state.speedKmh))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.activityBackgroundTint(Color(.systemBackground))
.activitySystemActionForegroundColor(.primary)
}
}
// MARK: - Dynamic Island views
private struct ExpandedLeadingView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
Text(state.activityType)
.font(.headline)
}
}
private struct ExpandedTrailingView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
Text(formatElapsed(state.elapsedSec))
.font(.headline.monospacedDigit())
}
}
private struct ExpandedBottomView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
HStack {
Text(String(format: "%.2f km", state.distanceKm))
Spacer()
Text(String(format: "%.1f km/h", state.speedKmh))
}
.font(.subheadline.monospacedDigit())
}
}
private struct CompactLeadingView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
Text(String(state.activityType.prefix(1)))
.font(.headline)
}
}
private struct CompactTrailingView: View {
let context: ActivityViewContext<LiveActivitiesAppAttributes>
var body: some View {
let state = readSharedState(context)
Text(formatElapsed(state.elapsedSec))
.font(.headline.monospacedDigit())
}
}
private struct MinimalView: View {
var body: some View {
Image(systemName: "location.fill")
}
}
struct LiveActivityDebugWidget: Widget {
let kind: String = "LiveActivityDebugWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: GPSDebugProvider()) { _ in
Text("Pebblmed Widget OK")
.padding()
}
.configurationDisplayName("Pebblmed Debug")
.description("Confirms widget extension is installed.")
.supportedFamilies([.systemSmall])
}
}
private struct GPSDebugProvider: TimelineProvider {
func placeholder(in context: Context) -> GPSDebugEntry {
GPSDebugEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (GPSDebugEntry) -> Void) {
completion(GPSDebugEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<GPSDebugEntry>) -> Void) {
completion(
Timeline(
entries: [GPSDebugEntry(date: Date())],
policy: .never
)
)
}
}
private struct GPSDebugEntry: TimelineEntry {
let date: Date
}
Dart side files:
gps_live_activity_service
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:live_activities/live_activities.dart';
import 'app_file_logger.dart';
class GpsLiveActivityService {
GpsLiveActivityService._();
static final GpsLiveActivityService instance = GpsLiveActivityService._();
static const String _appGroupId = 'group.com.stefanosai.pebbl';
final LiveActivities _plugin = LiveActivities();
bool _inited = false;
String? _activityId;
Future<void> _log(String message) async {
await AppFileLogger.instance.log('LiveActivity', message);
}
Future<void> _ensureInit() async {
if (!Platform.isIOS || _inited) return;
await _log('init start appGroupId=$_appGroupId');
await _plugin.init(appGroupId: _appGroupId);
_inited = true;
await _log('init ok appGroupId=$_appGroupId');
}
Future<bool> _isSupportedAndEnabled() async {
if (!Platform.isIOS) return false;
await _ensureInit();
final supported = await _plugin.areActivitiesSupported();
final enabled = await _plugin.areActivitiesEnabled();
debugPrint('[LiveActivity] supported=$supported enabled=$enabled');
return supported && enabled;
}
Future<void> start({required String activityType}) async {
if (!await _isSupportedAndEnabled()) {
await _log('start skipped: unsupported or disabled');
return;
}
try {
await _log('start requested activityType=$activityType');
await _plugin.endAllActivities();
await _log('endAllActivities completed before start');
final requestedId = DateTime.now().millisecondsSinceEpoch.toString();
final nowStamp = DateTime.now().millisecondsSinceEpoch.toString();
final payload = <String, dynamic>{
'activityType': activityType,
'elapsedSec': '0',
'distanceKm': '0',
'speedKmh': '0',
'updatedAt': nowStamp,
};
await _log(
'createActivity requested requestedId=$requestedId payload=$payload',
);
final activityId = await _plugin.createActivity(
requestedId,
payload,
removeWhenAppIsKilled: true,
);
_activityId = activityId ?? requestedId;
await _log('created activityId=$_activityId');
// NEW: check state immediately after create
try {
final createdId = _activityId;
if (createdId != null) {
final state = await _plugin.getActivityState(createdId);
await _log('post-create state activityId=$createdId state=$state');
} else {
await _log('post-create state skipped: activityId is null');
}
} catch (e, st) {
await _log('post-create state check failed error=$e\n$st');
}
// OPTIONAL EXTRA: list all known activities
try {
final all = await _plugin.getAllActivities();
await _log('post-create all activities=$all');
} catch (e, st) {
await _log('post-create getAllActivities failed error=$e\n$st');
}
} catch (e, st) {
await _log('start exception=$e\n$st');
}
}
Future<void> update({
required int elapsedSec,
required double distanceKm,
required double speedKmh,
String activityType = 'GPS',
}) async {
if (!Platform.isIOS) {
await _log('update skipped: not iOS');
return;
}
await _ensureInit();
final activityId = _activityId;
if (activityId == null) {
await _log('update skipped: no activityId cached');
return;
}
final payload = <String, dynamic>{
'activityType': activityType,
'elapsedSec': elapsedSec.toString(),
'distanceKm': distanceKm.toStringAsFixed(3),
'speedKmh': speedKmh.toStringAsFixed(2),
'updatedAt': DateTime.now().millisecondsSinceEpoch.toString(),
};
try {
// NEW: check state immediately before update
try {
final state = await _plugin.getActivityState(activityId);
await _log('pre-update state activityId=$activityId state=$state');
} catch (e, st) {
await _log('pre-update state check failed activityId=$activityId error=$e\n$st');
}
await _log(
'update requested activityId=$activityId '
'elapsed=$elapsedSec distance=${distanceKm.toStringAsFixed(3)} '
'speed=${speedKmh.toStringAsFixed(2)} payload=$payload',
);
await _plugin.updateActivity(activityId, payload);
await _log(
'update success activityId=$activityId '
'elapsed=$elapsedSec distance=${distanceKm.toStringAsFixed(3)} '
'speed=${speedKmh.toStringAsFixed(2)}',
);
} catch (e, st) {
await _log('update exception activityId=$activityId error=$e\n$st');
}
}
Future<void> end() async {
if (!Platform.isIOS) {
await _log('end skipped: not iOS');
return;
}
await _ensureInit();
final activityId = _activityId;
if (activityId == null) {
await _log('end skipped: no activityId cached');
return;
}
try {
await _log('end requested activityId=$activityId');
await _plugin.endActivity(activityId);
await _log('end success activityId=$activityId');
} catch (e, st) {
await _log('end exception activityId=$activityId error=$e\n$st');
} finally {
_activityId = null;
await _log('activityId cache cleared');
}
}
}
And the UI screen
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:pebbl/widgets/styled_dropdown_nullable.dart';
import 'package:uuid/uuid.dart';
import 'package:hive/hive.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:pebbl/models/gps_activity_entry.dart';
import 'package:pebbl/services/feedback_service.dart';
import 'package:pebbl/widgets/styled_elevated_button.dart';
import 'package:pebbl/services/gps_live_activity_service.dart';
import '../../../l10n/app_localizations.dart';
class GpsTrackingEntryIosScreen extends StatefulWidget {
const GpsTrackingEntryIosScreen({super.key});
@override
State<GpsTrackingEntryIosScreen> createState() =>
_GpsTrackingEntryIosScreenState();
}
class _GpsTrackingEntryIosScreenState extends State<GpsTrackingEntryIosScreen> {
final List<String> _types = ['Walk', 'Run', 'Cycle'];
String _selectedType = 'Walk';
bool _isTracking = false;
DateTime? _startTime;
Timer? _timer;
Duration _elapsed = Duration.zero;
double _distanceKm = 0.0;
final List<Position> _positions = [];
StreamSubscription<Position>? _positionStream;
DateTime? _lastLiveActivityUpdate;
static const int _liveActivityUpdateEverySeconds = 2;
// Live map state
GoogleMapController? _mapController;
final List<LatLng> _routePoints = [];
LatLng? _currentLatLng;
bool _followUser = true;
bool _isProgrammaticMove = false;
// Throttling / smoothing
static const double _minAddPointMeters = 3.0; // only add points if moved ~3m+
DateTime? _lastUiPointAdd;
static const int _minUiPointAddMs = 400; // don’t redraw polylines too fast
double _getMetValue(String type) {
switch (type) {
case 'Walk':
return 3.5;
case 'Run':
return 7.0;
case 'Cycle':
return 6.0;
default:
return 4.0;
}
}
@override
void dispose() {
_timer?.cancel();
_positionStream?.cancel();
_mapController?.dispose();
if (_isTracking) {
GpsLiveActivityService.instance.end();
}
super.dispose();
}
Future<void> _maybeUpdateLiveActivity() async {
if (!_isTracking || _startTime == null) return;
final now = DateTime.now();
final last = _lastLiveActivityUpdate;
if (last != null &&
now.difference(last).inSeconds < _liveActivityUpdateEverySeconds) {
return;
}
final elapsedSec = now.difference(_startTime!).inSeconds;
final durationHours = elapsedSec / 3600.0;
final speedKmh = durationHours > 0 ? _distanceKm / durationHours : 0.0;
await GpsLiveActivityService.instance.update(
elapsedSec: elapsedSec,
distanceKm: _distanceKm,
speedKmh: speedKmh,
);
_lastLiveActivityUpdate = now;
}
Future<void> _startTracking() async {
final permission = await Geolocator.checkPermission();
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (permission == LocationPermission.denied || !serviceEnabled) {
await Geolocator.requestPermission();
if (!mounted) return;
FeedbackService.showSnackBar(
context, 'Location permission is required.');
return;
}
setState(() {
_isTracking = true;
_startTime = DateTime.now();
_elapsed = Duration.zero;
_distanceKm = 0.0;
_positions.clear();
_routePoints.clear();
_currentLatLng = null;
_followUser = true;
_isProgrammaticMove = false;
_lastUiPointAdd = null;
});
// Start Live Activity (iOS only)
_lastLiveActivityUpdate = null;
await GpsLiveActivityService.instance.start(activityType: _selectedType);
_lastLiveActivityUpdate = DateTime.now();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
_elapsed = DateTime.now().difference(_startTime!);
});
});
final LocationSettings settings = Platform.isAndroid
? AndroidSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 5,
intervalDuration: const Duration(seconds: 3),
)
: AppleSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 5,
activityType: ActivityType.fitness,
pauseLocationUpdatesAutomatically: false,
);
_positionStream =
Geolocator.getPositionStream(locationSettings: settings).listen(
(position) async {
if (!mounted || !_isTracking) return;
// distance
if (_positions.isNotEmpty) {
final last = _positions.last;
final segmentMeters = Geolocator.distanceBetween(
last.latitude,
last.longitude,
position.latitude,
position.longitude,
);
_distanceKm += segmentMeters / 1000.0;
}
_positions.add(position);
// ✅ Update Live Activity (throttled)
await _maybeUpdateLiveActivity();
// map points (throttled + minimum movement)
final now = DateTime.now();
final newPoint = LatLng(position.latitude, position.longitude);
bool shouldAdd = false;
if (_routePoints.isEmpty) {
shouldAdd = true;
} else {
final lastPoint = _routePoints.last;
final movedMeters = Geolocator.distanceBetween(
lastPoint.latitude,
lastPoint.longitude,
newPoint.latitude,
newPoint.longitude,
);
if (movedMeters >= _minAddPointMeters) {
shouldAdd = true;
}
}
final uiOk = _lastUiPointAdd == null
? true
: now.difference(_lastUiPointAdd!).inMilliseconds >= _minUiPointAddMs;
if (shouldAdd && uiOk) {
_lastUiPointAdd = now;
setState(() {
_currentLatLng = newPoint;
_routePoints.add(newPoint);
});
if (_followUser) {
_animateTo(newPoint);
}
} else {
// still update current location (marker) occasionally even if not adding polyline point
if (_currentLatLng == null ||
now.second % 2 == 0) { // lightweight cadence
setState(() {
_currentLatLng = newPoint;
});
}
if (_followUser) {
_animateTo(newPoint);
}
}
},
);
}
Future<void> _stopTracking() async {
final l10n = AppLocalizations.of(context);
await GpsLiveActivityService.instance.end();
_lastLiveActivityUpdate = null;
_timer?.cancel();
await _positionStream?.cancel();
_positionStream = null;
final endTime = DateTime.now();
final path =
_positions.map((pos) => {'lat': pos.latitude, 'lng': pos.longitude}).toList();
final durationHours = _elapsed.inSeconds / 3600;
final avSpeedKmh = durationHours > 0 ? _distanceKm / durationHours : 0.0;
final met = _getMetValue(_selectedType);
final userWeight = 70.0; // Replace with actual profile weight if available
final calories = met * userWeight * durationHours;
final uid = FirebaseAuth.instance.currentUser?.uid ?? '';
final entry = GpsActivityEntry(
id: const Uuid().v4(),
type: _selectedType,
startTime: _startTime!,
endTime: endTime,
distanceKm: _distanceKm,
path: path,
uid: uid,
avSpeedKmh: avSpeedKmh,
calories: calories,
);
final box = Hive.isBoxOpen('gps_activities')
? Hive.box<GpsActivityEntry>('gps_activities')
: await Hive.openBox<GpsActivityEntry>('gps_activities');
await box.add(entry);
if (!mounted) return;
FeedbackService.showSnackBar(context, l10n.gpsIOSSnack);
Navigator.pop(context, true);
setState(() {
_isTracking = false;
_timer = null;
});
}
Future<void> _animateTo(LatLng target) async {
if (_mapController == null) return;
_isProgrammaticMove = true;
try {
// keep a pleasant zoom; don’t fight user zoom if they changed it
await _mapController!.animateCamera(
CameraUpdate.newLatLng(target),
);
} catch (_) {
// ignore
} finally {
// small delay so onCameraMoveStarted doesn’t immediately flip followUser
Future.delayed(const Duration(milliseconds: 250), () {
_isProgrammaticMove = false;
});
}
}
Widget _buildPermissionWarning() {
return FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done &&
snapshot.data != LocationPermission.always) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: TextButton(
onPressed: Geolocator.openAppSettings,
child: const Text(
'Location permission is not set to "Always". Tap here to update.',
style: TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
return const SizedBox.shrink();
},
);
}
Widget _buildLiveMap() {
if (!_isTracking) return const SizedBox.shrink();
// if we don’t have a first fix yet, show a placeholder
if (_currentLatLng == null) {
return Container(
height: 280,
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black12),
),
child: const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Waiting for GPS signal…'),
],
),
),
),
);
}
final polyline = Polyline(
polylineId: const PolylineId('live_route'),
points: _routePoints.isEmpty ? [_currentLatLng!] : List.of(_routePoints),
width: 5,
);
final markers = <Marker>{
Marker(
markerId: const MarkerId('current'),
position: _currentLatLng!,
),
};
return Container(
height: 280,
margin: const EdgeInsets.only(top: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black12),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
GoogleMap(
initialCameraPosition: CameraPosition(
target: _currentLatLng!,
zoom: 17,
),
myLocationEnabled: false, // we use our own marker
myLocationButtonEnabled: false,
compassEnabled: true,
zoomControlsEnabled: false,
markers: markers,
polylines: {polyline},
onMapCreated: (c) {
_mapController = c;
},
onCameraMoveStarted: () {
// If user drags/zooms, stop following
if (!_isProgrammaticMove) {
setState(() => _followUser = false);
}
},
),
Positioned(
right: 10,
top: 10,
child: Column(
children: [
if (!_followUser)
ElevatedButton.icon(
onPressed: () {
setState(() => _followUser = true);
_animateTo(_currentLatLng!);
},
icon: const Icon(Icons.my_location, size: 18),
label: const Text('Recenter'),
),
],
),
),
],
),
);
}
String _formatElapsed(Duration d) {
final mins = d.inMinutes;
final secs = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$mins:$secs';
}
@override
Widget build(BuildContext context) {
// If you want localization here later, swap these hard-coded strings for AppLocalizations keys.
return Scaffold(
appBar: AppBar(title: const Text('GPS Activity')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
StyledDropdownNullable(
value: _selectedType,
items: _types
.map<DropdownMenuItem<String>>(
(type) => DropdownMenuItem<String>(
value: type,
child: Text(type),
),
)
.toList(),
hintText: 'Activity Type',
onChanged: _isTracking
? null
: (String? value) {
if (value != null) {
setState(() => _selectedType = value);
}
},
),
const SizedBox(height: 16),
if (_isTracking) ...[
Text('Time Elapsed: ${_formatElapsed(_elapsed)}'),
Text('Distance: ${_distanceKm.toStringAsFixed(2)} km'),
_buildLiveMap(),
const SizedBox(height: 16),
StyledElevatedButton(
label: 'Stop',
type: StyledButtonType.delete,
onPressed: _stopTracking,
),
] else ...[
StyledElevatedButton(
label: 'Start',
type: StyledButtonType.save,
onPressed: _startTracking,
),
],
_buildPermissionWarning(),
],
),
),
);
}
}
What am I missing? I’ve been trying to get this working for weeks, and really feel out of my depth, I’ve even tried using ChatGPT out of desperation but that didn’t help haha.
I would be eternally grateful if someone could help me sort it out. Thanks!