В рамках разрабатываемого мной приложения я хочу добавить интерактивные занятия для iOS в раздел приложения, где пользователь может выбрать «Ходьба», «Бег» или «Велоспорт». В настоящее время, когда пользователь нажимает «Старт», он отслеживает его маршрут, расстояние и прошедшее время и сохраняет их, как только он нажимает «Стоп». Чего я хочу добиться, так это иметь живую активность, показывающую пройденное расстояние и затраченное время, которые часто обновляются.
Я создаю для iOS 16.6+
Я добавил эти ключи в Info.plist:
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)"
}
}
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(),
],
),
),
);
}
}
Когда я нажимаю «Пуск» и блокирую экран, ничего не происходит и никакие действия в реальном времени не отображаются. Я не уверен, что я пропустил? В консоли ошибок не вижу, а сама функция gps работает нормально и сохраняет корректно, просто живой активности нет. Это моя пятая попытка, и я продолжаю сдаваться и возвращаться назад, но мне бы очень хотелось, наконец, разобраться во всем этом.
Кто-нибудь может сказать мне, где я ошибся?
В рамках разрабатываемого мной приложения я хочу добавить интерактивные занятия для iOS в раздел приложения, где пользователь может выбрать «Ходьба», «Бег» или «Велоспорт». В настоящее время, когда пользователь нажимает «Старт», он отслеживает его маршрут, расстояние и прошедшее время и сохраняет их, как только он нажимает «Стоп». Чего я хочу добиться, так это иметь живую активность, показывающую пройденное расстояние и затраченное время, которые часто обновляются. Я создаю для iOS 16.6+ Я добавил эти ключи в Info.plist: [code]NSSupportsLiveActivities
NSSupportsLiveActivitiesFrequentUpdates
[/code] Я создал расширение виджета в Xcode с группами приложений и получил следующие быстрые файлы: GPSLiveActivityLiveActivity (целевое членство: виджет) [code]import ActivityKit import WidgetKit import SwiftUI
// MARK: - Live Activity Widget
struct GPSLiveActivityLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in
// 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]) } }
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() } } } [/code] На стороне дротика у меня есть эти файлы: gps_live_activity_service: [code]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;
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 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;
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;
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(), ], ), ), ); } } [/code] Когда я нажимаю «Пуск» и блокирую экран, ничего не происходит и никакие действия в реальном времени не отображаются. Я не уверен, что я пропустил? В консоли ошибок не вижу, а сама функция gps работает нормально и сохраняет корректно, просто живой активности нет. Это моя пятая попытка, и я продолжаю сдаваться и возвращаться назад, но мне бы очень хотелось, наконец, разобраться во всем этом. Кто-нибудь может сказать мне, где я ошибся?