positioning tutorial

This commit is contained in:
stopflock
2025-12-11 16:01:45 -06:00
parent eca227032d
commit 656dbc8ce8
15 changed files with 365 additions and 10 deletions

View File

@@ -1,4 +1,10 @@
{
"2.1.2": {
"content": [
"• New positioning tutorial - first-time users must drag the map to refine location when creating or editing nodes, helping ensure accurate positioning",
"• Tutorial automatically dismisses after moving the map at least 1 meter and never shows again"
]
},
"2.1.0": {
"content": [
"• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags",

View File

@@ -56,6 +56,10 @@ class AppState extends ChangeNotifier {
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
// Positioning tutorial state
LatLng? _tutorialStartPosition; // Track where the tutorial started
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
Timer? _messageCheckTimer;
AppState() {
@@ -437,6 +441,11 @@ class AppState extends ChangeNotifier {
target: target,
refinedTags: refinedTags,
);
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
}
void updateEditSession({
@@ -455,6 +464,11 @@ class AppState extends ChangeNotifier {
extractFromWay: extractFromWay,
refinedTags: refinedTags,
);
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
}
// For map view to check for pending snap backs
@@ -462,6 +476,46 @@ class AppState extends ChangeNotifier {
return _sessionState.consumePendingSnapBack();
}
// Positioning tutorial methods
void registerTutorialCallback(VoidCallback onComplete) {
debugPrint('[AppState] Registering tutorial callback');
_tutorialCompletionCallback = onComplete;
// Record the starting position when tutorial begins
if (session?.target != null) {
_tutorialStartPosition = session!.target;
debugPrint('[AppState] Tutorial start position (add): ${_tutorialStartPosition}');
} else if (editSession?.target != null) {
_tutorialStartPosition = editSession!.target;
debugPrint('[AppState] Tutorial start position (edit): ${_tutorialStartPosition}');
}
}
void clearTutorialCallback() {
_tutorialCompletionCallback = null;
_tutorialStartPosition = null;
}
void _checkTutorialCompletion(LatLng newPosition) {
if (_tutorialCompletionCallback == null || _tutorialStartPosition == null) return;
// Calculate distance moved
final distance = Distance();
final distanceMoved = distance.as(LengthUnit.Meter, _tutorialStartPosition!, newPosition);
debugPrint('[AppState] Tutorial movement check: ${distanceMoved.toStringAsFixed(2)}m (need ${kPositioningTutorialMinMovementMeters}m)');
if (distanceMoved >= kPositioningTutorialMinMovementMeters) {
debugPrint('[AppState] Tutorial completed! Calling callback and marking as complete');
// Tutorial completed! Mark as complete and notify callback immediately
final callback = _tutorialCompletionCallback;
clearTutorialCallback();
callback?.call();
// Mark as complete in background (don't await to avoid delays)
ChangelogService().markPositioningTutorialCompleted();
}
}
void addDirection() {
_sessionState.addDirection();
}

View File

@@ -126,6 +126,10 @@ const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown betw
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
// Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movement to complete tutorial
// Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)

View File

@@ -446,6 +446,11 @@
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
"gotIt": "Verstanden!"
},
"positioningTutorial": {
"title": "Position verfeinern",
"instructions": "Ziehen Sie die Karte, um die Geräte-Markierung präzise über dem Standort des Überwachungsgeräts zu positionieren.",
"hint": "Sie können für bessere Genauigkeit vor der Positionierung hineinzoomen."
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Don't show this guide again",
"gotIt": "Got It!"
},
"positioningTutorial": {
"title": "Refine Your Location",
"instructions": "Drag the map to position the device marker precisely over the surveillance device's location.",
"hint": "You can zoom in for better accuracy before positioning."
},
"actions": {
"tagNode": "New Node",
"download": "Download",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "No mostrar esta guía otra vez",
"gotIt": "¡Entendido!"
},
"positioningTutorial": {
"title": "Refinar Ubicación",
"instructions": "Arrastra el mapa para posicionar el marcador del dispositivo con precisión sobre la ubicación del dispositivo de vigilancia.",
"hint": "Puedes acercar el zoom para obtener mejor precisión antes de posicionar."
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Ne plus afficher ce guide",
"gotIt": "Compris !"
},
"positioningTutorial": {
"title": "Affiner la Position",
"instructions": "Faites glisser la carte pour positionner le marqueur de l'appareil précisément au-dessus de l'emplacement du dispositif de surveillance.",
"hint": "Vous pouvez zoomer pour une meilleure précision avant de positionner."
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Non mostrare più questa guida",
"gotIt": "Capito!"
},
"positioningTutorial": {
"title": "Affinare la Posizione",
"instructions": "Trascina la mappa per posizionare il marcatore del dispositivo precisamente sopra la posizione del dispositivo di sorveglianza.",
"hint": "Puoi ingrandire per una maggiore precisione prima di posizionare."
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "Não mostrar este guia novamente",
"gotIt": "Entendi!"
},
"positioningTutorial": {
"title": "Refinar Posição",
"instructions": "Arraste o mapa para posicionar o marcador do dispositivo precisamente sobre a localização do dispositivo de vigilância.",
"hint": "Você pode aumentar o zoom para melhor precisão antes de posicionar."
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",

View File

@@ -37,6 +37,11 @@
"dontShowAgain": "不再显示此指南",
"gotIt": "明白了!"
},
"positioningTutorial": {
"title": "精确定位",
"instructions": "拖动地图将设备标记精确定位在监控设备的位置上。",
"hint": "您可以在定位前放大地图以获得更高的精度。"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",

View File

@@ -16,6 +16,7 @@ class ChangelogService {
static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
static const String _hasCompletedPositioningTutorialKey = 'has_completed_positioning_tutorial';
Map<String, dynamic>? _changelogData;
bool _initialized = false;
@@ -82,6 +83,18 @@ class ChangelogService {
await prefs.setBool(_hasSeenSubmissionGuideKey, true);
}
/// Check if user has completed the positioning tutorial
Future<bool> hasCompletedPositioningTutorial() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_hasCompletedPositioningTutorialKey) ?? false;
}
/// Mark that user has completed the positioning tutorial
Future<void> markPositioningTutorialCompleted() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hasCompletedPositioningTutorialKey, true);
}
/// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -11,12 +11,83 @@ import '../services/changelog_service.dart';
import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
import 'positioning_tutorial_overlay.dart';
class AddNodeSheet extends StatelessWidget {
class AddNodeSheet extends StatefulWidget {
const AddNodeSheet({super.key, required this.session});
final AddNodeSession session;
@override
State<AddNodeSheet> createState() => _AddNodeSheetState();
}
class _AddNodeSheetState extends State<AddNodeSheet> {
bool _showTutorial = false;
bool _isCheckingTutorial = true;
@override
void initState() {
super.initState();
_checkTutorialStatus();
}
Future<void> _checkTutorialStatus() async {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (mounted) {
setState(() {
_showTutorial = !hasCompleted;
_isCheckingTutorial = false;
});
// If tutorial should be shown, register callback with AppState
if (_showTutorial) {
final appState = context.read<AppState>();
appState.registerTutorialCallback(_hideTutorial);
}
}
}
/// Listen for tutorial completion from AppState
void _onTutorialCompleted() {
_hideTutorial();
}
/// Also check periodically if tutorial was completed by another sheet
void _recheckTutorialStatus() async {
if (_showTutorial) {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (hasCompleted && mounted) {
setState(() {
_showTutorial = false;
});
}
}
}
void _hideTutorial() {
debugPrint('[AddNodeSheet] Tutorial completion callback triggered');
if (mounted && _showTutorial) {
debugPrint('[AddNodeSheet] Hiding tutorial overlay');
setState(() {
_showTutorial = false;
});
}
}
@override
void dispose() {
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
context.read<AppState>().clearTutorialCallback();
} catch (e) {
// Context might be unavailable during disposal, ignore
}
}
super.dispose();
}
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
@@ -40,14 +111,14 @@ class AddNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Only check proximity if we have a target location
if (session.target == null) {
if (widget.session.target == null) {
_commitWithoutCheck(context, appState, locService);
return;
}
// Check for nearby nodes within the configured distance
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target!,
widget.session.target!,
kNodeProximityWarningDistance,
);
@@ -220,6 +291,7 @@ class AddNodeSheet extends StatelessWidget {
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
@@ -246,7 +318,11 @@ class AddNodeSheet extends StatelessWidget {
}
}
return Column(
return Stack(
clipBehavior: Clip.none,
fit: StackFit.loose,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -374,6 +450,14 @@ class AddNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
),
],
);
},
);

