diff --git a/assets/changelog.json b/assets/changelog.json index 7222127..b95fb08 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -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", diff --git a/lib/app_state.dart b/lib/app_state.dart index 63aaf90..20f25fa 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -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(); } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 317926f..dfdddcb 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -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) diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 4c9f8a0..bce6105 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -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...", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 512ce9c..e19c068 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -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", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 5a791f1..483cd4e 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -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", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index ff912fb..18f7799 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -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", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 6ea97fe..5059d5c 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -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", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index ba61d42..3fd4b6a 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -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", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index ca22db8..69945ab 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -37,6 +37,11 @@ "dontShowAgain": "不再显示此指南", "gotIt": "明白了!" }, + "positioningTutorial": { + "title": "精确定位", + "instructions": "拖动地图将设备标记精确定位在监控设备的位置上。", + "hint": "您可以在定位前放大地图以获得更高的精度。" + }, "actions": { "tagNode": "新建节点", "download": "下载", diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index 8f83bc9..244fc19 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -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? _changelogData; bool _initialized = false; @@ -82,6 +83,18 @@ class ChangelogService { await prefs.setBool(_hasSeenSubmissionGuideKey, true); } + /// Check if user has completed the positioning tutorial + Future hasCompletedPositioningTutorial() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_hasCompletedPositioningTutorialKey) ?? false; + } + + /// Mark that user has completed the positioning tutorial + Future markPositioningTutorialCompleted() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hasCompletedPositioningTutorialKey, true); + } + /// Check if app version has changed since last launch Future hasVersionChanged() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index a519188..19c0364 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -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 createState() => _AddNodeSheetState(); +} + +class _AddNodeSheetState extends State { + bool _showTutorial = false; + bool _isCheckingTutorial = true; + + @override + void initState() { + super.initState(); + _checkTutorialStatus(); + } + + Future _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.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().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(), + ), + ], ); }, ); diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 6d069c8..e708cc7 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -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 createState() => _EditNodeSheetState(); +} + +class _EditNodeSheetState extends State { + bool _showTutorial = false; + bool _isCheckingTutorial = true; + + @override + void initState() { + super.initState(); + _checkTutorialStatus(); + } + + Future _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.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().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), ); } } \ No newline at end of file diff --git a/lib/widgets/positioning_tutorial_overlay.dart b/lib/widgets/positioning_tutorial_overlay.dart new file mode 100644 index 0000000..20475cb --- /dev/null +++ b/lib/widgets/positioning_tutorial_overlay.dart @@ -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, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index bc17b56..f7a9502 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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+