Skip to content

Commit bda3691

Browse files
committed
Add support for importing custom API configuration from clipboard and enhance offline sessions display
1 parent e282426 commit bda3691

3 files changed

Lines changed: 167 additions & 29 deletions

File tree

docs/CUSTOM_API_ENDPOINT.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@ Your endpoint should return any `2xx` HTTP status code on success. The response
192192

193193
MeshMapper does **not** retry failed custom API requests. Each batch is sent exactly once.
194194

195+
## Configuration Link for End Users
196+
197+
As an endpoint operator, you can generate a configuration link that your users paste into their clipboard. In MeshMapper Settings > API Endpoints, they tap **Import from Clipboard** to auto-fill the URL and API key.
198+
199+
**Format:**
200+
201+
```
202+
meshmapper://custom-api?url=your-server.com/api/wardrive&key=your-api-key-here
203+
```
204+
205+
- `url` — Your endpoint host and path, **without** `https://` (MeshMapper prepends it automatically).
206+
- `key` — The API key the user should send. This will be set as their `X-API-Key` header value.
207+
208+
**Example:**
209+
210+
```
211+
meshmapper://custom-api?url=data.myproject.org/ingest/wardrive&key=sk_live_abc123def456
212+
```
213+
214+
The user copies this link, opens MeshMapper Settings > API Endpoints, and taps "Import from Clipboard." Both fields are populated instantly.
215+
195216
## Security Notes
196217

197218
- **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level.

lib/screens/settings_screen.dart

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
554554
),
555555
]),
556556

557+
// Offline Sessions
558+
_buildSection(context, 'Offline Sessions', [
559+
if (appState.offlineSessions.isEmpty)
560+
ListTile(
561+
leading: Icon(Icons.cloud_off, color: Colors.grey.shade400),
562+
title: Text(
563+
'No offline sessions stored',
564+
style: TextStyle(color: Colors.grey.shade500),
565+
),
566+
subtitle: Text(
567+
'Sessions recorded in offline mode will appear here',
568+
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
569+
),
570+
)
571+
else
572+
...appState.offlineSessions.map((session) => _OfflineSessionTile(
573+
session: session,
574+
uploadEnabled: !appState.isUploadingOfflineSession,
575+
onUpload: () => _uploadOfflineSession(context, appState, session.filename),
576+
onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename),
577+
onDownload: () => _downloadOfflineSession(context, appState, session.filename),
578+
)),
579+
]),
580+
557581
// API Endpoints
558582
_buildSection(context, 'API Endpoints', [
559583
const ListTile(
@@ -591,31 +615,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
591615
trailing: const Icon(Icons.chevron_right),
592616
onTap: isAutoMode ? null : () => _showCustomApiKeyDialog(context, appState),
593617
),
594-
],
595-
]),
596-
597-
// Offline Sessions
598-
_buildSection(context, 'Offline Sessions', [
599-
if (appState.offlineSessions.isEmpty)
600618
ListTile(
601-
leading: Icon(Icons.cloud_off, color: Colors.grey.shade400),
602-
title: Text(
603-
'No offline sessions stored',
604-
style: TextStyle(color: Colors.grey.shade500),
605-
),
606-
subtitle: Text(
607-
'Sessions recorded in offline mode will appear here',
608-
style: TextStyle(fontSize: 12, color: Colors.grey.shade400),
609-
),
610-
)
611-
else
612-
...appState.offlineSessions.map((session) => _OfflineSessionTile(
613-
session: session,
614-
uploadEnabled: !appState.isUploadingOfflineSession,
615-
onUpload: () => _uploadOfflineSession(context, appState, session.filename),
616-
onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename),
617-
onDownload: () => _downloadOfflineSession(context, appState, session.filename),
618-
)),
619+
leading: const Icon(Icons.content_paste),
620+
title: const Text('Import from Clipboard'),
621+
subtitle: const Text('Paste a meshmapper:// config link'),
622+
onTap: isAutoMode ? null : () => _importCustomApiFromClipboard(context, appState),
623+
),
624+
],
619625
]),
620626

621627
// About
@@ -991,6 +997,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
991997
case 'bike': return 'Bike';
992998
case 'boat': return 'Boat';
993999
case 'walk': return 'Walk';
1000+
case 'pacman': return 'Pac-Man';
9941001
case 'arrow':
9951002
default: return 'Arrow';
9961003
}
@@ -2301,6 +2308,63 @@ class _SettingsScreenState extends State<SettingsScreen> {
23012308
);
23022309
}
23032310

