mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-11 06:22:17 +02:00
Compare commits
42 Commits
v2.7.1-rel
...
fix/node-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5eca8a6b5 | ||
|
|
4d1032e56d | ||
|
|
834861bcaf | ||
|
|
ba80b88595 | ||
|
|
ebb7fd090f | ||
|
|
fe401cc04b | ||
|
|
de65cecc6a | ||
|
|
122b303378 | ||
|
|
91e5177056 | ||
|
|
f3f40f36ef | ||
|
|
2d92214bed | ||
|
|
be446fbcbc | ||
|
|
5728b4f70f | ||
|
|
aeb1903bbc | ||
|
|
57df8e83a7 | ||
|
|
bc671c4efe | ||
|
|
4941c2726d | ||
|
|
b56e9325b3 | ||
|
|
30f546be29 | ||
|
|
dc817e5eb7 | ||
|
|
e1cca2f503 | ||
|
|
abd8682b49 | ||
|
|
90a806a10d | ||
|
|
b6bcd23667 | ||
|
|
dba375c63d | ||
|
|
6c52541361 | ||
|
|
fcf7ff7a98 | ||
|
|
206b3afe9d | ||
|
|
e72d557d2a | ||
|
|
25a34aab0b | ||
|
|
610c5c71b1 | ||
|
|
8983939b05 | ||
|
|
9448305738 | ||
|
|
775148cfb7 | ||
|
|
0137fd66aa | ||
|
|
fe20356734 | ||
|
|
14d7c10ca6 | ||
|
|
348256270d | ||
|
|
8a759e88e9 | ||
|
|
e168b6e19c | ||
|
|
9ae7d9c894 | ||
|
|
97675f9f48 |
141
.github/workflows/pr.yml
vendored
141
.github/workflows/pr.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
@@ -24,3 +25,143 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
build-debug-apk:
|
||||
name: Build Debug APK
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: build/app/outputs/flutter-apk/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
build-ios-simulator:
|
||||
name: Build iOS Simulator
|
||||
runs-on: macos-26
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
|
||||
- name: Build iOS simulator app
|
||||
run: flutter build ios --debug --simulator
|
||||
|
||||
- name: Zip Runner.app
|
||||
run: cd build/ios/iphonesimulator && zip -r "$GITHUB_WORKSPACE/Runner.app.zip" Runner.app
|
||||
|
||||
- name: Upload simulator build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-simulator
|
||||
path: Runner.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
comment-artifacts:
|
||||
name: Post Artifact Links
|
||||
needs: [build-debug-apk, build-ios-simulator]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !cancelled()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
env:
|
||||
APK_RESULT: ${{ needs.build-debug-apk.result }}
|
||||
IOS_RESULT: ${{ needs.build-ios-simulator.result }}
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}#artifacts`;
|
||||
const apkOk = process.env.APK_RESULT === 'success';
|
||||
const iosOk = process.env.IOS_RESULT === 'success';
|
||||
|
||||
const lines = ['## Debug builds', ''];
|
||||
|
||||
if (apkOk || iosOk) {
|
||||
lines.push(`Download from the [artifacts page](${runUrl}):`);
|
||||
if (apkOk) lines.push('- **debug-apk** — install on Android device/emulator');
|
||||
if (iosOk) lines.push('- **ios-simulator** — unzip and install with `xcrun simctl install booted Runner.app`');
|
||||
}
|
||||
|
||||
if (!apkOk || !iosOk) {
|
||||
const failed = [];
|
||||
if (!apkOk) failed.push('Android APK');
|
||||
if (!iosOk) failed.push('iOS simulator');
|
||||
lines.push('', `**Failed:** ${failed.join(', ')} — check the [workflow run](${runUrl}) for details.`);
|
||||
}
|
||||
|
||||
const body = lines.join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.startsWith('## Debug builds'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
4
.github/workflows/workflow.yml
vendored
4
.github/workflows/workflow.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
build-ios:
|
||||
name: Build iOS
|
||||
needs: get-version
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
upload-to-stores:
|
||||
name: Upload to App Stores
|
||||
needs: [get-version, build-android-aab, build-ios]
|
||||
runs-on: macos-latest # Need macOS for iOS uploads
|
||||
runs-on: macos-26 # Need macOS for iOS uploads
|
||||
if: needs.get-version.outputs.should_upload_to_stores == 'true'
|
||||
steps:
|
||||
- name: Download AAB artifact for Google Play
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -73,12 +73,13 @@ fuchsia/build/
|
||||
web/build/
|
||||
|
||||
# ───────────────────────────────
|
||||
# IDE / Editor Settings
|
||||
# IDE / Editor / AI Tool Settings
|
||||
# ───────────────────────────────
|
||||
.idea/
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
# Swap files
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
11
COMMENT
Normal file
11
COMMENT
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
An alternative approach to addressing this issue could be adjusting the `optionsBuilder` logic to avoid returning any suggestions when the input text field is empty, rather than guarding `onFieldSubmitted`. For instance:
|
||||
|
||||
```dart
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text.isEmpty) return <String>[];
|
||||
return suggestions.where((s) => s.contains(textEditingValue.text));
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that the `RawAutocomplete` widget doesn't offer any options to auto-select on submission when the field is cleared, potentially simplifying the implementation and avoiding the need for additional boolean flags (`guardOnSubmitted`). This pattern can be seen in some implementations "in the wild."
|
||||
@@ -17,28 +17,27 @@ if (keystorePropertiesFile.exists()) {
|
||||
android {
|
||||
namespace = "me.deflock.deflockapp"
|
||||
|
||||
// Matches current stable Flutter (compileSdk 34 as of July 2025)
|
||||
compileSdk = 36
|
||||
|
||||
// NDK only needed if you build native plugins; keep your pinned version
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
minSdk = maxOf(flutter.minSdkVersion, 23)
|
||||
targetSdk = 36
|
||||
|
||||
@@ -76,6 +75,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
{
|
||||
"2.9.1": {
|
||||
"content": [
|
||||
"• When hitting node render limit, only render nodes closest to center of viewport."
|
||||
]
|
||||
},
|
||||
"2.9.0": {
|
||||
"content": [
|
||||
"• Caching, tile retries, offline areas, now working properly. Map imagery should load correctly."
|
||||
]
|
||||
},
|
||||
"2.8.1": {
|
||||
"content": [
|
||||
"• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission",
|
||||
"• Added drag handles so profiles can be reordered to customize dropdown order when submitting"
|
||||
]
|
||||
},
|
||||
"2.8.0": {
|
||||
"content": [
|
||||
"• Update dependencies and build chain tools; no code changes"
|
||||
]
|
||||
},
|
||||
"2.7.2": {
|
||||
"content": [
|
||||
"• Now following OSM UserAgent guidelines"
|
||||
]
|
||||
},
|
||||
"2.7.1": {
|
||||
"content": [
|
||||
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles",
|
||||
|
||||
@@ -28,7 +28,7 @@ read_from_file() {
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(cat "$file"; echo)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'services/http_client.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -335,29 +335,32 @@ class AppState extends ChangeNotifier {
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
if (accessToken == null) return false;
|
||||
|
||||
final client = UserAgentClient();
|
||||
try {
|
||||
// Try to fetch user details - this should include message data if scope is correct
|
||||
final response = await http.get(
|
||||
final response = await client.get(
|
||||
Uri.parse('${_getApiHost()}/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
|
||||
if (response.statusCode == 403) {
|
||||
// Forbidden - likely missing scope
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final messages = data['user']?['messages'];
|
||||
// If messages field is missing, we might not have the right scope
|
||||
return messages == null;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// On error, assume no re-auth needed to avoid annoying users
|
||||
return false;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +407,10 @@ class AppState extends ChangeNotifier {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
_profileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
const String kContactEmail = 'admin@stopflock.com';
|
||||
const String kHomepageUrl = 'https://deflock.org';
|
||||
|
||||
// Upload and changeset configuration
|
||||
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
|
||||
@@ -153,15 +155,6 @@ const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom
|
||||
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
|
||||
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
|
||||
|
||||
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
|
||||
const int kTileFetchConcurrentThreads = 8; // Reduced from 10 to 8 for better cross-platform performance
|
||||
const int kTileFetchInitialDelayMs = 150; // Reduced from 200ms for faster retries
|
||||
const double kTileFetchBackoffMultiplier = 1.4; // Slightly reduced for faster recovery
|
||||
const int kTileFetchMaxDelayMs = 4000; // Reduced from 5000ms for faster max retry
|
||||
const int kTileFetchRandomJitterMs = 50; // Reduced jitter for more predictable timing
|
||||
const int kTileFetchMaxQueueSize = 100; // Reasonable queue size to prevent memory bloat
|
||||
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// OpenStreetMap OAuth client IDs for this app.
|
||||
// These must be provided via --dart-define at build time.
|
||||
|
||||
/// Whether OSM OAuth secrets were provided at build time.
|
||||
/// When false, the app should force simulate mode.
|
||||
bool get kHasOsmSecrets {
|
||||
const prod = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
return prod.isNotEmpty && sandbox.isNotEmpty;
|
||||
}
|
||||
|
||||
String get kOsmProdClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
|
||||
String get kOsmSandboxClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
@@ -144,7 +144,10 @@
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}",
|
||||
"offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).",
|
||||
"currentTileProvider": "aktuelle Kachel",
|
||||
"noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
@@ -292,13 +295,16 @@
|
||||
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
|
||||
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
|
||||
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten",
|
||||
"attribution": "Karten-Zuschreibung"
|
||||
"attribution": "Karten-Zuschreibung",
|
||||
"mapAttribution": "Kartenquelle: {}",
|
||||
"couldNotOpenLink": "Link konnte nicht geöffnet werden",
|
||||
"openLicense": "Lizenz öffnen: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
@@ -325,7 +331,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen",
|
||||
"neverFetched": "Nie abgerufen",
|
||||
"daysAgo": "vor {} Tagen",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"minutesAgo": "vor {} Minuten",
|
||||
"justNow": "Gerade eben"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Verdächtiger Standort #{}",
|
||||
"ticketNo": "Ticket-Nr.",
|
||||
"address": "Adresse",
|
||||
"street": "Straße",
|
||||
"street": "Straße",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland",
|
||||
"intersectingStreet": "Kreuzende Straße",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"submitAnyway": "Submit Anyway",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Device",
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
"downloadFailed": "Failed to start download: {}",
|
||||
"offlineNotPermitted": "The {} server does not permit offline downloads. Switch to a tile provider that allows offline use (e.g., Bing Maps, Mapbox, or a self-hosted tile server).",
|
||||
"currentTileProvider": "current tile",
|
||||
"noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "How would you like to add a profile?",
|
||||
"createCustomProfile": "Create Custom Profile",
|
||||
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers",
|
||||
"attribution": "Map Attribution"
|
||||
"attribution": "Map Attribution",
|
||||
"mapAttribution": "Map attribution: {}",
|
||||
"couldNotOpenLink": "Could not open link",
|
||||
"openLicense": "Open license: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
@@ -443,7 +449,7 @@
|
||||
"mobileEditors": "Mobile Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Full-featured web editor - always works",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Advanced Android OSM editor",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Failed to update suspected locations",
|
||||
"neverFetched": "Never fetched",
|
||||
"daysAgo": "{} days ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"justNow": "Just now"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Suspected Location #{}",
|
||||
"ticketNo": "Ticket No",
|
||||
"address": "Address",
|
||||
"street": "Street",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"intersectingStreet": "Intersecting Street",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
"downloadFailed": "Error al iniciar la descarga: {}",
|
||||
"offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).",
|
||||
"currentTileProvider": "mosaico actual",
|
||||
"noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
|
||||
"createCustomProfile": "Crear Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores",
|
||||
"attribution": "Atribución del Mapa"
|
||||
"attribution": "Atribución del Mapa",
|
||||
"mapAttribution": "Atribución del mapa: {}",
|
||||
"couldNotOpenLink": "No se pudo abrir el enlace",
|
||||
"openLicense": "Abrir licencia: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Error al actualizar ubicaciones sospechosas",
|
||||
"neverFetched": "Nunca obtenido",
|
||||
"daysAgo": "hace {} días",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"minutesAgo": "hace {} minutos",
|
||||
"justNow": "Ahora mismo"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Ubicación Sospechosa #{}",
|
||||
"ticketNo": "No. de Ticket",
|
||||
"address": "Dirección",
|
||||
"street": "Calle",
|
||||
"street": "Calle",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Calle que Intersecta",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}",
|
||||
"offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).",
|
||||
"currentTileProvider": "tuile actuelle",
|
||||
"noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
|
||||
"createCustomProfile": "Créer Profil Personnalisé",
|
||||
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs",
|
||||
"attribution": "Attribution de Carte"
|
||||
"attribution": "Attribution de Carte",
|
||||
"mapAttribution": "Attribution de la carte : {}",
|
||||
"couldNotOpenLink": "Impossible d'ouvrir le lien",
|
||||
"openLicense": "Ouvrir la licence : {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Échec de la mise à jour des emplacements suspects",
|
||||
"neverFetched": "Jamais récupéré",
|
||||
"daysAgo": "il y a {} jours",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"minutesAgo": "il y a {} minutes",
|
||||
"justNow": "À l'instant"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Emplacement Suspect #{}",
|
||||
"ticketNo": "N° de Ticket",
|
||||
"address": "Adresse",
|
||||
"street": "Rue",
|
||||
"street": "Rue",
|
||||
"city": "Ville",
|
||||
"state": "État",
|
||||
"intersectingStreet": "Rue Transversale",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
"downloadFailed": "Impossibile avviare il download: {}",
|
||||
"offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).",
|
||||
"currentTileProvider": "tile attuale",
|
||||
"noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
|
||||
"createCustomProfile": "Crea Profilo Personalizzato",
|
||||
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
"manageProviders": "Gestisci Fornitori",
|
||||
"attribution": "Attribuzione Mappa"
|
||||
"attribution": "Attribuzione Mappa",
|
||||
"mapAttribution": "Attribuzione mappa: {}",
|
||||
"couldNotOpenLink": "Impossibile aprire il link",
|
||||
"openLicense": "Apri licenza: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Visualizza Profilo",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"operatorName": "Nome operatore",
|
||||
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Aggiornamento posizioni sospette fallito",
|
||||
"neverFetched": "Mai recuperato",
|
||||
"daysAgo": "{} giorni fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"minutesAgo": "{} minuti fa",
|
||||
"justNow": "Proprio ora"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Posizione Sospetta #{}",
|
||||
"ticketNo": "N. Ticket",
|
||||
"address": "Indirizzo",
|
||||
"street": "Via",
|
||||
"street": "Via",
|
||||
"city": "Città",
|
||||
"state": "Stato",
|
||||
"intersectingStreet": "Via che Interseca",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
561
lib/localizations/nl.json
Normal file
561
lib/localizations/nl.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Nederlands"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Surveillance Transparantie",
|
||||
"description": "DeFlock is een privacy-gerichte mobiele app voor het in kaart brengen van openbare surveillance infrastructuur met behulp van OpenStreetMap. Documenteer camera's, ALPR's, schot detectoren en andere surveillance apparaten in uw gemeenschap om deze infrastructuur zichtbaar en doorzoekbaar te maken.",
|
||||
"features": "• Offline-capabel karteren met downloadbare gebieden\n• Direct uploaden naar OpenStreetMap met OAuth2\n• Ingebouwde profielen voor grote fabrikanten\n• Privacy-respecterend - geen gebruikersgegevens verzameld\n• Meerdere kaart tile providers (OSM, satellietbeelden)",
|
||||
"initiative": "Onderdeel van het bredere DeFlock initiatief om surveillance transparantie te bevorderen.",
|
||||
"footer": "Bezoek: deflock.me\nGebouwd met Flutter • Open Source",
|
||||
"showWelcome": "Toon Welkomstbericht",
|
||||
"showSubmissionGuide": "Toon Inzendingsgids",
|
||||
"viewReleaseNotes": "Bekijk Release Notes"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welkom bij DeFlock",
|
||||
"description": "DeFlock werd opgericht op het idee dat openbare surveillance tools transparant zouden moeten zijn. Binnen deze mobiele app, net zoals op de website, zult u de locatie van ALPR's en andere surveillance infrastructuur in uw lokale gebied en in het buitenland kunnen bekijken.",
|
||||
"mission": "Echter, dit project is niet geautomatiseerd; het vraagt ons allemaal om dit project beter te maken. Bij het bekijken van de kaart kunt u op \"Nieuwe Node\" tikken om een voorheen onbekende installatie toe te voegen. Met uw hulp kunnen we ons doel van verhoogde transparantie en publiek bewustzijn van surveillance infrastructuur bereiken.",
|
||||
"firsthandKnowledge": "BELANGRIJK: Draag alleen surveillance apparaten bij die u persoonlijk uit de eerste hand heeft waargenomen. OpenStreetMap en Google beleid verbiedt het gebruik van bronnen zoals Street View beelden voor inzendingen. Uw bijdragen moeten gebaseerd zijn op uw eigen directe waarnemingen.",
|
||||
"privacy": "Privacy Opmerking: Deze app draait volledig lokaal op uw apparaat en gebruikt de derde partij OpenStreetMap API voor gegevensopslag en inzendingen. DeFlock verzamelt of slaat geen gebruikersgegevens op van welke aard dan ook, en is niet verantwoordelijk voor accountbeheer.",
|
||||
"tileNote": "OPMERKING: De gratis kaart tiles van OpenStreetMap kunnen erg traag laden. Alternatieve tile providers kunnen geconfigureerd worden in Instellingen > Geavanceerd.",
|
||||
"moreInfo": "U kunt meer links vinden onder Instellingen > Over.",
|
||||
"dontShowAgain": "Toon dit welkomstbericht niet opnieuw",
|
||||
"getStarted": "Laten we beginnen met DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Inzending Beste Praktijken",
|
||||
"description": "Voordat u uw eerste surveillance apparaat inzendt, neem even de tijd om deze belangrijke richtlijnen door te nemen om hoogwaardige bijdragen aan OpenStreetMap te garanderen.",
|
||||
"bestPractices": "• Breng alleen apparaten in kaart die u persoonlijk uit de eerste hand heeft waargenomen\n• Neem de tijd om het apparaattype en fabrikant nauwkeurig te identificeren\n• Gebruik precieze positionering - zoom dichtbij voordat u de marker plaatst\n• Neem richtingsinformatie mee wanneer van toepassing\n• Controleer uw tag selecties dubbel voordat u inzendt",
|
||||
"placementNote": "Onthoud: Nauwkeurige, eerste hands gegevens zijn essentieel voor de DeFlock gemeenschap en OpenStreetMap project.",
|
||||
"moreInfo": "Voor gedetailleerde begeleiding bij apparaat identificatie en kartering beste praktijken:",
|
||||
"identificationGuide": "Identificatie Gids",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Toon deze gids niet opnieuw",
|
||||
"gotIt": "Begrepen!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Verfijn Uw Locatie",
|
||||
"instructions": "Sleep de kaart om de apparaat marker precies over de locatie van het surveillance apparaat te positioneren.",
|
||||
"hint": "U kunt inzoomen voor betere nauwkeurigheid voordat u positioneert."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nieuwe Node",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"cancel": "Annuleren",
|
||||
"ok": "OK",
|
||||
"close": "Sluiten",
|
||||
"submit": "Indienen",
|
||||
"logIn": "Inloggen",
|
||||
"saveEdit": "Bewerking Opslaan",
|
||||
"clear": "Wissen",
|
||||
"viewOnOSM": "Bekijk op OSM",
|
||||
"advanced": "Geavanceerd",
|
||||
"useAdvancedEditor": "Gebruik Geavanceerde Editor"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Node Zeer Dichtbij Bestaand Apparaat",
|
||||
"message": "Deze node is slechts {} meter van een bestaand surveillance apparaat.",
|
||||
"suggestion": "Als meerdere apparaten op dezelfde paal staan, gebruik dan meerdere richtingen op een enkele node in plaats van aparte nodes te creëren.",
|
||||
"nearbyNodes": "Nabijgelegen apparaat/apparaten gevonden ({}):",
|
||||
"nodeInfo": "Node #{} - {}",
|
||||
"andMore": "...en {} meer",
|
||||
"goBack": "Ga Terug",
|
||||
"submitAnyway": "Toch Indienen",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Openbare Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Apparaat",
|
||||
"unknown": "Onbekend Apparaat"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Schakel volg-me in",
|
||||
"follow": "Schakel volg-me in (roterend)",
|
||||
"rotating": "Schakel volg-me uit"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal & Regio",
|
||||
"systemDefault": "Systeem Standaard",
|
||||
"aboutInfo": "Over / Info",
|
||||
"aboutThisApp": "Over Deze App",
|
||||
"aboutSubtitle": "App informatie en credits",
|
||||
"languageSubtitle": "Kies uw voorkeurtaal en eenheden",
|
||||
"distanceUnit": "Afstand Eenheden",
|
||||
"distanceUnitSubtitle": "Kies tussen metrische (km/m) of imperiale (mijl/voet) eenheden",
|
||||
"metricUnits": "Metrisch (km, m)",
|
||||
"imperialUnits": "Imperiaal (mijl, voet)",
|
||||
"maxNodes": "Max getekende nodes",
|
||||
"maxNodesSubtitle": "Stel een bovengrens in voor het aantal nodes op de kaart.",
|
||||
"maxNodesWarning": "U wilt dit waarschijnlijk niet doen tenzij u absoluut zeker weet dat u daar een goede reden voor heeft.",
|
||||
"offlineMode": "Offline Modus",
|
||||
"offlineModeSubtitle": "Schakel alle netwerk verzoeken uit behalve voor lokale/offline gebieden.",
|
||||
"pauseQueueProcessing": "Pauzeer Upload Wachtrij",
|
||||
"pauseQueueProcessingSubtitle": "Stop het uploaden van wachtrij veranderingen terwijl live data toegang behouden blijft.",
|
||||
"offlineModeWarningTitle": "Actieve Downloads",
|
||||
"offlineModeWarningMessage": "Het inschakelen van offline modus zal alle actieve gebied downloads annuleren. Wilt u doorgaan?",
|
||||
"enableOfflineMode": "Schakel Offline Modus In",
|
||||
"profiles": "Profielen",
|
||||
"profilesSubtitle": "Beheer node en operator profielen",
|
||||
"offlineSettings": "Offline Instellingen",
|
||||
"offlineSettingsSubtitle": "Beheer offline modus en gedownloade gebieden",
|
||||
"advancedSettings": "Geavanceerde Instellingen",
|
||||
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
|
||||
"proximityAlerts": "Nabijheids Waarschuwingen",
|
||||
"networkStatusIndicator": "Netwerk Status Indicator"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
|
||||
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
|
||||
"notificationsEnabled": "✓ Meldingen ingeschakeld",
|
||||
"notificationsDisabled": "⚠ Meldingen uitgeschakeld",
|
||||
"permissionRequired": "Melding toestemming vereist",
|
||||
"permissionExplanation": "Push meldingen zijn uitgeschakeld. U ziet alleen in-app waarschuwingen en wordt niet gewaarschuwd wanneer de app op de achtergrond draait.",
|
||||
"enableNotifications": "Schakel Meldingen In",
|
||||
"checkingPermissions": "Toestemmingen controleren...",
|
||||
"alertDistance": "Waarschuwingsafstand: ",
|
||||
"rangeInfo": "Bereik: {}-{} {} (standaard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Apparaat Tags",
|
||||
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
|
||||
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
|
||||
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
|
||||
"confirmDeleteTitle": "Verwijder Node",
|
||||
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profiel",
|
||||
"selectProfile": "Selecteer een profiel...",
|
||||
"profileRequired": "Selecteer een profiel om door te gaan.",
|
||||
"direction": "Richting {}°",
|
||||
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
|
||||
"mustBeLoggedIn": "U moet ingelogd zijn om nieuwe nodes in te dienen. Log in via Instellingen.",
|
||||
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nieuwe nodes in te dienen.",
|
||||
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nieuwe nodes in te dienen.",
|
||||
"loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.",
|
||||
"refineTags": "Tags Verfijnen"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Bewerk Node #{}",
|
||||
"profile": "Profiel",
|
||||
"selectProfile": "Selecteer een profiel...",
|
||||
"profileRequired": "Selecteer een profiel om door te gaan.",
|
||||
"direction": "Richting {}°",
|
||||
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
|
||||
"temporarilyDisabled": "Bewerkingen zijn tijdelijk uitgeschakeld terwijl we een bug oplossen - excuses - controleer binnenkort opnieuw.",
|
||||
"mustBeLoggedIn": "U moet ingelogd zijn om nodes te bewerken. Log in via Instellingen.",
|
||||
"sandboxModeWarning": "Kan geen bewerkingen op productie nodes naar sandbox versturen. Schakel naar Productie modus in Instellingen om nodes te bewerken.",
|
||||
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nodes te bewerken.",
|
||||
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nodes te bewerken.",
|
||||
"loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.",
|
||||
"cannotMoveConstrainedNode": "Kan deze camera niet verplaatsen - het is verbonden met een ander kaart element (OSM weg/relatie). U kunt nog steeds de tags en richting bewerken.",
|
||||
"zoomInRequiredMessage": "Zoom in tot ten minste niveau {} om surveillance nodes toe te voegen of te bewerken. Dit zorgt voor precieze positionering voor nauwkeurige kartering.",
|
||||
"extractFromWay": "Haal node uit weg/relatie",
|
||||
"extractFromWaySubtitle": "Maak nieuwe node met dezelfde tags, sta verplaatsing naar nieuwe locatie toe",
|
||||
"refineTags": "Tags Verfijnen",
|
||||
"existingTags": "<Bestaande tags>",
|
||||
"noChangesDetected": "Geen wijzigingen gedetecteerd - niets om in te dienen",
|
||||
"noChangesTitle": "Geen Wijzigingen om In Te Dienen",
|
||||
"noChangesMessage": "U heeft geen wijzigingen gemaakt aan deze node. Om een bewerking in te dienen, moet u de locatie, profiel, richtingen of tags wijzigen."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Kaart Gebied",
|
||||
"maxZoomLevel": "Max zoom niveau",
|
||||
"storageEstimate": "Opslag schatting:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Min zoom:",
|
||||
"maxRecommendedZoom": "Max aanbevolen zoom: Z{}",
|
||||
"withinTileLimit": "Binnen {} tile limiet",
|
||||
"exceedsTileLimit": "Huidige selectie overschrijdt {} tile limiet",
|
||||
"offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.",
|
||||
"areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.",
|
||||
"downloadStarted": "Download gestart! Tiles en nodes ophalen...",
|
||||
"downloadFailed": "Download starten mislukt: {}",
|
||||
"offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).",
|
||||
"currentTileProvider": "huidige tegel",
|
||||
"noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Gestart",
|
||||
"message": "Download gestart! Tiles en nodes ophalen...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Bekijk Voortgang in Instellingen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Bestemming",
|
||||
"subtitle": "Kies waar camera's geüpload worden",
|
||||
"production": "Productie",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simuleren",
|
||||
"productionDescription": "Upload naar de live OSM database (zichtbaar voor alle gebruikers)",
|
||||
"sandboxDescription": "Uploads gaan naar de OSM Sandbox (veilig voor testen, wordt regelmatig gereset).",
|
||||
"simulateDescription": "Simuleer uploads (neemt geen contact op met OSM servers)",
|
||||
"cannotChangeWithQueue": "Kan upload bestemming niet wijzigen terwijl {} items in wachtrij staan. Wis eerst de wachtrij."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Account",
|
||||
"osmAccountSubtitle": "Beheer uw OSM login en bekijk uw bijdragen",
|
||||
"loggedInAs": "Ingelogd als {}",
|
||||
"loginToOSM": "Inloggen bij OpenStreetMap",
|
||||
"tapToLogout": "Tik om uit te loggen",
|
||||
"requiredToSubmit": "Vereist om camera gegevens in te dienen",
|
||||
"loggedOut": "Uitgelogd",
|
||||
"testConnection": "Test Verbinding",
|
||||
"testConnectionSubtitle": "Verifieer dat OSM credentials werken",
|
||||
"connectionOK": "Verbinding OK - credentials zijn geldig",
|
||||
"connectionFailed": "Verbinding mislukt - log alstublieft opnieuw in",
|
||||
"viewMyEdits": "Bekijk Mijn Bewerkingen op OSM",
|
||||
"viewMyEditsSubtitle": "Zie uw bewerkingsgeschiedenis op OpenStreetMap",
|
||||
"aboutOSM": "Over OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap is een collaboratief, open-source kaartproject waar bijdragers een gratis, bewerkbare kaart van de wereld maken en onderhouden. Uw surveillance apparaat bijdragen helpen deze infrastructuur zichtbaar en doorzoekbaar te maken.",
|
||||
"visitOSM": "Bezoek OpenStreetMap",
|
||||
"deleteAccount": "Verwijder OSM Account",
|
||||
"deleteAccountSubtitle": "Beheer uw OpenStreetMap account",
|
||||
"deleteAccountExplanation": "Om uw OpenStreetMap account te verwijderen, moet u de OpenStreetMap website bezoeken. Dit zal permanent uw OSM account en alle bijbehorende gegevens verwijderen.",
|
||||
"deleteAccountWarning": "Waarschuwing: Deze actie kan niet ongedaan worden gemaakt en zal permanent uw OSM account verwijderen.",
|
||||
"goToOSM": "Ga naar OpenStreetMap",
|
||||
"accountManagement": "Account Beheer",
|
||||
"accountManagementDescription": "Om uw OpenStreetMap account te verwijderen, moet u de juiste OpenStreetMap website bezoeken. Dit zal permanent uw account en alle bijbehorende gegevens verwijderen.",
|
||||
"currentDestinationProduction": "Momenteel verbonden met: Productie OpenStreetMap",
|
||||
"currentDestinationSandbox": "Momenteel verbonden met: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Momenteel in: Simuleer modus (geen echt account)",
|
||||
"viewMessages": "Bekijk Berichten op OSM",
|
||||
"unreadMessagesCount": "U heeft {} ongelezen berichten",
|
||||
"noUnreadMessages": "Geen ongelezen berichten",
|
||||
"reauthRequired": "Ververs Authenticatie",
|
||||
"reauthExplanation": "U moet uw authenticatie verversen om OSM bericht meldingen te ontvangen via de app.",
|
||||
"reauthBenefit": "Dit zal melding stippen inschakelen wanneer u ongelezen berichten heeft op OpenStreetMap.",
|
||||
"reauthNow": "Doe Dat Nu",
|
||||
"reauthLater": "Later"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload Wachtrij",
|
||||
"subtitle": "Beheer wachtende surveillance apparaat uploads",
|
||||
"pendingUploads": "Wachtende uploads: {}",
|
||||
"pendingItemsCount": "Wachtende Items: {}",
|
||||
"nothingInQueue": "Niets in wachtrij",
|
||||
"simulateModeEnabled": "Simuleer modus ingeschakeld – uploads gesimuleerd",
|
||||
"sandboxMode": "Sandbox modus – uploads gaan naar OSM Sandbox",
|
||||
"tapToViewQueue": "Tik om wachtrij te bekijken",
|
||||
"clearUploadQueue": "Wis Upload Wachtrij",
|
||||
"removeAllPending": "Verwijder alle {} wachtende uploads",
|
||||
"clearQueueTitle": "Wis Wachtrij",
|
||||
"clearQueueConfirm": "Alle {} wachtende uploads verwijderen?",
|
||||
"queueCleared": "Wachtrij gewist",
|
||||
"uploadQueueTitle": "Upload Wachtrij ({} items)",
|
||||
"queueIsEmpty": "Wachtrij is leeg",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Fout)",
|
||||
"completing": " (Voltooien...)",
|
||||
"destination": "Bestemming: {}",
|
||||
"latitude": "Breedtegraad: {}",
|
||||
"longitude": "Lengtegraad: {}",
|
||||
"direction": "Richting: {}°",
|
||||
"attempts": "Pogingen: {}",
|
||||
"uploadFailedRetry": "Upload mislukt. Tik opnieuw proberen om nog eens te proberen.",
|
||||
"retryUpload": "Probeer upload opnieuw",
|
||||
"clearAll": "Wis Alles",
|
||||
"errorDetails": "Fout Details",
|
||||
"creatingChangeset": " (Changeset maken...)",
|
||||
"uploading": " (Uploaden...)",
|
||||
"closingChangeset": " (Changeset sluiten...)",
|
||||
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
|
||||
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
|
||||
"pausedByUser": "Upload verwerking is handmatig gepauzeerd."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "Geen tile providers geconfigureerd",
|
||||
"tileTypesCount": "{} tile types",
|
||||
"apiKeyConfigured": "API Key geconfigureerd",
|
||||
"needsApiKey": "Heeft API key nodig",
|
||||
"editProvider": "Bewerk Provider",
|
||||
"addProvider": "Voeg Provider Toe",
|
||||
"deleteProvider": "Verwijder Provider",
|
||||
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"providerName": "Provider Naam",
|
||||
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
|
||||
"providerNameRequired": "Provider naam is vereist",
|
||||
"apiKey": "API Key (Optioneel)",
|
||||
"apiKeyHint": "Voer API key in indien vereist door tile types",
|
||||
"tileTypes": "Tile Types",
|
||||
"addType": "Voeg Type Toe",
|
||||
"noTileTypesConfigured": "Geen tile types geconfigureerd",
|
||||
"atLeastOneTileTypeRequired": "Minstens één tile type is vereist",
|
||||
"manageTileProviders": "Beheer Providers"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Bewerk Tile Type",
|
||||
"addTileType": "Voeg Tile Type Toe",
|
||||
"name": "Naam",
|
||||
"nameHint": "bijv., Satelliet",
|
||||
"nameRequired": "Naam is vereist",
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is vereist",
|
||||
"urlTemplatePlaceholders": "URL moet ofwel {quadkey} of {z}, {x}, en {y} placeholders bevatten",
|
||||
"attribution": "Attributie",
|
||||
"attributionHint": "© Kaart Provider",
|
||||
"attributionRequired": "Attributie is vereist",
|
||||
"maxZoom": "Max Zoom Niveau",
|
||||
"maxZoomHint": "Maximum zoom niveau (1-23)",
|
||||
"maxZoomRequired": "Max zoom is vereist",
|
||||
"maxZoomInvalid": "Max zoom moet een nummer zijn",
|
||||
"maxZoomRange": "Max zoom moet tussen {} en {} zijn",
|
||||
"fetchPreview": "Haal Voorbeeld Op",
|
||||
"previewTileLoaded": "Voorbeeld tile succesvol geladen",
|
||||
"previewTileFailed": "Kon voorbeeld niet ophalen: {}",
|
||||
"save": "Opslaan"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Node Profielen",
|
||||
"newProfile": "Nieuw Profiel",
|
||||
"builtIn": "Ingebouwd",
|
||||
"custom": "Aangepast",
|
||||
"view": "Bekijk",
|
||||
"deleteProfile": "Verwijder Profiel",
|
||||
"deleteProfileConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"profileDeleted": "Profiel verwijderd",
|
||||
"getMore": "Krijg meer...",
|
||||
"addProfileChoice": "Voeg Profiel Toe",
|
||||
"addProfileChoiceMessage": "Hoe wilt u een profiel toevoegen?",
|
||||
"createCustomProfile": "Maak Aangepast Profiel",
|
||||
"createCustomProfileDescription": "Bouw een profiel vanaf nul met uw eigen tags",
|
||||
"importFromWebsite": "Importeer van Website",
|
||||
"importFromWebsiteDescription": "Blader en importeer profielen van deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Kaart Tiles",
|
||||
"manageProviders": "Beheer Providers",
|
||||
"attribution": "Kaart Attributie",
|
||||
"mapAttribution": "Kaartbron: {}",
|
||||
"couldNotOpenLink": "Kon link niet openen",
|
||||
"openLicense": "Open licentie: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Bekijk Profiel",
|
||||
"newProfile": "Nieuw Profiel",
|
||||
"editProfile": "Bewerk Profiel",
|
||||
"profileName": "Profiel naam",
|
||||
"profileNameHint": "bijv., Aangepaste ALPR Camera",
|
||||
"profileNameRequired": "Profiel naam is vereist",
|
||||
"requiresDirection": "Vereist Richting",
|
||||
"requiresDirectionSubtitle": "Of camera's van dit type een richting tag nodig hebben",
|
||||
"fov": "Gezichtsveld",
|
||||
"fovHint": "FOV in graden (laat leeg voor standaard)",
|
||||
"fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat",
|
||||
"fovInvalid": "FOV moet tussen 1 en 360 graden zijn",
|
||||
"submittable": "Indienbaar",
|
||||
"submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen",
|
||||
"osmTags": "OSM Tags",
|
||||
"addTag": "Voeg tag toe",
|
||||
"saveProfile": "Sla Profiel Op",
|
||||
"keyHint": "sleutel",
|
||||
"valueHint": "waarde",
|
||||
"atLeastOneTagRequired": "Minstens één tag is vereist",
|
||||
"profileSaved": "Profiel \"{}\" opgeslagen"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nieuw Operator Profiel",
|
||||
"editOperatorProfile": "Bewerk Operator Profiel",
|
||||
"operatorName": "Operator naam",
|
||||
"operatorNameHint": "bijv., Amsterdam Politie",
|
||||
"operatorNameRequired": "Operator naam is vereist",
|
||||
"operatorProfileSaved": "Operator profiel \"{}\" opgeslagen"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operator Profielen",
|
||||
"noProfilesMessage": "Geen operator profielen gedefinieerd. Maak er een om operator tags toe te passen op node inzendingen.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Verwijder Operator Profiel",
|
||||
"deleteOperatorProfileConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"operatorProfileDeleted": "Operator profiel verwijderd"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline Gebieden",
|
||||
"noAreasTitle": "Geen offline gebieden",
|
||||
"noAreasSubtitle": "Download een kaart gebied voor offline gebruik.",
|
||||
"provider": "Provider",
|
||||
"maxZoom": "Max zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Breedtegraad",
|
||||
"longitude": "Lengtegraad",
|
||||
"tiles": "Tiles",
|
||||
"size": "Grootte",
|
||||
"nodes": "Nodes",
|
||||
"areaIdFallback": "Gebied {}...",
|
||||
"renameArea": "Hernoem gebied",
|
||||
"refreshWorldTiles": "Ververs/herdownload wereld tiles",
|
||||
"deleteOfflineArea": "Verwijder offline gebied",
|
||||
"cancelDownload": "Annuleer download",
|
||||
"renameAreaDialogTitle": "Hernoem Offline Gebied",
|
||||
"areaNameLabel": "Gebied Naam",
|
||||
"renameButton": "Hernoem",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Ververs gebied",
|
||||
"refreshAreaDialogTitle": "Ververs Offline Gebied",
|
||||
"refreshAreaDialogSubtitle": "Kies wat te verversen voor dit gebied:",
|
||||
"refreshTiles": "Ververs Kaart Tiles",
|
||||
"refreshTilesSubtitle": "Herdownload alle tiles voor bijgewerkte beelden",
|
||||
"refreshNodes": "Ververs Nodes",
|
||||
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
||||
"startRefresh": "Start Verversen",
|
||||
"refreshStarted": "Verversen gestart!",
|
||||
"refreshFailed": "Verversen mislukt: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Verfijn Tags",
|
||||
"operatorProfile": "Operator Profiel",
|
||||
"done": "Klaar",
|
||||
"none": "Geen",
|
||||
"noAdditionalOperatorTags": "Geen aanvullende operator tags",
|
||||
"additionalTags": "aanvullende tags",
|
||||
"additionalTagsTitle": "Aanvullende Tags",
|
||||
"noTagsDefinedForProfile": "Geen tags gedefinieerd voor dit operator profiel.",
|
||||
"noOperatorProfiles": "Geen operator profielen gedefinieerd",
|
||||
"noOperatorProfilesMessage": "Maak operator profielen in Instellingen om aanvullende tags toe te passen op uw node inzendingen.",
|
||||
"profileTags": "Profiel Tags",
|
||||
"profileTagsDescription": "Vul deze optionele tag waarden in voor meer gedetailleerde inzendingen:",
|
||||
"selectValue": "Selecteer waarde...",
|
||||
"noValue": "(laat leeg)",
|
||||
"noSuggestions": "Geen suggesties beschikbaar",
|
||||
"existingTagsTitle": "Bestaande Tags",
|
||||
"existingTagsDescription": "Bewerk de bestaande tags op dit apparaat. Voeg toe, verwijder of wijzig elke tag:",
|
||||
"existingOperator": "<Bestaande operator>",
|
||||
"existingOperatorTags": "bestaande operator tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kan tile types niet wijzigen tijdens het downloaden van offline gebieden",
|
||||
"selectMapLayer": "Selecteer Kaart Laag",
|
||||
"noTileProvidersAvailable": "Geen tile providers beschikbaar"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Geavanceerde Bewerkingsopties",
|
||||
"subtitle": "Deze editors bieden meer geavanceerde functies voor complexe bewerkingen.",
|
||||
"webEditors": "Web Editors",
|
||||
"mobileEditors": "Mobiele Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Volledig uitgeruste web editor - werkt altijd",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-geassisteerde bewerking met Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Geavanceerde Android OSM editor",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Enquête-gebaseerde mapping app",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Snelle POI bewerking",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM editor",
|
||||
"couldNotOpenEditor": "Kon editor niet openen - app is mogelijk niet geïnstalleerd",
|
||||
"couldNotOpenURL": "Kon URL niet openen",
|
||||
"couldNotOpenOSMWebsite": "Kon OSM website niet openen"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Toon netwerk status indicator",
|
||||
"showIndicatorSubtitle": "Toon surveillance data laden en fout status",
|
||||
"loading": "Surveillance data laden...",
|
||||
"timedOut": "Verzoek verlopen",
|
||||
"noData": "Geen offline data",
|
||||
"success": "Surveillance data geladen",
|
||||
"nodeDataSlow": "Surveillance data traag",
|
||||
"rateLimited": "Snelheid beperkt door server",
|
||||
"networkError": "Netwerk fout"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "{rendered} van {total} apparaten getoond",
|
||||
"editingDisabledMessage": "Te veel apparaten getoond om veilig te bewerken. Zoom verder in om het aantal zichtbare apparaten te verminderen, probeer dan opnieuw."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Zoek Locatie",
|
||||
"searchPlaceholder": "Zoek plaatsen of coördinaten...",
|
||||
"routeTo": "Route Naar",
|
||||
"routeFrom": "Route Vanaf",
|
||||
"selectLocation": "Selecteer Locatie",
|
||||
"calculatingRoute": "Route berekenen...",
|
||||
"routeCalculationFailed": "Route berekening mislukt",
|
||||
"start": "Start",
|
||||
"resume": "Hervatten",
|
||||
"endRoute": "Beëindig Route",
|
||||
"routeOverview": "Route Overzicht",
|
||||
"retry": "Opnieuw Proberen",
|
||||
"cancelSearch": "Annuleer zoeken",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"searching": "Zoeken...",
|
||||
"location": "Locatie",
|
||||
"startPoint": "Start",
|
||||
"endPoint": "Einde",
|
||||
"startSelect": "Start (selecteer)",
|
||||
"endSelect": "Einde (selecteer)",
|
||||
"distance": "Afstand: {} km",
|
||||
"routeActive": "Route actief",
|
||||
"locationsTooClose": "Start en eind locaties zijn te dicht bij elkaar",
|
||||
"navigationSettings": "Navigatie",
|
||||
"navigationSettingsSubtitle": "Route planning en vermijding instellingen",
|
||||
"avoidanceDistance": "Vermijding Afstand",
|
||||
"avoidanceDistanceSubtitle": "Minimum afstand om weg te blijven van surveillance apparaten",
|
||||
"searchHistory": "Max Zoekgeschiedenis",
|
||||
"searchHistorySubtitle": "Maximum aantal recente zoekopdrachten om te onthouden"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdachte Locaties",
|
||||
"showSuspectedLocations": "Toon Verdachte Locaties",
|
||||
"showSuspectedLocationsSubtitle": "Toon vraagteken markers voor verdachte surveillance sites van nutsbedrijf vergunning data",
|
||||
"lastUpdated": "Laatst Bijgewerkt",
|
||||
"refreshNow": "Ververs nu",
|
||||
"dataSource": "Gegevensbron",
|
||||
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
|
||||
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
|
||||
"minimumDistance": "Minimum Afstand van Echte Nodes",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten",
|
||||
"updating": "Verdachte Locaties Bijwerken",
|
||||
"downloadingAndProcessing": "Data downloaden en verwerken...",
|
||||
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",
|
||||
"updateFailed": "Kon verdachte locaties niet bijwerken",
|
||||
"neverFetched": "Nooit opgehaald",
|
||||
"daysAgo": "{} dagen geleden",
|
||||
"hoursAgo": "{} uur geleden",
|
||||
"minutesAgo": "{} minuten geleden",
|
||||
"justNow": "Zojuist"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Verdachte Locatie #{}",
|
||||
"ticketNo": "Ticket Nr",
|
||||
"address": "Adres",
|
||||
"street": "Straat",
|
||||
"city": "Stad",
|
||||
"state": "Provincie",
|
||||
"intersectingStreet": "Kruisende Straat",
|
||||
"workDoneFor": "Werk Gedaan Voor",
|
||||
"remarks": "Opmerkingen",
|
||||
"url": "URL",
|
||||
"coordinates": "Coördinaten",
|
||||
"noAddressAvailable": "Geen adres beschikbaar"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mijl",
|
||||
"metersLong": "meters",
|
||||
"feetLong": "voet",
|
||||
"kilometersLong": "kilometers",
|
||||
"milesLong": "mijlen",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperiaal",
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperiaal (mijl, ft)"
|
||||
}
|
||||
}
|
||||
561
lib/localizations/pl.json
Normal file
561
lib/localizations/pl.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Polski"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Przejrzystość Nadzoru",
|
||||
"description": "DeFlock to mobilna aplikacja skoncentrowana na prywatności do mapowania publicznej infrastruktury nadzoru przy użyciu OpenStreetMap. Dokumentuj kamery, ALPR, detektory wystrzałów i inne urządzenia nadzoru w swojej społeczności, aby uczynić tę infrastrukturę widoczną i możliwą do przeszukiwania.",
|
||||
"features": "• Mapowanie offline z obszarami do pobrania\n• Przesyłanie bezpośrednio do OpenStreetMap z OAuth2\n• Wbudowane profile dla głównych producentów\n• Szanujące prywatność - nie zbierane są dane użytkownika\n• Wielu dostawców kafelków map (OSM, zdjęcia satelitarne)",
|
||||
"initiative": "Część szerszej inicjatywy DeFlock promującej przejrzystość nadzoru.",
|
||||
"footer": "Odwiedź: deflock.me\nZbudowane z Flutter • Open Source",
|
||||
"showWelcome": "Pokaż Wiadomość Powitalną",
|
||||
"showSubmissionGuide": "Pokaż Przewodnik Zgłaszania",
|
||||
"viewReleaseNotes": "Zobacz Notatki Wydania"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Witamy w DeFlock",
|
||||
"description": "DeFlock został założony na idei, że narzędzia publicznego nadzoru powinny być przejrzyste. W tej aplikacji mobilnej, jak i na stronie internetowej, będziesz mógł przeglądać lokalizacje ALPR i innych infrastruktur nadzoru w Twoim lokalnym obszarze i za granicą.",
|
||||
"mission": "Jednak ten projekt nie jest zautomatyzowany; potrzeba nas wszystkich, aby ten projekt uczynić lepszym. Podczas przeglądania mapy możesz stuknąć \"Nowy Węzeł\", aby dodać wcześniej nieznaną instalację. Z Twoją pomocą możemy osiągnąć nasz cel zwiększonej przejrzystości i publicznej świadomości infrastruktury nadzoru.",
|
||||
"firsthandKnowledge": "WAŻNE: Dodawaj tylko urządzenia nadzoru, które osobiście obserwowałeś z pierwszej ręki. Polityki OpenStreetMap i Google zabraniają używania źródeł takich jak zdjęcia Street View do zgłoszeń. Twoje wkłady powinny być oparte na Twoich własnych bezpośrednich obserwacjach.",
|
||||
"privacy": "Uwaga o Prywatności: Ta aplikacja działa całkowicie lokalnie na Twoim urządzeniu i używa zewnętrznego API OpenStreetMap do przechowywania danych i zgłoszeń. DeFlock nie zbiera ani nie przechowuje żadnych danych użytkownika jakiegokolwiek rodzaju i nie jest odpowiedzialny za zarządzanie kontami.",
|
||||
"tileNote": "UWAGA: Darmowe kafelki map z OpenStreetMap mogą ładować się bardzo wolno. Alternatywni dostawcy kafelków mogą być skonfigurowani w Ustawieniach > Zaawansowane.",
|
||||
"moreInfo": "Więcej linków znajdziesz w Ustawieniach > O aplikacji.",
|
||||
"dontShowAgain": "Nie pokazuj ponownie tej wiadomości powitalnej",
|
||||
"getStarted": "Zacznijmy DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Najlepsze Praktyki Zgłaszania",
|
||||
"description": "Przed zgłoszeniem Twojego pierwszego urządzenia nadzoru, poświęć chwilę na przejrzenie tych ważnych wytycznych, aby zapewnić wysokiej jakości wkłady do OpenStreetMap.",
|
||||
"bestPractices": "• Mapuj tylko urządzenia, które osobiście obserwowałeś z pierwszej ręki\n• Poświęć czas na dokładną identyfikację typu urządzenia i producenta\n• Używaj precyzyjnego pozycjonowania - przybliż przed umieszczeniem znacznika\n• Dołączaj informacje o kierunku, gdy ma to zastosowanie\n• Sprawdź dwukrotnie swoje wybory tagów przed zgłoszeniem",
|
||||
"placementNote": "Pamiętaj: Dokładne dane z pierwszej ręki są kluczowe dla społeczności DeFlock i projektu OpenStreetMap.",
|
||||
"moreInfo": "Dla szczegółowego przewodnika po identyfikacji urządzeń i najlepszych praktykach mapowania:",
|
||||
"identificationGuide": "Przewodnik Identyfikacji",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Nie pokazuj ponownie tego przewodnika",
|
||||
"gotIt": "Rozumiem!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Doprecyzuj Swoją Lokalizację",
|
||||
"instructions": "Przeciągnij mapę, aby precyzyjnie ustawić znacznik urządzenia nad lokalizacją urządzenia nadzoru.",
|
||||
"hint": "Możesz przybliżyć dla lepszej dokładności przed pozycjonowaniem."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nowy Węzeł",
|
||||
"download": "Pobierz",
|
||||
"settings": "Ustawienia",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń",
|
||||
"cancel": "Anuluj",
|
||||
"ok": "OK",
|
||||
"close": "Zamknij",
|
||||
"submit": "Zgłoś",
|
||||
"logIn": "Zaloguj",
|
||||
"saveEdit": "Zapisz Edycję",
|
||||
"clear": "Wyczyść",
|
||||
"viewOnOSM": "Zobacz w OSM",
|
||||
"advanced": "Zaawansowane",
|
||||
"useAdvancedEditor": "Użyj Zaawansowanego Edytora"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Węzeł Bardzo Blisko Istniejącego Urządzenia",
|
||||
"message": "Ten węzeł jest tylko {} metrów od istniejącego urządzenia nadzoru.",
|
||||
"suggestion": "Jeśli wiele urządzeń znajduje się na tym samym słupie, użyj wielu kierunków na jednym węźle zamiast tworzenia oddzielnych węzłów.",
|
||||
"nearbyNodes": "Znaleziono pobliskie urządzenie/urządzenia ({}):",
|
||||
"nodeInfo": "Węzeł #{} - {}",
|
||||
"andMore": "...i {} więcej",
|
||||
"goBack": "Wróć",
|
||||
"submitAnyway": "Zgłoś Mimo To",
|
||||
"nodeType": {
|
||||
"alpr": "Kamera ALPR/ANPR",
|
||||
"publicCamera": "Kamera Nadzoru Publicznego",
|
||||
"camera": "Kamera Nadzoru",
|
||||
"amenity": "{}",
|
||||
"device": "Urządzenie {}",
|
||||
"unknown": "Nieznane Urządzenie"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Włącz śledzenie",
|
||||
"follow": "Włącz śledzenie (obracające)",
|
||||
"rotating": "Wyłącz śledzenie"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
"language": "Język i Region",
|
||||
"systemDefault": "Domyślne Systemowe",
|
||||
"aboutInfo": "O / Informacje",
|
||||
"aboutThisApp": "O Tej Aplikacji",
|
||||
"aboutSubtitle": "Informacje o aplikacji i autorzy",
|
||||
"languageSubtitle": "Wybierz preferowany język i jednostki",
|
||||
"distanceUnit": "Jednostki Odległości",
|
||||
"distanceUnitSubtitle": "Wybierz między jednostkami metrycznymi (km/m) lub imperialnymi (mila/stopa)",
|
||||
"metricUnits": "Metryczne (km, m)",
|
||||
"imperialUnits": "Imperialne (mila, stopa)",
|
||||
"maxNodes": "Maksymalna liczba rysowanych węzłów",
|
||||
"maxNodesSubtitle": "Ustaw górny limit liczby węzłów na mapie.",
|
||||
"maxNodesWarning": "Prawdopodobnie nie chcesz tego robić, chyba że jesteś absolutnie pewien, że masz dobry powód.",
|
||||
"offlineMode": "Tryb Offline",
|
||||
"offlineModeSubtitle": "Wyłącz wszystkie żądania sieciowe z wyjątkiem lokalnych/offline obszarów.",
|
||||
"pauseQueueProcessing": "Wstrzymaj Przetwarzanie Kolejki Przesyłania",
|
||||
"pauseQueueProcessingSubtitle": "Zatrzymaj przesyłanie kolejkowanych zmian zachowując dostęp do danych na żywo.",
|
||||
"offlineModeWarningTitle": "Aktywne Pobierania",
|
||||
"offlineModeWarningMessage": "Włączenie trybu offline anuluje wszystkie aktywne pobierania obszarów. Czy chcesz kontynuować?",
|
||||
"enableOfflineMode": "Włącz Tryb Offline",
|
||||
"profiles": "Profile",
|
||||
"profilesSubtitle": "Zarządzaj profilami węzłów i operatorów",
|
||||
"offlineSettings": "Ustawienia Offline",
|
||||
"offlineSettingsSubtitle": "Zarządzaj trybem offline i pobranymi obszarami",
|
||||
"advancedSettings": "Ustawienia Zaawansowane",
|
||||
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
|
||||
"proximityAlerts": "Alerty Bliskości",
|
||||
"networkStatusIndicator": "Wskaźnik Stanu Sieci"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
|
||||
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
|
||||
"notificationsEnabled": "✓ Powiadomienia włączone",
|
||||
"notificationsDisabled": "⚠ Powiadomienia wyłączone",
|
||||
"permissionRequired": "Wymagane uprawnienie do powiadomień",
|
||||
"permissionExplanation": "Powiadomienia push są wyłączone. Będziesz widzieć tylko alerty w aplikacji i nie będziesz powiadamiany, gdy aplikacja jest w tle.",
|
||||
"enableNotifications": "Włącz Powiadomienia",
|
||||
"checkingPermissions": "Sprawdzanie uprawnień...",
|
||||
"alertDistance": "Odległość alertu: ",
|
||||
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Węzeł #{}",
|
||||
"tagSheetTitle": "Tagi Urządzenia Nadzoru",
|
||||
"queuedForUpload": "Węzeł umieszczony w kolejce do przesłania",
|
||||
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
||||
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
||||
"confirmDeleteTitle": "Usuń Węzeł",
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Wybierz profil...",
|
||||
"profileRequired": "Proszę wybrać profil, aby kontynuować.",
|
||||
"direction": "Kierunek {}°",
|
||||
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
|
||||
"mustBeLoggedIn": "Musisz być zalogowany, aby zgłaszać nowe węzły. Zaloguj się przez Ustawienia.",
|
||||
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby zgłaszać nowe węzły.",
|
||||
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby zgłaszać nowe węzły.",
|
||||
"loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.",
|
||||
"refineTags": "Doprecyzuj Tagi"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edytuj Węzeł #{}",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Wybierz profil...",
|
||||
"profileRequired": "Proszę wybrać profil, aby kontynuować.",
|
||||
"direction": "Kierunek {}°",
|
||||
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
|
||||
"temporarilyDisabled": "Edycje zostały tymczasowo wyłączone, gdy rozwiązujemy błąd - przepraszamy - sprawdź wkrótce.",
|
||||
"mustBeLoggedIn": "Musisz być zalogowany, aby edytować węzły. Zaloguj się przez Ustawienia.",
|
||||
"sandboxModeWarning": "Nie można przesyłać edycji węzłów produkcyjnych do piaskownicy. Przełącz na tryb Produkcyjny w Ustawieniach, aby edytować węzły.",
|
||||
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby edytować węzły.",
|
||||
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby edytować węzły.",
|
||||
"loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.",
|
||||
"cannotMoveConstrainedNode": "Nie można przenieść tej kamery - jest połączona z innym elementem mapy (droga/relacja OSM). Nadal możesz edytować jej tagi i kierunek.",
|
||||
"zoomInRequiredMessage": "Przybliż do co najmniej poziomu {}, aby dodawać lub edytować węzły nadzoru. Zapewnia to precyzyjne pozycjonowanie dla dokładnego mapowania.",
|
||||
"extractFromWay": "Wyciągnij węzeł z drogi/relacji",
|
||||
"extractFromWaySubtitle": "Utwórz nowy węzeł z tymi samymi tagami, pozwalając na przeniesienie do nowej lokalizacji",
|
||||
"refineTags": "Doprecyzuj Tagi",
|
||||
"existingTags": "<Istniejące tagi>",
|
||||
"noChangesDetected": "Nie wykryto zmian - nie ma nic do zgłoszenia",
|
||||
"noChangesTitle": "Brak Zmian do Zgłoszenia",
|
||||
"noChangesMessage": "Nie wprowadziłeś żadnych zmian do tego węzła. Aby zgłosić edycję, musisz zmienić lokalizację, profil, kierunki lub tagi."
|
||||
},
|
||||
"download": {
|
||||
"title": "Pobierz Obszar Mapy",
|
||||
"maxZoomLevel": "Maksymalny poziom przybliżenia",
|
||||
"storageEstimate": "Oszacowanie pamięci:",
|
||||
"tilesAndSize": "{} kafelków, {} MB",
|
||||
"minZoom": "Min przybliżenie:",
|
||||
"maxRecommendedZoom": "Maksymalne zalecane przybliżenie: Z{}",
|
||||
"withinTileLimit": "W granicach {} limitu kafelków",
|
||||
"exceedsTileLimit": "Obecny wybór przekracza {} limit kafelków",
|
||||
"offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.",
|
||||
"areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.",
|
||||
"downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...",
|
||||
"downloadFailed": "Nie udało się rozpocząć pobierania: {}",
|
||||
"offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).",
|
||||
"currentTileProvider": "bieżący kafelek",
|
||||
"noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Pobieranie Rozpoczęte",
|
||||
"message": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Zobacz Postęp w Ustawieniach"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Miejsce Docelowe Przesyłania",
|
||||
"subtitle": "Wybierz gdzie przesyłane są kamery",
|
||||
"production": "Produkcja",
|
||||
"sandbox": "Piaskownica",
|
||||
"simulate": "Symulacja",
|
||||
"productionDescription": "Prześlij do aktywnej bazy danych OSM (widoczne dla wszystkich użytkowników)",
|
||||
"sandboxDescription": "Przesłania trafiają do Piaskownicy OSM (bezpieczne do testowania, regularne resetowanie).",
|
||||
"simulateDescription": "Symuluj przesyłanie (nie kontaktuje się z serwerami OSM)",
|
||||
"cannotChangeWithQueue": "Nie można zmienić miejsca docelowego przesyłania, gdy {} elementów jest w kolejce. Najpierw wyczyść kolejkę."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Konto OpenStreetMap",
|
||||
"osmAccountSubtitle": "Zarządzaj logowaniem OSM i przeglądaj swoje wkłady",
|
||||
"loggedInAs": "Zalogowany jako {}",
|
||||
"loginToOSM": "Zaloguj się do OpenStreetMap",
|
||||
"tapToLogout": "Stuknij aby się wylogować",
|
||||
"requiredToSubmit": "Wymagane do zgłaszania danych kamer",
|
||||
"loggedOut": "Wylogowany",
|
||||
"testConnection": "Testuj Połączenie",
|
||||
"testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają",
|
||||
"connectionOK": "Połączenie OK - dane logowania są ważne",
|
||||
"connectionFailed": "Połączenie nie powiodło się - zaloguj się ponownie",
|
||||
"viewMyEdits": "Zobacz Moje Edycje w OSM",
|
||||
"viewMyEditsSubtitle": "Zobacz swoją historię edycji w OpenStreetMap",
|
||||
"aboutOSM": "O OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap to współpracujący projekt mapowania open-source, gdzie współtwórcy tworzą i utrzymują darmową, edytowalną mapę świata. Twoje wkłady urządzeń nadzoru pomagają uczynić tę infrastrukturę widoczną i możliwą do przeszukiwania.",
|
||||
"visitOSM": "Odwiedź OpenStreetMap",
|
||||
"deleteAccount": "Usuń Konto OSM",
|
||||
"deleteAccountSubtitle": "Zarządzaj swoim kontem OpenStreetMap",
|
||||
"deleteAccountExplanation": "Aby usunąć swoje konto OpenStreetMap, musisz odwiedzić stronę OpenStreetMap. To trwale usunie twoje konto OSM i wszystkie powiązane dane.",
|
||||
"deleteAccountWarning": "Ostrzeżenie: Ta akcja nie może być cofnięta i trwale usunie twoje konto OSM.",
|
||||
"goToOSM": "Przejdź do OpenStreetMap",
|
||||
"accountManagement": "Zarządzanie Kontem",
|
||||
"accountManagementDescription": "Aby usunąć swoje konto OpenStreetMap, musisz odwiedzić odpowiednią stronę OpenStreetMap. To trwale usunie twoje konto i wszystkie powiązane dane.",
|
||||
"currentDestinationProduction": "Obecnie połączony z: Produkcyjny OpenStreetMap",
|
||||
"currentDestinationSandbox": "Obecnie połączony z: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Obecnie w: Trybie symulacji (brak rzeczywistego konta)",
|
||||
"viewMessages": "Zobacz Wiadomości w OSM",
|
||||
"unreadMessagesCount": "Masz {} nieprzeczytanych wiadomości",
|
||||
"noUnreadMessages": "Brak nieprzeczytanych wiadomości",
|
||||
"reauthRequired": "Odśwież Uwierzytelnienie",
|
||||
"reauthExplanation": "Musisz odświeżyć uwierzytelnienie, aby otrzymywać powiadomienia o wiadomościach OSM przez aplikację.",
|
||||
"reauthBenefit": "To włączy kropki powiadomień, gdy masz nieprzeczytane wiadomości w OpenStreetMap.",
|
||||
"reauthNow": "Zrób To Teraz",
|
||||
"reauthLater": "Później"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Kolejka Przesyłania",
|
||||
"subtitle": "Zarządzaj oczekującymi przesłaniami urządzeń nadzoru",
|
||||
"pendingUploads": "Oczekujące przesłania: {}",
|
||||
"pendingItemsCount": "Oczekujące Elementy: {}",
|
||||
"nothingInQueue": "Nic w kolejce",
|
||||
"simulateModeEnabled": "Tryb symulacji włączony – przesłania symulowane",
|
||||
"sandboxMode": "Tryb piaskownicy – przesłania idą do OSM Sandbox",
|
||||
"tapToViewQueue": "Stuknij aby zobaczyć kolejkę",
|
||||
"clearUploadQueue": "Wyczyść Kolejkę Przesyłania",
|
||||
"removeAllPending": "Usuń wszystkie {} oczekujące przesłania",
|
||||
"clearQueueTitle": "Wyczyść Kolejkę",
|
||||
"clearQueueConfirm": "Usunąć wszystkie {} oczekujące przesłania?",
|
||||
"queueCleared": "Kolejka wyczyszczona",
|
||||
"uploadQueueTitle": "Kolejka Przesyłania ({} elementów)",
|
||||
"queueIsEmpty": "Kolejka jest pusta",
|
||||
"itemWithIndex": "Element {}",
|
||||
"error": " (Błąd)",
|
||||
"completing": " (Kończenie...)",
|
||||
"destination": "Cel: {}",
|
||||
"latitude": "Szerokość: {}",
|
||||
"longitude": "Długość: {}",
|
||||
"direction": "Kierunek: {}°",
|
||||
"attempts": "Próby: {}",
|
||||
"uploadFailedRetry": "Przesyłanie nie powiodło się. Stuknij ponownie aby spróbować ponownie.",
|
||||
"retryUpload": "Spróbuj ponownie przesłać",
|
||||
"clearAll": "Wyczyść Wszystko",
|
||||
"errorDetails": "Szczegóły Błędu",
|
||||
"creatingChangeset": " (Tworzenie zestawu zmian...)",
|
||||
"uploading": " (Przesyłanie...)",
|
||||
"closingChangeset": " (Zamykanie zestawu zmian...)",
|
||||
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
|
||||
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
|
||||
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Dostawcy Kafelków",
|
||||
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
|
||||
"tileTypesCount": "{} typów kafelków",
|
||||
"apiKeyConfigured": "Klucz API skonfigurowany",
|
||||
"needsApiKey": "Potrzebuje klucz API",
|
||||
"editProvider": "Edytuj Dostawcę",
|
||||
"addProvider": "Dodaj Dostawcę",
|
||||
"deleteProvider": "Usuń Dostawcę",
|
||||
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"providerName": "Nazwa Dostawcy",
|
||||
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
|
||||
"providerNameRequired": "Nazwa dostawcy jest wymagana",
|
||||
"apiKey": "Klucz API (Opcjonalnie)",
|
||||
"apiKeyHint": "Wprowadź klucz API jeśli wymagany przez typy kafelków",
|
||||
"tileTypes": "Typy Kafelków",
|
||||
"addType": "Dodaj Typ",
|
||||
"noTileTypesConfigured": "Brak skonfigurowanych typów kafelków",
|
||||
"atLeastOneTileTypeRequired": "Przynajmniej jeden typ kafelka jest wymagany",
|
||||
"manageTileProviders": "Zarządzaj Dostawcami"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Edytuj Typ Kafelka",
|
||||
"addTileType": "Dodaj Typ Kafelka",
|
||||
"name": "Nazwa",
|
||||
"nameHint": "np., Satelita",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
"urlTemplate": "Szablon URL",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Szablon URL jest wymagany",
|
||||
"urlTemplatePlaceholders": "URL musi zawierać albo {quadkey} albo {z}, {x} i {y} symbole zastępcze",
|
||||
"attribution": "Atrybucja",
|
||||
"attributionHint": "© Dostawca Map",
|
||||
"attributionRequired": "Atrybucja jest wymagana",
|
||||
"maxZoom": "Maksymalny Poziom Przybliżenia",
|
||||
"maxZoomHint": "Maksymalny poziom przybliżenia (1-23)",
|
||||
"maxZoomRequired": "Maksymalne przybliżenie jest wymagane",
|
||||
"maxZoomInvalid": "Maksymalne przybliżenie musi być liczbą",
|
||||
"maxZoomRange": "Maksymalne przybliżenie musi być między {} a {}",
|
||||
"fetchPreview": "Pobierz Podgląd",
|
||||
"previewTileLoaded": "Kafelek podglądu załadowany pomyślnie",
|
||||
"previewTileFailed": "Nie udało się pobrać podglądu: {}",
|
||||
"save": "Zapisz"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profile Węzłów",
|
||||
"newProfile": "Nowy Profil",
|
||||
"builtIn": "Wbudowany",
|
||||
"custom": "Niestandardowy",
|
||||
"view": "Zobacz",
|
||||
"deleteProfile": "Usuń Profil",
|
||||
"deleteProfileConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"profileDeleted": "Profil usunięty",
|
||||
"getMore": "Pobierz więcej...",
|
||||
"addProfileChoice": "Dodaj Profil",
|
||||
"addProfileChoiceMessage": "Jak chciałbyś dodać profil?",
|
||||
"createCustomProfile": "Utwórz Niestandardowy Profil",
|
||||
"createCustomProfileDescription": "Zbuduj profil od zera z własnymi tagami",
|
||||
"importFromWebsite": "Importuj ze Strony",
|
||||
"importFromWebsiteDescription": "Przeglądaj i importuj profile z deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Kafelki Mapy",
|
||||
"manageProviders": "Zarządzaj Dostawcami",
|
||||
"attribution": "Atrybucja Mapy",
|
||||
"mapAttribution": "Źródło mapy: {}",
|
||||
"couldNotOpenLink": "Nie udało się otworzyć linku",
|
||||
"openLicense": "Otwórz licencję: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Zobacz Profil",
|
||||
"newProfile": "Nowy Profil",
|
||||
"editProfile": "Edytuj Profil",
|
||||
"profileName": "Nazwa profilu",
|
||||
"profileNameHint": "np., Niestandardowa Kamera ALPR",
|
||||
"profileNameRequired": "Nazwa profilu jest wymagana",
|
||||
"requiresDirection": "Wymaga Kierunku",
|
||||
"requiresDirectionSubtitle": "Czy kamery tego typu potrzebują tagu kierunku",
|
||||
"fov": "Pole Widzenia",
|
||||
"fovHint": "FOV w stopniach (zostaw puste dla domyślnego)",
|
||||
"fovSubtitle": "Pole widzenia kamery - używane dla szerokości stożka i formatu zgłaszania zasięgu",
|
||||
"fovInvalid": "FOV musi być między 1 a 360 stopniami",
|
||||
"submittable": "Możliwy do Zgłoszenia",
|
||||
"submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer",
|
||||
"osmTags": "Tagi OSM",
|
||||
"addTag": "Dodaj tag",
|
||||
"saveProfile": "Zapisz Profil",
|
||||
"keyHint": "klucz",
|
||||
"valueHint": "wartość",
|
||||
"atLeastOneTagRequired": "Przynajmniej jeden tag jest wymagany",
|
||||
"profileSaved": "Profil \"{}\" zapisany"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nowy Profil Operatora",
|
||||
"editOperatorProfile": "Edytuj Profil Operatora",
|
||||
"operatorName": "Nazwa operatora",
|
||||
"operatorNameHint": "np., Policja Warszawska",
|
||||
"operatorNameRequired": "Nazwa operatora jest wymagana",
|
||||
"operatorProfileSaved": "Profil operatora \"{}\" zapisany"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profile Operatorów",
|
||||
"noProfilesMessage": "Brak zdefiniowanych profili operatorów. Utwórz jeden, aby zastosować tagi operatorów do zgłoszeń węzłów.",
|
||||
"tagsCount": "{} tagów",
|
||||
"deleteOperatorProfile": "Usuń Profil Operatora",
|
||||
"deleteOperatorProfileConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil operatora usunięty"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Obszary Offline",
|
||||
"noAreasTitle": "Brak obszarów offline",
|
||||
"noAreasSubtitle": "Pobierz obszar mapy do użytku offline.",
|
||||
"provider": "Dostawca",
|
||||
"maxZoom": "Maksymalne przybliżenie",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Szerokość",
|
||||
"longitude": "Długość",
|
||||
"tiles": "Kafelki",
|
||||
"size": "Rozmiar",
|
||||
"nodes": "Węzły",
|
||||
"areaIdFallback": "Obszar {}...",
|
||||
"renameArea": "Zmień nazwę obszaru",
|
||||
"refreshWorldTiles": "Odśwież/pobierz ponownie kafelki światowe",
|
||||
"deleteOfflineArea": "Usuń obszar offline",
|
||||
"cancelDownload": "Anuluj pobieranie",
|
||||
"renameAreaDialogTitle": "Zmień Nazwę Obszaru Offline",
|
||||
"areaNameLabel": "Nazwa Obszaru",
|
||||
"renameButton": "Zmień Nazwę",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Odśwież obszar",
|
||||
"refreshAreaDialogTitle": "Odśwież Obszar Offline",
|
||||
"refreshAreaDialogSubtitle": "Wybierz co odświeżyć dla tego obszaru:",
|
||||
"refreshTiles": "Odśwież Kafelki Mapy",
|
||||
"refreshTilesSubtitle": "Pobierz ponownie wszystkie kafelki dla zaktualizowanych obrazów",
|
||||
"refreshNodes": "Odśwież Węzły",
|
||||
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
||||
"startRefresh": "Rozpocznij Odświeżanie",
|
||||
"refreshStarted": "Odświeżanie rozpoczęte!",
|
||||
"refreshFailed": "Odświeżanie nie powiodło się: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Doprecyzuj Tagi",
|
||||
"operatorProfile": "Profil Operatora",
|
||||
"done": "Gotowe",
|
||||
"none": "Brak",
|
||||
"noAdditionalOperatorTags": "Brak dodatkowych tagów operatora",
|
||||
"additionalTags": "dodatkowe tagi",
|
||||
"additionalTagsTitle": "Dodatkowe Tagi",
|
||||
"noTagsDefinedForProfile": "Brak tagów zdefiniowanych dla tego profilu operatora.",
|
||||
"noOperatorProfiles": "Brak zdefiniowanych profili operatorów",
|
||||
"noOperatorProfilesMessage": "Utwórz profile operatorów w Ustawieniach, aby zastosować dodatkowe tagi do swoich zgłoszeń węzłów.",
|
||||
"profileTags": "Tagi Profilu",
|
||||
"profileTagsDescription": "Wypełnij te opcjonalne wartości tagów dla bardziej szczegółowych zgłoszeń:",
|
||||
"selectValue": "Wybierz wartość...",
|
||||
"noValue": "(zostaw puste)",
|
||||
"noSuggestions": "Brak dostępnych sugestii",
|
||||
"existingTagsTitle": "Istniejące Tagi",
|
||||
"existingTagsDescription": "Edytuj istniejące tagi na tym urządzeniu. Dodaj, usuń lub zmodyfikuj dowolny tag:",
|
||||
"existingOperator": "<Istniejący operator>",
|
||||
"existingOperatorTags": "istniejące tagi operatora"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Nie można zmieniać typów kafelków podczas pobierania obszarów offline",
|
||||
"selectMapLayer": "Wybierz Warstwę Mapy",
|
||||
"noTileProvidersAvailable": "Brak dostępnych dostawców kafelków"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Zaawansowane Opcje Edycji",
|
||||
"subtitle": "Te edytory oferują bardziej zaawansowane funkcje dla złożonych edycji.",
|
||||
"webEditors": "Edytory Webowe",
|
||||
"mobileEditors": "Edytory Mobilne",
|
||||
"iDEditor": "Edytor iD",
|
||||
"iDEditorSubtitle": "W pełni funkcjonalny edytor webowy - zawsze działa",
|
||||
"rapidEditor": "Edytor RapiD",
|
||||
"rapidEditorSubtitle": "Edycja wspomagana AI z danymi Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Zaawansowany edytor OSM dla Androida",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Aplikacja do mapowania oparta na ankietach",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Szybka edycja POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Edytor OSM dla iOS",
|
||||
"couldNotOpenEditor": "Nie można otworzyć edytora - aplikacja może nie być zainstalowana",
|
||||
"couldNotOpenURL": "Nie można otworzyć URL",
|
||||
"couldNotOpenOSMWebsite": "Nie można otworzyć strony OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Pokaż wskaźnik stanu sieci",
|
||||
"showIndicatorSubtitle": "Wyświetl status ładowania danych nadzoru i błędów",
|
||||
"loading": "Ładowanie danych nadzoru...",
|
||||
"timedOut": "Żądanie przekroczyło limit czasu",
|
||||
"noData": "Brak danych offline",
|
||||
"success": "Dane nadzoru załadowane",
|
||||
"nodeDataSlow": "Dane nadzoru powolne",
|
||||
"rateLimited": "Ograniczone przez serwer",
|
||||
"networkError": "Błąd sieci"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Pokazuje {rendered} z {total} urządzeń",
|
||||
"editingDisabledMessage": "Pokazano zbyt wiele urządzeń, aby bezpiecznie edytować. Przybliż dalej, aby zmniejszyć liczbę widocznych urządzeń, następnie spróbuj ponownie."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Szukaj Lokalizacji",
|
||||
"searchPlaceholder": "Szukaj miejsc lub współrzędnych...",
|
||||
"routeTo": "Trasa Do",
|
||||
"routeFrom": "Trasa Od",
|
||||
"selectLocation": "Wybierz Lokalizację",
|
||||
"calculatingRoute": "Obliczanie trasy...",
|
||||
"routeCalculationFailed": "Obliczanie trasy nie powiodło się",
|
||||
"start": "Start",
|
||||
"resume": "Wznów",
|
||||
"endRoute": "Zakończ Trasę",
|
||||
"routeOverview": "Przegląd Trasy",
|
||||
"retry": "Spróbuj Ponownie",
|
||||
"cancelSearch": "Anuluj wyszukiwanie",
|
||||
"noResultsFound": "Nie znaleziono wyników",
|
||||
"searching": "Wyszukiwanie...",
|
||||
"location": "Lokalizacja",
|
||||
"startPoint": "Start",
|
||||
"endPoint": "Koniec",
|
||||
"startSelect": "Start (wybierz)",
|
||||
"endSelect": "Koniec (wybierz)",
|
||||
"distance": "Odległość: {} km",
|
||||
"routeActive": "Trasa aktywna",
|
||||
"locationsTooClose": "Lokalizacje startu i mety są zbyt blisko siebie",
|
||||
"navigationSettings": "Nawigacja",
|
||||
"navigationSettingsSubtitle": "Planowanie tras i ustawienia unikania",
|
||||
"avoidanceDistance": "Odległość Unikania",
|
||||
"avoidanceDistanceSubtitle": "Minimalna odległość do utrzymania od urządzeń nadzoru",
|
||||
"searchHistory": "Maksymalna Historia Wyszukiwania",
|
||||
"searchHistorySubtitle": "Maksymalna liczba ostatnich wyszukiwań do zapamiętania"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Podejrzane Lokalizacje",
|
||||
"showSuspectedLocations": "Pokaż Podejrzane Lokalizacje",
|
||||
"showSuspectedLocationsSubtitle": "Pokaż znaczniki znaku zapytania dla podejrzanych miejsc nadzoru z danych pozwoleń użyteczności publicznej",
|
||||
"lastUpdated": "Ostatnio Zaktualizowane",
|
||||
"refreshNow": "Odśwież teraz",
|
||||
"dataSource": "Źródło Danych",
|
||||
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
|
||||
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
|
||||
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru",
|
||||
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
|
||||
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
|
||||
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
|
||||
"updateFailed": "Nie udało się zaktualizować podejrzanych lokalizacji",
|
||||
"neverFetched": "Nigdy nie pobrano",
|
||||
"daysAgo": "{} dni temu",
|
||||
"hoursAgo": "{} godzin temu",
|
||||
"minutesAgo": "{} minut temu",
|
||||
"justNow": "Właśnie teraz"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Podejrzana Lokalizacja #{}",
|
||||
"ticketNo": "Nr Biletu",
|
||||
"address": "Adres",
|
||||
"street": "Ulica",
|
||||
"city": "Miasto",
|
||||
"state": "Województwo",
|
||||
"intersectingStreet": "Przecinająca Ulica",
|
||||
"workDoneFor": "Praca Wykonana Dla",
|
||||
"remarks": "Uwagi",
|
||||
"url": "URL",
|
||||
"coordinates": "Współrzędne",
|
||||
"noAddressAvailable": "Brak dostępnego adresu"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mila",
|
||||
"metersLong": "metry",
|
||||
"feetLong": "stopy",
|
||||
"kilometersLong": "kilometry",
|
||||
"milesLong": "mile",
|
||||
"metric": "Metryczny",
|
||||
"imperial": "Imperialny",
|
||||
"metricDescription": "Metryczny (km, m)",
|
||||
"imperialDescription": "Imperialny (mila, ft)"
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
"downloadFailed": "Falha ao iniciar o download: {}",
|
||||
"offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).",
|
||||
"currentTileProvider": "tile atual",
|
||||
"noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
|
||||
"createCustomProfile": "Criar Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
"manageProviders": "Gerenciar Provedores",
|
||||
"attribution": "Atribuição do Mapa"
|
||||
"attribution": "Atribuição do Mapa",
|
||||
"mapAttribution": "Atribuição do mapa: {}",
|
||||
"couldNotOpenLink": "Não foi possível abrir o link",
|
||||
"openLicense": "Abrir licença: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Novo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nome do operador",
|
||||
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Falha ao atualizar localizações suspeitas",
|
||||
"neverFetched": "Nunca buscado",
|
||||
"daysAgo": "{} dias atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"minutesAgo": "{} minutos atrás",
|
||||
"justNow": "Agora mesmo"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Localização Suspeita #{}",
|
||||
"ticketNo": "N° do Ticket",
|
||||
"address": "Endereço",
|
||||
"street": "Rua",
|
||||
"street": "Rua",
|
||||
"city": "Cidade",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Rua que Cruza",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
561
lib/localizations/tr.json
Normal file
561
lib/localizations/tr.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Türkçe"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Gözetleme Şeffaflığı",
|
||||
"description": "DeFlock, OpenStreetMap kullanarak kamusal gözetleme altyapısını haritalamak için tasarlanmış gizlilik odaklı bir mobil uygulamadır. Topluluğunuzdaki kameraları, ALPR'leri, silah sesi dedektörlerini ve diğer gözetleme cihazlarını belgeleyerek bu altyapıyı görünür ve aranabilir hale getirin.",
|
||||
"features": "• İndirilebilir alanlarla çevrimdışı haritalama\n• OAuth2 ile doğrudan OpenStreetMap'e yükleme\n• Büyük üreticiler için yerleşik profiller\n• Gizlilik dostu - kullanıcı verisi toplanmaz\n• Çoklu harita döşemeleri (OSM, uydu görüntüleri)",
|
||||
"initiative": "Gözetleme şeffaflığını destekleyen daha geniş DeFlock girişiminin parçasıdır.",
|
||||
"footer": "Ziyaret edin: deflock.me\nFlutter ile yapıldı • Açık Kaynak",
|
||||
"showWelcome": "Hoş Geldin Mesajını Göster",
|
||||
"showSubmissionGuide": "Gönderim Rehberini Göster",
|
||||
"viewReleaseNotes": "Sürüm Notlarını Görüntüle"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "DeFlock'a Hoş Geldiniz",
|
||||
"description": "DeFlock, kamusal gözetleme araçlarının şeffaf olması gerektiği fikri üzerine kurulmuştur. Bu mobil uygulama içerisinde, web sitesinde olduğu gibi, yerel bölgenizdeki ve yurtdışındaki ALPR'lerin ve diğer gözetleme altyapılarının konumlarını görebileceksiniz.",
|
||||
"mission": "Ancak bu proje otomatik değil; bu projeyi daha iyi hale getirmek hepimize bağlı. Haritayı görüntülerken, daha önce bilinmeyen bir kurulumu eklemek için \"Yeni Düğüm\"e dokunabilirsiniz. Yardımınızla, gözetleme altyapısının şeffaflığını ve kamusal farkındalığını artırma hedefimize ulaşabiliriz.",
|
||||
"firsthandKnowledge": "ÖNEMLİ: Sadece kişisel olarak gözlemlediğiniz gözetleme cihazlarını ekleyin. OpenStreetMap ve Google politikaları, Street View görüntüleri gibi kaynaklarının gönderimler için kullanılmasını yasaklar. Katkılarınız kendi doğrudan gözlemlerinize dayanmalıdır.",
|
||||
"privacy": "Gizlilik Notu: Bu uygulama tamamen cihazınızda yerel olarak çalışır ve veri depolama ve gönderimler için üçüncü taraf OpenStreetMap API'sini kullanır. DeFlock herhangi bir kullanıcı verisini toplamaz veya saklamaz ve hesap yönetiminden sorumlu değildir.",
|
||||
"tileNote": "NOT: OpenStreetMap'den ücretsiz harita döşemeleri yüklenmesi çok yavaş olabilir. Alternatif döşeme sağlayıcıları Ayarlar > Gelişmiş'te yapılandırılabilir.",
|
||||
"moreInfo": "Daha fazla bağlantıyı Ayarlar > Hakkında'da bulabilirsiniz.",
|
||||
"dontShowAgain": "Bu hoş geldin mesajını bir daha gösterme",
|
||||
"getStarted": "Hadi DeFlocking'e Başlayalım!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Gönderim En İyi Uygulamaları",
|
||||
"description": "İlk gözetleme cihazınızı göndermeden önce, OpenStreetMap'e yüksek kaliteli katkılar sağlamak için bu önemli yönergeleri gözden geçirin.",
|
||||
"bestPractices": "• Sadece kişisel olarak gözlemlediğiniz cihazları haritalayın\n• Cihaz tipini ve üreticisini doğru şekilde belirlemeye zaman ayırın\n• Hassas konumlandırma kullanın - işaretçiyi yerleştirmeden önce yakınlaştırın\n• Uygun olduğunda yön bilgisini dahil edin\n• Göndermeden önce etiket seçimlerinizi iki kez kontrol edin",
|
||||
"placementNote": "Unutmayın: Doğru, ilk elden veriler DeFlock topluluğu ve OpenStreetMap projesi için temeldir.",
|
||||
"moreInfo": "Cihaz tanımlama ve haritalama en iyi uygulamaları için ayrıntılı rehberlik:",
|
||||
"identificationGuide": "Tanımlama Rehberi",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Bu rehberi bir daha gösterme",
|
||||
"gotIt": "Anladım!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Konumunuzu Hassaslaştırın",
|
||||
"instructions": "Cihaz işaretçisini gözetleme cihazının konumu üzerine tam olarak yerleştirmek için haritayı sürükleyin.",
|
||||
"hint": "Konumlandırmadan önce daha iyi doğruluk için yakınlaştırabilirsiniz."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Yeni Düğüm",
|
||||
"download": "İndir",
|
||||
"settings": "Ayarlar",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"ok": "Tamam",
|
||||
"close": "Kapat",
|
||||
"submit": "Gönder",
|
||||
"logIn": "Giriş Yap",
|
||||
"saveEdit": "Düzenlemeyi Kaydet",
|
||||
"clear": "Temizle",
|
||||
"viewOnOSM": "OSM'de Görüntüle",
|
||||
"advanced": "Gelişmiş",
|
||||
"useAdvancedEditor": "Gelişmiş Düzenleyiciyi Kullan"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Düğüm Mevcut Cihaza Çok Yakın",
|
||||
"message": "Bu düğüm mevcut bir gözetleme cihazından sadece {} metre uzakta.",
|
||||
"suggestion": "Aynı direk üzerinde birden fazla cihaz varsa, ayrı düğümler oluşturmak yerine lütfen tek bir düğümde birden fazla yön kullanın.",
|
||||
"nearbyNodes": "Yakındaki cihaz(lar) bulundu ({}):",
|
||||
"nodeInfo": "Düğüm #{} - {}",
|
||||
"andMore": "...ve {} tane daha",
|
||||
"goBack": "Geri Dön",
|
||||
"submitAnyway": "Yine de Gönder",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Kamerası",
|
||||
"publicCamera": "Kamusal Güvenlik Kamerası",
|
||||
"camera": "Güvenlik Kamerası",
|
||||
"amenity": "{}",
|
||||
"device": "{} Cihazı",
|
||||
"unknown": "Bilinmeyen Cihaz"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Takip etmeyi etkinleştir",
|
||||
"follow": "Takip etmeyi etkinleştir (dönen)",
|
||||
"rotating": "Takip etmeyi devre dışı bırak"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil ve Bölge",
|
||||
"systemDefault": "Sistem Varsayılanı",
|
||||
"aboutInfo": "Hakkında / Bilgi",
|
||||
"aboutThisApp": "Bu Uygulama Hakkında",
|
||||
"aboutSubtitle": "Uygulama bilgileri ve krediler",
|
||||
"languageSubtitle": "Tercih ettiğiniz dili ve birimleri seçin",
|
||||
"distanceUnit": "Mesafe Birimleri",
|
||||
"distanceUnitSubtitle": "Metrik (km/m) veya imperial (mil/ft) birimler arasında seçim yapın",
|
||||
"metricUnits": "Metrik (km, m)",
|
||||
"imperialUnits": "İmperial (mil, ft)",
|
||||
"maxNodes": "Çizilen maksimum düğüm",
|
||||
"maxNodesSubtitle": "Haritadaki düğüm sayısı için üst limit belirleyin.",
|
||||
"maxNodesWarning": "Bunu yapmak için kesinlikle iyi bir nedeniniz olduğundan emin değilseniz, bunu yapmak istemezsiniz.",
|
||||
"offlineMode": "Çevrimdışı Mod",
|
||||
"offlineModeSubtitle": "Yerel/çevrimdışı alanlar dışındaki tüm ağ isteklerini devre dışı bırak.",
|
||||
"pauseQueueProcessing": "Yükleme Kuyruğunu Duraklat",
|
||||
"pauseQueueProcessingSubtitle": "Canlı veri erişimini korurken sıraya alınan değişiklikleri yüklemeyi durdur.",
|
||||
"offlineModeWarningTitle": "Aktif İndirmeler",
|
||||
"offlineModeWarningMessage": "Çevrimdışı modu etkinleştirmek aktif alan indirmelerini iptal edecektir. Devam etmek istiyor musunuz?",
|
||||
"enableOfflineMode": "Çevrimdışı Modu Etkinleştir",
|
||||
"profiles": "Profiller",
|
||||
"profilesSubtitle": "Düğüm ve operatör profillerini yönet",
|
||||
"offlineSettings": "Çevrimdışı Ayarlar",
|
||||
"offlineSettingsSubtitle": "Çevrimdışı mod ve indirilen alanları yönet",
|
||||
"advancedSettings": "Gelişmiş Ayarlar",
|
||||
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
|
||||
"proximityAlerts": "Yakınlık Uyarıları",
|
||||
"networkStatusIndicator": "Ağ Durumu Göstergesi"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
|
||||
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
|
||||
"notificationsEnabled": "✓ Bildirimler etkinleştirildi",
|
||||
"notificationsDisabled": "⚠ Bildirimler devre dışı",
|
||||
"permissionRequired": "Bildirim izni gerekli",
|
||||
"permissionExplanation": "Push bildirimleri devre dışı. Sadece uygulama içi uyarıları göreceksiniz ve uygulama arka plandayken bilgilendirilmeyeceksiniz.",
|
||||
"enableNotifications": "Bildirimleri Etkinleştir",
|
||||
"checkingPermissions": "İzinler kontrol ediliyor...",
|
||||
"alertDistance": "Uyarı mesafesi: ",
|
||||
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Düğüm #{}",
|
||||
"tagSheetTitle": "Gözetleme Cihazı Etiketleri",
|
||||
"queuedForUpload": "Düğüm yükleme için sıraya alındı",
|
||||
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||
"confirmDeleteTitle": "Düğümü Sil",
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Bir profil seçin...",
|
||||
"profileRequired": "Devam etmek için lütfen bir profil seçin.",
|
||||
"direction": "Yön {}°",
|
||||
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
|
||||
"mustBeLoggedIn": "Yeni düğümler göndermek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
|
||||
"enableSubmittableProfile": "Yeni düğümler göndermek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
|
||||
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Yeni düğümler göndermek için lütfen gönderilebilir bir profil seçin.",
|
||||
"loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.",
|
||||
"refineTags": "Etiketleri Düzenle"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Düğümü Düzenle #{}",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Bir profil seçin...",
|
||||
"profileRequired": "Devam etmek için lütfen bir profil seçin.",
|
||||
"direction": "Yön {}°",
|
||||
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
|
||||
"temporarilyDisabled": "Bir hatayı çözmemiz sırasında düzenlemeler geçici olarak devre dışı bırakıldı - özür dileriz - yakında tekrar kontrol edin.",
|
||||
"mustBeLoggedIn": "Düğümleri düzenlemek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
|
||||
"sandboxModeWarning": "Üretim düğümlerinde düzenlemeleri sandbox'a gönderemezsiniz. Düğümleri düzenlemek için Ayarlar'da Üretim moduna geçin.",
|
||||
"enableSubmittableProfile": "Düğümleri düzenlemek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
|
||||
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Düğümleri düzenlemek için lütfen gönderilebilir bir profil seçin.",
|
||||
"loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.",
|
||||
"cannotMoveConstrainedNode": "Bu kamerayı taşıyamazsınız - başka bir harita öğesine (OSM yolu/ilişkisi) bağlı. Yine de etiketlerini ve yönünü düzenleyebilirsiniz.",
|
||||
"zoomInRequiredMessage": "Gözetleme düğümleri eklemek veya düzenlemek için en az {} seviyesine yakınlaştırın. Bu doğru haritalama için hassas konumlandırmayı sağlar.",
|
||||
"extractFromWay": "Düğümü yol/ilişkiden çıkar",
|
||||
"extractFromWaySubtitle": "Aynı etiketlerle yeni düğüm oluştur, yeni konuma taşımaya izin ver",
|
||||
"refineTags": "Etiketleri Düzenle",
|
||||
"existingTags": "<Mevcut etiketler>",
|
||||
"noChangesDetected": "Değişiklik tespit edilmedi - gönderilecek bir şey yok",
|
||||
"noChangesTitle": "Gönderilecek Değişiklik Yok",
|
||||
"noChangesMessage": "Bu düğümde herhangi bir değişiklik yapmadınız. Düzenleme göndermek için konumu, profili, yönleri veya etiketleri değiştirmeniz gerekir."
|
||||
},
|
||||
"download": {
|
||||
"title": "Harita Alanını İndir",
|
||||
"maxZoomLevel": "Maksimum yakınlaştırma seviyesi",
|
||||
"storageEstimate": "Depolama tahmini:",
|
||||
"tilesAndSize": "{} döşeme, {} MB",
|
||||
"minZoom": "Min yakınlaştırma:",
|
||||
"maxRecommendedZoom": "Maksimum önerilen yakınlaştırma: Z{}",
|
||||
"withinTileLimit": "{} döşeme sınırı içinde",
|
||||
"exceedsTileLimit": "Mevcut seçim {} döşeme sınırını aşıyor",
|
||||
"offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.",
|
||||
"areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.",
|
||||
"downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...",
|
||||
"downloadFailed": "İndirme başlatılamadı: {}",
|
||||
"offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).",
|
||||
"currentTileProvider": "mevcut döşeme",
|
||||
"noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "İndirme Başladı",
|
||||
"message": "İndirme başladı! Döşemeler ve düğümler getiriliyor...",
|
||||
"ok": "Tamam",
|
||||
"viewProgress": "Ayarlarda İlerlemeyi Görüntüle"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Yükleme Hedefi",
|
||||
"subtitle": "Kameraların nereye yüklendiğini seçin",
|
||||
"production": "Üretim",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simülasyon",
|
||||
"productionDescription": "Canlı OSM veritabanına yükle (tüm kullanıcılara görünür)",
|
||||
"sandboxDescription": "Yüklemeler OSM Sandbox'ına gider (test için güvenli, düzenli olarak sıfırlanır).",
|
||||
"simulateDescription": "Yüklemeleri simüle et (OSM sunucularıyla iletişim kurmaz)",
|
||||
"cannotChangeWithQueue": "Sırada {} öğe varken yükleme hedefi değiştirilemez. Önce sırayı temizleyin."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Hesabı",
|
||||
"osmAccountSubtitle": "OSM girişinizi yönetin ve katkılarınızı görüntüleyin",
|
||||
"loggedInAs": "{} olarak giriş yapıldı",
|
||||
"loginToOSM": "OpenStreetMap'e giriş yap",
|
||||
"tapToLogout": "Çıkış yapmak için dokun",
|
||||
"requiredToSubmit": "Kamera verisi göndermek için gerekli",
|
||||
"loggedOut": "Çıkış yapıldı",
|
||||
"testConnection": "Bağlantıyı Test Et",
|
||||
"testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın",
|
||||
"connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli",
|
||||
"connectionFailed": "Bağlantı başarısız - lütfen yeniden giriş yapın",
|
||||
"viewMyEdits": "OSM'deki Düzenlemelerimi Görüntüle",
|
||||
"viewMyEditsSubtitle": "OpenStreetMap'teki düzenleme geçmişinizi görün",
|
||||
"aboutOSM": "OpenStreetMap Hakkında",
|
||||
"aboutOSMDescription": "OpenStreetMap, katkıda bulunanların dünyanın ücretsiz, düzenlenebilir haritasını oluşturdukları ve sürdürdükleri işbirlikçi, açık kaynaklı bir haritalama projesidir. Gözetleme cihazı katkılarınız bu altyapıyı görünür ve aranabilir hale getirmeye yardımcı olur.",
|
||||
"visitOSM": "OpenStreetMap'i Ziyaret Et",
|
||||
"deleteAccount": "OSM Hesabını Sil",
|
||||
"deleteAccountSubtitle": "OpenStreetMap hesabınızı yönetin",
|
||||
"deleteAccountExplanation": "OpenStreetMap hesabınızı silmek için OpenStreetMap web sitesini ziyaret etmeniz gerekecek. Bu, OSM hesabınızı ve ilişkili tüm verileri kalıcı olarak kaldıracaktır.",
|
||||
"deleteAccountWarning": "Uyarı: Bu işlem geri alınamaz ve OSM hesabınızı kalıcı olarak silecektir.",
|
||||
"goToOSM": "OpenStreetMap'e Git",
|
||||
"accountManagement": "Hesap Yönetimi",
|
||||
"accountManagementDescription": "OpenStreetMap hesabınızı silmek için uygun OpenStreetMap web sitesini ziyaret etmeniz gerekecek. Bu, hesabınızı ve ilişkili tüm verileri kalıcı olarak kaldıracaktır.",
|
||||
"currentDestinationProduction": "Şu anda bağlı: Üretim OpenStreetMap",
|
||||
"currentDestinationSandbox": "Şu anda bağlı: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Şu anda: Simülasyon modu (gerçek hesap yok)",
|
||||
"viewMessages": "OSM'deki Mesajları Görüntüle",
|
||||
"unreadMessagesCount": "{} okunmamış mesajınız var",
|
||||
"noUnreadMessages": "Okunmamış mesaj yok",
|
||||
"reauthRequired": "Kimlik Doğrulamayı Yenile",
|
||||
"reauthExplanation": "Uygulama üzerinden OSM mesaj bildirimlerini alabilmek için kimlik doğrulamanızı yenilemeniz gerekir.",
|
||||
"reauthBenefit": "Bu, OpenStreetMap'te okunmamış mesajlarınız olduğunda bildirim noktalarını etkinleştirecek.",
|
||||
"reauthNow": "Şimdi Yap",
|
||||
"reauthLater": "Sonra"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Yükleme Kuyruğu",
|
||||
"subtitle": "Bekleyen gözetleme cihazı yüklemelerini yönet",
|
||||
"pendingUploads": "Bekleyen yüklemeler: {}",
|
||||
"pendingItemsCount": "Bekleyen Öğeler: {}",
|
||||
"nothingInQueue": "Kuyrukta hiçbir şey yok",
|
||||
"simulateModeEnabled": "Simülasyon modu etkin – yüklemeler simüle ediliyor",
|
||||
"sandboxMode": "Sandbox modu – yüklemeler OSM Sandbox'ına gidiyor",
|
||||
"tapToViewQueue": "Kuyruğu görüntülemek için dokun",
|
||||
"clearUploadQueue": "Yükleme Kuyruğunu Temizle",
|
||||
"removeAllPending": "Tüm {} bekleyen yüklemeyi kaldır",
|
||||
"clearQueueTitle": "Kuyruğu Temizle",
|
||||
"clearQueueConfirm": "Tüm {} bekleyen yükleme kaldırılsın mı?",
|
||||
"queueCleared": "Kuyruk temizlendi",
|
||||
"uploadQueueTitle": "Yükleme Kuyruğu ({} öğe)",
|
||||
"queueIsEmpty": "Kuyruk boş",
|
||||
"itemWithIndex": "Öğe {}",
|
||||
"error": " (Hata)",
|
||||
"completing": " (Tamamlanıyor...)",
|
||||
"destination": "Hedef: {}",
|
||||
"latitude": "Enlem: {}",
|
||||
"longitude": "Boylam: {}",
|
||||
"direction": "Yön: {}°",
|
||||
"attempts": "Denemeler: {}",
|
||||
"uploadFailedRetry": "Yükleme başarısız. Tekrar denemek için yeniden dene'ye dokun.",
|
||||
"retryUpload": "Yüklemeyi yeniden dene",
|
||||
"clearAll": "Tümünü Temizle",
|
||||
"errorDetails": "Hata Detayları",
|
||||
"creatingChangeset": " (Değişiklik seti oluşturuluyor...)",
|
||||
"uploading": " (Yükleniyor...)",
|
||||
"closingChangeset": " (Değişiklik seti kapatılıyor...)",
|
||||
"processingPaused": "Kuyruk İşleme Duraklatıldı",
|
||||
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
|
||||
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Döşeme Sağlayıcıları",
|
||||
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
|
||||
"tileTypesCount": "{} döşeme türü",
|
||||
"apiKeyConfigured": "API Anahtarı yapılandırıldı",
|
||||
"needsApiKey": "API anahtarı gerekiyor",
|
||||
"editProvider": "Sağlayıcıyı Düzenle",
|
||||
"addProvider": "Sağlayıcı Ekle",
|
||||
"deleteProvider": "Sağlayıcıyı Sil",
|
||||
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"providerName": "Sağlayıcı Adı",
|
||||
"providerNameHint": "örn., Özel Haritalar A.Ş.",
|
||||
"providerNameRequired": "Sağlayıcı adı gerekli",
|
||||
"apiKey": "API Anahtarı (İsteğe Bağlı)",
|
||||
"apiKeyHint": "Döşeme türleri gerektiriyorsa API anahtarını girin",
|
||||
"tileTypes": "Döşeme Türleri",
|
||||
"addType": "Tür Ekle",
|
||||
"noTileTypesConfigured": "Döşeme türü yapılandırılmamış",
|
||||
"atLeastOneTileTypeRequired": "En az bir döşeme türü gerekli",
|
||||
"manageTileProviders": "Sağlayıcıları Yönet"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Döşeme Türünü Düzenle",
|
||||
"addTileType": "Döşeme Türü Ekle",
|
||||
"name": "Ad",
|
||||
"nameHint": "örn., Uydu",
|
||||
"nameRequired": "Ad gerekli",
|
||||
"urlTemplate": "URL Şablonu",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL şablonu gerekli",
|
||||
"urlTemplatePlaceholders": "URL ya {quadkey} ya da {z}, {x}, ve {y} yer tutucularını içermelidir",
|
||||
"attribution": "Atıf",
|
||||
"attributionHint": "© Harita Sağlayıcısı",
|
||||
"attributionRequired": "Atıf gerekli",
|
||||
"maxZoom": "Maksimum Yakınlaştırma Seviyesi",
|
||||
"maxZoomHint": "Maksimum yakınlaştırma seviyesi (1-23)",
|
||||
"maxZoomRequired": "Maksimum yakınlaştırma gerekli",
|
||||
"maxZoomInvalid": "Maksimum yakınlaştırma bir sayı olmalıdır",
|
||||
"maxZoomRange": "Maksimum yakınlaştırma {} ile {} arasında olmalıdır",
|
||||
"fetchPreview": "Önizleme Getir",
|
||||
"previewTileLoaded": "Önizleme döşemesi başarıyla yüklendi",
|
||||
"previewTileFailed": "Önizleme getirilemedi: {}",
|
||||
"save": "Kaydet"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Düğüm Profilleri",
|
||||
"newProfile": "Yeni Profil",
|
||||
"builtIn": "Yerleşik",
|
||||
"custom": "Özel",
|
||||
"view": "Görüntüle",
|
||||
"deleteProfile": "Profili Sil",
|
||||
"deleteProfileConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"profileDeleted": "Profil silindi",
|
||||
"getMore": "Daha fazla al...",
|
||||
"addProfileChoice": "Profil Ekle",
|
||||
"addProfileChoiceMessage": "Nasıl bir profil eklemek istersiniz?",
|
||||
"createCustomProfile": "Özel Profil Oluştur",
|
||||
"createCustomProfileDescription": "Kendi etiketlerinizle sıfırdan bir profil oluşturun",
|
||||
"importFromWebsite": "Web Sitesinden İçe Aktar",
|
||||
"importFromWebsiteDescription": "deflock.me/identify'dan profilleri inceleyin ve içe aktarın"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Harita Döşemeleri",
|
||||
"manageProviders": "Sağlayıcıları Yönet",
|
||||
"attribution": "Harita Atfı",
|
||||
"mapAttribution": "Harita kaynağı: {}",
|
||||
"couldNotOpenLink": "Bağlantı açılamadı",
|
||||
"openLicense": "Lisansı aç: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profili Görüntüle",
|
||||
"newProfile": "Yeni Profil",
|
||||
"editProfile": "Profili Düzenle",
|
||||
"profileName": "Profil adı",
|
||||
"profileNameHint": "örn., Özel ALPR Kamerası",
|
||||
"profileNameRequired": "Profil adı gerekli",
|
||||
"requiresDirection": "Yön Gerektirir",
|
||||
"requiresDirectionSubtitle": "Bu türdeki kameraların yön etiketi gerekip gerekmediği",
|
||||
"fov": "Görüş Alanı",
|
||||
"fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)",
|
||||
"fovSubtitle": "Kamera görüş alanı - koni genişliği ve aralık gönderim formatı için kullanılır",
|
||||
"fovInvalid": "FOV 1 ile 360 derece arasında olmalıdır",
|
||||
"submittable": "Gönderilebilir",
|
||||
"submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı",
|
||||
"osmTags": "OSM Etiketleri",
|
||||
"addTag": "Etiket ekle",
|
||||
"saveProfile": "Profili Kaydet",
|
||||
"keyHint": "anahtar",
|
||||
"valueHint": "değer",
|
||||
"atLeastOneTagRequired": "En az bir etiket gerekli",
|
||||
"profileSaved": "\"{}\" profili kaydedildi"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Yeni Operatör Profili",
|
||||
"editOperatorProfile": "Operatör Profilini Düzenle",
|
||||
"operatorName": "Operatör adı",
|
||||
"operatorNameHint": "örn., Ankara Polis Müdürlüğü",
|
||||
"operatorNameRequired": "Operatör adı gerekli",
|
||||
"operatorProfileSaved": "\"{}\" operatör profili kaydedildi"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operatör Profilleri",
|
||||
"noProfilesMessage": "Operatör profili tanımlanmamış. Düğüm gönderimlerine operatör etiketleri uygulamak için bir tane oluşturun.",
|
||||
"tagsCount": "{} etiket",
|
||||
"deleteOperatorProfile": "Operatör Profilini Sil",
|
||||
"deleteOperatorProfileConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"operatorProfileDeleted": "Operatör profili silindi"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Çevrimdışı Alanlar",
|
||||
"noAreasTitle": "Çevrimdışı alan yok",
|
||||
"noAreasSubtitle": "Çevrimdışı kullanım için bir harita alanı indirin.",
|
||||
"provider": "Sağlayıcı",
|
||||
"maxZoom": "Maksimum yakınlaştırma",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Enlem",
|
||||
"longitude": "Boylam",
|
||||
"tiles": "Döşemeler",
|
||||
"size": "Boyut",
|
||||
"nodes": "Düğümler",
|
||||
"areaIdFallback": "Alan {}...",
|
||||
"renameArea": "Alanı yeniden adlandır",
|
||||
"refreshWorldTiles": "Dünya döşemelerini yenile/yeniden indir",
|
||||
"deleteOfflineArea": "Çevrimdışı alanı sil",
|
||||
"cancelDownload": "İndirmeyi iptal et",
|
||||
"renameAreaDialogTitle": "Çevrimdışı Alanı Yeniden Adlandır",
|
||||
"areaNameLabel": "Alan Adı",
|
||||
"renameButton": "Yeniden Adlandır",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Alanı yenile",
|
||||
"refreshAreaDialogTitle": "Çevrimdışı Alanı Yenile",
|
||||
"refreshAreaDialogSubtitle": "Bu alan için ne yenilenecek seçin:",
|
||||
"refreshTiles": "Harita Döşemelerini Yenile",
|
||||
"refreshTilesSubtitle": "Güncellenmiş görüntüler için tüm döşemeleri yeniden indir",
|
||||
"refreshNodes": "Düğümleri Yenile",
|
||||
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
||||
"startRefresh": "Yenilemeyi Başlat",
|
||||
"refreshStarted": "Yenileme başladı!",
|
||||
"refreshFailed": "Yenileme başarısız: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Etiketleri Düzenle",
|
||||
"operatorProfile": "Operatör Profili",
|
||||
"done": "Tamam",
|
||||
"none": "Hiçbiri",
|
||||
"noAdditionalOperatorTags": "Ek operatör etiketi yok",
|
||||
"additionalTags": "ek etiketler",
|
||||
"additionalTagsTitle": "Ek Etiketler",
|
||||
"noTagsDefinedForProfile": "Bu operatör profili için etiket tanımlanmamış.",
|
||||
"noOperatorProfiles": "Operatör profili tanımlanmamış",
|
||||
"noOperatorProfilesMessage": "Düğüm gönderimlerinize ek etiketler uygulamak için Ayarlar'da operatör profilleri oluşturun.",
|
||||
"profileTags": "Profil Etiketleri",
|
||||
"profileTagsDescription": "Daha detaylı gönderimler için bu isteğe bağlı etiket değerlerini tamamlayın:",
|
||||
"selectValue": "Değer seçin...",
|
||||
"noValue": "(boş bırak)",
|
||||
"noSuggestions": "Öneri bulunmuyor",
|
||||
"existingTagsTitle": "Mevcut Etiketler",
|
||||
"existingTagsDescription": "Bu cihazdaki mevcut etiketleri düzenleyin. Herhangi bir etiketi ekleyin, kaldırın veya değiştirin:",
|
||||
"existingOperator": "<Mevcut operatör>",
|
||||
"existingOperatorTags": "mevcut operatör etiketleri"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Çevrimdışı alanlar indirilirken döşeme türleri değiştirilemez",
|
||||
"selectMapLayer": "Harita Katmanını Seç",
|
||||
"noTileProvidersAvailable": "Döşeme sağlayıcısı mevcut değil"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Gelişmiş Düzenleme Seçenekleri",
|
||||
"subtitle": "Bu editörler karmaşık düzenlemeler için daha gelişmiş özellikler sunar.",
|
||||
"webEditors": "Web Editörleri",
|
||||
"mobileEditors": "Mobil Editörler",
|
||||
"iDEditor": "iD Editörü",
|
||||
"iDEditorSubtitle": "Tam özellikli web editörü - her zaman çalışır",
|
||||
"rapidEditor": "RapiD Editörü",
|
||||
"rapidEditorSubtitle": "Facebook verileriyle AI destekli düzenleme",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Gelişmiş Android OSM editörü",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Anket tabanlı haritalama uygulaması",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Hızlı POI düzenleme",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM editörü",
|
||||
"couldNotOpenEditor": "Editör açılamadı - uygulama yüklü olmayabilir",
|
||||
"couldNotOpenURL": "URL açılamadı",
|
||||
"couldNotOpenOSMWebsite": "OSM web sitesi açılamadı"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Ağ durumu göstergesini göster",
|
||||
"showIndicatorSubtitle": "Gözetleme verisi yükleme ve hata durumunu göster",
|
||||
"loading": "Gözetleme verisi yükleniyor...",
|
||||
"timedOut": "İstek zaman aşımına uğradı",
|
||||
"noData": "Çevrimdışı veri yok",
|
||||
"success": "Gözetleme verisi yüklendi",
|
||||
"nodeDataSlow": "Gözetleme verisi yavaş",
|
||||
"rateLimited": "Sunucu tarafından hız sınırlandı",
|
||||
"networkError": "Ağ hatası"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "{total} cihazdan {rendered} tanesi gösteriliyor",
|
||||
"editingDisabledMessage": "Güvenli düzenleme için çok fazla cihaz gösteriliyor. Görünen cihaz sayısını azaltmak için daha fazla yakınlaştırın, sonra tekrar deneyin."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Konum Ara",
|
||||
"searchPlaceholder": "Yerler veya koordinatlar ara...",
|
||||
"routeTo": "Buraya Yol Tarifi",
|
||||
"routeFrom": "Buradan Yol Tarifi",
|
||||
"selectLocation": "Konum Seç",
|
||||
"calculatingRoute": "Rota hesaplanıyor...",
|
||||
"routeCalculationFailed": "Rota hesaplama başarısız",
|
||||
"start": "Başlat",
|
||||
"resume": "Devam Et",
|
||||
"endRoute": "Rotayı Bitir",
|
||||
"routeOverview": "Rota Özeti",
|
||||
"retry": "Yeniden Dene",
|
||||
"cancelSearch": "Aramayı iptal et",
|
||||
"noResultsFound": "Sonuç bulunamadı",
|
||||
"searching": "Aranıyor...",
|
||||
"location": "Konum",
|
||||
"startPoint": "Başlangıç",
|
||||
"endPoint": "Bitiş",
|
||||
"startSelect": "Başlangıç (seç)",
|
||||
"endSelect": "Bitiş (seç)",
|
||||
"distance": "Mesafe: {} km",
|
||||
"routeActive": "Rota aktif",
|
||||
"locationsTooClose": "Başlangıç ve bitiş konumları birbirine çok yakın",
|
||||
"navigationSettings": "Navigasyon",
|
||||
"navigationSettingsSubtitle": "Rota planlama ve kaçınma ayarları",
|
||||
"avoidanceDistance": "Kaçınma Mesafesi",
|
||||
"avoidanceDistanceSubtitle": "Gözetleme cihazlarından uzak durmak için minimum mesafe",
|
||||
"searchHistory": "Maksimum Arama Geçmişi",
|
||||
"searchHistorySubtitle": "Hatırlanacak son aramaların maksimum sayısı"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Şüpheli Konumlar",
|
||||
"showSuspectedLocations": "Şüpheli Konumları Göster",
|
||||
"showSuspectedLocationsSubtitle": "Altyapı izin verilerinden şüpheli gözetleme siteleri için soru işareti işaretçilerini göster",
|
||||
"lastUpdated": "Son Güncellenme",
|
||||
"refreshNow": "Şimdi yenile",
|
||||
"dataSource": "Veri Kaynağı",
|
||||
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
|
||||
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
|
||||
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
|
||||
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}m yakınındaki şüpheli konumları gizle",
|
||||
"updating": "Şüpheli Konumlar Güncelleniyor",
|
||||
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
|
||||
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
|
||||
"updateFailed": "Şüpheli konumları güncelleme başarısız",
|
||||
"neverFetched": "Hiç getirilmedi",
|
||||
"daysAgo": "{} gün önce",
|
||||
"hoursAgo": "{} saat önce",
|
||||
"minutesAgo": "{} dakika önce",
|
||||
"justNow": "Şimdi"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Şüpheli Konum #{}",
|
||||
"ticketNo": "Bilet No",
|
||||
"address": "Adres",
|
||||
"street": "Sokak",
|
||||
"city": "Şehir",
|
||||
"state": "Eyalet",
|
||||
"intersectingStreet": "Kesişen Sokak",
|
||||
"workDoneFor": "İş Yapılan",
|
||||
"remarks": "Açıklamalar",
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinatlar",
|
||||
"noAddressAvailable": "Adres mevcut değil"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mil",
|
||||
"metersLong": "metre",
|
||||
"feetLong": "fit",
|
||||
"kilometersLong": "kilometre",
|
||||
"milesLong": "mil",
|
||||
"metric": "Metrik",
|
||||
"imperial": "İmperial",
|
||||
"metricDescription": "Metrik (km, m)",
|
||||
"imperialDescription": "İmperial (mil, ft)"
|
||||
}
|
||||
}
|
||||
561
lib/localizations/uk.json
Normal file
561
lib/localizations/uk.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Українська"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Прозорість Спостереження",
|
||||
"description": "DeFlock - це мобільний додаток, зосереджений на конфіденційності, для картування громадської інфраструктури спостереження з використанням OpenStreetMap. Документуйте камери, ALPR, детектори пострілів та інші пристрої спостереження у вашій громаді, щоб зробити цю інфраструктуру видимою та доступною для пошуку.",
|
||||
"features": "• Картування в офлайн-режимі з завантажуваними областями\n• Завантаження безпосередньо в OpenStreetMap через OAuth2\n• Вбудовані профілі для великих виробників\n• Повага до приватності - дані користувача не збираються\n• Кілька постачальників карт (OSM, супутникові знімки)",
|
||||
"initiative": "Частина ширшої ініціативи DeFlock з просування прозорості спостереження.",
|
||||
"footer": "Відвідайте: deflock.me\nПобудовано з Flutter • Відкритий код",
|
||||
"showWelcome": "Показати Привітальне Повідомлення",
|
||||
"showSubmissionGuide": "Показати Посібник Подання",
|
||||
"viewReleaseNotes": "Переглянути Примітки Випуску"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Ласкаво просимо до DeFlock",
|
||||
"description": "DeFlock був заснований на ідеї, що інструменти громадського спостереження повинні бути прозорими. В цьому мобільному додатку, як і на веб-сайті, ви зможете переглядати місця розташування ALPR та іншої інфраструктури спостереження у вашому районі та за кордоном.",
|
||||
"mission": "Однак цей проект не автоматизований; потрібні зусилля всіх нас, щоб покращити цей проект. Переглядаючи карту, ви можете натиснути \"Новий Вузол\", щоб додати раніше невідому установку. З вашою допомогою ми можемо досягти нашої мети підвищення прозорості та громадської обізнаності щодо інфраструктури спостереження.",
|
||||
"firsthandKnowledge": "ВАЖЛИВО: Додавайте лише пристрої спостереження, які ви особисто спостерігали власними очима. Політика OpenStreetMap та Google забороняє використання таких джерел, як зображення Street View для подань. Ваші внески повинні базуватися на ваших власних безпосередніх спостереженнях.",
|
||||
"privacy": "Примітка щодо конфіденційності: Цей додаток працює повністю локально на вашому пристрої та використовує сторонній API OpenStreetMap для зберігання даних та подань. DeFlock не збирає та не зберігає жодних даних користувачів і не несе відповідальності за управління обліковими записами.",
|
||||
"tileNote": "ПРИМІТКА: Безкоштовні плитки карт з OpenStreetMap можуть завантажуватися дуже повільно. Альтернативні постачальники плиток можна налаштувати в Налаштуваннях > Розширені.",
|
||||
"moreInfo": "Більше посилань можна знайти в Налаштуваннях > Про програму.",
|
||||
"dontShowAgain": "Не показувати це привітальне повідомлення знову",
|
||||
"getStarted": "Давайте почнемо DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Найкращі Практики Подання",
|
||||
"description": "Перш ніж подати ваш перший пристрій спостереження, будь ласка, приділіть хвилину для перегляду цих важливих вказівок, щоб забезпечити високоякісні внески в OpenStreetMap.",
|
||||
"bestPractices": "• Картуйте лише пристрої, які ви особисто спостерігали\n• Приділіть час точному визначенню типу пристрою та виробника\n• Використовуйте точне позиціонування - збільшуйте масштаб перед розміщенням маркера\n• Включайте інформацію про напрямок, коли це застосовно\n• Двічі перевірте ваші вибори тегів перед поданням",
|
||||
"placementNote": "Пам'ятайте: Точні дані з перших рук є важливими для спільноти DeFlock та проекту OpenStreetMap.",
|
||||
"moreInfo": "Для детального керівництва з ідентифікації пристроїв та найкращих практик картування:",
|
||||
"identificationGuide": "Посібник Ідентифікації",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Не показувати цей посібник знову",
|
||||
"gotIt": "Зрозуміло!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Уточнити Ваше Місце",
|
||||
"instructions": "Перетягніть карту, щоб точно розмістити маркер пристрою над місцем розташування пристрою спостереження.",
|
||||
"hint": "Ви можете збільшити масштаб для кращої точності перед позиціонуванням."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Новий Вузол",
|
||||
"download": "Завантажити",
|
||||
"settings": "Налаштування",
|
||||
"edit": "Редагувати",
|
||||
"delete": "Видалити",
|
||||
"cancel": "Скасувати",
|
||||
"ok": "ОК",
|
||||
"close": "Закрити",
|
||||
"submit": "Подати",
|
||||
"logIn": "Увійти",
|
||||
"saveEdit": "Зберегти Редагування",
|
||||
"clear": "Очистити",
|
||||
"viewOnOSM": "Переглянути в OSM",
|
||||
"advanced": "Розширені",
|
||||
"useAdvancedEditor": "Використати Розширений Редактор"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Вузол Дуже Близько до Існуючого Пристрою",
|
||||
"message": "Цей вузол знаходиться лише в {} метрах від існуючого пристрою спостереження.",
|
||||
"suggestion": "Якщо кілька пристроїв знаходяться на одному стовпі, будь ласка, використовуйте кілька напрямків на одному вузлі замість створення окремих вузлів.",
|
||||
"nearbyNodes": "Знайдено близькі пристрої ({}):",
|
||||
"nodeInfo": "Вузол #{} - {}",
|
||||
"andMore": "...та ще {}",
|
||||
"goBack": "Повернутися",
|
||||
"submitAnyway": "Все одно Подати",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Камера",
|
||||
"publicCamera": "Камера Громадського Спостереження",
|
||||
"camera": "Камера Спостереження",
|
||||
"amenity": "{}",
|
||||
"device": "Пристрій {}",
|
||||
"unknown": "Невідомий Пристрій"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Увімкнути слідування",
|
||||
"follow": "Увімкнути слідування (обертання)",
|
||||
"rotating": "Вимкнути слідування"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Налаштування",
|
||||
"language": "Мова та Регіон",
|
||||
"systemDefault": "Системна За Замовчуванням",
|
||||
"aboutInfo": "Про / Інформація",
|
||||
"aboutThisApp": "Про Цей Додаток",
|
||||
"aboutSubtitle": "Інформація про додаток та автори",
|
||||
"languageSubtitle": "Оберіть вашу бажану мову та одиниці вимірювання",
|
||||
"distanceUnit": "Одиниці Відстані",
|
||||
"distanceUnitSubtitle": "Оберіть між метричними (км/м) або імперськими (миля/фут) одиницями",
|
||||
"metricUnits": "Метричні (км, м)",
|
||||
"imperialUnits": "Імперські (миля, фут)",
|
||||
"maxNodes": "Максимум намальованих вузлів",
|
||||
"maxNodesSubtitle": "Встановити верхню межу для кількості вузлів на карті.",
|
||||
"maxNodesWarning": "Ви, мабуть, не хочете робити це, якщо ви абсолютно не впевнені, що у вас є вагома причина для цього.",
|
||||
"offlineMode": "Офлайн Режим",
|
||||
"offlineModeSubtitle": "Вимкнути всі мережеві запити, крім локальних/офлайн областей.",
|
||||
"pauseQueueProcessing": "Призупинити Обробку Черги Завантаження",
|
||||
"pauseQueueProcessingSubtitle": "Припинити завантаження змін у черзі, зберігаючи доступ до живих даних.",
|
||||
"offlineModeWarningTitle": "Активні Завантаження",
|
||||
"offlineModeWarningMessage": "Включення офлайн режиму скасує всі активні завантаження областей. Ви хочете продовжити?",
|
||||
"enableOfflineMode": "Увімкнути Офлайн Режим",
|
||||
"profiles": "Профілі",
|
||||
"profilesSubtitle": "Управління профілями вузлів та операторів",
|
||||
"offlineSettings": "Офлайн Налаштування",
|
||||
"offlineSettingsSubtitle": "Управління офлайн режимом та завантаженими областями",
|
||||
"advancedSettings": "Розширені Налаштування",
|
||||
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
||||
"proximityAlerts": "Сповіщення Про Близькість",
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
||||
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
||||
"notificationsEnabled": "✓ Сповіщення увімкнено",
|
||||
"notificationsDisabled": "⚠ Сповіщення вимкнено",
|
||||
"permissionRequired": "Потрібен дозвіл на сповіщення",
|
||||
"permissionExplanation": "Push-сповіщення вимкнено. Ви бачитимете лише сповіщення в додатку і не будете сповіщені, коли додаток працює у фоновому режимі.",
|
||||
"enableNotifications": "Увімкнути Сповіщення",
|
||||
"checkingPermissions": "Перевірка дозволів...",
|
||||
"alertDistance": "Відстань сповіщення: ",
|
||||
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Вузол #{}",
|
||||
"tagSheetTitle": "Теги Пристрою Спостереження",
|
||||
"queuedForUpload": "Вузол поставлено в чергу для завантаження",
|
||||
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||
"confirmDeleteTitle": "Видалити Вузол",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
"profileRequired": "Будь ласка, оберіть профіль для продовження.",
|
||||
"direction": "Напрямок {}°",
|
||||
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
|
||||
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.",
|
||||
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.",
|
||||
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.",
|
||||
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
|
||||
"refineTags": "Уточнити Теги"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Редагувати Вузол #{}",
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
"profileRequired": "Будь ласка, оберіть профіль для продовження.",
|
||||
"direction": "Напрямок {}°",
|
||||
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
|
||||
"temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.",
|
||||
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.",
|
||||
"sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.",
|
||||
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.",
|
||||
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.",
|
||||
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
|
||||
"cannotMoveConstrainedNode": "Неможливо перемістити цю камеру - вона підключена до іншого елементу карти (OSM шлях/відношення). Ви все ще можете редагувати її теги та напрямок.",
|
||||
"zoomInRequiredMessage": "Збільште масштаб до принаймні рівня {} для додавання або редагування вузлів спостереження. Це забезпечує точне позиціонування для точного картування.",
|
||||
"extractFromWay": "Витягнути вузол з шляху/відношення",
|
||||
"extractFromWaySubtitle": "Створити новий вузол з тими ж тегами, дозволити переміщення до нового місця",
|
||||
"refineTags": "Уточнити Теги",
|
||||
"existingTags": "<Існуючі теги>",
|
||||
"noChangesDetected": "Зміни не виявлено - нічого подавати",
|
||||
"noChangesTitle": "Немає Змін для Подання",
|
||||
"noChangesMessage": "Ви не внесли жодних змін до цього вузла. Щоб подати редагування, вам потрібно змінити місце, профіль, напрямки або теги."
|
||||
},
|
||||
"download": {
|
||||
"title": "Завантажити Область Карти",
|
||||
"maxZoomLevel": "Максимальний рівень масштабування",
|
||||
"storageEstimate": "Оцінка сховища:",
|
||||
"tilesAndSize": "{} плиток, {} МБ",
|
||||
"minZoom": "Мін масштаб:",
|
||||
"maxRecommendedZoom": "Максимальний рекомендований масштаб: Z{}",
|
||||
"withinTileLimit": "В межах {} ліміту плиток",
|
||||
"exceedsTileLimit": "Поточний вибір перевищує {} ліміт плиток",
|
||||
"offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.",
|
||||
"areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.",
|
||||
"downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}",
|
||||
"offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).",
|
||||
"currentTileProvider": "поточна плитка",
|
||||
"noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Завантаження Почалося",
|
||||
"message": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"ok": "ОК",
|
||||
"viewProgress": "Переглянути Прогрес в Налаштуваннях"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Місце Призначення Завантаження",
|
||||
"subtitle": "Оберіть, куди завантажуються камери",
|
||||
"production": "Виробництво",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Симуляція",
|
||||
"productionDescription": "Завантажити в активну базу даних OSM (видима всім користувачам)",
|
||||
"sandboxDescription": "Завантаження йдуть в OSM Sandbox (безпечно для тестування, регулярно скидається).",
|
||||
"simulateDescription": "Симулювати завантаження (не зв'язується з серверами OSM)",
|
||||
"cannotChangeWithQueue": "Неможливо змінити місце призначення завантаження, поки в черзі є {} елементів. Спочатку очистіть чергу."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Обліковий Запис OpenStreetMap",
|
||||
"osmAccountSubtitle": "Управління входом OSM та перегляд ваших внесків",
|
||||
"loggedInAs": "Увійшли як {}",
|
||||
"loginToOSM": "Увійти в OpenStreetMap",
|
||||
"tapToLogout": "Натисніть для виходу",
|
||||
"requiredToSubmit": "Потрібно для подання даних камер",
|
||||
"loggedOut": "Вихід здійснено",
|
||||
"testConnection": "Тестувати З'єднання",
|
||||
"testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють",
|
||||
"connectionOK": "З'єднання в порядку - облікові дані дійсні",
|
||||
"connectionFailed": "З'єднання не вдалося - будь ласка, увійдіть знову",
|
||||
"viewMyEdits": "Переглянути Мої Редагування в OSM",
|
||||
"viewMyEditsSubtitle": "Побачити вашу історію редагувань в OpenStreetMap",
|
||||
"aboutOSM": "Про OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap - це колаборативний проект картування з відкритим кодом, де учасники створюють і підтримують безкоштовну, редаговану карту світу. Ваші внески пристроїв спостереження допомагають зробити цю інфраструктуру видимою та доступною для пошуку.",
|
||||
"visitOSM": "Відвідати OpenStreetMap",
|
||||
"deleteAccount": "Видалити Обліковий Запис OSM",
|
||||
"deleteAccountSubtitle": "Управління обліковим записом OpenStreetMap",
|
||||
"deleteAccountExplanation": "Щоб видалити ваш обліковий запис OpenStreetMap, вам потрібно відвідати веб-сайт OpenStreetMap. Це назавжди видалить ваш обліковий запис OSM та всі пов'язані дані.",
|
||||
"deleteAccountWarning": "Попередження: Цю дію не можна скасувати і вона назавжди видалить ваш обліковий запис OSM.",
|
||||
"goToOSM": "Перейти до OpenStreetMap",
|
||||
"accountManagement": "Управління Обліковим Записом",
|
||||
"accountManagementDescription": "Щоб видалити ваш обліковий запис OpenStreetMap, вам потрібно відвідати відповідний веб-сайт OpenStreetMap. Це назавжди видалить ваш обліковий запис та всі пов'язані дані.",
|
||||
"currentDestinationProduction": "Зараз підключено до: Виробничий OpenStreetMap",
|
||||
"currentDestinationSandbox": "Зараз підключено до: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Зараз в: Режимі симуляції (без справжнього облікового запису)",
|
||||
"viewMessages": "Переглянути Повідомлення в OSM",
|
||||
"unreadMessagesCount": "У вас {} непрочитаних повідомлень",
|
||||
"noUnreadMessages": "Немає непрочитаних повідомлень",
|
||||
"reauthRequired": "Оновити Автентифікацію",
|
||||
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
|
||||
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
|
||||
"reauthNow": "Зробити Зараз",
|
||||
"reauthLater": "Пізніше"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Черга Завантаження",
|
||||
"subtitle": "Управління очікуваними завантаженнями пристроїв спостереження",
|
||||
"pendingUploads": "Очікувані завантаження: {}",
|
||||
"pendingItemsCount": "Очікуючі Елементи: {}",
|
||||
"nothingInQueue": "Нічого в черзі",
|
||||
"simulateModeEnabled": "Режим симуляції увімкнено – завантаження симулюються",
|
||||
"sandboxMode": "Режим sandbox – завантаження йдуть в OSM Sandbox",
|
||||
"tapToViewQueue": "Натисніть для перегляду черги",
|
||||
"clearUploadQueue": "Очистити Чергу Завантаження",
|
||||
"removeAllPending": "Видалити всі {} очікувані завантаження",
|
||||
"clearQueueTitle": "Очистити Чергу",
|
||||
"clearQueueConfirm": "Видалити всі {} очікувані завантаження?",
|
||||
"queueCleared": "Чергу очищено",
|
||||
"uploadQueueTitle": "Черга Завантаження ({} елементів)",
|
||||
"queueIsEmpty": "Черга порожня",
|
||||
"itemWithIndex": "Елемент {}",
|
||||
"error": " (Помилка)",
|
||||
"completing": " (Завершуємо...)",
|
||||
"destination": "Місце призначення: {}",
|
||||
"latitude": "Широта: {}",
|
||||
"longitude": "Довгота: {}",
|
||||
"direction": "Напрямок: {}°",
|
||||
"attempts": "Спроби: {}",
|
||||
"uploadFailedRetry": "Завантаження не вдалося. Натисніть повторити, щоб спробувати знову.",
|
||||
"retryUpload": "Повторити завантаження",
|
||||
"clearAll": "Очистити Все",
|
||||
"errorDetails": "Деталі Помилки",
|
||||
"creatingChangeset": " (Створення набору змін...)",
|
||||
"uploading": " (Завантаження...)",
|
||||
"closingChangeset": " (Закриття набору змін...)",
|
||||
"processingPaused": "Обробка Черги Призупинена",
|
||||
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну."
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Постачальники Плиток",
|
||||
"noProvidersConfigured": "Постачальників плиток не налаштовано",
|
||||
"tileTypesCount": "{} типів плиток",
|
||||
"apiKeyConfigured": "API ключ налаштовано",
|
||||
"needsApiKey": "Потрібен API ключ",
|
||||
"editProvider": "Редагувати Постачальника",
|
||||
"addProvider": "Додати Постачальника",
|
||||
"deleteProvider": "Видалити Постачальника",
|
||||
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"providerName": "Назва Постачальника",
|
||||
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
||||
"providerNameRequired": "Назва постачальника обов'язкова",
|
||||
"apiKey": "API Ключ (Опціонально)",
|
||||
"apiKeyHint": "Введіть API ключ, якщо потрібно для типів плиток",
|
||||
"tileTypes": "Типи Плиток",
|
||||
"addType": "Додати Тип",
|
||||
"noTileTypesConfigured": "Типи плиток не налаштовано",
|
||||
"atLeastOneTileTypeRequired": "Потрібен принаймні один тип плитки",
|
||||
"manageTileProviders": "Управляти Постачальниками"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Редагувати Тип Плитки",
|
||||
"addTileType": "Додати Тип Плитки",
|
||||
"name": "Назва",
|
||||
"nameHint": "напр., Супутник",
|
||||
"nameRequired": "Назва обов'язкова",
|
||||
"urlTemplate": "Шаблон URL",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Шаблон URL обов'язковий",
|
||||
"urlTemplatePlaceholders": "URL повинен містити або {quadkey}, або {z}, {x} і {y} заповнювачі",
|
||||
"attribution": "Атрибуція",
|
||||
"attributionHint": "© Постачальник Карт",
|
||||
"attributionRequired": "Атрибуція обов'язкова",
|
||||
"maxZoom": "Максимальний Рівень Масштабування",
|
||||
"maxZoomHint": "Максимальний рівень масштабування (1-23)",
|
||||
"maxZoomRequired": "Максимальний масштаб обов'язковий",
|
||||
"maxZoomInvalid": "Максимальний масштаб повинен бути числом",
|
||||
"maxZoomRange": "Максимальний масштаб повинен бути між {} і {}",
|
||||
"fetchPreview": "Отримати Попередній Перегляд",
|
||||
"previewTileLoaded": "Плитка попереднього перегляду успішно завантажена",
|
||||
"previewTileFailed": "Не вдалося отримати попередній перегляд: {}",
|
||||
"save": "Зберегти"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Профілі Вузлів",
|
||||
"newProfile": "Новий Профіль",
|
||||
"builtIn": "Вбудований",
|
||||
"custom": "Власний",
|
||||
"view": "Переглянути",
|
||||
"deleteProfile": "Видалити Профіль",
|
||||
"deleteProfileConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"profileDeleted": "Профіль видалено",
|
||||
"getMore": "Отримати більше...",
|
||||
"addProfileChoice": "Додати Профіль",
|
||||
"addProfileChoiceMessage": "Як би ви хотіли додати профіль?",
|
||||
"createCustomProfile": "Створити Власний Профіль",
|
||||
"createCustomProfileDescription": "Побудувати профіль з нуля з власними тегами",
|
||||
"importFromWebsite": "Імпортувати з Веб-сайту",
|
||||
"importFromWebsiteDescription": "Переглянути та імпортувати профілі з deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Плитки Карти",
|
||||
"manageProviders": "Управляти Постачальниками",
|
||||
"attribution": "Атрибуція Карти",
|
||||
"mapAttribution": "Джерело карти: {}",
|
||||
"couldNotOpenLink": "Не вдалося відкрити посилання",
|
||||
"openLicense": "Відкрити ліцензію: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Переглянути Профіль",
|
||||
"newProfile": "Новий Профіль",
|
||||
"editProfile": "Редагувати Профіль",
|
||||
"profileName": "Назва профілю",
|
||||
"profileNameHint": "напр., Власна ALPR Камера",
|
||||
"profileNameRequired": "Назва профілю обов'язкова",
|
||||
"requiresDirection": "Потребує Напрямку",
|
||||
"requiresDirectionSubtitle": "Чи потрібен тег напрямку для камер цього типу",
|
||||
"fov": "Поле Зору",
|
||||
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
|
||||
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
|
||||
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
|
||||
"submittable": "Можна Подавати",
|
||||
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
|
||||
"osmTags": "OSM Теги",
|
||||
"addTag": "Додати тег",
|
||||
"saveProfile": "Зберегти Профіль",
|
||||
"keyHint": "ключ",
|
||||
"valueHint": "значення",
|
||||
"atLeastOneTagRequired": "Потрібен принаймні один тег",
|
||||
"profileSaved": "Профіль \"{}\" збережено"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Новий Профіль Оператора",
|
||||
"editOperatorProfile": "Редагувати Профіль Оператора",
|
||||
"operatorName": "Назва оператора",
|
||||
"operatorNameHint": "напр., Поліція Києва",
|
||||
"operatorNameRequired": "Назва оператора обов'язкова",
|
||||
"operatorProfileSaved": "Профіль оператора \"{}\" збережено"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Профілі Операторів",
|
||||
"noProfilesMessage": "Профілі операторів не визначено. Створіть один для застосування тегів операторів до подань вузлів.",
|
||||
"tagsCount": "{} тегів",
|
||||
"deleteOperatorProfile": "Видалити Профіль Оператора",
|
||||
"deleteOperatorProfileConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"operatorProfileDeleted": "Профіль оператора видалено"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Офлайн Області",
|
||||
"noAreasTitle": "Немає офлайн областей",
|
||||
"noAreasSubtitle": "Завантажте область карти для офлайн використання.",
|
||||
"provider": "Постачальник",
|
||||
"maxZoom": "Максимальний масштаб",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Широта",
|
||||
"longitude": "Довгота",
|
||||
"tiles": "Плитки",
|
||||
"size": "Розмір",
|
||||
"nodes": "Вузли",
|
||||
"areaIdFallback": "Область {}...",
|
||||
"renameArea": "Перейменувати область",
|
||||
"refreshWorldTiles": "Оновити/перезавантажити світові плитки",
|
||||
"deleteOfflineArea": "Видалити офлайн область",
|
||||
"cancelDownload": "Скасувати завантаження",
|
||||
"renameAreaDialogTitle": "Перейменувати Офлайн Область",
|
||||
"areaNameLabel": "Назва Області",
|
||||
"renameButton": "Перейменувати",
|
||||
"megabytes": "МБ",
|
||||
"kilobytes": "КБ",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Оновити область",
|
||||
"refreshAreaDialogTitle": "Оновити Офлайн Область",
|
||||
"refreshAreaDialogSubtitle": "Виберіть що оновити для цієї області:",
|
||||
"refreshTiles": "Оновити Плитки Карти",
|
||||
"refreshTilesSubtitle": "Перезавантажити всі плитки для оновлених зображень",
|
||||
"refreshNodes": "Оновити Вузли",
|
||||
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
||||
"startRefresh": "Почати Оновлення",
|
||||
"refreshStarted": "Оновлення розпочато!",
|
||||
"refreshFailed": "Оновлення не вдалося: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Уточнити Теги",
|
||||
"operatorProfile": "Профіль Оператора",
|
||||
"done": "Готово",
|
||||
"none": "Немає",
|
||||
"noAdditionalOperatorTags": "Немає додаткових тегів оператора",
|
||||
"additionalTags": "додаткові теги",
|
||||
"additionalTagsTitle": "Додаткові Теги",
|
||||
"noTagsDefinedForProfile": "Для цього профілю оператора не визначено тегів.",
|
||||
"noOperatorProfiles": "Профілі операторів не визначено",
|
||||
"noOperatorProfilesMessage": "Створіть профілі операторів в Налаштуваннях для застосування додаткових тегів до ваших подань вузлів.",
|
||||
"profileTags": "Теги Профілю",
|
||||
"profileTagsDescription": "Заповніть ці опціональні значення тегів для більш детальних подань:",
|
||||
"selectValue": "Вибрати значення...",
|
||||
"noValue": "(залишити порожнім)",
|
||||
"noSuggestions": "Немає доступних пропозицій",
|
||||
"existingTagsTitle": "Існуючі Теги",
|
||||
"existingTagsDescription": "Редагуйте існуючі теги на цьому пристрої. Додайте, видаліть або змініть будь-який тег:",
|
||||
"existingOperator": "<Існуючий оператор>",
|
||||
"existingOperatorTags": "існуючі теги оператора"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Неможливо змінити типи плиток під час завантаження офлайн областей",
|
||||
"selectMapLayer": "Вибрати Шар Карти",
|
||||
"noTileProvidersAvailable": "Немає доступних постачальників плиток"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Розширені Опції Редагування",
|
||||
"subtitle": "Ці редактори пропонують більш розширені можливості для складних редагувань.",
|
||||
"webEditors": "Веб Редактори",
|
||||
"mobileEditors": "Мобільні Редактори",
|
||||
"iDEditor": "iD Редактор",
|
||||
"iDEditorSubtitle": "Повнофункціональний веб редактор - завжди працює",
|
||||
"rapidEditor": "RapiD Редактор",
|
||||
"rapidEditorSubtitle": "AI-асистоване редагування з даними Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Розширений Android OSM редактор",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Додаток для картування на основі опитувань",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Швидке редагування POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM редактор",
|
||||
"couldNotOpenEditor": "Не вдалося відкрити редактор - додаток може бути не встановлений",
|
||||
"couldNotOpenURL": "Не вдалося відкрити URL",
|
||||
"couldNotOpenOSMWebsite": "Не вдалося відкрити веб-сайт OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Показувати індикатор стану мережі",
|
||||
"showIndicatorSubtitle": "Відображати стан завантаження та помилки даних спостереження",
|
||||
"loading": "Завантаження даних спостереження...",
|
||||
"timedOut": "Запит перевищив час очікування",
|
||||
"noData": "Немає офлайн даних",
|
||||
"success": "Дані спостереження завантажено",
|
||||
"nodeDataSlow": "Повільні дані спостереження",
|
||||
"rateLimited": "Обмежено швидкість сервером",
|
||||
"networkError": "Помилка мережі"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Показано {rendered} з {total} пристроїв",
|
||||
"editingDisabledMessage": "Показано забагато пристроїв для безпечного редагування. Збільште масштаб далі, щоб зменшити кількість видимих пристроїв, потім спробуйте знову."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Пошук Локації",
|
||||
"searchPlaceholder": "Шукати місця або координати...",
|
||||
"routeTo": "Маршрут До",
|
||||
"routeFrom": "Маршрут Від",
|
||||
"selectLocation": "Вибрати Локацію",
|
||||
"calculatingRoute": "Розрахунок маршруту...",
|
||||
"routeCalculationFailed": "Розрахунок маршруту не вдався",
|
||||
"start": "Почати",
|
||||
"resume": "Відновити",
|
||||
"endRoute": "Завершити Маршрут",
|
||||
"routeOverview": "Огляд Маршруту",
|
||||
"retry": "Повторити",
|
||||
"cancelSearch": "Скасувати пошук",
|
||||
"noResultsFound": "Результатів не знайдено",
|
||||
"searching": "Пошук...",
|
||||
"location": "Локація",
|
||||
"startPoint": "Початок",
|
||||
"endPoint": "Кінець",
|
||||
"startSelect": "Початок (вибрати)",
|
||||
"endSelect": "Кінець (вибрати)",
|
||||
"distance": "Відстань: {} км",
|
||||
"routeActive": "Маршрут активний",
|
||||
"locationsTooClose": "Початкова та кінцева локації занадто близько одна до одної",
|
||||
"navigationSettings": "Навігація",
|
||||
"navigationSettingsSubtitle": "Планування маршруту та налаштування уникнення",
|
||||
"avoidanceDistance": "Відстань Уникнення",
|
||||
"avoidanceDistanceSubtitle": "Мінімальна відстань для уникнення пристроїв спостереження",
|
||||
"searchHistory": "Макс Історія Пошуку",
|
||||
"searchHistorySubtitle": "Максимальна кількість нещодавніх пошуків для запам'ятовування"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Підозрілі Локації",
|
||||
"showSuspectedLocations": "Показувати Підозрілі Локації",
|
||||
"showSuspectedLocationsSubtitle": "Показувати маркери знаку питання для підозрілих сайтів спостереження з даних дозволів комунальних служб",
|
||||
"lastUpdated": "Останнє Оновлення",
|
||||
"refreshNow": "Оновити зараз",
|
||||
"dataSource": "Джерело Даних",
|
||||
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
||||
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
||||
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
|
||||
"updating": "Оновлення Підозрілих Локацій",
|
||||
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
||||
"updateSuccess": "Підозрілі локації успішно оновлено",
|
||||
"updateFailed": "Не вдалося оновити підозрілі локації",
|
||||
"neverFetched": "Ніколи не отримувалося",
|
||||
"daysAgo": "{} днів тому",
|
||||
"hoursAgo": "{} годин тому",
|
||||
"minutesAgo": "{} хвилин тому",
|
||||
"justNow": "Щойно"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Підозріла Локація #{}",
|
||||
"ticketNo": "Номер Квитка",
|
||||
"address": "Адреса",
|
||||
"street": "Вулиця",
|
||||
"city": "Місто",
|
||||
"state": "Область",
|
||||
"intersectingStreet": "Перехрещувана Вулиця",
|
||||
"workDoneFor": "Робота Виконана Для",
|
||||
"remarks": "Зауваження",
|
||||
"url": "URL",
|
||||
"coordinates": "Координати",
|
||||
"noAddressAvailable": "Адреса недоступна"
|
||||
},
|
||||
"units": {
|
||||
"meters": "м",
|
||||
"feet": "фут",
|
||||
"kilometers": "км",
|
||||
"miles": "миля",
|
||||
"metersLong": "метри",
|
||||
"feetLong": "фути",
|
||||
"kilometersLong": "кілометри",
|
||||
"milesLong": "милі",
|
||||
"metric": "Метричні",
|
||||
"imperial": "Імперські",
|
||||
"metricDescription": "Метричні (км, м)",
|
||||
"imperialDescription": "Імперські (миля, фут)"
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
"downloadFailed": "启动下载失败:{}",
|
||||
"offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。",
|
||||
"currentTileProvider": "当前瓦片",
|
||||
"noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商",
|
||||
"attribution": "地图归属"
|
||||
"attribution": "地图归属",
|
||||
"mapAttribution": "地图来源:{}",
|
||||
"couldNotOpenLink": "无法打开链接",
|
||||
"openLicense": "打开许可证:{}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "疑似位置更新失败",
|
||||
"neverFetched": "从未获取",
|
||||
"daysAgo": "{}天前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"minutesAgo": "{}分钟前",
|
||||
"justNow": "刚刚"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "疑似位置 #{}",
|
||||
"ticketNo": "工单号",
|
||||
"address": "地址",
|
||||
"street": "街道",
|
||||
"street": "街道",
|
||||
"city": "城市",
|
||||
"state": "州/省",
|
||||
"intersectingStreet": "交叉街道",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart';
|
||||
import 'screens/osm_account_screen.dart';
|
||||
import 'screens/upload_queue_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/provider_tile_cache_manager.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
|
||||
@@ -21,13 +22,16 @@ import 'services/deep_link_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
// Initialize version service
|
||||
await VersionService().init();
|
||||
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
// Resolve platform cache directory for per-provider tile caching
|
||||
await ProviderTileCacheManager.init();
|
||||
|
||||
// Initialize deep link service
|
||||
await DeepLinkService().init();
|
||||
DeepLinkService().setNavigatorKey(_navigatorKey);
|
||||
|
||||
@@ -114,6 +114,34 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize profile ordering for existing users (v2.7.3)
|
||||
static Future<void> migrate_2_7_3(AppState appState) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const orderKey = 'profile_order';
|
||||
|
||||
// Check if user already has custom profile ordering
|
||||
if (prefs.containsKey(orderKey)) {
|
||||
debugPrint('[Migration] 2.7.3: Profile order already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with current profile order (preserves existing UI order)
|
||||
final currentProfiles = appState.profiles;
|
||||
final initialOrder = currentProfiles.map((p) => p.id).toList();
|
||||
|
||||
if (initialOrder.isNotEmpty) {
|
||||
await prefs.setStringList(orderKey, initialOrder);
|
||||
debugPrint('[Migration] 2.7.3: Initialized profile order with ${initialOrder.length} profiles');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.7.3 completed: initialized profile ordering');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.7.3 ERROR: Failed to initialize profile ordering: $e');
|
||||
// Don't rethrow - this is non-critical, profiles will just use default order
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -127,6 +155,8 @@ class OneTimeMigrations {
|
||||
return migrate_1_8_0;
|
||||
case '2.1.0':
|
||||
return migrate_2_1_0;
|
||||
case '2.7.3':
|
||||
return migrate_2_7_3;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -269,16 +269,33 @@ class NodeProfile {
|
||||
/// Used as the default `<Existing tags>` option when editing nodes
|
||||
/// All existing tags will flow through as additionalExistingTags
|
||||
static NodeProfile createExistingTagsProfile(OsmNode node) {
|
||||
// Calculate FOV from existing direction ranges if applicable
|
||||
// Only assign FOV if the original direction string actually contained range notation
|
||||
// (e.g., "90-270" or "55-125"), not if it was just single directions (e.g., "90")
|
||||
double? calculatedFov;
|
||||
|
||||
// If node has direction/FOV pairs, check if they all have the same FOV
|
||||
if (node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
final raw = node.tags['direction'] ?? node.tags['camera:direction'];
|
||||
if (raw != null) {
|
||||
// Check if any part of the direction string contains range notation (dash with numbers)
|
||||
final parts = raw.split(';');
|
||||
bool hasRangeNotation = false;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
// Look for range pattern: numbers-numbers (e.g., "90-270", "55-125")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
hasRangeNotation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only calculate FOV if the node originally had range notation
|
||||
if (hasRangeNotation && node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +307,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: false,
|
||||
fov: calculatedFov, // Use calculated FOV from existing direction ranges
|
||||
fov: calculatedFov, // Only use FOV if original had explicit range notation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/service_policy.dart';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
@@ -10,7 +12,7 @@ class TileType {
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
final int maxZoom; // Maximum zoom level for this tile type
|
||||
|
||||
const TileType({
|
||||
TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
@@ -76,6 +78,15 @@ class TileType {
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
/// The service policy that applies to this tile type's server.
|
||||
/// Cached because [urlTemplate] is immutable.
|
||||
late final ServicePolicy servicePolicy =
|
||||
ServicePolicyResolver.resolve(urlTemplate);
|
||||
|
||||
/// Whether this tile server's usage policy permits offline/bulk downloading.
|
||||
/// Resolved via [ServicePolicyResolver] from the URL template.
|
||||
bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
|
||||
@@ -578,37 +578,41 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: canDownload ? () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,76 +34,101 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: appState.profiles.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
appState.reorderProfiles(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final p = appState.profiles[index];
|
||||
return ListTile(
|
||||
key: ValueKey(p.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Checkbox
|
||||
Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget {
|
||||
subtitle: Text(locService.t('uploadMode.subtitle')),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
// This entire section is gated behind kEnableDevelopmentModes
|
||||
// in osm_account_screen.dart, so all modes are always available here.
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/http_client.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
@@ -407,6 +407,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
_isLoadingPreview = true;
|
||||
});
|
||||
|
||||
final client = UserAgentClient();
|
||||
try {
|
||||
// Create a temporary TileType to use the getTileUrl method
|
||||
final tempTileType = TileType(
|
||||
@@ -415,21 +416,21 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: 'Preview',
|
||||
);
|
||||
|
||||
|
||||
final url = tempTileType.getTileUrl(
|
||||
kPreviewTileZoom,
|
||||
kPreviewTileX,
|
||||
kPreviewTileY,
|
||||
apiKey: null, // Don't use API key for preview
|
||||
);
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
final response = await client.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() {
|
||||
_previewTile = response.bodyBytes;
|
||||
});
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))),
|
||||
@@ -445,6 +446,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
setState(() {
|
||||
_isLoadingPreview = false;
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:oauth2_client/oauth2_client.dart';
|
||||
import 'package:oauth2_client/oauth2_helper.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../keys.dart';
|
||||
import '../app_state.dart' show UploadMode;
|
||||
import 'http_client.dart';
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
@@ -36,6 +36,7 @@ class AuthService {
|
||||
|
||||
void setUploadMode(UploadMode mode) {
|
||||
_mode = mode;
|
||||
if (mode == UploadMode.simulate || !kHasOsmSecrets) return;
|
||||
final isSandbox = (mode == UploadMode.sandbox);
|
||||
final authBase = isSandbox
|
||||
? 'https://master.apis.dev.openstreetmap.org'
|
||||
@@ -150,7 +151,9 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
await _helper.removeAllTokens();
|
||||
if (_mode != UploadMode.simulate) {
|
||||
await _helper.removeAllTokens();
|
||||
}
|
||||
_displayName = null;
|
||||
return await login();
|
||||
}
|
||||
@@ -178,9 +181,11 @@ class AuthService {
|
||||
: 'https://api.openstreetmap.org';
|
||||
}
|
||||
|
||||
final _client = UserAgentClient();
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
final resp = await http.get(
|
||||
final resp = await _client.get(
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
@@ -225,6 +225,10 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.6.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
|
||||
versionsNeedingMigration.add('2.7.3');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
|
||||
@@ -1,157 +1,471 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
|
||||
///
|
||||
/// This replaces the complex HTTP interception approach with a clean TileProvider
|
||||
/// implementation that directly interfaces with our MapDataProvider system.
|
||||
class DeflockTileProvider extends TileProvider {
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
/// Thrown when a tile load is cancelled (tile scrolled off screen).
|
||||
/// TileLayerManager skips retry for these — the tile is already gone.
|
||||
class TileLoadCancelledException implements Exception {
|
||||
const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
/// Thrown when a tile is not available offline (no offline area or cache hit).
|
||||
/// TileLayerManager skips retry for these — retrying won't help without network.
|
||||
class TileNotAvailableOfflineException implements Exception {
|
||||
const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
/// Custom tile provider that extends NetworkTileProvider to leverage its
|
||||
/// built-in disk cache, RetryClient, ETag revalidation, and abort support,
|
||||
/// while routing URLs through our TileType logic and supporting offline tiles.
|
||||
///
|
||||
/// Each instance is configured for a specific tile provider/type combination
|
||||
/// with frozen config — no AppState lookups at request time (except for the
|
||||
/// global offlineMode toggle).
|
||||
///
|
||||
/// Two runtime paths:
|
||||
/// 1. **Common path** (no offline areas for current provider): delegates to
|
||||
/// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider
|
||||
/// pipeline (disk cache, ETag revalidation, RetryClient, abort support).
|
||||
/// 2. **Offline-first path** (has offline areas or offline mode): returns
|
||||
/// DeflockOfflineTileImageProvider — checks disk cache and local tiles
|
||||
/// first, falls back to HTTP via shared RetryClient on miss.
|
||||
class DeflockTileProvider extends NetworkTileProvider {
|
||||
/// The shared HTTP client we own. We keep a reference because
|
||||
/// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient
|
||||
/// will be false (we passed it in), so super.dispose() won't close it.
|
||||
final Client _sharedHttpClient;
|
||||
|
||||
/// Frozen config for this provider instance.
|
||||
final String providerId;
|
||||
final models.TileType tileType;
|
||||
final String? apiKey;
|
||||
|
||||
/// Opaque fingerprint of the config this provider was created with.
|
||||
/// Used by [TileLayerManager] to detect config drift after edits.
|
||||
final String configFingerprint;
|
||||
|
||||
/// Caching provider for the offline-first path. The same instance is passed
|
||||
/// to super for the common path — we keep a reference here so we can also
|
||||
/// use it in [DeflockOfflineTileImageProvider].
|
||||
final MapCachingProvider? _cachingProvider;
|
||||
|
||||
/// Called when a tile loads successfully via the network in the offline-first
|
||||
/// path. Used by [TileLayerManager] to reset exponential backoff.
|
||||
VoidCallback? onNetworkSuccess;
|
||||
|
||||
// ignore: use_super_parameters
|
||||
DeflockTileProvider._({
|
||||
required Client httpClient,
|
||||
required this.providerId,
|
||||
required this.tileType,
|
||||
this.apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
this.configFingerprint = '',
|
||||
}) : _sharedHttpClient = httpClient,
|
||||
_cachingProvider = cachingProvider,
|
||||
super(
|
||||
httpClient: httpClient,
|
||||
cachingProvider: cachingProvider,
|
||||
// Let errors propagate so flutter_map marks tiles as failed
|
||||
// (loadError = true) rather than caching transparent images as
|
||||
// "successfully loaded". The TileLayerManager wires a reset stream
|
||||
// that retries failed tiles after a debounced delay.
|
||||
silenceExceptions: false,
|
||||
);
|
||||
|
||||
factory DeflockTileProvider({
|
||||
required String providerId,
|
||||
required models.TileType tileType,
|
||||
String? apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
VoidCallback? onNetworkSuccess,
|
||||
String configFingerprint = '',
|
||||
}) {
|
||||
final client = UserAgentClient(RetryClient(Client()));
|
||||
return DeflockTileProvider._(
|
||||
httpClient: client,
|
||||
providerId: providerId,
|
||||
tileType: tileType,
|
||||
apiKey: apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
configFingerprint: configFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
// Get current provider info to include in cache key
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockTileImageProvider(
|
||||
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
|
||||
return tileType.getTileUrl(
|
||||
coordinates.z,
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageProvider getImageWithCancelLoadingSupport(
|
||||
TileCoordinates coordinates,
|
||||
TileLayer options,
|
||||
Future<void> cancelLoading,
|
||||
) {
|
||||
if (!_shouldCheckOfflineCache(coordinates.z)) {
|
||||
// Common path: no offline areas — delegate to NetworkTileProvider's
|
||||
// full pipeline (disk cache, ETag, RetryClient, abort support).
|
||||
return super.getImageWithCancelLoadingSupport(
|
||||
coordinates,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
}
|
||||
|
||||
// Offline-first path: check local tiles first, fall back to network.
|
||||
return DeflockOfflineTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: _mapDataProvider,
|
||||
httpClient: _sharedHttpClient,
|
||||
headers: headers,
|
||||
cancelLoading: cancelLoading,
|
||||
isOfflineOnly: AppState.instance.offlineMode,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeId: tileType.id,
|
||||
tileUrl: getTileUrl(coordinates, options),
|
||||
cachingProvider: _cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// Determine if we should check offline cache for this tile request.
|
||||
/// Only returns true if:
|
||||
/// 1. We're in offline mode (forced), OR
|
||||
/// 2. We have offline areas for the current provider/type
|
||||
///
|
||||
/// This avoids the offline-first path (and its filesystem searches) when
|
||||
/// browsing online with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache(int zoom) {
|
||||
// Always use offline path in offline mode
|
||||
if (AppState.instance.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only use offline path if we have relevant offline data
|
||||
// at this zoom level — tiles outside any area's zoom range go through the
|
||||
// common NetworkTileProvider path for better performance.
|
||||
final offlineService = OfflineAreaService();
|
||||
return offlineService.hasOfflineAreasForProviderAtZoom(
|
||||
providerId,
|
||||
tileType.id,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
// Only call super — do NOT close _sharedHttpClient here.
|
||||
// flutter_map calls dispose() whenever the TileLayer widget is recycled
|
||||
// (e.g. provider switch causes a new FlutterMap key), but
|
||||
// TileLayerManager caches and reuses provider instances across switches.
|
||||
// Closing the HTTP client here would leave the cached instance broken —
|
||||
// all future tile requests would fail with "Client closed".
|
||||
//
|
||||
// Since we passed our own httpClient to NetworkTileProvider,
|
||||
// _isInternallyCreatedClient is false, so super.dispose() won't close it
|
||||
// either. The client is closed in [shutdown], called by
|
||||
// TileLayerManager.dispose() when the map is truly torn down.
|
||||
await super.dispose();
|
||||
}
|
||||
|
||||
/// Permanently close the HTTP client. Called by [TileLayerManager.dispose]
|
||||
/// when the map widget is being torn down — NOT by flutter_map's widget
|
||||
/// recycling.
|
||||
void shutdown() {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image provider that fetches tiles through our MapDataProvider.
|
||||
///
|
||||
/// This handles the actual tile fetching using our existing offline/online
|
||||
/// routing logic without any HTTP interception complexity.
|
||||
class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
|
||||
/// Image provider for the offline-first path.
|
||||
///
|
||||
/// Checks disk cache and offline areas before falling back to the network.
|
||||
/// Caches successful network fetches to disk so panning back doesn't re-fetch.
|
||||
/// On cancellation, lets in-flight downloads complete and caches the result
|
||||
/// (fire-and-forget) instead of discarding downloaded bytes.
|
||||
///
|
||||
/// **Online mode flow:**
|
||||
/// 1. Disk cache (fast hash-based file read) → hit + fresh → return
|
||||
/// 2. Offline areas (file scan) → hit → return
|
||||
/// 3. Network fetch with conditional headers from stale cache entry
|
||||
/// 4. On cancel → fire-and-forget cache write for the in-flight download
|
||||
/// 5. On 304 → return stale cached bytes, update cache metadata
|
||||
/// 6. On 200 → cache to disk, decode and return
|
||||
/// 7. On error → throw (flutter_map marks tile as failed)
|
||||
///
|
||||
/// **Offline mode flow:**
|
||||
/// 1. Offline areas (primary source — guaranteed available)
|
||||
/// 2. Disk cache (tiles cached from previous online sessions)
|
||||
/// 3. Throw if both miss (flutter_map marks tile as failed)
|
||||
class DeflockOfflineTileImageProvider
|
||||
extends ImageProvider<DeflockOfflineTileImageProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
final TileLayer options;
|
||||
final MapDataProvider mapDataProvider;
|
||||
final Client httpClient;
|
||||
final Map<String, String> headers;
|
||||
final Future<void> cancelLoading;
|
||||
final bool isOfflineOnly;
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
|
||||
const DeflockTileImageProvider({
|
||||
final String tileUrl;
|
||||
final MapCachingProvider? cachingProvider;
|
||||
final VoidCallback? onNetworkSuccess;
|
||||
|
||||
const DeflockOfflineTileImageProvider({
|
||||
required this.coordinates,
|
||||
required this.options,
|
||||
required this.mapDataProvider,
|
||||
required this.httpClient,
|
||||
required this.headers,
|
||||
required this.cancelLoading,
|
||||
required this.isOfflineOnly,
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
required this.tileUrl,
|
||||
this.cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Future<DeflockTileImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DeflockTileImageProvider>(this);
|
||||
Future<DeflockOfflineTileImageProvider> obtainKey(
|
||||
ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DeflockOfflineTileImageProvider>(this);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) {
|
||||
ImageStreamCompleter loadImage(
|
||||
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode, chunkEvents),
|
||||
// Chain whenComplete into the codec future so there's a single future
|
||||
// for MultiFrameImageStreamCompleter to handle. Without this, the
|
||||
// whenComplete creates an orphaned future whose errors go unhandled.
|
||||
codec: _loadAsync(key, decode, chunkEvents).whenComplete(() {
|
||||
chunkEvents.close();
|
||||
}),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Try to read a tile from the disk cache. Returns null on miss or error.
|
||||
Future<CachedMapTile?> _getCachedTile() async {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return null;
|
||||
try {
|
||||
return await cachingProvider!.getTile(tileUrl);
|
||||
} on CachedMapTileReadFailure {
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a tile to the disk cache (best-effort, never throws).
|
||||
void _putCachedTile({
|
||||
required Map<String, String> responseHeaders,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return;
|
||||
try {
|
||||
final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders);
|
||||
cachingProvider!
|
||||
.putTile(url: tileUrl, metadata: metadata, bytes: bytes)
|
||||
.catchError((_) {});
|
||||
} catch (_) {
|
||||
// Best-effort: never fail the tile load due to cache write errors.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec> _loadAsync(
|
||||
DeflockTileImageProvider key,
|
||||
DeflockOfflineTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async {
|
||||
Future<Codec> decodeBytes(Uint8List bytes) =>
|
||||
ImmutableBuffer.fromUint8List(bytes).then(decode);
|
||||
|
||||
// Track cancellation synchronously via Completer so the catch block
|
||||
// can reliably check it without microtask ordering races.
|
||||
final cancelled = Completer<void>();
|
||||
cancelLoading.then((_) {
|
||||
if (!cancelled.isCompleted) cancelled.complete();
|
||||
}).ignore();
|
||||
|
||||
try {
|
||||
// Get current tile provider and type from app state
|
||||
final appState = AppState.instance;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
throw Exception('No tile provider configured');
|
||||
if (isOfflineOnly) {
|
||||
return await _loadOffline(decodeBytes, cancelled);
|
||||
}
|
||||
|
||||
// Smart cache routing: only check offline cache when needed
|
||||
final MapSource source = _shouldCheckOfflineCache(appState)
|
||||
? MapSource.auto // Check offline first, then network
|
||||
: MapSource.remote; // Skip offline cache, go directly to network
|
||||
|
||||
final tileBytes = await mapDataProvider.getTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
source: source,
|
||||
);
|
||||
|
||||
// Decode the image bytes
|
||||
final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes));
|
||||
return await decode(buffer);
|
||||
|
||||
return await _loadOnline(decodeBytes, cancelled);
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses to avoid console spam
|
||||
if (!e.toString().contains('offline mode is enabled')) {
|
||||
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
|
||||
// Cancelled tiles throw — flutter_map handles the error silently.
|
||||
// Preserve TileNotAvailableOfflineException even if the tile was also
|
||||
// cancelled — it has distinct semantics (genuine cache miss) that
|
||||
// matter for diagnostics and future UI indicators.
|
||||
if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) {
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
|
||||
// This is better than trying to provide fallback images
|
||||
|
||||
// Let real errors propagate so flutter_map marks loadError = true
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Online mode: disk cache → offline areas → network (with caching).
|
||||
Future<Codec> _loadOnline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check disk cache — fast hash-based file read.
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null && !cachedTile.metadata.isStale) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 2. Check offline areas — file scan per area.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network
|
||||
}
|
||||
|
||||
// 3. If cancelled before network, bail.
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
|
||||
// 4. Network fetch with conditional headers from stale cache entry.
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
if (cachedTile != null) {
|
||||
if (cachedTile.metadata.lastModified case final lastModified?) {
|
||||
request.headers[HttpHeaders.ifModifiedSinceHeader] =
|
||||
HttpDate.format(lastModified);
|
||||
}
|
||||
if (cachedTile.metadata.etag case final etag?) {
|
||||
request.headers[HttpHeaders.ifNoneMatchHeader] = etag;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Race the download against cancelLoading.
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (
|
||||
statusCode: response.statusCode,
|
||||
bytes: bytes,
|
||||
headers: response.headers,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (
|
||||
statusCode: 0,
|
||||
bytes: Uint8List(0),
|
||||
headers: <String, String>{},
|
||||
)),
|
||||
]);
|
||||
|
||||
// 6. On cancel — fire-and-forget cache write for the in-flight download
|
||||
// instead of discarding the downloaded bytes.
|
||||
if (cancelled.isCompleted || result.statusCode == 0) {
|
||||
networkFuture.then((r) {
|
||||
if (r.statusCode == 200 && r.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: r.headers, bytes: r.bytes);
|
||||
}
|
||||
}).ignore();
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
// 7. On 304 Not Modified → return stale cached bytes, update metadata.
|
||||
if (result.statusCode == HttpStatus.notModified && cachedTile != null) {
|
||||
_putCachedTile(responseHeaders: result.headers);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 8. On 200 OK → cache to disk, decode and return.
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: result.headers, bytes: result.bytes);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
// 9. Network error — throw so flutter_map marks the tile as failed.
|
||||
// Don't include tileUrl in the exception — it may contain API keys.
|
||||
throw HttpException(
|
||||
'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'returned status ${result.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Offline mode: offline areas → disk cache → throw.
|
||||
Future<Codec> _loadOffline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check offline areas (primary source — guaranteed available).
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} on TileLoadCancelledException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
// Local miss — fall through to disk cache
|
||||
}
|
||||
|
||||
// 2. Check disk cache (tiles cached from previous online sessions).
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 3. Both miss — throw so flutter_map marks the tile as failed.
|
||||
throw const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DeflockTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
return other is DeflockOfflineTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId &&
|
||||
other.isOfflineOnly == isOfflineOnly;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
|
||||
/// Determine if we should check offline cache for this tile request.
|
||||
/// Only check offline cache if:
|
||||
/// 1. We're in offline mode (forced), OR
|
||||
/// 2. We have offline areas for the current provider/type
|
||||
///
|
||||
/// This avoids expensive filesystem searches when browsing online
|
||||
/// with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache(AppState appState) {
|
||||
// Always check offline cache in offline mode
|
||||
if (appState.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only check if we might actually have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check: do we have any offline areas for this provider/type?
|
||||
// This avoids the expensive per-tile filesystem search in fetchLocalTile
|
||||
final offlineService = OfflineAreaService();
|
||||
final hasRelevantAreas = offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
);
|
||||
|
||||
return hasRelevantAreas;
|
||||
}
|
||||
}
|
||||
int get hashCode =>
|
||||
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
|
||||
}
|
||||
|
||||
34
lib/services/http_client.dart
Normal file
34
lib/services/http_client.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../dev_config.dart';
|
||||
import 'version_service.dart';
|
||||
|
||||
/// An [http.BaseClient] that injects a User-Agent header into every request.
|
||||
///
|
||||
/// Reads the app name and version dynamically from [VersionService] so the UA
|
||||
/// string stays in sync with pubspec.yaml without hard-coding values.
|
||||
///
|
||||
/// Uses [putIfAbsent] so a manually-set User-Agent is never overwritten.
|
||||
class UserAgentClient extends http.BaseClient {
|
||||
final http.Client _inner;
|
||||
|
||||
UserAgentClient([http.Client? inner]) : _inner = inner ?? http.Client();
|
||||
|
||||
/// The User-Agent string sent with every request.
|
||||
///
|
||||
/// Format follows OSM tile usage policy recommendations:
|
||||
/// `AppName/version (+homepage; contact: email)`
|
||||
static String get userAgent {
|
||||
final vs = VersionService();
|
||||
return '${vs.appName}/${vs.version} (+$kHomepageUrl; contact: $kContactEmail)';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
request.headers.putIfAbsent('User-Agent', () => userAgent);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => _inner.close();
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'node_data_manager.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
@@ -24,6 +24,7 @@ class MapDataProvider {
|
||||
MapDataProvider._();
|
||||
|
||||
final NodeDataManager _nodeDataManager = NodeDataManager();
|
||||
final UserAgentClient _httpClient = UserAgentClient();
|
||||
|
||||
bool get isOfflineMode => AppState.instance.offlineMode;
|
||||
void setOfflineMode(bool enabled) {
|
||||
@@ -97,29 +98,24 @@ class MapDataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch remote tile using current provider from AppState
|
||||
/// Fetch remote tile using current provider from AppState.
|
||||
/// Only used by offline area downloader — the main tile pipeline now goes
|
||||
/// through NetworkTileProvider (see DeflockTileProvider).
|
||||
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
// We guarantee that a provider and tile type are always selected
|
||||
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
throw Exception('No tile provider selected - this should never happen');
|
||||
}
|
||||
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests (call when map view changes significantly)
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearTileQueueSelective(LatLngBounds currentBounds) {
|
||||
clearRemoteTileQueueSelective(currentBounds);
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
final resp = await _httpClient.get(Uri.parse(tileUrl));
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
return resp.bodyBytes;
|
||||
}
|
||||
throw Exception('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
}
|
||||
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
@@ -7,6 +7,8 @@ import 'package:xml/xml.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../http_client.dart';
|
||||
import '../service_policy.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
||||
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||
@@ -33,6 +35,8 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}
|
||||
}
|
||||
|
||||
final _client = UserAgentClient();
|
||||
|
||||
/// Internal method that performs the actual OSM API fetch.
|
||||
Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
required LatLngBounds bounds,
|
||||
@@ -56,28 +60,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
// Enforce max 2 concurrent download threads per OSM API usage policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client.get(Uri.parse(url));
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
return nodes;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] Exception: $e');
|
||||
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that matches the current provider, or throw if not found.
|
||||
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
|
||||
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
|
||||
///
|
||||
/// When [providerId] and [tileTypeId] are supplied the lookup is pinned to
|
||||
/// those values (avoids a race when the user switches provider mid-flight).
|
||||
/// Otherwise falls back to the current AppState selection.
|
||||
Future<List<int>> fetchLocalTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
String? providerId,
|
||||
String? tileTypeId,
|
||||
}) async {
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
|
||||
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -18,29 +32,58 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
|
||||
if (hasTile) {
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
if (await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
}
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
|
||||
|
||||
// O(1) bounds check instead of enumerating all tiles at this zoom level
|
||||
if (!tileInBounds(area.bounds, z, x, y)) continue;
|
||||
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
try {
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.notFound) continue;
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
} on FileSystemException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
|
||||
throw Exception('Tile $z/$x/$y from provider $currentProviderId/$currentTileTypeId not found in any offline area');
|
||||
}
|
||||
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
|
||||
return await candidates.first.file.readAsBytes();
|
||||
}
|
||||
|
||||
/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds.
|
||||
///
|
||||
/// Uses the same Mercator projection math as [latLonToTile] in
|
||||
/// offline_tile_utils.dart, but only computes the bounding tile range
|
||||
/// instead of enumerating every tile at that zoom level.
|
||||
///
|
||||
/// Note: Y axis is inverted in tile coordinates — north = lower Y.
|
||||
@visibleForTesting
|
||||
bool tileInBounds(LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final west = bounds.west;
|
||||
final east = bounds.east;
|
||||
final north = bounds.north;
|
||||
final south = bounds.south;
|
||||
|
||||
final minX = ((west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((east + 180.0) / 360.0 * n).floor();
|
||||
// North → lower Y (Mercator projection inverts latitude)
|
||||
final minY = ((1.0 - log(tan(north * pi / 180.0) +
|
||||
1.0 / cos(north * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
final maxY = ((1.0 - log(tan(south * pi / 180.0) +
|
||||
1.0 / cos(south * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
String _tilePath(String areaDir, int z, int x, int y) =>
|
||||
'$areaDir/tiles/$z/$x/$y.png';
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the given bounds
|
||||
void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
final clearedCount = _tileFetchSemaphore.clearStaleRequests((z, x, y) {
|
||||
// Return true if tile should be cleared (i.e., is NOT visible)
|
||||
return !_isTileVisible(z, x, y, currentBounds);
|
||||
});
|
||||
|
||||
if (clearedCount > 0) {
|
||||
debugPrint('[RemoteTiles] Selectively cleared $clearedCount non-visible tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate retry delay using configurable backoff strategy.
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
// Add random jitter to avoid thundering herd
|
||||
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
|
||||
|
||||
// Apply max delay cap
|
||||
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to lat/lng bounds for spatial filtering
|
||||
class _TileBounds {
|
||||
final double north, south, east, west;
|
||||
_TileBounds({required this.north, required this.south, required this.east, required this.west});
|
||||
}
|
||||
|
||||
/// Calculate the lat/lng bounds for a given tile
|
||||
_TileBounds _tileToBounds(int z, int x, int y) {
|
||||
final n = pow(2, z);
|
||||
final lon1 = (x / n) * 360.0 - 180.0;
|
||||
final lon2 = ((x + 1) / n) * 360.0 - 180.0;
|
||||
final lat1 = _yToLatitude(y, z);
|
||||
final lat2 = _yToLatitude(y + 1, z);
|
||||
|
||||
return _TileBounds(
|
||||
north: max(lat1, lat2),
|
||||
south: min(lat1, lat2),
|
||||
east: max(lon1, lon2),
|
||||
west: min(lon1, lon2),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert tile Y coordinate to latitude
|
||||
double _yToLatitude(int y, int z) {
|
||||
final n = pow(2, z);
|
||||
final latRad = atan(_sinh(pi * (1 - 2 * y / n)));
|
||||
return latRad * 180.0 / pi;
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double _sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
/// Check if a tile intersects with the current view bounds
|
||||
bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
|
||||
final tileBounds = _tileToBounds(z, x, y);
|
||||
|
||||
// Check if tile bounds intersect with view bounds
|
||||
return !(tileBounds.east < viewBounds.west ||
|
||||
tileBounds.west > viewBounds.east ||
|
||||
tileBounds.north < viewBounds.south ||
|
||||
tileBounds.south > viewBounds.north);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider with unlimited retries.
|
||||
/// Returns tile image bytes. Retries forever until success.
|
||||
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
|
||||
try {
|
||||
// Only log on first attempt
|
||||
if (attempt == 0) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success!
|
||||
if (attempt > 1) {
|
||||
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
|
||||
}
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Calculate delay and retry (no attempt limit - keep trying forever)
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
} else if (attempt % 10 == 0) {
|
||||
// Log every 10th attempt to show we're still working
|
||||
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
@Deprecated('Use fetchRemoteTile instead')
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
return fetchRemoteTile(
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Enhanced tile request entry that tracks coordinates for spatial filtering
|
||||
class _TileRequest {
|
||||
final int z, x, y;
|
||||
final VoidCallback callback;
|
||||
|
||||
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
|
||||
}
|
||||
|
||||
/// Spatially-aware counting semaphore for tile requests with deduplication
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<_TileRequest> _queue = [];
|
||||
final Set<String> _inFlightTiles = {}; // Track in-flight requests for deduplication
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire({int? z, int? x, int? y}) async {
|
||||
// Create tile key for deduplication
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
|
||||
// If this tile is already in flight, skip the request
|
||||
if (_inFlightTiles.contains(tileKey)) {
|
||||
debugPrint('[SimpleSemaphore] Skipping duplicate request for $tileKey');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to in-flight tracking
|
||||
_inFlightTiles.add(tileKey);
|
||||
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
// Check queue size limit to prevent memory bloat
|
||||
if (_queue.length >= kTileFetchMaxQueueSize) {
|
||||
// Remove oldest request to make room
|
||||
final oldRequest = _queue.removeAt(0);
|
||||
final oldKey = '${oldRequest.z}/${oldRequest.x}/${oldRequest.y}';
|
||||
_inFlightTiles.remove(oldKey);
|
||||
debugPrint('[SimpleSemaphore] Queue full, dropped oldest request: $oldKey');
|
||||
}
|
||||
|
||||
final c = Completer<void>();
|
||||
final request = _TileRequest(
|
||||
z: z ?? -1,
|
||||
x: x ?? -1,
|
||||
y: y ?? -1,
|
||||
callback: () => c.complete(),
|
||||
);
|
||||
_queue.add(request);
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release({int? z, int? x, int? y}) {
|
||||
// Remove from in-flight tracking
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
_inFlightTiles.remove(tileKey);
|
||||
|
||||
if (_queue.isNotEmpty) {
|
||||
final request = _queue.removeAt(0);
|
||||
request.callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all queued requests (call when view changes significantly)
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
_inFlightTiles.clear(); // Also clear deduplication tracking
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
/// Clear only tiles that don't pass the visibility filter
|
||||
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
|
||||
final initialCount = _queue.length;
|
||||
final initialInFlightCount = _inFlightTiles.length;
|
||||
|
||||
// Remove stale requests from queue
|
||||
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
|
||||
|
||||
// Remove stale tiles from in-flight tracking
|
||||
_inFlightTiles.removeWhere((tileKey) {
|
||||
final parts = tileKey.split('/');
|
||||
if (parts.length == 3) {
|
||||
final z = int.tryParse(parts[0]) ?? -1;
|
||||
final x = int.tryParse(parts[1]) ?? -1;
|
||||
final y = int.tryParse(parts[2]) ?? -1;
|
||||
return isStale(z, x, y);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
final queueClearedCount = initialCount - _queue.length;
|
||||
final inFlightClearedCount = initialInFlightCount - _inFlightTiles.length;
|
||||
|
||||
if (queueClearedCount > 0 || inFlightClearedCount > 0) {
|
||||
debugPrint('[SimpleSemaphore] Cleared $queueClearedCount stale queue + $inFlightClearedCount stale in-flight, kept ${_queue.length}');
|
||||
}
|
||||
|
||||
return queueClearedCount + inFlightClearedCount;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index
|
||||
class NSIService {
|
||||
@@ -11,7 +11,7 @@ class NSIService {
|
||||
factory NSIService() => _instance;
|
||||
NSIService._();
|
||||
|
||||
static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)';
|
||||
final _client = UserAgentClient();
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
// Cache to avoid repeated API calls
|
||||
@@ -55,10 +55,7 @@ class NSIService {
|
||||
'rp': '15', // Get top 15 most commonly used values
|
||||
});
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'User-Agent': _userAgent},
|
||||
).timeout(_timeout);
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('TagInfo API returned status ${response.statusCode}');
|
||||
|
||||
@@ -33,14 +33,37 @@ class OfflineAreaService {
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
|
||||
return _areas.any((area) =>
|
||||
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId
|
||||
);
|
||||
}
|
||||
|
||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||
/// offline-first path for tiles that will never be found locally.
|
||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||
if (!_initialized) return false;
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId &&
|
||||
zoom >= area.minZoom &&
|
||||
zoom <= area.maxZoom
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset service state and inject areas for unit tests.
|
||||
@visibleForTesting
|
||||
void setAreasForTesting(List<OfflineArea> areas) {
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(areas);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
@@ -213,7 +236,7 @@ class OfflineAreaService {
|
||||
area = OfflineArea(
|
||||
id: id,
|
||||
name: name ?? area?.name ?? '',
|
||||
bounds: bounds,
|
||||
bounds: normalizeBounds(bounds),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_node.dart';
|
||||
import 'offline_tile_utils.dart' show normalizeBounds;
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -71,10 +72,10 @@ class OfflineArea {
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
final bounds = LatLngBounds(
|
||||
final bounds = normalizeBounds(LatLngBounds(
|
||||
LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']),
|
||||
LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']),
|
||||
);
|
||||
));
|
||||
return OfflineArea(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
|
||||
@@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero)
|
||||
/// spans are expanded by epsilon. Call this before storing bounds so that
|
||||
/// `tileInBounds` and [computeTileList] see consistent corner ordering.
|
||||
LatLngBounds normalizeBounds(LatLngBounds bounds) {
|
||||
const double epsilon = 1e-7;
|
||||
double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
// Expand degenerate/flat areas a hair
|
||||
var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
if ((latMax - latMin).abs() < epsilon) {
|
||||
latMin -= epsilon;
|
||||
latMax += epsilon;
|
||||
@@ -20,6 +21,16 @@ Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
lonMin -= epsilon;
|
||||
lonMax += epsilon;
|
||||
}
|
||||
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
|
||||
}
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
final normalized = normalizeBounds(bounds);
|
||||
final double latMin = normalized.south;
|
||||
final double latMax = normalized.north;
|
||||
final double lonMin = normalized.west;
|
||||
final double lonMax = normalized.east;
|
||||
for (int z = zMin; z <= zMax; z++) {
|
||||
final n = pow(2, z).toInt();
|
||||
final minTileRaw = latLonToTileRaw(latMin, lonMin, z);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../state/settings_state.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Service for checking OSM user messages
|
||||
class OSMMessagesService {
|
||||
static const _messageCheckCacheDuration = Duration(minutes: 5);
|
||||
final _client = UserAgentClient();
|
||||
|
||||
DateTime? _lastCheck;
|
||||
int? _lastUnreadCount;
|
||||
@@ -38,7 +39,7 @@ class OSMMessagesService {
|
||||
|
||||
try {
|
||||
final apiHost = _getApiHost(uploadMode);
|
||||
final response = await http.get(
|
||||
final response = await _client.get(
|
||||
Uri.parse('$apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Simple Overpass API client with proper HTTP retry logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
@@ -14,7 +15,8 @@ class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
final http.Client _client;
|
||||
|
||||
OverpassService({http.Client? client}) : _client = client ?? http.Client();
|
||||
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
|
||||
106
lib/services/provider_tile_cache_manager.dart
Normal file
106
lib/services/provider_tile_cache_manager.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'provider_tile_cache_store.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Factory and registry for per-provider [ProviderTileCacheStore] instances.
|
||||
///
|
||||
/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`.
|
||||
/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to
|
||||
/// resolve the platform cache directory. After init, [getOrCreate] is
|
||||
/// synchronous — the cache store lazily creates its directory on first write.
|
||||
class ProviderTileCacheManager {
|
||||
static final Map<String, ProviderTileCacheStore> _stores = {};
|
||||
static String? _baseCacheDir;
|
||||
|
||||
/// Resolve the platform cache directory. Call once at startup.
|
||||
static Future<void> init() async {
|
||||
if (_baseCacheDir != null) return;
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
_baseCacheDir = p.join(cacheDir.path, 'tile_cache');
|
||||
}
|
||||
|
||||
/// Whether the manager has been initialized.
|
||||
static bool get isInitialized => _baseCacheDir != null;
|
||||
|
||||
/// Get or create a cache store for a specific provider/tile type combination.
|
||||
///
|
||||
/// Synchronous after [init] has been called. The cache store lazily creates
|
||||
/// its directory on first write.
|
||||
static ProviderTileCacheStore getOrCreate({
|
||||
required String providerId,
|
||||
required String tileTypeId,
|
||||
required ServicePolicy policy,
|
||||
int? maxCacheBytes,
|
||||
}) {
|
||||
if (_baseCacheDir == null) {
|
||||
throw StateError(
|
||||
'ProviderTileCacheManager.init() must be called before getOrCreate()',
|
||||
);
|
||||
}
|
||||
|
||||
final key = '$providerId/$tileTypeId';
|
||||
if (_stores.containsKey(key)) return _stores[key]!;
|
||||
|
||||
final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId);
|
||||
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: cacheDir,
|
||||
maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024,
|
||||
overrideFreshAge: policy.minCacheTtl,
|
||||
);
|
||||
|
||||
_stores[key] = store;
|
||||
return store;
|
||||
}
|
||||
|
||||
/// Delete a specific provider's cache directory and remove the store.
|
||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||
final key = '$providerId/$tileTypeId';
|
||||
final store = _stores.remove(key);
|
||||
if (store != null) {
|
||||
await store.clear();
|
||||
} else if (_baseCacheDir != null) {
|
||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||
if (await cacheDir.exists()) {
|
||||
await cacheDir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get estimated cache sizes for all active stores.
|
||||
///
|
||||
/// Returns a map of `providerId/tileTypeId` → size in bytes.
|
||||
static Future<Map<String, int>> getCacheSizes() async {
|
||||
final sizes = <String, int>{};
|
||||
for (final entry in _stores.entries) {
|
||||
sizes[entry.key] = await entry.value.estimatedSizeBytes;
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/// Remove a store from the registry (e.g., when a provider is disposed).
|
||||
static void unregister(String providerId, String tileTypeId) {
|
||||
_stores.remove('$providerId/$tileTypeId');
|
||||
}
|
||||
|
||||
/// Clear all stores and reset the registry (for testing).
|
||||
@visibleForTesting
|
||||
static Future<void> resetAll() async {
|
||||
for (final store in _stores.values) {
|
||||
await store.clear();
|
||||
}
|
||||
_stores.clear();
|
||||
_baseCacheDir = null;
|
||||
}
|
||||
|
||||
/// Set the base cache directory directly (for testing).
|
||||
@visibleForTesting
|
||||
static void setBaseCacheDir(String dir) {
|
||||
_baseCacheDir = dir;
|
||||
}
|
||||
}
|
||||
315
lib/services/provider_tile_cache_store.dart
Normal file
315
lib/services/provider_tile_cache_store.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Per-provider tile cache implementing flutter_map's [MapCachingProvider].
|
||||
///
|
||||
/// Each instance manages an isolated cache directory with:
|
||||
/// - Deterministic UUID v5 key generation from tile URLs
|
||||
/// - Optional TTL override from [ServicePolicy.minCacheTtl]
|
||||
/// - Configurable max cache size with oldest-modified eviction
|
||||
///
|
||||
/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON
|
||||
/// metadata containing staleAt, lastModified, etag).
|
||||
class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final String cacheDirectory;
|
||||
final int maxCacheBytes;
|
||||
final Duration? overrideFreshAge;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Running estimate of cache size in bytes. Initialized lazily on first
|
||||
/// [putTile] call to avoid blocking construction.
|
||||
int? _estimatedSize;
|
||||
|
||||
/// Throttle: don't re-scan more than once per minute.
|
||||
DateTime? _lastPruneCheck;
|
||||
|
||||
/// One-shot latch for lazy directory creation (safe under concurrent calls).
|
||||
Completer<void>? _directoryReady;
|
||||
|
||||
/// Guard against concurrent eviction runs.
|
||||
bool _isEvicting = false;
|
||||
|
||||
ProviderTileCacheStore({
|
||||
required this.cacheDirectory,
|
||||
this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default
|
||||
this.overrideFreshAge,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async {
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['staleAt'] as int,
|
||||
isUtc: true,
|
||||
),
|
||||
lastModified: metaJson['lastModified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['lastModified'] as int,
|
||||
isUtc: true,
|
||||
)
|
||||
: null,
|
||||
etag: metaJson['etag'] as String?,
|
||||
);
|
||||
|
||||
return (bytes: bytes, metadata: metadata);
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw CachedMapTileReadFailure(
|
||||
url: url,
|
||||
description: 'Failed to read cached tile',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {
|
||||
await _ensureDirectory();
|
||||
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
// Apply minimum TTL override if configured (e.g., OSM 7-day minimum).
|
||||
// Use the later of server-provided staleAt and our minimum to avoid
|
||||
// accidentally shortening a longer server-provided freshness lifetime.
|
||||
final effectiveMetadata = overrideFreshAge != null
|
||||
? (() {
|
||||
final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!);
|
||||
final staleAt = metadata.staleAt.isAfter(overrideStaleAt)
|
||||
? metadata.staleAt
|
||||
: overrideStaleAt;
|
||||
return CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: metadata.lastModified,
|
||||
etag: metadata.etag,
|
||||
);
|
||||
})()
|
||||
: metadata;
|
||||
|
||||
final metaJson = json.encode({
|
||||
'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch,
|
||||
'lastModified':
|
||||
effectiveMetadata.lastModified?.millisecondsSinceEpoch,
|
||||
'etag': effectiveMetadata.etag,
|
||||
});
|
||||
|
||||
// Write .tile before .meta: if we crash between the two writes, the
|
||||
// read path's both-must-exist check sees a miss rather than an orphan .meta.
|
||||
if (bytes != null) {
|
||||
await tileFile.writeAsBytes(bytes);
|
||||
}
|
||||
await metaFile.writeAsString(metaJson);
|
||||
|
||||
// Reset size estimate so it resyncs from disk on next check.
|
||||
// This avoids drift from overwrites where the old size isn't subtracted.
|
||||
_estimatedSize = null;
|
||||
|
||||
// Schedule lazy size check
|
||||
_scheduleEvictionCheck();
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists (lazy creation on first write).
|
||||
///
|
||||
/// Uses a Completer latch so concurrent callers share a single create().
|
||||
/// Safe under Dart's single-threaded event loop: the null check and
|
||||
/// assignment happen in the same synchronous block with no `await`
|
||||
/// between them, so no other microtask can interleave.
|
||||
Future<void> _ensureDirectory() {
|
||||
if (_directoryReady == null) {
|
||||
final completer = Completer<void>();
|
||||
_directoryReady = completer;
|
||||
Directory(cacheDirectory).create(recursive: true).then(
|
||||
(_) => completer.complete(),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
// Reset latch on error so later calls can retry directory creation.
|
||||
if (identical(_directoryReady, completer)) {
|
||||
_directoryReady = null;
|
||||
}
|
||||
completer.completeError(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _directoryReady!.future;
|
||||
}
|
||||
|
||||
/// Generate a cache key from URL using UUID v5 (same as flutter_map built-in).
|
||||
@visibleForTesting
|
||||
static String keyFor(String url) => _uuid.v5(Namespace.url.value, url);
|
||||
|
||||
/// Estimate total cache size (lazy, first call scans directory).
|
||||
Future<int> _getEstimatedSize() async {
|
||||
if (_estimatedSize != null) return _estimatedSize!;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) {
|
||||
_estimatedSize = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
total += await entity.length();
|
||||
}
|
||||
}
|
||||
_estimatedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Schedule eviction if we haven't checked recently.
|
||||
void _scheduleEvictionCheck() {
|
||||
final now = DateTime.now();
|
||||
if (_lastPruneCheck != null &&
|
||||
now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) {
|
||||
return;
|
||||
}
|
||||
_lastPruneCheck = now;
|
||||
|
||||
// Fire-and-forget: eviction is best-effort background work.
|
||||
// _estimatedSize may be momentarily stale between eviction start and
|
||||
// completion, but this is acceptable — the guard only needs to be
|
||||
// approximately correct to prevent unbounded growth, and the throttle
|
||||
// ensures we re-check within a minute.
|
||||
// ignore: discarded_futures
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Evict oldest-modified tiles if cache exceeds size limit.
|
||||
///
|
||||
/// Sorts by file mtime (oldest first), not by last access — true LRU would
|
||||
/// require touching files on every [getTile] read, adding I/O on the hot
|
||||
/// path. In practice write-recency tracks usage well because tiles are
|
||||
/// immutable and flutter_map holds visible tiles in memory.
|
||||
///
|
||||
/// Guarded by [_isEvicting] to prevent concurrent runs from corrupting
|
||||
/// [_estimatedSize].
|
||||
Future<void> _evictIfNeeded() async {
|
||||
if (_isEvicting) return;
|
||||
_isEvicting = true;
|
||||
try {
|
||||
final currentSize = await _getEstimatedSize();
|
||||
if (currentSize <= maxCacheBytes) return;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
// Collect all files, separating .tile and .meta for eviction + orphan cleanup.
|
||||
final tileFiles = <File>[];
|
||||
final metaFiles = <String>{};
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.path.endsWith('.tile')) {
|
||||
tileFiles.add(entity);
|
||||
} else if (entity.path.endsWith('.meta')) {
|
||||
metaFiles.add(p.basenameWithoutExtension(entity.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tileFiles.isEmpty) return;
|
||||
|
||||
// Sort by modification time, oldest first
|
||||
final stats = await Future.wait(
|
||||
tileFiles.map((f) async => (file: f, stat: await f.stat())),
|
||||
);
|
||||
stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified));
|
||||
|
||||
var freedBytes = 0;
|
||||
final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80%
|
||||
final evictedKeys = <String>{};
|
||||
|
||||
for (final entry in stats) {
|
||||
if (currentSize - freedBytes <= targetSize) break;
|
||||
|
||||
final key = p.basenameWithoutExtension(entry.file.path);
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
await entry.file.delete();
|
||||
freedBytes += entry.stat.size;
|
||||
evictedKeys.add(key);
|
||||
if (await metaFile.exists()) {
|
||||
final metaStat = await metaFile.stat();
|
||||
await metaFile.delete();
|
||||
freedBytes += metaStat.size;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphan .meta files (no matching .tile file).
|
||||
// Exclude keys we just evicted — their .tile is gone so they're orphans.
|
||||
final remainingTileKeys = tileFiles
|
||||
.map((f) => p.basenameWithoutExtension(f.path))
|
||||
.toSet()
|
||||
..removeAll(evictedKeys);
|
||||
for (final metaKey in metaFiles) {
|
||||
if (!remainingTileKeys.contains(metaKey)) {
|
||||
try {
|
||||
final orphan = File(p.join(cacheDirectory, '$metaKey.meta'));
|
||||
final orphanStat = await orphan.stat();
|
||||
await orphan.delete();
|
||||
freedBytes += orphanStat.size;
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_estimatedSize = currentSize - freedBytes;
|
||||
debugPrint(
|
||||
'[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB '
|
||||
'from $cacheDirectory',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Eviction error: $e');
|
||||
} finally {
|
||||
_isEvicting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cached tiles in this store's directory.
|
||||
Future<void> clear() async {
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_estimatedSize = null;
|
||||
_directoryReady = null; // Allow lazy re-creation
|
||||
_lastPruneCheck = null; // Reset throttle so next write can trigger eviction
|
||||
}
|
||||
|
||||
/// Get the current estimated cache size in bytes.
|
||||
Future<int> get estimatedSizeBytes => _getEstimatedSize();
|
||||
|
||||
/// Force an eviction check, bypassing the throttle.
|
||||
/// Only exposed for testing — production code uses [_scheduleEvictionCheck].
|
||||
@visibleForTesting
|
||||
Future<void> forceEviction() => _evictIfNeeded();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
@@ -26,10 +27,9 @@ class RouteResult {
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
final http.Client _client;
|
||||
|
||||
RoutingService({http.Client? client}) : _client = client ?? http.Client();
|
||||
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
@@ -75,7 +75,6 @@ class RoutingService {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: json.encode(params)
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Cached search result with expiry.
|
||||
class _CachedResult {
|
||||
final List<SearchResult> results;
|
||||
final DateTime cachedAt;
|
||||
|
||||
_CachedResult(this.results) : cachedAt = DateTime.now();
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
class SearchService {
|
||||
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
|
||||
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
static const int _maxResults = 5;
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
final _client = UserAgentClient();
|
||||
|
||||
/// Client-side result cache, keyed by normalized query + viewbox.
|
||||
/// Required by Nominatim usage policy. Static so all SearchService
|
||||
/// instances share the cache and don't generate redundant requests.
|
||||
static final Map<String, _CachedResult> _resultCache = {};
|
||||
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
@@ -27,23 +45,23 @@ class SearchService {
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
SearchResult? _tryParseCoordinates(String query) {
|
||||
// Remove common separators and normalize
|
||||
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
|
||||
final parts = normalized.split(RegExp(r'\s+'));
|
||||
|
||||
|
||||
if (parts.length != 2) return null;
|
||||
|
||||
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lon = double.tryParse(parts[1]);
|
||||
|
||||
|
||||
if (lat == null || lon == null) return null;
|
||||
|
||||
|
||||
// Basic validation for Earth coordinates
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
|
||||
return SearchResult(
|
||||
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
|
||||
coordinates: LatLng(lat, lon),
|
||||
@@ -51,17 +69,17 @@ class SearchService {
|
||||
type: 'point',
|
||||
);
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
/// Search using Nominatim API with rate limiting and result caching.
|
||||
///
|
||||
/// Nominatim usage policy requires:
|
||||
/// - Max 1 request per second
|
||||
/// - Client-side result caching
|
||||
/// - No auto-complete / typeahead
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
// Normalize the viewbox first so both the cache key and the request
|
||||
// params use the same effective values (rounded + min-span expanded).
|
||||
String? viewboxParam;
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
@@ -80,36 +98,83 @@ class SearchService {
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
viewboxParam = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final cacheKey = _buildCacheKey(query, viewboxParam);
|
||||
|
||||
// Check cache first (Nominatim policy requires client-side caching)
|
||||
final cached = _resultCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
debugPrint('[SearchService] Cache hit for "$query"');
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
if (viewboxParam != null) {
|
||||
params['viewbox'] = viewboxParam;
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
// Rate limit: max 1 request/sec per Nominatim policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.nominatim);
|
||||
try {
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'User-Agent': _userAgent,
|
||||
},
|
||||
).timeout(_timeout);
|
||||
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
|
||||
final List<dynamic> jsonResults = json.decode(response.body);
|
||||
final results = jsonResults
|
||||
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
|
||||
// Cache the results
|
||||
_resultCache[cacheKey] = _CachedResult(results);
|
||||
_pruneCache();
|
||||
|
||||
debugPrint('[SearchService] Found ${results.length} results');
|
||||
return results;
|
||||
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SearchService] Search failed: $e');
|
||||
throw Exception('Search failed: $e');
|
||||
Error.throwWithStackTrace(e, stackTrace);
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a cache key from the query and the already-normalized viewbox string.
|
||||
///
|
||||
/// The viewbox should be the same `west,north,east,south` string sent to
|
||||
/// Nominatim (after rounding and min-span expansion) so that requests with
|
||||
/// different raw bounds but the same effective viewbox share a cache entry.
|
||||
String _buildCacheKey(String query, String? viewboxParam) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (viewboxParam == null) return normalizedQuery;
|
||||
return '$normalizedQuery|$viewboxParam';
|
||||
}
|
||||
|
||||
/// Remove expired entries and limit cache size.
|
||||
void _pruneCache() {
|
||||
_resultCache.removeWhere((_, cached) => cached.isExpired);
|
||||
// Limit cache to 50 entries to prevent unbounded growth
|
||||
if (_resultCache.length > 50) {
|
||||
final sortedKeys = _resultCache.keys.toList()
|
||||
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
|
||||
for (final key in sortedKeys.take(_resultCache.length - 50)) {
|
||||
_resultCache.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
402
lib/services/service_policy.dart
Normal file
402
lib/services/service_policy.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Identifies the type of external service being accessed.
|
||||
/// Used by [ServicePolicyResolver] to determine the correct compliance policy.
|
||||
enum ServiceType {
|
||||
// OSMF official services
|
||||
osmEditingApi, // api.openstreetmap.org — editing & data queries
|
||||
osmTileServer, // tile.openstreetmap.org — raster tiles
|
||||
nominatim, // nominatim.openstreetmap.org — geocoding
|
||||
overpass, // overpass-api.de — read-only data queries
|
||||
tagInfo, // taginfo.openstreetmap.org — tag metadata
|
||||
|
||||
// Third-party tile services
|
||||
bingTiles, // *.tiles.virtualearth.net
|
||||
mapboxTiles, // api.mapbox.com
|
||||
|
||||
// Everything else
|
||||
custom, // user's own infrastructure / unknown
|
||||
}
|
||||
|
||||
/// Defines the compliance rules for a specific service.
|
||||
///
|
||||
/// Each policy captures the rate limits, caching requirements, offline
|
||||
/// permissions, and attribution obligations mandated by the service operator.
|
||||
/// When the app talks to official OSMF infrastructure the strict policies
|
||||
/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom]
|
||||
/// provides permissive defaults.
|
||||
class ServicePolicy {
|
||||
/// Max concurrent HTTP connections to this service.
|
||||
/// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114).
|
||||
final int maxConcurrentRequests;
|
||||
|
||||
/// Minimum interval between consecutive requests. Null means no rate limit.
|
||||
final Duration? minRequestInterval;
|
||||
|
||||
/// Whether this endpoint permits offline/bulk downloading of tiles.
|
||||
final bool allowsOfflineDownload;
|
||||
|
||||
/// Whether the client must cache responses (e.g., Nominatim policy).
|
||||
final bool requiresClientCaching;
|
||||
|
||||
/// Minimum cache TTL to enforce regardless of server headers.
|
||||
/// Null means "use server-provided max-age as-is".
|
||||
final Duration? minCacheTtl;
|
||||
|
||||
/// License/attribution URL to display in the attribution dialog.
|
||||
/// Null means no special attribution link is needed.
|
||||
final String? attributionUrl;
|
||||
|
||||
const ServicePolicy({
|
||||
this.maxConcurrentRequests = 8,
|
||||
this.minRequestInterval,
|
||||
this.allowsOfflineDownload = true,
|
||||
this.requiresClientCaching = false,
|
||||
this.minCacheTtl,
|
||||
this.attributionUrl,
|
||||
});
|
||||
|
||||
/// OSM editing API (api.openstreetmap.org)
|
||||
/// Policy: max 2 concurrent download threads.
|
||||
/// https://operations.osmfoundation.org/policies/api/
|
||||
const ServicePolicy.osmEditingApi()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a for API
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// OSM tile server (tile.openstreetmap.org)
|
||||
/// Policy: min 7-day cache, must honor cache headers.
|
||||
/// Concurrency managed by flutter_map's NetworkTileProvider.
|
||||
/// https://operations.osmfoundation.org/policies/tiles/
|
||||
const ServicePolicy.osmTileServer()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true,
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = const Duration(days: 7),
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Nominatim geocoding (nominatim.openstreetmap.org)
|
||||
/// Policy: max 1 req/sec, single machine only, results must be cached.
|
||||
/// https://operations.osmfoundation.org/policies/nominatim/
|
||||
const ServicePolicy.nominatim()
|
||||
: maxConcurrentRequests = 1,
|
||||
minRequestInterval = const Duration(seconds: 1),
|
||||
allowsOfflineDownload = true, // n/a for geocoding
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Overpass API (overpass-api.de)
|
||||
/// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore.
|
||||
const ServicePolicy.overpass()
|
||||
: maxConcurrentRequests = 0, // managed by NodeDataManager
|
||||
minRequestInterval = null, // managed by NodeDataManager
|
||||
allowsOfflineDownload = true, // n/a for data queries
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// TagInfo API (taginfo.openstreetmap.org)
|
||||
const ServicePolicy.tagInfo()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a
|
||||
requiresClientCaching = true, // already cached in NSIService
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Bing Maps tiles (*.tiles.virtualearth.net)
|
||||
const ServicePolicy.bingTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // check Bing ToS separately
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Mapbox tiles (api.mapbox.com)
|
||||
const ServicePolicy.mapboxTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // permitted with valid token
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Custom/self-hosted service — permissive defaults.
|
||||
const ServicePolicy.custom({
|
||||
int maxConcurrent = 8,
|
||||
bool allowsOffline = true,
|
||||
Duration? minInterval,
|
||||
String? attribution,
|
||||
}) : maxConcurrentRequests = maxConcurrent,
|
||||
minRequestInterval = minInterval,
|
||||
allowsOfflineDownload = allowsOffline,
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = attribution;
|
||||
|
||||
@override
|
||||
String toString() => 'ServicePolicy('
|
||||
'maxConcurrent: $maxConcurrentRequests, '
|
||||
'minInterval: $minRequestInterval, '
|
||||
'offlineDownload: $allowsOfflineDownload, '
|
||||
'clientCaching: $requiresClientCaching, '
|
||||
'minCacheTtl: $minCacheTtl, '
|
||||
'attributionUrl: $attributionUrl)';
|
||||
}
|
||||
|
||||
/// Resolves URLs and tile providers to their applicable [ServicePolicy].
|
||||
///
|
||||
/// Built-in patterns cover all OSMF official services and common third-party
|
||||
/// tile providers. Custom overrides can be registered for self-hosted endpoints
|
||||
/// via [registerCustomPolicy].
|
||||
class ServicePolicyResolver {
|
||||
/// Host → ServiceType mapping for known services.
|
||||
static final Map<String, ServiceType> _hostPatterns = {
|
||||
'api.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'api06.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
||||
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
||||
'overpass-api.de': ServiceType.overpass,
|
||||
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
||||
'tiles.virtualearth.net': ServiceType.bingTiles,
|
||||
'api.mapbox.com': ServiceType.mapboxTiles,
|
||||
};
|
||||
|
||||
/// ServiceType → policy mapping.
|
||||
static final Map<ServiceType, ServicePolicy> _policies = {
|
||||
ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(),
|
||||
ServiceType.osmTileServer: const ServicePolicy.osmTileServer(),
|
||||
ServiceType.nominatim: const ServicePolicy.nominatim(),
|
||||
ServiceType.overpass: const ServicePolicy.overpass(),
|
||||
ServiceType.tagInfo: const ServicePolicy.tagInfo(),
|
||||
ServiceType.bingTiles: const ServicePolicy.bingTiles(),
|
||||
ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(),
|
||||
ServiceType.custom: const ServicePolicy(),
|
||||
};
|
||||
|
||||
/// Custom host overrides registered at runtime (for self-hosted services).
|
||||
static final Map<String, ServicePolicy> _customOverrides = {};
|
||||
|
||||
/// Resolve a URL to its applicable [ServicePolicy].
|
||||
///
|
||||
/// Checks custom overrides first, then built-in host patterns. Falls back
|
||||
/// to [ServicePolicy.custom] for unrecognized hosts.
|
||||
static ServicePolicy resolve(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return const ServicePolicy();
|
||||
|
||||
// Check custom overrides first (exact or subdomain matching)
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in patterns (support subdomain matching)
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return _policies[entry.value] ?? const ServicePolicy();
|
||||
}
|
||||
}
|
||||
|
||||
return const ServicePolicy();
|
||||
}
|
||||
|
||||
/// Resolve a URL to its [ServiceType].
|
||||
///
|
||||
/// Returns [ServiceType.custom] for unrecognized hosts.
|
||||
static ServiceType resolveType(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return ServiceType.custom;
|
||||
|
||||
// Check custom overrides first — a registered custom policy means
|
||||
// the host is treated as ServiceType.custom with custom rules.
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return ServiceType.custom;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceType.custom;
|
||||
}
|
||||
|
||||
/// Look up the [ServicePolicy] for a known [ServiceType].
|
||||
static ServicePolicy resolveByType(ServiceType type) =>
|
||||
_policies[type] ?? const ServicePolicy();
|
||||
|
||||
/// Register a custom policy override for a host pattern.
|
||||
///
|
||||
/// Use this to configure self-hosted services:
|
||||
/// ```dart
|
||||
/// ServicePolicyResolver.registerCustomPolicy(
|
||||
/// 'tiles.myserver.com',
|
||||
/// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20),
|
||||
/// );
|
||||
/// ```
|
||||
static void registerCustomPolicy(String hostPattern, ServicePolicy policy) {
|
||||
_customOverrides[hostPattern] = policy;
|
||||
}
|
||||
|
||||
/// Remove a custom policy override.
|
||||
static void removeCustomPolicy(String hostPattern) {
|
||||
_customOverrides.remove(hostPattern);
|
||||
}
|
||||
|
||||
/// Clear all custom policy overrides (useful for testing).
|
||||
static void clearCustomPolicies() {
|
||||
_customOverrides.clear();
|
||||
}
|
||||
|
||||
/// Extract the host from a URL or URL template.
|
||||
static String? _extractHost(String url) {
|
||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
// and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...'
|
||||
try {
|
||||
// Strip template variables from subdomain part for parsing
|
||||
final cleaned = url
|
||||
.replaceAll(RegExp(r'\{0_3\}'), '0')
|
||||
.replaceAll(RegExp(r'\{1_4\}'), '1')
|
||||
.replaceAll(RegExp(r'\{quadkey\}'), 'quadkey')
|
||||
.replaceAll(RegExp(r'\{z\}'), '0')
|
||||
.replaceAll(RegExp(r'\{x\}'), '0')
|
||||
.replaceAll(RegExp(r'\{y\}'), '0')
|
||||
.replaceAll(RegExp(r'\{api_key\}'), 'key');
|
||||
return Uri.parse(cleaned).host.toLowerCase();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable per-service rate limiter and concurrency controller.
|
||||
///
|
||||
/// Enforces the rate limits and concurrency constraints defined in each
|
||||
/// service's [ServicePolicy]. Call [acquire] before making a request and
|
||||
/// [release] after the request completes.
|
||||
///
|
||||
/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0
|
||||
/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere
|
||||
/// (flutter_map, PR #114) are passed through without blocking.
|
||||
class ServiceRateLimiter {
|
||||
/// Injectable clock for testing. Defaults to [DateTime.now].
|
||||
///
|
||||
/// Override with a deterministic clock (e.g. from `FakeAsync`) so tests
|
||||
/// don't rely on wall-clock time and stay fast and stable under CI load.
|
||||
@visibleForTesting
|
||||
static DateTime Function() clock = DateTime.now;
|
||||
|
||||
/// Per-service timestamps of the last acquired request slot / request start
|
||||
/// (used for rate limiting in [acquire], not updated on completion).
|
||||
static final Map<ServiceType, DateTime> _lastRequestTime = {};
|
||||
|
||||
/// Per-service concurrency semaphores.
|
||||
static final Map<ServiceType, _Semaphore> _semaphores = {};
|
||||
|
||||
/// Acquire a slot: wait for rate limit compliance, then take a connection slot.
|
||||
///
|
||||
/// Blocks if:
|
||||
/// 1. The minimum interval between requests hasn't elapsed yet, or
|
||||
/// 2. All concurrent connection slots are in use.
|
||||
static Future<void> acquire(ServiceType service) async {
|
||||
final policy = ServicePolicyResolver.resolveByType(service);
|
||||
|
||||
// Concurrency: acquire a semaphore slot first so that at most
|
||||
// [policy.maxConcurrentRequests] callers proceed concurrently.
|
||||
// The min-interval check below is only race-free when
|
||||
// maxConcurrentRequests == 1 (currently only Nominatim). For services
|
||||
// with higher concurrency the interval is approximate, which is
|
||||
// acceptable — their policies don't specify a min interval.
|
||||
_Semaphore? semaphore;
|
||||
if (policy.maxConcurrentRequests > 0) {
|
||||
semaphore = _semaphores.putIfAbsent(
|
||||
service,
|
||||
() => _Semaphore(policy.maxConcurrentRequests),
|
||||
);
|
||||
await semaphore.acquire();
|
||||
}
|
||||
|
||||
try {
|
||||
// Rate limit: wait if we sent a request too recently
|
||||
if (policy.minRequestInterval != null) {
|
||||
final lastTime = _lastRequestTime[service];
|
||||
if (lastTime != null) {
|
||||
final elapsed = clock().difference(lastTime);
|
||||
final remaining = policy.minRequestInterval! - elapsed;
|
||||
if (remaining > Duration.zero) {
|
||||
debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms');
|
||||
await Future.delayed(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record request time
|
||||
_lastRequestTime[service] = clock();
|
||||
} catch (_) {
|
||||
// Release the semaphore slot if the rate-limit delay fails,
|
||||
// to avoid permanently leaking a slot.
|
||||
semaphore?.release();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a connection slot after request completes.
|
||||
static void release(ServiceType service) {
|
||||
_semaphores[service]?.release();
|
||||
}
|
||||
|
||||
/// Reset all rate limiter state (for testing).
|
||||
@visibleForTesting
|
||||
static void reset() {
|
||||
_lastRequestTime.clear();
|
||||
_semaphores.clear();
|
||||
clock = DateTime.now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple async counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int _maxCount;
|
||||
int _currentCount = 0;
|
||||
final List<Completer<void>> _waiters = [];
|
||||
|
||||
_Semaphore(this._maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < _maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiters.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiters.isNotEmpty) {
|
||||
final next = _waiters.removeAt(0);
|
||||
next.complete();
|
||||
} else if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
} else {
|
||||
throw StateError(
|
||||
'Semaphore.release() called more times than acquire(); '
|
||||
'currentCount is already zero.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:csv/csv.dart';
|
||||
|
||||
import '../dev_config.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import 'http_client.dart';
|
||||
import 'suspected_location_cache.dart';
|
||||
|
||||
class SuspectedLocationService {
|
||||
@@ -112,9 +113,8 @@ class SuspectedLocationService {
|
||||
|
||||
// Use streaming download for progress tracking
|
||||
final request = http.Request('GET', Uri.parse(kSuspectedLocationsCsvUrl));
|
||||
request.headers['User-Agent'] = 'DeFlock/1.0 (OSM surveillance mapping app)';
|
||||
|
||||
final client = http.Client();
|
||||
|
||||
final client = UserAgentClient();
|
||||
final streamedResponse = await client.send(request).timeout(_timeout);
|
||||
|
||||
if (streamedResponse.statusCode != 200) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Service for fetching missing tile preview images
|
||||
class TilePreviewService {
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
static final _client = UserAgentClient();
|
||||
|
||||
/// Attempt to fetch missing preview tiles for tile types that don't already have preview data
|
||||
/// Fails silently - no error handling or user notification on failure
|
||||
@@ -62,7 +63,7 @@ class TilePreviewService {
|
||||
try {
|
||||
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
|
||||
|
||||
final response = await http.get(Uri.parse(url)).timeout(_timeout);
|
||||
final response = await _client.get(Uri.parse(url)).timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
|
||||
debugPrint('TilePreviewService: Fetched preview for ${tileType.name}');
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../models/pending_upload.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'http_client.dart';
|
||||
import 'version_service.dart';
|
||||
|
||||
class UploadResult {
|
||||
@@ -348,6 +349,7 @@ class Uploader {
|
||||
Map<String, String> get _headers => {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'text/xml',
|
||||
'User-Agent': UserAgentClient.userAgent,
|
||||
};
|
||||
|
||||
/// Sanitize text for safe inclusion in XML attributes and content
|
||||
|
||||
@@ -6,9 +6,17 @@ import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _profileOrderPrefsKey = 'profile_order';
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||
|
||||
// Test-only getters for accessing private state
|
||||
@visibleForTesting
|
||||
List<NodeProfile> get internalProfiles => _profiles;
|
||||
@visibleForTesting
|
||||
Set<NodeProfile> get internalEnabled => _enabled;
|
||||
|
||||
// Callback for when a profile is deleted (used to clear stale sessions)
|
||||
void Function(NodeProfile)? _onProfileDeleted;
|
||||
@@ -18,10 +26,10 @@ class ProfileState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||
bool isEnabled(NodeProfile p) => _enabled.contains(p);
|
||||
List<NodeProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
_getOrderedProfiles().where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
@@ -34,7 +42,7 @@ class ProfileState extends ChangeNotifier {
|
||||
await ProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
// Load enabled profile IDs and custom order from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
@@ -44,6 +52,9 @@ class ProfileState extends ChangeNotifier {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
|
||||
// Load custom order
|
||||
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||
}
|
||||
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
@@ -70,7 +81,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -84,7 +95,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
|
||||
// Notify about profile deletion so other parts can clean up
|
||||
_onProfileDeleted?.call(p);
|
||||
@@ -92,12 +103,79 @@ class ProfileState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Reorder profiles (for drag-and-drop in settings)
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
final orderedProfiles = _getOrderedProfiles();
|
||||
|
||||
// Standard Flutter reordering logic
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = orderedProfiles.removeAt(oldIndex);
|
||||
orderedProfiles.insert(newIndex, item);
|
||||
|
||||
// Update custom order with new sequence
|
||||
_customOrder = orderedProfiles.map((p) => p.id).toList();
|
||||
_saveCustomOrder();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Get profiles in custom order, with unordered profiles at the end
|
||||
List<NodeProfile> _getOrderedProfiles() {
|
||||
if (_customOrder.isEmpty) {
|
||||
return List.from(_profiles);
|
||||
}
|
||||
|
||||
final ordered = <NodeProfile>[];
|
||||
final profilesById = {for (final p in _profiles) p.id: p};
|
||||
|
||||
// Add profiles in custom order
|
||||
for (final id in _customOrder) {
|
||||
final profile = profilesById[id];
|
||||
if (profile != null) {
|
||||
ordered.add(profile);
|
||||
profilesById.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining profiles that weren't in the custom order
|
||||
ordered.addAll(profilesById.values);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[ProfileState] Failed to save enabled profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save profiles to storage
|
||||
Future<void> _saveProfilesToStorage() async {
|
||||
try {
|
||||
await ProfileService().save(_profiles);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if storage isn't available
|
||||
debugPrint('[ProfileState] Failed to save profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save custom order to disk
|
||||
Future<void> _saveCustomOrder() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[ProfileState] Failed to save custom order: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../keys.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
@@ -41,7 +42,8 @@ class SettingsState extends ChangeNotifier {
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
// Default must account for missing secrets (preview builds) even before init() runs
|
||||
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
@@ -150,8 +152,16 @@ class SettingsState extends ChangeNotifier {
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// In production builds, force production mode if development modes are disabled
|
||||
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
// Override persisted upload mode when the current build configuration
|
||||
// doesn't support it. This handles two cases:
|
||||
// 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes
|
||||
// 2. Production builds — force production (prefs may have sandbox/simulate
|
||||
// from a previous dev build on the same device)
|
||||
if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) {
|
||||
debugPrint('SettingsState: No OSM secrets available, forcing simulate mode');
|
||||
_uploadMode = UploadMode.simulate;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
} else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
_uploadMode = UploadMode.production;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
@@ -258,11 +268,10 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// In production builds, only allow production mode
|
||||
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
mode = UploadMode.production;
|
||||
}
|
||||
// The upload mode dropdown is only visible when kEnableDevelopmentModes is
|
||||
// true (gated in osm_account_screen.dart), so no secrets/dev-mode guards
|
||||
// are needed here. The init() method handles forcing the correct mode on
|
||||
// startup for production builds and builds without OAuth secrets.
|
||||
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -262,16 +262,44 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Guard: provider and tile type must be non-null for a
|
||||
// useful offline area (fetchLocalTile requires exact match).
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.noTileProviderSelected')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
if (!context.mounted) return;
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
@@ -282,10 +310,10 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
tileProviderId: selectedProvider.id,
|
||||
tileProviderName: selectedProvider.name,
|
||||
tileTypeId: selectedTileType.id,
|
||||
tileTypeName: selectedTileType.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
|
||||
@@ -10,6 +10,13 @@ import '../../dev_config.dart';
|
||||
/// Manages data fetching, filtering, and node limit logic for the map.
|
||||
/// Handles profile changes, zoom level restrictions, and node rendering limits.
|
||||
class MapDataManager {
|
||||
final List<OsmNode> Function(LatLngBounds bounds) _getNodesForBounds;
|
||||
|
||||
MapDataManager({
|
||||
List<OsmNode> Function(LatLngBounds bounds)? getNodesForBounds,
|
||||
}) : _getNodesForBounds = getNodesForBounds ??
|
||||
((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds));
|
||||
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
@@ -51,28 +58,42 @@ class MapDataManager {
|
||||
List<OsmNode> allNodes;
|
||||
List<OsmNode> nodesToRender;
|
||||
bool isLimitActive = false;
|
||||
|
||||
int validNodesCount = 0;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
|
||||
if (mapBounds != null) {
|
||||
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
|
||||
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
|
||||
allNodes = _getNodesForBounds(expandedBounds);
|
||||
} else {
|
||||
allNodes = <OsmNode>[];
|
||||
}
|
||||
|
||||
|
||||
// Filter out invalid coordinates before applying limit
|
||||
final validNodes = allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).toList();
|
||||
|
||||
// Apply rendering limit to prevent UI lag
|
||||
if (validNodes.length > maxNodes) {
|
||||
validNodesCount = validNodes.length;
|
||||
|
||||
// Apply rendering limit to prevent UI lag.
|
||||
// Sort by distance from viewport center so the most visible nodes
|
||||
// always make the cut, preventing gaps that shift as you pan.
|
||||
if (validNodesCount > maxNodes) {
|
||||
final bounds = mapBounds!;
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
validNodes.sort((a, b) {
|
||||
final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) +
|
||||
(a.coord.longitude - centerLng) * (a.coord.longitude - centerLng);
|
||||
final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) +
|
||||
(b.coord.longitude - centerLng) * (b.coord.longitude - centerLng);
|
||||
final cmp = distA.compareTo(distB);
|
||||
return cmp != 0 ? cmp : a.id.compareTo(b.id);
|
||||
});
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
|
||||
} else {
|
||||
nodesToRender = validNodes;
|
||||
isLimitActive = false;
|
||||
@@ -87,6 +108,9 @@ class MapDataManager {
|
||||
// Notify parent if limit state changed (for button disabling)
|
||||
if (isLimitActive != _lastNodeLimitState) {
|
||||
_lastNodeLimitState = isLimitActive;
|
||||
if (isLimitActive) {
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of $validNodesCount valid devices');
|
||||
}
|
||||
// Schedule callback after build completes to avoid setState during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onNodeLimitChanged?.call(isLimitActive);
|
||||
@@ -97,11 +121,7 @@ class MapDataManager {
|
||||
allNodes: allNodes,
|
||||
nodesToRender: nodesToRender,
|
||||
isLimitActive: isLimitActive,
|
||||
validNodesCount: isLimitActive ? allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).length : 0,
|
||||
validNodesCount: isLimitActive ? validNodesCount : 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
@@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget {
|
||||
this.onSearchPressed,
|
||||
});
|
||||
|
||||
/// Show full attribution text in a dialog
|
||||
/// Show full attribution text in a dialog with license link.
|
||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Get the license URL from the current tile provider's service policy
|
||||
final appState = AppState.instance;
|
||||
final tileType = appState.selectedTileType;
|
||||
final attributionUrl = tileType?.servicePolicy.attributionUrl;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('mapTiles.attribution')),
|
||||
content: SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (attributionUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
link: true,
|
||||
label: locService.t('mapTiles.openLicense', params: [attributionUrl]),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
final uri = Uri.parse(attributionUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
attributionUrl,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget {
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,99 +1,304 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/deflock_tile_provider.dart';
|
||||
import '../../services/provider_tile_cache_manager.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Uses DeFlock's custom tile provider for clean integration.
|
||||
/// Manages tile layer creation with per-provider caching and provider switching.
|
||||
///
|
||||
/// Each tile provider/type combination gets its own [DeflockTileProvider]
|
||||
/// instance with isolated caching (separate cache directory, configurable size
|
||||
/// limit, and policy-driven TTL enforcement). Providers are created lazily on
|
||||
/// first use and cached for instant switching.
|
||||
class TileLayerManager {
|
||||
DeflockTileProvider? _tileProvider;
|
||||
final Map<String, DeflockTileProvider> _providers = {};
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastProviderId;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
/// Stream that triggers flutter_map to drop all tiles and reload.
|
||||
/// Fired after a debounced delay when tile errors are detected.
|
||||
final StreamController<void> _resetController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
/// Debounce timer for scheduling a tile reset after errors.
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// Current retry delay — starts at [_minRetryDelay] and doubles on each
|
||||
/// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay]
|
||||
/// when a tile loads successfully.
|
||||
Duration _retryDelay = const Duration(seconds: 2);
|
||||
|
||||
static const _minRetryDelay = Duration(seconds: 2);
|
||||
static const _maxRetryDelay = Duration(seconds: 60);
|
||||
|
||||
/// Get the current map rebuild key for cache busting.
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
/// Current retry delay (exposed for testing).
|
||||
@visibleForTesting
|
||||
Duration get retryDelay => _retryDelay;
|
||||
|
||||
/// Stream of reset events (exposed for testing).
|
||||
@visibleForTesting
|
||||
Stream<void> get resetStream => _resetController.stream;
|
||||
|
||||
/// Initialize the tile layer manager.
|
||||
///
|
||||
/// [ProviderTileCacheManager.init] is called in main() before any widgets
|
||||
/// build, so this is a no-op retained for API compatibility.
|
||||
void initialize() {
|
||||
// Don't create tile provider here - create it fresh for each build
|
||||
// Cache directory is already resolved in main().
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
/// Dispose of all provider resources.
|
||||
///
|
||||
/// Synchronous to match Flutter's [State.dispose] contract. Calls
|
||||
/// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP
|
||||
/// client. (We don't call provider.dispose() here — flutter_map already
|
||||
/// called it when the TileLayer widget was removed, and it's safe to call
|
||||
/// again but unnecessary.)
|
||||
void dispose() {
|
||||
// No resources to dispose with the new tile provider
|
||||
_retryTimer?.cancel();
|
||||
_resetController.close();
|
||||
for (final provider in _providers.values) {
|
||||
provider.shutdown();
|
||||
}
|
||||
_providers.clear();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentProviderId,
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
if (_lastProviderId != currentProviderId) {
|
||||
reason = 'provider ($currentProviderId)';
|
||||
shouldClear = true;
|
||||
} else if (_lastTileTypeId != currentTileTypeId) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
} else if (_lastOfflineMode != currentOfflineMode) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
// Force map rebuild with new key to bust flutter_map cache.
|
||||
// We don't dispose providers here — they're reusable across switches.
|
||||
_mapRebuildKey++;
|
||||
// Also force new tile provider instance to ensure fresh cache
|
||||
_tileProvider = null;
|
||||
// Reset backoff so the new provider starts with a clean slate.
|
||||
// Cancel any pending retry timer — it belongs to the old provider's errors.
|
||||
_retryDelay = _minRetryDelay;
|
||||
_retryTimer?.cancel();
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastProviderId = currentProviderId;
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
/// Clear the tile request queue (call after cache clear).
|
||||
///
|
||||
/// In the old architecture this incremented [_mapRebuildKey] a second time
|
||||
/// to force a rebuild after the provider was disposed and recreated. With
|
||||
/// per-provider caching, [checkAndClearCacheIfNeeded] already increments the
|
||||
/// key, so this is now a no-op. Kept for API compatibility with map_view.
|
||||
void clearTileQueue() {
|
||||
// With the new tile provider, clearing is handled by FlutterMap's internal cache
|
||||
// We just need to increment the rebuild key to bust the cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
|
||||
// No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey.
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
void clearTileQueueImmediate() {
|
||||
// No immediate clearing needed with the new architecture
|
||||
// FlutterMap handles this naturally
|
||||
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds.
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
// No selective clearing needed with the new architecture
|
||||
// FlutterMap's internal caching is efficient enough
|
||||
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Called by flutter_map when a tile fails to load. Schedules a debounced
|
||||
/// reset so that all failed tiles get retried after the burst of errors
|
||||
/// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap.
|
||||
///
|
||||
/// Skips retry for [TileLoadCancelledException] (tile scrolled off screen)
|
||||
/// and [TileNotAvailableOfflineException] (no cached data, retrying won't
|
||||
/// help without network).
|
||||
@visibleForTesting
|
||||
void onTileLoadError(
|
||||
TileImage tile,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
) {
|
||||
// Cancelled tiles are already gone — no retry needed.
|
||||
if (error is TileLoadCancelledException) return;
|
||||
|
||||
// Offline misses won't resolve by retrying — tile isn't cached.
|
||||
if (error is TileNotAvailableOfflineException) return;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Tile error at '
|
||||
'${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, '
|
||||
'scheduling retry in ${_retryDelay.inSeconds}s',
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/// Schedule a debounced tile reset with exponential backoff.
|
||||
///
|
||||
/// Cancels any pending retry timer and starts a new one at the current
|
||||
/// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped
|
||||
/// at [_maxRetryDelay]).
|
||||
@visibleForTesting
|
||||
void scheduleRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(_retryDelay, () {
|
||||
if (!_resetController.isClosed) {
|
||||
debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles');
|
||||
_resetController.add(null);
|
||||
}
|
||||
// Back off for next failure cycle
|
||||
_retryDelay = Duration(
|
||||
milliseconds: min(
|
||||
_retryDelay.inMilliseconds * 2,
|
||||
_maxRetryDelay.inMilliseconds,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset backoff to minimum delay. Called when a tile loads successfully
|
||||
/// via the offline-first path, indicating connectivity has been restored.
|
||||
///
|
||||
/// Note: the common path (`NetworkTileImageProvider`) does not call this,
|
||||
/// so backoff resets only when the offline-first path succeeds over the
|
||||
/// network. In practice this is fine — the common path's `RetryClient`
|
||||
/// handles its own retries, and the reset stream only retries tiles that
|
||||
/// flutter_map has already marked as `loadError`.
|
||||
void onTileLoadSuccess() {
|
||||
_retryDelay = _minRetryDelay;
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
|
||||
///
|
||||
/// Gets or creates a [DeflockTileProvider] for the given provider/type
|
||||
/// combination, each with its own isolated cache.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Create a fresh tile provider instance if we don't have one or cache was cleared
|
||||
_tileProvider ??= DeflockTileProvider();
|
||||
|
||||
// Use provider/type info in URL template for FlutterMap's cache key generation
|
||||
// This ensures different providers/types get different cache keys
|
||||
final urlTemplate = '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
final tileProvider = _getOrCreateProvider(
|
||||
selectedProvider: selectedProvider,
|
||||
selectedTileType: selectedTileType,
|
||||
);
|
||||
|
||||
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
|
||||
// override handles the real URL generation; flutter_map uses urlTemplate
|
||||
// internally for cache key generation.
|
||||
final urlTemplate = selectedTileType?.urlTemplate
|
||||
?? '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
|
||||
|
||||
return TileLayer(
|
||||
urlTemplate: urlTemplate, // Critical for cache key generation
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
|
||||
tileProvider: _tileProvider!,
|
||||
tileProvider: tileProvider,
|
||||
// Wire the reset stream so failed tiles get retried after a delay.
|
||||
reset: _resetController.stream,
|
||||
errorTileCallback: onTileLoadError,
|
||||
// Clean up error tiles when they scroll off screen.
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a config fingerprint for drift detection.
|
||||
///
|
||||
/// If any of these fields change (e.g. user edits the URL template or
|
||||
/// rotates an API key) the cached [DeflockTileProvider] must be replaced.
|
||||
static String _configFingerprint(
|
||||
models.TileProvider provider,
|
||||
models.TileType tileType,
|
||||
) =>
|
||||
'${provider.id}/${tileType.id}'
|
||||
'|${tileType.urlTemplate}'
|
||||
'|${tileType.maxZoom}'
|
||||
'|${provider.apiKey ?? ''}';
|
||||
|
||||
/// Get or create a [DeflockTileProvider] for the given provider/type.
|
||||
///
|
||||
/// Providers are cached by `providerId/tileTypeId`. If the effective config
|
||||
/// (URL template, max zoom, API key) has changed since the provider was
|
||||
/// created, the stale instance is shut down and replaced.
|
||||
DeflockTileProvider _getOrCreateProvider({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
// No provider configured — return a fallback with default config.
|
||||
return _providers.putIfAbsent(
|
||||
'_fallback',
|
||||
() => DeflockTileProvider(
|
||||
providerId: 'unknown',
|
||||
tileType: models.TileType(
|
||||
id: 'unknown',
|
||||
name: 'Unknown',
|
||||
urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}',
|
||||
attribution: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final key = '${selectedProvider.id}/${selectedTileType.id}';
|
||||
final fingerprint = _configFingerprint(selectedProvider, selectedTileType);
|
||||
|
||||
// Check for config drift: if the provider exists but its config has
|
||||
// changed, shut down the stale instance so a fresh one is created below.
|
||||
final existing = _providers[key];
|
||||
if (existing != null && existing.configFingerprint != fingerprint) {
|
||||
debugPrint(
|
||||
'[TileLayerManager] Config changed for $key — replacing provider',
|
||||
);
|
||||
existing.shutdown();
|
||||
_providers.remove(key);
|
||||
}
|
||||
|
||||
return _providers.putIfAbsent(key, () {
|
||||
final cachingProvider = ProviderTileCacheManager.isInitialized
|
||||
? ProviderTileCacheManager.getOrCreate(
|
||||
providerId: selectedProvider.id,
|
||||
tileTypeId: selectedTileType.id,
|
||||
policy: selectedTileType.servicePolicy,
|
||||
)
|
||||
: null;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Creating provider for $key '
|
||||
'(cache: ${cachingProvider != null ? "enabled" : "disabled"})',
|
||||
);
|
||||
|
||||
return DeflockTileProvider(
|
||||
providerId: selectedProvider.id,
|
||||
tileType: selectedTileType,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onTileLoadSuccess,
|
||||
configFingerprint: fingerprint,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,17 +284,12 @@ class MapViewState extends State<MapView> {
|
||||
onProfilesChanged: _refreshNodesFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
|
||||
// Check if provider, tile type, or offline mode changed and clear cache if needed
|
||||
_tileManager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: appState.selectedTileProvider?.id,
|
||||
currentTileTypeId: appState.selectedTileType?.id,
|
||||
currentOfflineMode: appState.offlineMode,
|
||||
);
|
||||
|
||||
if (cacheCleared) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tileManager.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
@@ -396,7 +391,7 @@ class MapViewState extends State<MapView> {
|
||||
if (_activePointers > 0) _activePointers--;
|
||||
},
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
|
||||
238
pubspec.lock
238
pubspec.lock
@@ -13,10 +13,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
version: "7.0.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -105,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -117,10 +125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -157,10 +165,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.12"
|
||||
desktop_webview_window:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -170,7 +178,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
@@ -181,10 +189,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -258,10 +266,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5
|
||||
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.1"
|
||||
version: "8.2.2"
|
||||
flutter_map_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -274,34 +282,34 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc"
|
||||
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.6"
|
||||
version: "2.4.7"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0-beta.4"
|
||||
version: "10.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c"
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -314,26 +322,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517"
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.1.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.2.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -343,18 +351,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182"
|
||||
sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0-alpha.3"
|
||||
version: "5.0.1"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2_platform_interface
|
||||
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
|
||||
sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0-alpha.0"
|
||||
version: "5.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -408,6 +416,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,6 +432,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -428,10 +452,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99"
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0-beta.2"
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -444,10 +468,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
version: "4.8.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -460,10 +484,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
version: "4.11.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -524,10 +548,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999"
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -568,6 +600,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -580,18 +620,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: oauth2_client
|
||||
sha256: d6a146049f36ef2da32bdc7a7a9e5671a0e66ea596d8f70a26de4cddfcab4d2e
|
||||
sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.2.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -628,18 +676,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.17"
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,10 +716,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -692,10 +740,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "6.5.0"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -708,10 +756,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
random_string:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -724,26 +780,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -785,18 +841,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -809,10 +857,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -913,10 +961,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
|
||||
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
version: "2.3.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -929,34 +977,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.16"
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -969,26 +1017,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1009,10 +1057,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
version: "1.2.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1041,10 +1089,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
version: "5.15.0"
|
||||
window_to_front:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1073,10 +1121,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1086,5 +1134,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
13
pubspec.yaml
13
pubspec.yaml
@@ -1,10 +1,10 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.7.1+47 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+)
|
||||
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -22,12 +22,12 @@ dependencies:
|
||||
flutter_local_notifications: ^17.2.2
|
||||
url_launcher: ^6.3.0
|
||||
flutter_linkify: ^6.0.0
|
||||
app_links: ^6.1.4
|
||||
app_links: ^7.0.0
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
flutter_web_auth_2: 5.0.0-alpha.3
|
||||
flutter_secure_storage: 10.0.0-beta.4
|
||||
flutter_web_auth_2: ^5.0.1
|
||||
flutter_secure_storage: ^10.0.0
|
||||
|
||||
# Persistence
|
||||
shared_preferences: ^2.2.2
|
||||
@@ -35,7 +35,7 @@ dependencies:
|
||||
path: ^1.8.3
|
||||
path_provider: ^2.1.0
|
||||
uuid: ^4.0.0
|
||||
package_info_plus: ^8.0.0
|
||||
package_info_plus: ^9.0.0
|
||||
csv: ^6.0.0
|
||||
collection: ^1.18.0
|
||||
|
||||
@@ -43,6 +43,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.4
|
||||
fake_async: ^1.3.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_native_splash: ^2.4.6
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
import 'package:deflockapp/state/profile_state.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('NodeProfile', () {
|
||||
test('toJson/fromJson round-trip preserves all fields', () {
|
||||
final profile = NodeProfile(
|
||||
@@ -72,5 +79,180 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
expect(a, isNot(equals(c)));
|
||||
});
|
||||
|
||||
group('createExistingTagsProfile', () {
|
||||
test('should NOT assign FOV for nodes with single direction', () {
|
||||
// This is the core bug fix: nodes with just "direction=90" should not get a default FOV
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90',
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single direction nodes should not get default FOV');
|
||||
expect(profile.name, equals('<Existing tags>'));
|
||||
expect(profile.tags, isEmpty, reason: 'Existing tags profile should have empty tags');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with range notation', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125', // Range notation = explicit FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNotNull, reason: 'Range notation should preserve FOV');
|
||||
expect(profile.fov, equals(70.0), reason: 'Range 55-125 should calculate to 70 degree FOV');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with multiple consistent ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125;235-305', // Two ranges with same FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, equals(70.0), reason: 'Multiple consistent ranges should preserve FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for mixed single directions and ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180-360', // Mix of single direction and range
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Mixed notation should not assign FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for multiple single directions', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180;270', // Multiple single directions
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Multiple single directions should not get default FOV');
|
||||
});
|
||||
|
||||
test('should handle camera:direction tag', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'camera:direction': '180', // Using camera:direction instead of direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'camera',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single camera:direction should not get default FOV');
|
||||
});
|
||||
|
||||
test('should fix the specific bug: direction=90 should not become direction=55-125', () {
|
||||
// This tests the exact bug scenario mentioned in the issue
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90', // Single direction, should stay as single direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
// Key fix: profile should NOT have an FOV, so upload won't convert to range notation
|
||||
expect(profile.fov, isNull, reason: 'direction=90 should not get converted to direction=55-125');
|
||||
|
||||
// Verify the node does have directionFovPairs (for rendering), but profile ignores them
|
||||
expect(node.directionFovPairs, hasLength(1));
|
||||
expect(node.directionFovPairs.first.centerDegrees, equals(90.0));
|
||||
expect(node.directionFovPairs.first.fovDegrees, equals(70.0)); // Default FOV for rendering
|
||||
});
|
||||
});
|
||||
|
||||
group('ProfileState reordering', () {
|
||||
test('should reorder profiles correctly', () async {
|
||||
final profileState = ProfileState();
|
||||
|
||||
// Add some test profiles directly to avoid storage operations
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to the internal list to avoid storage
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Initial order should be A, B, C
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
|
||||
|
||||
// Move profile at index 0 (A) to index 2 (should become B, A, C due to Flutter's reorder logic)
|
||||
profileState.reorderProfiles(0, 2);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c']));
|
||||
|
||||
// Move profile at index 1 (A) to index 0 (should become A, B, C)
|
||||
profileState.reorderProfiles(1, 0);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
test('should maintain enabled status after reordering', () {
|
||||
final profileState = ProfileState();
|
||||
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to avoid storage operations
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Disable profile B
|
||||
profileState.internalEnabled.remove(profileB);
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
|
||||
// Reorder profiles
|
||||
profileState.reorderProfiles(0, 2);
|
||||
|
||||
// Profile B should still be disabled after reordering
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
expect(profileState.isEnabled(profileA), isTrue);
|
||||
expect(profileState.isEnabled(profileC), isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,119 +1,571 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/services/map_data_provider.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
class MockMapCachingProvider extends Mock implements MapCachingProvider {}
|
||||
|
||||
void main() {
|
||||
late DeflockTileProvider provider;
|
||||
late MockAppState mockAppState;
|
||||
|
||||
final osmTileType = models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final mapboxTileType = models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
|
||||
// Default stubs: online, no offline areas
|
||||
when(() => mockAppState.offlineMode).thenReturn(false);
|
||||
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
provider.shutdown();
|
||||
AppState.instance = MockAppState();
|
||||
});
|
||||
|
||||
group('DeflockTileProvider', () {
|
||||
late DeflockTileProvider provider;
|
||||
late MockAppState mockAppState;
|
||||
|
||||
setUp(() {
|
||||
provider = DeflockTileProvider();
|
||||
mockAppState = MockAppState();
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(null);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(null);
|
||||
AppState.instance = mockAppState;
|
||||
test('supportsCancelLoading is true', () {
|
||||
expect(provider.supportsCancelLoading, isTrue);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
// Reset to a clean mock so stubbed state doesn't leak to other tests
|
||||
AppState.instance = MockAppState();
|
||||
test('getTileUrl() uses frozen tileType config', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
|
||||
test('creates image provider for tile coordinates', () {
|
||||
const coordinates = TileCoordinates(0, 0, 0);
|
||||
final options = TileLayer(
|
||||
urlTemplate: 'test/{z}/{x}/{y}',
|
||||
test('getTileUrl() includes API key when present', () async {
|
||||
provider.shutdown();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'mapbox',
|
||||
tileType: mapboxTileType,
|
||||
apiKey: 'test_key_123',
|
||||
);
|
||||
|
||||
final imageProvider = provider.getImage(coordinates, options);
|
||||
const coords = TileCoordinates(1, 2, 10);
|
||||
final options = TileLayer(urlTemplate: 'ignored');
|
||||
|
||||
expect(imageProvider, isA<DeflockTileImageProvider>());
|
||||
expect((imageProvider as DeflockTileImageProvider).coordinates,
|
||||
equals(coordinates));
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
|
||||
expect(url, contains('access_token=test_key_123'));
|
||||
expect(url, contains('/10/1/2@2x'));
|
||||
});
|
||||
|
||||
test('routes to network path when no offline areas exist', () {
|
||||
// offlineMode = false, OfflineAreaService not initialized → no offline areas
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
// Should NOT be a DeflockOfflineTileImageProvider — it should be the
|
||||
// NetworkTileImageProvider returned by super
|
||||
expect(imageProvider, isNot(isA<DeflockOfflineTileImageProvider>()));
|
||||
});
|
||||
|
||||
test('routes to offline path when offline mode is enabled', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.isOfflineOnly, isTrue);
|
||||
expect(offlineProvider.coordinates, equals(coords));
|
||||
expect(offlineProvider.providerId, equals('openstreetmap'));
|
||||
expect(offlineProvider.tileTypeId, equals('osm_street'));
|
||||
});
|
||||
|
||||
test('frozen config is independent of AppState', () {
|
||||
// Provider was created with OSM config — changing AppState should not affect it
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileImageProvider', () {
|
||||
test('generates consistent keys for same coordinates', () {
|
||||
const coordinates1 = TileCoordinates(1, 2, 3);
|
||||
const coordinates2 = TileCoordinates(1, 2, 3);
|
||||
const coordinates3 = TileCoordinates(1, 2, 4);
|
||||
|
||||
group('DeflockOfflineTileImageProvider', () {
|
||||
test('equal for same coordinates, provider/type, and offlineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final mapDataProvider = MapDataProvider();
|
||||
|
||||
final provider1 = DeflockTileImageProvider(
|
||||
coordinates: coordinates1,
|
||||
final a = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'https://example.com/3/1/2',
|
||||
);
|
||||
final provider2 = DeflockTileImageProvider(
|
||||
coordinates: coordinates2,
|
||||
final b = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
);
|
||||
final provider3 = DeflockTileImageProvider(
|
||||
coordinates: coordinates3,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'test_provider',
|
||||
tileTypeId: 'test_type',
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
|
||||
);
|
||||
|
||||
// Same coordinates should be equal
|
||||
expect(provider1, equals(provider2));
|
||||
expect(provider1.hashCode, equals(provider2.hashCode));
|
||||
|
||||
// Different coordinates should not be equal
|
||||
expect(provider1, isNot(equals(provider3)));
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('generates different keys for different providers/types', () {
|
||||
const coordinates = TileCoordinates(1, 2, 3);
|
||||
test('not equal for different isOfflineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final mapDataProvider = MapDataProvider();
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final provider1 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
final online = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_a',
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final provider2 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
final offline = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_b',
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final provider3 = DeflockTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
|
||||
expect(online, isNot(equals(offline)));
|
||||
});
|
||||
|
||||
test('not equal for different coordinates', () {
|
||||
const coords1 = TileCoordinates(1, 2, 3);
|
||||
const coords2 = TileCoordinates(1, 2, 4);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final a = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords1,
|
||||
options: options,
|
||||
mapDataProvider: mapDataProvider,
|
||||
providerId: 'provider_a',
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url1',
|
||||
);
|
||||
final b = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords2,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url2',
|
||||
);
|
||||
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('not equal for different provider or type', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final base = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final diffProvider = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_b',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final diffType = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_2',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
// Different providers should not be equal (even with same coordinates)
|
||||
expect(provider1, isNot(equals(provider2)));
|
||||
expect(provider1.hashCode, isNot(equals(provider2.hashCode)));
|
||||
expect(base, isNot(equals(diffProvider)));
|
||||
expect(base.hashCode, isNot(equals(diffProvider.hashCode)));
|
||||
expect(base, isNot(equals(diffType)));
|
||||
expect(base.hashCode, isNot(equals(diffType.hashCode)));
|
||||
});
|
||||
|
||||
// Different tile types should not be equal (even with same coordinates and provider)
|
||||
expect(provider1, isNot(equals(provider3)));
|
||||
expect(provider1.hashCode, isNot(equals(provider3.hashCode)));
|
||||
test('equality ignores cachingProvider and onNetworkSuccess', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final withCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
cachingProvider: MockMapCachingProvider(),
|
||||
onNetworkSuccess: () {},
|
||||
);
|
||||
final withoutCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(withCaching, equals(withoutCaching));
|
||||
expect(withCaching.hashCode, equals(withoutCaching.hashCode));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileProvider caching integration', () {
|
||||
test('passes cachingProvider through to offline path', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
final mockCaching = MockMapCachingProvider();
|
||||
var successCalled = false;
|
||||
|
||||
final cachingProvider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
cachingProvider: mockCaching,
|
||||
onNetworkSuccess: () => successCalled = true,
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = cachingProvider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, same(mockCaching));
|
||||
expect(offlineProvider.onNetworkSuccess, isNotNull);
|
||||
|
||||
// Invoke the callback to verify it's wired correctly
|
||||
offlineProvider.onNetworkSuccess!();
|
||||
expect(successCalled, isTrue);
|
||||
|
||||
cachingProvider.shutdown();
|
||||
});
|
||||
|
||||
test('offline provider has null caching when not provided', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, isNull);
|
||||
expect(offlineProvider.onNetworkSuccess, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider caching helpers', () {
|
||||
late Directory tempDir;
|
||||
late ProviderTileCacheStore cacheStore;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('disk cache integration: putTile then getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/3/1/2.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"tile-etag"',
|
||||
);
|
||||
|
||||
// Write to cache
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
// Read back
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(cached.metadata.etag, equals('"tile-etag"'));
|
||||
expect(cached.metadata.isStale, isFalse);
|
||||
});
|
||||
|
||||
test('disk cache: stale tiles are detectable', () async {
|
||||
const url = 'https://tile.example.com/stale.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.isStale, isTrue);
|
||||
// Bytes are still available even when stale (for conditional revalidation)
|
||||
expect(cached.bytes, equals(bytes));
|
||||
});
|
||||
|
||||
test('disk cache: metadata-only update preserves bytes', () async {
|
||||
const url = 'https://tile.example.com/revalidated.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
|
||||
// Initial write with bytes
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
),
|
||||
bytes: bytes,
|
||||
);
|
||||
|
||||
// Metadata-only update (simulating 304 Not Modified revalidation)
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
),
|
||||
// No bytes — metadata only
|
||||
);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // original bytes preserved
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
expect(cached.metadata.isStale, isFalse); // now fresh
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider load error paths', () {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
/// Load the tile via [loadImage] and return the first error from the
|
||||
/// image stream. The decode callback should never be reached on error
|
||||
/// paths, so we throw if it is.
|
||||
Future<Object> loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider provider) {
|
||||
final completer = Completer<Object>();
|
||||
final stream = provider.loadImage(
|
||||
provider,
|
||||
(buffer, {getTargetSize}) async =>
|
||||
throw StateError('decode should not be called'),
|
||||
);
|
||||
stream.addListener(ImageStreamListener(
|
||||
(_, _) {
|
||||
if (!completer.isCompleted) {
|
||||
completer
|
||||
.completeError(StateError('expected error but got image'));
|
||||
}
|
||||
},
|
||||
onError: (error, _) {
|
||||
if (!completer.isCompleted) completer.complete(error);
|
||||
},
|
||||
));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
test('offline both-miss throws TileNotAvailableOfflineException',
|
||||
() async {
|
||||
// No offline areas, no cache → both miss.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileNotAvailableOfflineException>());
|
||||
});
|
||||
|
||||
test('cancelled offline tile throws TileLoadCancelledException',
|
||||
() async {
|
||||
// cancelLoading already resolved → _loadAsync catch block detects
|
||||
// cancellation and throws TileLoadCancelledException instead of
|
||||
// the underlying TileNotAvailableOfflineException.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('online cancel before network throws TileLoadCancelledException',
|
||||
() async {
|
||||
// Online mode: cache miss, local miss, then cancelled check fires
|
||||
// before reaching the network fetch.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('network error throws HttpException', () async {
|
||||
// Online mode: cache miss, local miss, not cancelled, network
|
||||
// returns 500 → HttpException with tile coordinates and status.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(4, 5, 6),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: MockClient((_) async => http.Response('', 500)),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/6/4/5.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<HttpException>());
|
||||
expect((error as HttpException).message, contains('6/4/5'));
|
||||
expect(error.message, contains('500'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
90
test/services/http_client_test.dart
Normal file
90
test/services/http_client_test.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
import 'package:deflockapp/services/http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('UserAgentClient', () {
|
||||
test('adds User-Agent header to GET requests', () async {
|
||||
String? capturedUserAgent;
|
||||
|
||||
final inner = MockClient((request) async {
|
||||
capturedUserAgent = request.headers['User-Agent'];
|
||||
return http.Response('ok', 200);
|
||||
});
|
||||
|
||||
final client = UserAgentClient(inner);
|
||||
await client.get(Uri.parse('https://example.com'));
|
||||
|
||||
expect(capturedUserAgent, isNotNull);
|
||||
expect(capturedUserAgent, startsWith('DeFlock/'));
|
||||
expect(capturedUserAgent, contains('+$kHomepageUrl'));
|
||||
expect(capturedUserAgent, contains('contact: $kContactEmail'));
|
||||
});
|
||||
|
||||
test('adds User-Agent header to POST requests', () async {
|
||||
String? capturedUserAgent;
|
||||
|
||||
final inner = MockClient((request) async {
|
||||
capturedUserAgent = request.headers['User-Agent'];
|
||||
return http.Response('ok', 200);
|
||||
});
|
||||
|
||||
final client = UserAgentClient(inner);
|
||||
await client.post(
|
||||
Uri.parse('https://example.com'),
|
||||
body: json.encode({'key': 'value'}),
|
||||
);
|
||||
|
||||
expect(capturedUserAgent, isNotNull);
|
||||
expect(capturedUserAgent, startsWith('DeFlock/'));
|
||||
});
|
||||
|
||||
test('preserves existing headers alongside User-Agent', () async {
|
||||
Map<String, String>? capturedHeaders;
|
||||
|
||||
final inner = MockClient((request) async {
|
||||
capturedHeaders = request.headers;
|
||||
return http.Response('ok', 200);
|
||||
});
|
||||
|
||||
final client = UserAgentClient(inner);
|
||||
await client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
headers: {'Authorization': 'Bearer token123'},
|
||||
);
|
||||
|
||||
expect(capturedHeaders, isNotNull);
|
||||
expect(capturedHeaders!['Authorization'], equals('Bearer token123'));
|
||||
expect(capturedHeaders!['User-Agent'], startsWith('DeFlock/'));
|
||||
});
|
||||
|
||||
test('does not overwrite a manually-set User-Agent', () async {
|
||||
String? capturedUserAgent;
|
||||
|
||||
final inner = MockClient((request) async {
|
||||
capturedUserAgent = request.headers['User-Agent'];
|
||||
return http.Response('ok', 200);
|
||||
});
|
||||
|
||||
final client = UserAgentClient(inner);
|
||||
await client.get(
|
||||
Uri.parse('https://example.com'),
|
||||
headers: {'User-Agent': 'CustomAgent/1.0'},
|
||||
);
|
||||
|
||||
expect(capturedUserAgent, equals('CustomAgent/1.0'));
|
||||
});
|
||||
|
||||
test('static userAgent getter returns expected format', () {
|
||||
final ua = UserAgentClient.userAgent;
|
||||
expect(ua, startsWith('DeFlock/'));
|
||||
expect(ua, contains('+$kHomepageUrl'));
|
||||
expect(ua, contains('contact: $kContactEmail'));
|
||||
});
|
||||
});
|
||||
}
|
||||
93
test/services/offline_area_service_test.dart
Normal file
93
test/services/offline_area_service_test.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import 'package:deflockapp/services/offline_area_service.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
||||
|
||||
OfflineArea _makeArea({
|
||||
String providerId = 'osm',
|
||||
String tileTypeId = 'standard',
|
||||
int minZoom = 5,
|
||||
int maxZoom = 12,
|
||||
OfflineAreaStatus status = OfflineAreaStatus.complete,
|
||||
}) {
|
||||
return OfflineArea(
|
||||
id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom',
|
||||
bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: '/tmp/test-area',
|
||||
status: status,
|
||||
tileProviderId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = OfflineAreaService();
|
||||
|
||||
setUp(() {
|
||||
service.setAreasForTesting([]);
|
||||
});
|
||||
|
||||
group('hasOfflineAreasForProviderAtZoom', () {
|
||||
test('returns true for zoom within range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue);
|
||||
});
|
||||
|
||||
test('returns false for zoom outside range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong provider', () {
|
||||
service.setAreasForTesting([_makeArea(providerId: 'osm')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong tile type', () {
|
||||
service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for non-complete areas', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(status: OfflineAreaStatus.downloading),
|
||||
_makeArea(status: OfflineAreaStatus.error),
|
||||
]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when initialized with no areas', () {
|
||||
service.setAreasForTesting([]);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('matches when any area covers the zoom level', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(minZoom: 5, maxZoom: 8),
|
||||
_makeArea(minZoom: 10, maxZoom: 14),
|
||||
]);
|
||||
|
||||
// In first area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue);
|
||||
// In gap between areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse);
|
||||
// In second area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue);
|
||||
// Beyond both areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
517
test/services/provider_tile_cache_store_test.dart
Normal file
517
test/services/provider_tile_cache_store_test.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_manager.dart';
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore', () {
|
||||
late ProviderTileCacheStore store;
|
||||
|
||||
setUp(() {
|
||||
store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSupported is true', () {
|
||||
expect(store.isSupported, isTrue);
|
||||
});
|
||||
|
||||
test('getTile returns null for uncached URL', () async {
|
||||
final result = await store.getTile('https://tile.example.com/1/2/3.png');
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('putTile and getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final staleAt = DateTime.utc(2026, 3, 1);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"abc123"',
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(
|
||||
cached.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(staleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
expect(cached.metadata.etag, equals('"abc123"'));
|
||||
expect(cached.metadata.lastModified, isNotNull);
|
||||
});
|
||||
|
||||
test('putTile without bytes updates metadata only', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata1 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
);
|
||||
|
||||
// Write with bytes first
|
||||
await store.putTile(url: url, metadata: metadata1, bytes: bytes);
|
||||
|
||||
// Update metadata only
|
||||
final metadata2 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 4, 1),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
);
|
||||
await store.putTile(url: url, metadata: metadata2);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // bytes unchanged
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
});
|
||||
|
||||
test('handles null lastModified and etag', () async {
|
||||
const url = 'https://tile.example.com/simple.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.lastModified, isNull);
|
||||
expect(cached.metadata.etag, isNull);
|
||||
});
|
||||
|
||||
test('creates cache directory lazily on first putTile', () async {
|
||||
final subDir = p.join(tempDir.path, 'lazy', 'nested');
|
||||
final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir);
|
||||
|
||||
// Directory should not exist yet
|
||||
expect(await Directory(subDir).exists(), isFalse);
|
||||
|
||||
await lazyStore.putTile(
|
||||
url: 'https://example.com/tile.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([1]),
|
||||
);
|
||||
|
||||
// Directory should now exist
|
||||
expect(await Directory(subDir).exists(), isTrue);
|
||||
});
|
||||
|
||||
test('clear deletes all cached tiles', () async {
|
||||
// Write some tiles
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://example.com/$i.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([i]),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tiles exist
|
||||
expect(await store.getTile('https://example.com/0.png'), isNotNull);
|
||||
|
||||
// Clear
|
||||
await store.clear();
|
||||
|
||||
// Directory should be gone
|
||||
expect(await Directory(tempDir.path).exists(), isFalse);
|
||||
|
||||
// getTile should return null (directory gone)
|
||||
expect(await store.getTile('https://example.com/0.png'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore TTL override', () {
|
||||
test('overrideFreshAge bumps staleAt forward', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
overrideFreshAge: const Duration(days: 7),
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/osm.png';
|
||||
// Server says stale in 1 hour, but policy requires 7 days
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
|
||||
// staleAt should be ~7 days from now, not 1 hour
|
||||
final expectedMin = DateTime.timestamp().add(const Duration(days: 6));
|
||||
expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue);
|
||||
});
|
||||
|
||||
test('without overrideFreshAge, server staleAt is preserved', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
// No overrideFreshAge
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/bing.png';
|
||||
final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0);
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: serverStaleAt,
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(
|
||||
cached!.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(serverStaleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore isolation', () {
|
||||
test('separate directories do not interfere', () async {
|
||||
final dirA = p.join(tempDir.path, 'provider_a', 'type_1');
|
||||
final dirB = p.join(tempDir.path, 'provider_b', 'type_1');
|
||||
|
||||
final storeA = ProviderTileCacheStore(cacheDirectory: dirA);
|
||||
final storeB = ProviderTileCacheStore(cacheDirectory: dirB);
|
||||
|
||||
const url = 'https://tile.example.com/shared-url.png';
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await storeA.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([1, 1, 1]),
|
||||
);
|
||||
await storeB.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([2, 2, 2]),
|
||||
);
|
||||
|
||||
final cachedA = await storeA.getTile(url);
|
||||
final cachedB = await storeB.getTile(url);
|
||||
|
||||
expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1])));
|
||||
expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2])));
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheManager', () {
|
||||
test('getOrCreate returns same instance for same key', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isTrue);
|
||||
});
|
||||
|
||||
test('getOrCreate returns different instances for different keys', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isFalse);
|
||||
});
|
||||
|
||||
test('passes overrideFreshAge from policy.minCacheTtl', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy.osmTileServer(),
|
||||
);
|
||||
|
||||
expect(store.overrideFreshAge, equals(const Duration(days: 7)));
|
||||
});
|
||||
|
||||
test('custom maxCacheBytes is applied', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'big',
|
||||
tileTypeId: 'tiles',
|
||||
policy: const ServicePolicy(),
|
||||
maxCacheBytes: 1024 * 1024 * 1024, // 1 GB
|
||||
);
|
||||
|
||||
expect(store.maxCacheBytes, equals(1024 * 1024 * 1024));
|
||||
});
|
||||
|
||||
test('resetAll clears all stores from registry', () async {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeBefore = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
|
||||
// After reset, must set base dir again before creating stores
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
final storeAfter = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
// New instance should be created (not the old cached one)
|
||||
expect(identical(storeBefore, storeAfter), isFalse);
|
||||
});
|
||||
|
||||
test('unregister removes store from registry', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store1 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
ProviderTileCacheManager.unregister('osm', 'street');
|
||||
|
||||
// Should create a new instance after unregistering
|
||||
final store2 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(store1, store2), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore eviction', () {
|
||||
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
|
||||
/// Sets deterministic modification times (1 second apart) so eviction
|
||||
/// ordering is stable across platforms without relying on wall-clock delays.
|
||||
Future<void> fillCache(
|
||||
ProviderTileCacheStore store, {
|
||||
required int count,
|
||||
required int bytesPerTile,
|
||||
String prefix = '',
|
||||
}) async {
|
||||
final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42));
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
final baseTime = DateTime.utc(2026, 1, 1);
|
||||
for (var i = 0; i < count; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://tile.example.com/$prefix$i.png',
|
||||
metadata: metadata,
|
||||
bytes: bytes,
|
||||
);
|
||||
// Set deterministic mtime so eviction order is stable across platforms.
|
||||
final key = ProviderTileCacheStore.keyFor(
|
||||
'https://tile.example.com/$prefix$i.png',
|
||||
);
|
||||
final tileFile = File(p.join(store.cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(store.cacheDirectory, '$key.meta'));
|
||||
final mtime = baseTime.add(Duration(seconds: i));
|
||||
await tileFile.setLastModified(mtime);
|
||||
await metaFile.setLastModified(mtime);
|
||||
}
|
||||
}
|
||||
|
||||
test('eviction reduces cache when exceeding maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write tiles that exceed the limit
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
|
||||
// Explicitly trigger eviction (bypasses throttle)
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
expect(sizeAfter, lessThanOrEqualTo(500),
|
||||
reason: 'Eviction should reduce cache to at or below limit');
|
||||
});
|
||||
|
||||
test('eviction targets 80% of maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 1000,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 200);
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
// Target is 80% of 1000 = 800 bytes
|
||||
expect(sizeAfter, lessThanOrEqualTo(800),
|
||||
reason: 'Eviction should target 80% of maxCacheBytes');
|
||||
});
|
||||
|
||||
test('oldest-modified tiles are evicted first', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write old tiles first (these should be evicted)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_');
|
||||
|
||||
// Write newer tiles (these should survive)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_');
|
||||
|
||||
await store.forceEviction();
|
||||
|
||||
// Newest tile should still be present
|
||||
final newestTile = await store.getTile('https://tile.example.com/new_4.png');
|
||||
expect(newestTile, isNotNull,
|
||||
reason: 'Newest tiles should survive eviction');
|
||||
|
||||
// Oldest tile should have been evicted
|
||||
final oldestTile = await store.getTile('https://tile.example.com/old_0.png');
|
||||
expect(oldestTile, isNull,
|
||||
reason: 'Oldest tiles should be evicted first');
|
||||
});
|
||||
|
||||
test('orphan .meta files are cleaned up during eviction', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write a tile to create the directory
|
||||
await fillCache(store, count: 1, bytesPerTile: 50);
|
||||
|
||||
// Manually create an orphan .meta file (no matching .tile)
|
||||
final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta'));
|
||||
await orphanMetaFile.writeAsString('{"staleAt":0}');
|
||||
expect(await orphanMetaFile.exists(), isTrue);
|
||||
|
||||
// Write enough tiles to exceed the limit, then force eviction
|
||||
await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_');
|
||||
await store.forceEviction();
|
||||
|
||||
// The orphan .meta file should have been cleaned up
|
||||
expect(await orphanMetaFile.exists(), isFalse,
|
||||
reason: 'Orphan .meta file should be cleaned up during eviction');
|
||||
});
|
||||
|
||||
test('evicted tiles have their .meta files removed too', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 300,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
await store.forceEviction();
|
||||
|
||||
// After eviction, count remaining .tile and .meta files
|
||||
final dir = Directory(tempDir.path);
|
||||
final files = await dir.list().toList();
|
||||
final tileFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.tile'))
|
||||
.length;
|
||||
final metaFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.meta'))
|
||||
.length;
|
||||
|
||||
// Every remaining .tile should have a matching .meta (1:1)
|
||||
expect(metaFiles, equals(tileFiles),
|
||||
reason: '.meta count should match .tile count after eviction');
|
||||
});
|
||||
|
||||
test('no eviction when cache is under limit', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 100000, // 100KB — way more than we'll write
|
||||
);
|
||||
|
||||
await fillCache(store, count: 3, bytesPerTile: 50);
|
||||
final sizeBefore = await store.estimatedSizeBytes;
|
||||
|
||||
await store.forceEviction();
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
|
||||
expect(sizeAfter, equals(sizeBefore),
|
||||
reason: 'No eviction needed when under limit');
|
||||
});
|
||||
});
|
||||
}
|
||||
426
test/services/service_policy_test.dart
Normal file
426
test/services/service_policy_test.dart
Normal file
@@ -0,0 +1,426 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('ServicePolicyResolver', () {
|
||||
setUp(() {
|
||||
ServicePolicyResolver.clearCustomPolicies();
|
||||
});
|
||||
|
||||
group('resolveType', () {
|
||||
test('resolves OSM editing API from production URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from sandbox URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from dev URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM tile server from tile URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'),
|
||||
ServiceType.osmTileServer,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Nominatim from geocoding URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'),
|
||||
ServiceType.nominatim,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Overpass API', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'),
|
||||
ServiceType.overpass,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves TagInfo', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'),
|
||||
ServiceType.tagInfo,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Bing tiles from virtualearth URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'),
|
||||
ServiceType.bingTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Mapbox tiles', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'),
|
||||
ServiceType.mapboxTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for unknown host', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for empty string', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType(''),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for malformed URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('not-a-url'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve', () {
|
||||
test('OSM tile server policy allows offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('OSM tile server policy requires 7-day min cache TTL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
});
|
||||
|
||||
test('OSM tile server has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('Nominatim policy enforces 1-second rate limit', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
});
|
||||
|
||||
test('Nominatim policy requires client caching', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
});
|
||||
|
||||
test('Nominatim has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('OSM editing API allows max 2 concurrent requests', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
});
|
||||
|
||||
test('Bing tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('Mapbox tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('custom/unknown host gets permissive defaults', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve with URL templates', () {
|
||||
test('handles {z}/{x}/{y} template variables', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('handles {quadkey} template variable', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('handles {0_3} subdomain template', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg',
|
||||
);
|
||||
expect(type, ServiceType.bingTiles);
|
||||
});
|
||||
|
||||
test('handles {api_key} template variable', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
);
|
||||
expect(type, ServiceType.mapboxTiles);
|
||||
});
|
||||
});
|
||||
|
||||
group('custom policy overrides', () {
|
||||
test('custom override takes precedence over built-in', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'overpass-api.de',
|
||||
const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true),
|
||||
);
|
||||
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 20);
|
||||
});
|
||||
|
||||
test('custom policy for self-hosted tiles allows offline', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'tiles.myserver.com',
|
||||
const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16),
|
||||
);
|
||||
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.maxConcurrentRequests, 16);
|
||||
});
|
||||
|
||||
test('removing custom override restores built-in policy', () {
|
||||
ServicePolicyResolver.registerCustomPolicy(
|
||||
'overpass-api.de',
|
||||
const ServicePolicy.custom(maxConcurrent: 20),
|
||||
);
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
||||
20,
|
||||
);
|
||||
|
||||
ServicePolicyResolver.removeCustomPolicy('overpass-api.de');
|
||||
// Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere)
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('clearCustomPolicies removes all overrides', () {
|
||||
ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1));
|
||||
ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2));
|
||||
|
||||
ServicePolicyResolver.clearCustomPolicies();
|
||||
|
||||
// Both should now return custom (default) policy
|
||||
expect(
|
||||
ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests,
|
||||
8, // default custom maxConcurrent
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ServiceRateLimiter', () {
|
||||
setUp(() {
|
||||
ServiceRateLimiter.reset();
|
||||
});
|
||||
|
||||
test('acquire and release work for editing API (2 concurrent)', () async {
|
||||
// Should be able to acquire 2 slots without blocking
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Release both
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('third acquire blocks until a slot is released', () async {
|
||||
// Fill both slots (osmEditingApi maxConcurrentRequests = 2)
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Third acquire should block
|
||||
var thirdCompleted = false;
|
||||
final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) {
|
||||
thirdCompleted = true;
|
||||
});
|
||||
|
||||
// Give microtasks a chance to run — third should still be blocked
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(thirdCompleted, false);
|
||||
|
||||
// Release one slot — third should now complete
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
await thirdFuture;
|
||||
expect(thirdCompleted, true);
|
||||
|
||||
// Clean up
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('Nominatim rate limiting delays rapid requests', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// First request should be immediate
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
// Second request should be delayed by ~1 second
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1, reason: 'second acquire should be blocked');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(acquireCount, 2, reason: 'second acquire should have completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('services with no rate limit pass through immediately', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// Overpass has maxConcurrentRequests: 0, so acquire should not apply
|
||||
// any artificial rate limiting delays.
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('Nominatim enforces min interval under concurrent callers', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var completedCount = 0;
|
||||
|
||||
// Start two concurrent callers; only one should run at a time and
|
||||
// the minRequestInterval of ~1s should still be enforced.
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
|
||||
async.flushMicrotasks();
|
||||
expect(completedCount, 1, reason: 'only first caller should complete immediately');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(completedCount, 2, reason: 'second caller should complete after interval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicePolicy', () {
|
||||
test('osmTileServer policy has correct values', () {
|
||||
const policy = ServicePolicy.osmTileServer();
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
expect(policy.maxConcurrentRequests, 0); // managed by flutter_map
|
||||
});
|
||||
|
||||
test('nominatim policy has correct values', () {
|
||||
const policy = ServicePolicy.nominatim();
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
expect(policy.maxConcurrentRequests, 1);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('osmEditingApi policy has correct values', () {
|
||||
const policy = ServicePolicy.osmEditingApi();
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
});
|
||||
|
||||
test('custom policy uses permissive defaults', () {
|
||||
const policy = ServicePolicy();
|
||||
expect(policy.maxConcurrentRequests, 8);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.minCacheTtl, isNull);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
|
||||
test('custom policy accepts overrides', () {
|
||||
const policy = ServicePolicy.custom(
|
||||
maxConcurrent: 20,
|
||||
allowsOffline: false,
|
||||
attribution: 'https://example.com/license',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 20);
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
expect(policy.attributionUrl, 'https://example.com/license');
|
||||
});
|
||||
});
|
||||
}
|
||||
227
test/services/tiles_from_local_test.dart
Normal file
227
test/services/tiles_from_local_test.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeBounds', () {
|
||||
test('swapped corners are normalized', () {
|
||||
// NE as first arg, SW as second (swapped)
|
||||
final swapped = LatLngBounds(
|
||||
const LatLng(52.0, 1.0), // NE corner passed as SW
|
||||
const LatLng(51.0, -1.0), // SW corner passed as NE
|
||||
);
|
||||
final normalized = normalizeBounds(swapped);
|
||||
expect(normalized.south, closeTo(51.0, 1e-6));
|
||||
expect(normalized.north, closeTo(52.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-1.0, 1e-6));
|
||||
expect(normalized.east, closeTo(1.0, 1e-6));
|
||||
});
|
||||
|
||||
test('degenerate (zero-width) bounds are expanded', () {
|
||||
final point = LatLngBounds(
|
||||
const LatLng(51.5, -0.1),
|
||||
const LatLng(51.5, -0.1),
|
||||
);
|
||||
final normalized = normalizeBounds(point);
|
||||
expect(normalized.south, lessThan(51.5));
|
||||
expect(normalized.north, greaterThan(51.5));
|
||||
expect(normalized.west, lessThan(-0.1));
|
||||
expect(normalized.east, greaterThan(-0.1));
|
||||
});
|
||||
|
||||
test('already-normalized bounds are unchanged', () {
|
||||
final normal = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
final normalized = normalizeBounds(normal);
|
||||
expect(normalized.south, closeTo(40.0, 1e-6));
|
||||
expect(normalized.north, closeTo(60.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-10.0, 1e-6));
|
||||
expect(normalized.east, closeTo(30.0, 1e-6));
|
||||
});
|
||||
});
|
||||
|
||||
group('tileInBounds', () {
|
||||
/// Helper: compute expected tile range for [bounds] at [z] using the same
|
||||
/// Mercator projection math and return whether (x, y) is within range.
|
||||
bool referenceTileInBounds(
|
||||
LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final minX = ((bounds.west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((bounds.east + 180.0) / 360.0 * n).floor();
|
||||
final minY = ((1.0 -
|
||||
log(tan(bounds.north * pi / 180.0) +
|
||||
1.0 / cos(bounds.north * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
final maxY = ((1.0 -
|
||||
log(tan(bounds.south * pi / 180.0) +
|
||||
1.0 / cos(bounds.south * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
test('zoom 0: single tile covers the whole world', () {
|
||||
final world = LatLngBounds(
|
||||
const LatLng(-85, -180),
|
||||
const LatLng(85, 180),
|
||||
);
|
||||
expect(tileInBounds(world, 0, 0, 0), isTrue);
|
||||
});
|
||||
|
||||
test('zoom 1: London area covers NW and NE quadrants', () {
|
||||
// Bounds straddling the prime meridian in the northern hemisphere
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// NW quadrant (x=0, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 0), isTrue);
|
||||
// NE quadrant (x=1, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 0), isTrue);
|
||||
// SW quadrant (x=0, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 1), isFalse);
|
||||
// SE quadrant (x=1, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 1), isFalse);
|
||||
});
|
||||
|
||||
test('zoom 2: London area covers specific tiles', () {
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// Expected: X 1-2, Y 1
|
||||
expect(tileInBounds(londonArea, 2, 1, 1), isTrue);
|
||||
expect(tileInBounds(londonArea, 2, 2, 1), isTrue);
|
||||
// Outside X range
|
||||
expect(tileInBounds(londonArea, 2, 0, 1), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 3, 1), isFalse);
|
||||
// Outside Y range
|
||||
expect(tileInBounds(londonArea, 2, 1, 0), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 1, 2), isFalse);
|
||||
});
|
||||
|
||||
test('southern hemisphere: Sydney area', () {
|
||||
final sydneyArea = LatLngBounds(
|
||||
const LatLng(-34.0, 151.0),
|
||||
const LatLng(-33.5, 151.5),
|
||||
);
|
||||
|
||||
// At zoom 1, Sydney is in the SE quadrant (x=1, y=1)
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse);
|
||||
});
|
||||
|
||||
test('western hemisphere: NYC area at zoom 4', () {
|
||||
final nycArea = LatLngBounds(
|
||||
const LatLng(40.5, -74.5),
|
||||
const LatLng(41.0, -73.5),
|
||||
);
|
||||
|
||||
// At zoom 4 (16x16), NYC should be around x=4-5, y=6
|
||||
// x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4
|
||||
// x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4
|
||||
// So x range is just 4
|
||||
expect(tileInBounds(nycArea, 4, 4, 6), isTrue);
|
||||
expect(tileInBounds(nycArea, 4, 5, 6), isFalse);
|
||||
expect(tileInBounds(nycArea, 4, 3, 6), isFalse);
|
||||
});
|
||||
|
||||
test('higher zoom: smaller area at zoom 10', () {
|
||||
// Small area around central London
|
||||
final centralLondon = LatLngBounds(
|
||||
const LatLng(51.49, -0.13),
|
||||
const LatLng(51.52, -0.08),
|
||||
);
|
||||
|
||||
// Compute expected tile range at zoom 10 using reference
|
||||
const z = 10;
|
||||
final n = pow(2.0, z);
|
||||
final expectedMinX =
|
||||
((-0.13 + 180.0) / 360.0 * n).floor();
|
||||
final expectedMaxX =
|
||||
((-0.08 + 180.0) / 360.0 * n).floor();
|
||||
|
||||
// Tiles inside the computed range should be in bounds
|
||||
for (var x = expectedMinX; x <= expectedMaxX; x++) {
|
||||
expect(
|
||||
referenceTileInBounds(centralLondon, z, x, 340),
|
||||
equals(tileInBounds(centralLondon, z, x, 340)),
|
||||
reason: 'Mismatch at tile ($x, 340, $z)',
|
||||
);
|
||||
}
|
||||
|
||||
// Tiles outside X range should not be in bounds
|
||||
expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse);
|
||||
expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse);
|
||||
});
|
||||
|
||||
test('tile exactly at boundary is included', () {
|
||||
// Bounds whose edges align exactly with tile boundaries at zoom 1
|
||||
// At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180
|
||||
final halfWorld = LatLngBounds(
|
||||
const LatLng(0.0, 0.0),
|
||||
const LatLng(60.0, 180.0),
|
||||
);
|
||||
|
||||
// Tile (1, 0, 1) should be in bounds (NE quadrant)
|
||||
expect(tileInBounds(halfWorld, 1, 1, 0), isTrue);
|
||||
});
|
||||
|
||||
test('anti-meridian: bounds crossing 180° longitude', () {
|
||||
// Bounds from eastern Russia (170°E) to Alaska (170°W = -170°)
|
||||
// After normalization, west=170 east=-170 which is swapped —
|
||||
// normalizeBounds will swap to west=-170 east=170, which covers
|
||||
// nearly the whole world. This is the expected behavior since
|
||||
// LatLngBounds doesn't support anti-meridian wrapping.
|
||||
final antiMeridian = normalizeBounds(LatLngBounds(
|
||||
const LatLng(50.0, 170.0),
|
||||
const LatLng(70.0, -170.0),
|
||||
));
|
||||
|
||||
// After normalization, west=-170 east=170 (covers most longitudes)
|
||||
// At zoom 2, tiles 0-3 along X axis
|
||||
// Since the normalized bounds cover lon -170 to 170 (340° of 360°),
|
||||
// almost all tiles should be in bounds
|
||||
expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue);
|
||||
});
|
||||
|
||||
test('exhaustive check at zoom 3 matches reference', () {
|
||||
final bounds = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
|
||||
// Check all 64 tiles at zoom 3 against reference implementation
|
||||
const z = 3;
|
||||
final tilesPerSide = pow(2, z).toInt();
|
||||
for (var x = 0; x < tilesPerSide; x++) {
|
||||
for (var y = 0; y < tilesPerSide; y++) {
|
||||
expect(
|
||||
tileInBounds(bounds, z, x, y),
|
||||
equals(referenceTileInBounds(bounds, z, x, y)),
|
||||
reason: 'Mismatch at tile ($x, $y, $z)',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
73
test/state/settings_state_test.dart
Normal file
73
test/state/settings_state_test.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:deflockapp/state/settings_state.dart';
|
||||
import 'package:deflockapp/keys.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('kHasOsmSecrets (no --dart-define)', () {
|
||||
test('is false when built without secrets', () {
|
||||
expect(kHasOsmSecrets, isFalse);
|
||||
});
|
||||
|
||||
test('client ID getters return empty strings instead of throwing', () {
|
||||
expect(kOsmProdClientId, isEmpty);
|
||||
expect(kOsmSandboxClientId, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsState without secrets', () {
|
||||
test('defaults to simulate mode', () {
|
||||
final state = SettingsState();
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('init() forces simulate even if prefs has production stored', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.production.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
|
||||
// Verify it persisted the override
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getInt('upload_mode'), UploadMode.simulate.index);
|
||||
});
|
||||
|
||||
test('init() forces simulate even if prefs has sandbox stored', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.sandbox.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('init() keeps simulate if already simulate', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.simulate.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('setUploadMode() allows simulate', () async {
|
||||
final state = SettingsState();
|
||||
await state.setUploadMode(UploadMode.simulate);
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
});
|
||||
}
|
||||
618
test/widgets/map/tile_layer_manager_test.dart
Normal file
618
test/widgets/map/tile_layer_manager_test.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/widgets/map/tile_layer_manager.dart';
|
||||
|
||||
class MockTileImage extends Mock implements TileImage {}
|
||||
|
||||
void main() {
|
||||
group('TileLayerManager exponential backoff', () {
|
||||
test('initial retry delay is 2 seconds', () {
|
||||
final manager = TileLayerManager();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('scheduleRetry fires reset stream after delay', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay doubles after each retry fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// First retry: 2s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
// Second retry: 4s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Third retry: 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 8));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 16)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay caps at 60 seconds', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60
|
||||
var currentDelay = manager.retryDelay;
|
||||
while (currentDelay < const Duration(seconds: 60)) {
|
||||
manager.scheduleRetry();
|
||||
async.elapse(currentDelay);
|
||||
currentDelay = manager.retryDelay;
|
||||
}
|
||||
|
||||
// Should be capped at 60s
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
// Another cycle stays at 60s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 60));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('onTileLoadSuccess resets delay to minimum', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive up the delay
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Reset on success
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('rapid errors debounce: only last timer fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Fire 3 errors in quick succession (each cancels the previous timer)
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
|
||||
// 1s elapsed total since first error, but last timer started 0ms ago
|
||||
// Need to wait 2s from *last* scheduleRetry call
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty, reason: 'Timer should not fire yet');
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1), reason: 'Only one reset should fire');
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay stays at minimum if no retries have fired', () {
|
||||
final manager = TileLayerManager();
|
||||
// Just calling onTileLoadSuccess without any errors
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
final expectedDelays = [
|
||||
const Duration(seconds: 2),
|
||||
const Duration(seconds: 4),
|
||||
const Duration(seconds: 8),
|
||||
const Duration(seconds: 16),
|
||||
const Duration(seconds: 32),
|
||||
const Duration(seconds: 60),
|
||||
const Duration(seconds: 60), // capped
|
||||
];
|
||||
|
||||
for (var i = 0; i < expectedDelays.length; i++) {
|
||||
expect(manager.retryDelay, equals(expectedDelays[i]),
|
||||
reason: 'Step $i');
|
||||
manager.scheduleRetry();
|
||||
async.elapse(expectedDelays[i]);
|
||||
}
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('dispose cancels pending retry timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
late StreamSubscription<void> sub;
|
||||
sub = manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
// Dispose before timer fires
|
||||
sub.cancel();
|
||||
manager.dispose();
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager checkAndClearCacheIfNeeded', () {
|
||||
late TileLayerManager manager;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('first call triggers clear (initial null differs from provided values)', () {
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call: internal state is (null, null, false) → (osm, street, false)
|
||||
// provider null→osm triggers clear. Harmless: no tiles to clear yet.
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('same values on second call returns false', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('different provider triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different tile type triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different offline mode triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: true,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('cache clear increments mapRebuildKey', () {
|
||||
final initialKey = manager.mapRebuildKey;
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call increments (null → osm)
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 1));
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// Type change should increment again
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 2));
|
||||
});
|
||||
|
||||
test('no cache clear does not increment mapRebuildKey', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final keyAfterFirst = manager.mapRebuildKey;
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(manager.mapRebuildKey, equals(keyAfterFirst));
|
||||
});
|
||||
|
||||
test('null to non-null transition triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// null → osm is a change — triggers clear so stale tiles are flushed
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('non-null to null to non-null triggers clear both times', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
// Provider goes null (e.g., during reload)
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// Provider returns — should still trigger clear
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching back and forth triggers clear each time', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching providers with same tile type triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('provider switch resets retry delay and cancels pending timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Escalate backoff: 2s → 4s → 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Start another retry timer (hasn't fired yet)
|
||||
manager.scheduleRetry();
|
||||
|
||||
// Switch provider — should reset delay and cancel pending timer
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
// The pending 8s timer should have been cancelled
|
||||
final resetsBefore = resets.length;
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets.length, equals(resetsBefore),
|
||||
reason: 'Old retry timer should be cancelled on provider switch');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager config drift detection', () {
|
||||
late TileLayerManager manager;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
models.TileProvider makeProvider({String? apiKey}) => models.TileProvider(
|
||||
id: 'test_provider',
|
||||
name: 'Test',
|
||||
apiKey: apiKey,
|
||||
tileTypes: [],
|
||||
);
|
||||
|
||||
models.TileType makeTileType({
|
||||
String urlTemplate = 'https://example.com/{z}/{x}/{y}.png',
|
||||
int maxZoom = 18,
|
||||
}) =>
|
||||
models.TileType(
|
||||
id: 'test_tile',
|
||||
name: 'Test',
|
||||
urlTemplate: urlTemplate,
|
||||
attribution: 'Test',
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
|
||||
test('returns same provider for identical config', () {
|
||||
final provider = makeProvider();
|
||||
final tileType = makeTileType();
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isTrue,
|
||||
reason: 'Same config should return the cached provider instance',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when urlTemplate changes', () {
|
||||
final provider = makeProvider();
|
||||
final tileTypeV1 = makeTileType(
|
||||
urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
final tileTypeV2 = makeTileType(
|
||||
urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV1,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV2,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed urlTemplate should create a new provider',
|
||||
);
|
||||
expect(
|
||||
(layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate,
|
||||
'https://new.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when apiKey changes', () {
|
||||
final providerV1 = makeProvider(apiKey: 'old_key');
|
||||
final providerV2 = makeProvider(apiKey: 'new_key');
|
||||
final tileType = makeTileType();
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: providerV1,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: providerV2,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed apiKey should create a new provider',
|
||||
);
|
||||
expect(
|
||||
(layer2.tileProvider as DeflockTileProvider).apiKey,
|
||||
'new_key',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when maxZoom changes', () {
|
||||
final provider = makeProvider();
|
||||
final tileTypeV1 = makeTileType(maxZoom: 18);
|
||||
final tileTypeV2 = makeTileType(maxZoom: 20);
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV1,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV2,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed maxZoom should create a new provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager error-type filtering', () {
|
||||
late TileLayerManager manager;
|
||||
late MockTileImage mockTile;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
mockTile = MockTileImage();
|
||||
when(() => mockTile.coordinates)
|
||||
.thenReturn(const TileCoordinates(1, 2, 3));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('skips retry for TileLoadCancelledException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileLoadCancelledException(),
|
||||
null,
|
||||
);
|
||||
|
||||
// Even after waiting well past the retry delay, no reset should fire.
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('skips retry for TileNotAvailableOfflineException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileNotAvailableOfflineException(),
|
||||
null,
|
||||
);
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('schedules retry for other errors (e.g. HttpException)', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const HttpException('tile fetch failed'),
|
||||
null,
|
||||
);
|
||||
|
||||
// Should fire after the initial 2s retry delay.
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(resets, hasLength(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
164
test/widgets/map_data_manager_test.dart
Normal file
164
test/widgets/map_data_manager_test.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/widgets/map/map_data_manager.dart';
|
||||
|
||||
void main() {
|
||||
OsmNode nodeAt(int id, double lat, double lng) {
|
||||
return OsmNode(id: id, coord: LatLng(lat, lng), tags: {'surveillance': 'outdoor'});
|
||||
}
|
||||
|
||||
group('Node render prioritization', () {
|
||||
late MapDataManager dataManager;
|
||||
late List<OsmNode> testNodes;
|
||||
|
||||
setUp(() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
testNodes = [];
|
||||
dataManager = MapDataManager(
|
||||
getNodesForBounds: (_) => testNodes,
|
||||
);
|
||||
});
|
||||
|
||||
test('closest nodes to viewport center are kept', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
// Center is (38.5, -77.5)
|
||||
testNodes = [
|
||||
nodeAt(1, 38.9, -77.9), // far from center
|
||||
nodeAt(2, 38.5, -77.5), // at center
|
||||
nodeAt(3, 38.1, -77.1), // far from center
|
||||
nodeAt(4, 38.51, -77.49), // very close to center
|
||||
nodeAt(5, 38.0, -78.0), // corner — farthest
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 3,
|
||||
);
|
||||
|
||||
expect(result.isLimitActive, isTrue);
|
||||
expect(result.nodesToRender.length, 3);
|
||||
final ids = result.nodesToRender.map((n) => n.id).toSet();
|
||||
expect(ids.contains(2), isTrue, reason: 'Node at center should be kept');
|
||||
expect(ids.contains(4), isTrue, reason: 'Node near center should be kept');
|
||||
expect(ids.contains(5), isFalse, reason: 'Node at corner should be dropped');
|
||||
});
|
||||
|
||||
test('returns all nodes when under the limit', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [
|
||||
nodeAt(1, 38.5, -77.5),
|
||||
nodeAt(2, 38.6, -77.6),
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.isLimitActive, isFalse);
|
||||
expect(result.nodesToRender.length, 2);
|
||||
});
|
||||
|
||||
test('returns empty when below minimum zoom', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [nodeAt(1, 38.5, -77.5)];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 5.0,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.nodesToRender, isEmpty);
|
||||
});
|
||||
|
||||
test('panning viewport changes which nodes are prioritized', () {
|
||||
final nodes = [
|
||||
nodeAt(1, 38.0, -78.0), // SW
|
||||
nodeAt(2, 38.5, -77.5), // middle
|
||||
nodeAt(3, 39.0, -77.0), // NE
|
||||
];
|
||||
|
||||
// Viewport centered near SW
|
||||
testNodes = List.from(nodes);
|
||||
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
|
||||
final swResult = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0,
|
||||
mapBounds: swBounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 1,
|
||||
);
|
||||
expect(swResult.nodesToRender.first.id, 1,
|
||||
reason: 'SW node closest to SW-centered viewport');
|
||||
|
||||
// Viewport centered near NE
|
||||
testNodes = List.from(nodes);
|
||||
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
|
||||
final neResult = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0,
|
||||
mapBounds: neBounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 1,
|
||||
);
|
||||
expect(neResult.nodesToRender.first.id, 3,
|
||||
reason: 'NE node closest to NE-centered viewport');
|
||||
});
|
||||
|
||||
test('order is stable for repeated calls with same viewport', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
makeNodes() => [
|
||||
nodeAt(1, 38.9, -77.9),
|
||||
nodeAt(2, 38.5, -77.5),
|
||||
nodeAt(3, 38.1, -77.1),
|
||||
nodeAt(4, 38.51, -77.49),
|
||||
nodeAt(5, 38.0, -78.0),
|
||||
];
|
||||
|
||||
testNodes = makeNodes();
|
||||
final result1 = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0, mapBounds: bounds,
|
||||
uploadMode: UploadMode.production, maxNodes: 3,
|
||||
);
|
||||
|
||||
testNodes = makeNodes();
|
||||
final result2 = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0, mapBounds: bounds,
|
||||
uploadMode: UploadMode.production, maxNodes: 3,
|
||||
);
|
||||
|
||||
expect(
|
||||
result1.nodesToRender.map((n) => n.id).toList(),
|
||||
result2.nodesToRender.map((n) => n.id).toList(),
|
||||
);
|
||||
});
|
||||
|
||||
test('filters out invalid coordinates before prioritizing', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [
|
||||
nodeAt(1, 0, 0), // invalid (0,0)
|
||||
nodeAt(2, 38.5, -77.5), // valid, at center
|
||||
nodeAt(3, 200, -77.5), // invalid lat
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14.0,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.nodesToRender.length, 1);
|
||||
expect(result.nodesToRender.first.id, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user