As part of an app I am developing, I want to add live activities for iOS for a section of the app where a user can select Walk, Run, or Cycle. Currently when the user taps start, it tracks their route, distance, and time elapsed and saves that once they tap stop. What I want to achieve is to have a live activity showing their distance travelled and time elapsed that are frequently updated.
I’m building for iOS 16.6+
I’ve added these keys to Info.plist:
NSSupportsLiveActivities
NSSupportsLiveActivitiesFrequentUpdates
I created a widget extension in Xcode with App Groups and have these swift files:
GPSLiveActivityLiveActivity (target membership: Widget)
import ActivityKit
import WidgetKit
import SwiftUI
// MARK: - Live Activity Widget
struct GPSLiveActivityLiveActivity: 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) -> SharedState {
let sharedDefaults = UserDefaults(suiteName: context.attributes.appGroupId)
let type = sharedDefaults?.string(forKey: context.attributes.prefixedKey("activityType")) ?? "GPS"
// Read as strings because Dart writes strings reliably
let elapsedStr = sharedDefaults?.string(forKey: context.attributes.prefixedKey("elapsedSec")) ?? "0"
let distanceStr = sharedDefaults?.string(forKey: context.attributes.prefixedKey("distanceKm")) ?? "0"
let speedStr = sharedDefaults?.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: - Views
private struct LockScreenLiveActivityView: View {
let context: ActivityViewContext
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)
// Helpful sanity text while debugging:
Text("id: \(context.attributes.id)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
private struct ExpandedLeadingView: View {
let context: ActivityViewContext
var body: some View {
let state = readSharedState(context)
Text(state.activityType).font(.headline)
}
}
private struct ExpandedTrailingView: View {
let context: ActivityViewContext
var body: some View {
let state = readSharedState(context)
Text(formatElapsed(state.elapsedSec))
.font(.headline.monospacedDigit())
}
}
private struct ExpandedBottomView: View {
let context: ActivityViewContext
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
var body: some View {
let state = readSharedState(context)
Text(String(state.activityType.prefix(1))).font(.headline)
}
}
private struct CompactTrailingView: View {
let context: ActivityViewContext
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")
}
}
// MARK: - Debug widget (optional)
struct GPSLiveActivityDebugWidget: Widget {
let kind: String = "GPSLiveActivityDebugWidget"
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) -> Void) {
completion(Timeline(entries: [GPSDebugEntry(date: Date())], policy: .never))
}
}
private struct GPSDebugEntry: TimelineEntry {
let date: Date
}
GPSLiveActivityBundle (target membership: Widget)
import WidgetKit
import SwiftUI
@main
struct GPSLiveActivityBundle: WidgetBundle {
var body: some Widget {
GPSLiveActivityLiveActivity()
GPSLiveActivityDebugWidget()
}
}
LiveActivitiesAppAttributes (target membership: Runner and Widget)
import ActivityKit
// MUST be exactly this name for the plugin:
struct LiveActivitiesAppAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// required by ActivityKit even if you don't use it
var dummy: String = ""
}
// The id you pass from Dart createActivity(attributesId, ...)
var id: String
// The appGroupId you pass in plugin.init(appGroupId: ...)
var appGroupId: String
func prefixedKey(_ key: String) -> String {
"\(id)_\(key)"
}
}
AppDelegate:
import UIKit
import Flutter
import UserNotifications
import AVFAudio
import GoogleMaps
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UIApplication.shared.ignoreSnapshotOnNextApplicationLaunch()
// Set up audio playback category
do {
try AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .default,
options: [.mixWithOthers]
)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Failed to set audio session category.")
}
// Register Google Maps API key
GMSServices.provideAPIKey("REDACTED")
// Ensure notification taps are delivered to Flutter
UNUserNotificationCenter.current().delegate = self
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func applicationWillResignActive(_ application: UIApplication) {
// Add a white view over the window before backgrounding
let whiteView = UIView(frame: window?.bounds ?? .zero)
whiteView.backgroundColor = UIColor.white
whiteView.tag = 999 // So we can remove it later
window?.addSubview(whiteView)
}
override func applicationDidBecomeActive(_ application: UIApplication) {
// Remove the white view when app becomes active
if let whiteView = window?.viewWithTag(999) {
whiteView.removeFromSuperview()
}
}
}
On the dart side I have these files:
gps_live_activity_service:
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:live_activities/live_activities.dart';
import 'package:uuid/uuid.dart';
class GpsLiveActivityService {
GpsLiveActivityService._();
static final GpsLiveActivityService instance = GpsLiveActivityService._();
static const String _appGroupId = 'group.com.stefanosai.pebbl'; // update if you changed it
final LiveActivities _plugin = LiveActivities();
bool _inited = false;
/// The id YOU pass into createActivity (attributes.id in Swift)
String? _attributesId;
/// The id returned by createActivity (THIS is what update/end must use)
String? _activityId;
Future _ensureInit() async {
if (!Platform.isIOS || _inited) return;
await _plugin.init(appGroupId: _appGroupId);
_inited = true;
debugPrint('[LiveActivity] init ok appGroupId=$_appGroupId');
}
Future _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 start({required String activityType}) async {
if (!await _isSupportedAndEnabled()) return;
// Optional, but helps prevent stale ids during testing
await _plugin.endAllActivities();
final attributesId = const Uuid().v4();
_attributesId = attributesId;
debugPrint('[LiveActivity] creating attributesId=$attributesId');
try {
// IMPORTANT: store the RETURNED activity id for update/end.
final returnedActivityId = await _plugin.createActivity(
attributesId,
{
// keep strings if your Swift is reading strings from UserDefaults
'activityType': activityType,
'elapsedSec': '0',
'distanceKm': '0',
'speedKmh': '0',
},
removeWhenAppIsKilled: true,
);
debugPrint('[LiveActivity] create returned activityId=$returnedActivityId');
// Some versions return null; so we resolve from getAllActivitiesIds as fallback.
final ids = await _plugin.getAllActivitiesIds();
debugPrint('[LiveActivity] ids after create = $ids');
final resolved = (returnedActivityId != null && returnedActivityId.isNotEmpty)
? returnedActivityId
: (ids.isNotEmpty ? ids.first : null);
_activityId = resolved;
debugPrint('[LiveActivity] resolved activityId=$_activityId attributesId=$_attributesId');
} catch (e) {
debugPrint('[LiveActivity] create exception: $e');
}
}
Future update({
required int elapsedSec,
required double distanceKm,
required double speedKmh,
String activityType="GPS",
}) async {
if (!Platform.isIOS) return;
await _ensureInit();
final activityId = _activityId;
if (activityId == null) {
debugPrint('[LiveActivity] update skipped: no activityId cached');
return;
}
try {
await _plugin.updateActivity(
activityId, // MUST be the returned/resolved activityId
{
'activityType': activityType,
'elapsedSec': elapsedSec.toString(),
'distanceKm': distanceKm.toStringAsFixed(3),
'speedKmh': speedKmh.toStringAsFixed(2),
},
);
debugPrint('[LiveActivity] updated activityId=$activityId');
} catch (e) {
debugPrint('[LiveActivity] update exception: $e');
}
}
Future end() async {
if (!Platform.isIOS) return;
await _ensureInit();
final activityId = _activityId;
if (activityId == null) return;
try {
await _plugin.endActivity(activityId);
debugPrint('[LiveActivity] ended activityId=$activityId');
} catch (e) {
debugPrint('[LiveActivity] end exception: $e');
} finally {
_activityId = null;
_attributesId = null;
}
}
}
gps_tracking_entry_ios_screen:
import 'package:pebbl/l10n/app_localizations.dart';
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';
class GpsTrackingEntryIosScreen extends StatefulWidget {
const GpsTrackingEntryIosScreen({super.key});
@override
State createState() =>
_GpsTrackingEntryIosScreenState();
}
class _GpsTrackingEntryIosScreenState extends State {
final List _types = ['Walk', 'Run', 'Cycle'];
String _selectedType="Walk";
bool _isTracking = false;
DateTime? _startTime;
Timer? _timer;
Duration _elapsed = Duration.zero;
double _distanceKm = 0.0;
final List _positions = [];
StreamSubscription? _positionStream;
DateTime? _lastLiveActivityUpdate;
static const int _liveActivityUpdateEverySeconds = 10;
// Live map state
GoogleMapController? _mapController;
final List _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 _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 _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)
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 _stopTracking() async {
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('gps_activities')
: await Hive.openBox('gps_activities');
await box.add(entry);
if (!mounted) return;
FeedbackService.showSnackBar(context, 'GPS activity saved!');
Navigator.pop(context, true);
setState(() {
_isTracking = false;
_timer = null;
});
}
Future _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(
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(
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>(
(type) => DropdownMenuItem(
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(),
],
),
),
);
}
}
When I tap start and lock the screen, nothing appears to happen, and no live activities appear. I’m unsure what I have missed? I don’t see any errors in console, and the gps function itself works fine and saves correctly, there’s just no live activity. This is my 5th attempt at it and I keep giving up and reverting back, but I’d really like to finally get this sorted.
Is anyone able to tell me where I have gone wrong?