2311+
Future<void> _importCustomApiFromClipboard(BuildContext context, AppStateProvider appState) async {
2312+
final clipData = await Clipboard.getData('text/plain');
2313+
final text = clipData?.text?.trim();
2314+
2315+
if (text == null || text.isEmpty) {
2316+
if (context.mounted) AppToast.error(context, 'Clipboard is empty');
2317+
return;
2318+
}
2319+
2320+
// Expected format: meshmapper://custom-api?url=example.com/api.php&key=xxxxxxxxxx
2321+
if (!text.startsWith('meshmapper://')) {
2322+
if (context.mounted) {
2323+
AppToast.error(context, 'No meshmapper:// link found in clipboard');
2324+
}
2325+
return;
2326+
}
2327+
2328+
try {
2329+
final uri = Uri.parse(text);
2330+
final rawUrl = uri.queryParameters['url'];
2331+
final key = uri.queryParameters['key'];
2332+
2333+
if (rawUrl == null || rawUrl.isEmpty) {
2334+
if (context.mounted) AppToast.error(context, 'Link is missing the url parameter');
2335+
return;
2336+
}
2337+
if (key == null || key.isEmpty) {
2338+
if (context.mounted) AppToast.error(context, 'Link is missing the key parameter');
2339+
return;
2340+
}
2341+
2342+
final fullUrl = 'https://$rawUrl';
2343+
2344+
// Validate the constructed URL
2345+
final parsed = Uri.tryParse(fullUrl);
2346+
if (parsed == null || !parsed.hasAuthority) {
2347+
if (context.mounted) AppToast.error(context, 'Invalid URL in link: $rawUrl');
2348+
return;
2349+
}
2350+
2351+
appState.updatePreferences(
2352+
appState.preferences.copyWith(
2353+
customApiUrl: fullUrl,
2354+
customApiKey: key,
2355+
),
2356+
);
2357+
2358+
if (context.mounted) {
2359+
AppToast.success(context, 'Imported endpoint: $rawUrl');
2360+
}
2361+
debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl');
2362+
} catch (e) {
2363+
debugError('[CUSTOM API] Failed to parse clipboard link: $e');
2364+
if (context.mounted) AppToast.error(context, 'Invalid meshmapper:// link');
2365+
}
2366+
}
2367+
23042368
void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) {
23052369
final isConnected = appState.isConnected;
23062370

lib/widgets/map_widget.dart

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,9 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
702702
userAgentPackageName: 'com.meshmapper.app',
703703
maxZoom: 17,
704704
retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context),
705+
tileDisplay: const TileDisplay.fadeIn(
706+
reloadStartOpacity: 1.0,
707+
),
705708
tileProvider: SilentCancellableNetworkTileProvider(),
706709
);
707710
},
@@ -715,19 +718,21 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
715718
minZoom: 3,
716719
maxZoom: 17,
717720
tileDisplay: const TileDisplay.fadeIn(
718-
reloadStartOpacity: 1.0, // Keep old tile visible until new one loads
721+
reloadStartOpacity: 1.0,
719722
),
720723
tileProvider: SilentCancellableNetworkTileProvider(),
721724
),
722725

723726
// Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top
727+
// During focus mode, the focused marker is excluded and rendered in its own top layer
724728
MarkerLayer(
725729
markers: _buildCoverageMarkers(
726730
txPings: appState.txPings,
727731
rxPings: appState.rxPings,
728732
discEntries: appState.discLogEntries,
729733
discDropEnabled: appState.discDropEnabled,
730734
traceEntries: appState.traceLogEntries,
735+
excludeFocused: _focusedPingLocation != null,
731736
),
732737
),
733738

@@ -776,6 +781,16 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
776781
onlyConnected: true,
777782
),
778783
),
784+
// Focused ping marker (above everything except GPS)
785+
MarkerLayer(
786+
markers: _buildFocusedPingMarker(
787+
txPings: appState.txPings,
788+
rxPings: appState.rxPings,
789+
discEntries: appState.discLogEntries,
790+
discDropEnabled: appState.discDropEnabled,
791+
traceEntries: appState.traceLogEntries,
792+
),
793+
),
779794
] else
780795
// Normal mode: single layer with all repeaters
781796
MarkerLayer(
@@ -1782,22 +1797,60 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
17821797
required List<DiscLogEntry> discEntries,
17831798
required bool discDropEnabled,
17841799
required List<TraceLogEntry> traceEntries,
1800+
bool excludeFocused = false,
17851801
}) {
17861802
final timestamped = <(DateTime, Marker)>[
17871803
for (final ping in txPings)
1788-
(ping.timestamp, _buildTxMarker(ping)),
1804+
if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp))
1805+
(ping.timestamp, _buildTxMarker(ping)),
17891806
for (final ping in rxPings)
1790-
(ping.timestamp, _buildRxMarker(ping)),
1807+
if (!excludeFocused || !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp))
1808+
(ping.timestamp, _buildRxMarker(ping)),
17911809
for (final entry in discEntries)
1792-
(entry.timestamp, _buildDiscMarker(entry, discDropEnabled)),
1810+
if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp))
1811+
(entry.timestamp, _buildDiscMarker(entry, discDropEnabled)),
17931812
for (final entry in traceEntries)
1794-
(entry.timestamp, _buildTraceMarker(entry)),
1813+
if (!excludeFocused || !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp))
1814+
(entry.timestamp, _buildTraceMarker(entry)),
17951815
];
17961816

17971817
timestamped.sort((a, b) => a.$1.compareTo(b.$1));
17981818
return timestamped.map((e) => e.$2).toList();
17991819
}
18001820

1821+
/// Build just the focused ping marker for rendering in its own top layer.
1822+
List<Marker> _buildFocusedPingMarker({
1823+
required List<TxPing> txPings,
1824+
required List<RxPing> rxPings,
1825+
required List<DiscLogEntry> discEntries,
1826+
required bool discDropEnabled,
1827+
required List<TraceLogEntry> traceEntries,
1828+
}) {
1829+
if (_focusedPingLocation == null) return [];
1830+
1831+
for (final ping in txPings) {
1832+
if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) {
1833+
return [_buildTxMarker(ping)];
1834+
}
1835+
}
1836+
for (final ping in rxPings) {
1837+
if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) {
1838+
return [_buildRxMarker(ping)];
1839+
}
1840+
}
1841+
for (final entry in discEntries) {
1842+
if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) {
1843+
return [_buildDiscMarker(entry, discDropEnabled)];
1844+
}
1845+
}
1846+
for (final entry in traceEntries) {
1847+
if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) {
1848+
return [_buildTraceMarker(entry)];
1849+
}
1850+
}
1851+
return [];
1852+
}
1853+
18011854
/// Check if a ping at given lat/lon/timestamp is the currently focused ping.
18021855
bool _isFocusedPing(double lat, double lon, DateTime timestamp) {
18031856
return _focusedPingLocation != null &&

0 commit comments

Comments
 (0)