positioning tutorial

This commit is contained in:
stopflock committed 2025-12-11 16:01:45 -06:00
1 parent eca227032d
commit 656dbc8ce8
15 files changed
+365 -10

No files matched your search

+6
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": { "2.1.0": {
"content": [ "content": [
"• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags", "• Profile tag refinement system - any profile tag with an empty value now shows a dropdown in refine tags",
+54
View File
@@ -56,6 +56,10 @@ class AppState extends ChangeNotifier {
late final UploadQueueState _uploadQueueState; late final UploadQueueState _uploadQueueState;
bool _isInitialized = false; bool _isInitialized = false;
// Positioning tutorial state
LatLng? _tutorialStartPosition; // Track where the tutorial started
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
Timer? _messageCheckTimer; Timer? _messageCheckTimer;
AppState() { AppState() {
@@ -437,6 +441,11 @@ class AppState extends ChangeNotifier {
target: target, target: target,
refinedTags: refinedTags, refinedTags: refinedTags,
); );
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
} }
void updateEditSession({ void updateEditSession({
@@ -455,6 +464,11 @@ class AppState extends ChangeNotifier {
extractFromWay: extractFromWay, extractFromWay: extractFromWay,
refinedTags: refinedTags, refinedTags: refinedTags,
); );
// Check tutorial completion if position changed
if (target != null) {
_checkTutorialCompletion(target);
}
} }
// For map view to check for pending snap backs // For map view to check for pending snap backs
@@ -462,6 +476,46 @@ class AppState extends ChangeNotifier {
return _sessionState.consumePendingSnapBack(); 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() { void addDirection() {
_sessionState.addDirection(); _sessionState.addDirection();
} }
+4
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) // 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 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 // Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points 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) const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
+5
View File
@@ -446,6 +446,11 @@
"dontShowAgain": "Diese Anleitung nicht mehr anzeigen", "dontShowAgain": "Diese Anleitung nicht mehr anzeigen",
"gotIt": "Verstanden!" "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": { "navigation": {
"searchLocation": "Ort suchen", "searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...", "searchPlaceholder": "Orte oder Koordinaten suchen...",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "Don't show this guide again", "dontShowAgain": "Don't show this guide again",
"gotIt": "Got It!" "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": { "actions": {
"tagNode": "New Node", "tagNode": "New Node",
"download": "Download", "download": "Download",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "No mostrar esta guía otra vez", "dontShowAgain": "No mostrar esta guía otra vez",
"gotIt": "¡Entendido!" "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": { "actions": {
"tagNode": "Nuevo Nodo", "tagNode": "Nuevo Nodo",
"download": "Descargar", "download": "Descargar",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "Ne plus afficher ce guide", "dontShowAgain": "Ne plus afficher ce guide",
"gotIt": "Compris !" "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": { "actions": {
"tagNode": "Nouveau Nœud", "tagNode": "Nouveau Nœud",
"download": "Télécharger", "download": "Télécharger",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "Non mostrare più questa guida", "dontShowAgain": "Non mostrare più questa guida",
"gotIt": "Capito!" "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": { "actions": {
"tagNode": "Nuovo Nodo", "tagNode": "Nuovo Nodo",
"download": "Scarica", "download": "Scarica",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "Não mostrar este guia novamente", "dontShowAgain": "Não mostrar este guia novamente",
"gotIt": "Entendi!" "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": { "actions": {
"tagNode": "Novo Nó", "tagNode": "Novo Nó",
"download": "Baixar", "download": "Baixar",
+5
View File
@@ -37,6 +37,11 @@
"dontShowAgain": "不再显示此指南", "dontShowAgain": "不再显示此指南",
"gotIt": "明白了!" "gotIt": "明白了!"
}, },
"positioningTutorial": {
"title": "精确定位",
"instructions": "拖动地图将设备标记精确定位在监控设备的位置上。",
"hint": "您可以在定位前放大地图以获得更高的精度。"
},
"actions": { "actions": {
"tagNode": "新建节点", "tagNode": "新建节点",
"download": "下载", "download": "下载",
+13
View File
@@ -16,6 +16,7 @@ class ChangelogService {
static const String _lastSeenVersionKey = 'last_seen_version'; static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome'; static const String _hasSeenWelcomeKey = 'has_seen_welcome';
static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide'; static const String _hasSeenSubmissionGuideKey = 'has_seen_submission_guide';
static const String _hasCompletedPositioningTutorialKey = 'has_completed_positioning_tutorial';
Map<String, dynamic>? _changelogData; Map<String, dynamic>? _changelogData;
bool _initialized = false; bool _initialized = false;
@@ -82,6 +83,18 @@ class ChangelogService {
await prefs.setBool(_hasSeenSubmissionGuideKey, true); 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 /// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async { Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
+88 -4
View File
@@ -11,12 +11,83 @@ import '../services/changelog_service.dart';
import 'refine_tags_sheet.dart'; import 'refine_tags_sheet.dart';
import 'proximity_warning_dialog.dart'; import 'proximity_warning_dialog.dart';
import 'submission_guide_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}); const AddNodeSheet({super.key, required this.session});
final AddNodeSession 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) { void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService); _checkSubmissionGuideAndProceed(context, appState, locService);
} }
@@ -40,14 +111,14 @@ class AddNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) { void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Only check proximity if we have a target location // Only check proximity if we have a target location
if (session.target == null) { if (widget.session.target == null) {
_commitWithoutCheck(context, appState, locService); _commitWithoutCheck(context, appState, locService);
return; return;
} }
// Check for nearby nodes within the configured distance // Check for nearby nodes within the configured distance
final nearbyNodes = NodeCache.instance.findNodesWithinDistance( final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target!, widget.session.target!,
kNodeProximityWarningDistance, kNodeProximityWarningDistance,
); );
@@ -220,6 +291,7 @@ class AddNodeSheet extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
} }
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn && final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty && 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -374,6 +450,14 @@ class AddNodeSheet extends StatelessWidget {
), ),
const SizedBox(height: 20), 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(),
),
],
); );
}, },
); );
+72 -5
View File
@@ -13,12 +13,66 @@ import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart'; import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart'; import 'proximity_warning_dialog.dart';
import 'submission_guide_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}); const EditNodeSheet({super.key, required this.session});
final EditNodeSession 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) { void _checkProximityAndCommit(BuildContext context, AppState appState, LocalizationService locService) {
_checkSubmissionGuideAndProceed(context, appState, locService); _checkSubmissionGuideAndProceed(context, appState, locService);
} }
@@ -43,9 +97,9 @@ class EditNodeSheet extends StatelessWidget {
void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) { void _checkProximityOnly(BuildContext context, AppState appState, LocalizationService locService) {
// Check for nearby nodes within the configured distance, excluding the node being edited // Check for nearby nodes within the configured distance, excluding the node being edited
final nearbyNodes = NodeCache.instance.findNodesWithinDistance( final nearbyNodes = NodeCache.instance.findNodesWithinDistance(
session.target, widget.session.target,
kNodeProximityWarningDistance, kNodeProximityWarningDistance,
excludeNodeId: session.originalNode.id, excludeNodeId: widget.session.originalNode.id,
); );
if (nearbyNodes.isNotEmpty) { if (nearbyNodes.isNotEmpty) {
@@ -217,6 +271,7 @@ class EditNodeSheet extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
} }
final session = widget.session;
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList(); final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox; final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = kEnableNodeEdits && 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -447,6 +506,14 @@ class EditNodeSheet extends StatelessWidget {
), ),
const SizedBox(height: 20), 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( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode), builder: (context) => AdvancedEditOptionsSheet(node: widget.session.originalNode),
); );
} }
} }
@@ -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,
),
],
),
),
),
),
),
);
},
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: deflockapp name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none" 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: environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+ sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+