View File

@@ -13,12 +13,66 @@ import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
import 'submission_guide_dialog.dart';
import 'positioning_tutorial_overlay.dart';
class EditNodeSheet extends StatelessWidget {
class EditNodeSheet extends StatefulWidget {
const EditNodeSheet({super.key, required this.session});
final EditNodeSession session;
@override
State<EditNodeSheet> createState() => _EditNodeSheetState();
}
class _EditNodeSheetState extends State<EditNodeSheet> {
bool _showTutorial = false;
bool _isCheckingTutorial = true;
@override
void initState() {
super.initState();
_checkTutorialStatus();
}
Future<void> _checkTutorialStatus() async {
final hasCompleted = await ChangelogService().hasCompletedPositioningTutorial();
if (mounted) {
setState(() {
_showTutorial = !hasCompleted;
_isCheckingTutorial = false;
});
// If tutorial should be shown, register callback with AppState
if (_showTutorial) {
final appState = context.read<AppState>();
appState.registerTutorialCallback(_hideTutorial);
}
}
}
void _hideTutorial() {
debugPrint('[EditNodeSheet] Tutorial completion callback triggered');
if (mounted && _showTutorial) {
debugPrint('[EditNodeSheet] Hiding tutorial overlay');
setState(() {
_showTutorial = false;
});
}
}
@override
void dispose() {
// Clear tutorial callback when widget is disposed
if (_showTutorial) {
try {
context.read<AppState>().clearTutorialCallback();
} catch (e) {
// Context might be unavailable during disposal, ignore
}
}
super.dispose();
}
void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService);
}
@@ -43,9 +97,9 @@ class EditNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target,
widget.session.target,
kNodeProximityWarningDistance,
excludeNodeId: session.originalNode.id,
excludeNodeId: widget.session.originalNode.id,
);
if (nearbyNodes.isNotEmpty) {
@@ -217,6 +271,7 @@ class EditNodeSheet extends StatelessWidget {
Navigator.pop(context);
}
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = kEnableNodeEdits &&
@@ -245,7 +300,11 @@ class EditNodeSheet extends StatelessWidget {
}
}
return Column(
return Stack(
clipBehavior: Clip.none,
fit: StackFit.loose,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -447,6 +506,14 @@ class EditNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
),
],
);
},
);
@@ -456,7 +523,7 @@ class EditNodeSheet extends StatelessWidget {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
/// Overlay that appears over add/edit node sheets to guide users through
/// the positioning tutorial. Shows a blurred background with tutorial text.
class PositioningTutorialOverlay extends StatelessWidget {
const PositioningTutorialOverlay({
super.key,
this.onFadeOutComplete,
});
/// Called when the fade-out animation completes (if animated)
final VoidCallback? onFadeOutComplete;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: kPositioningTutorialBlurSigma,
sigmaY: kPositioningTutorialBlurSigma,
),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3), // Semi-transparent overlay
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tutorial icon
Icon(
Icons.pan_tool_outlined,
size: 48,
color: Colors.white,
),
const SizedBox(height: 16),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
);
},
);
}
}

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.1.1+34 # The thing after the + is the version code, incremented with each release
version: 2.1.2+35 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+