Improve map interaction, shelter selection, and offline caching
- Rework selection model so any shelter marker can be tapped to select, not just the nearest three in the bottom sheet list - Highlight selected shelter with a distinct amber marker icon - Track user map interaction (pan/zoom) to prevent auto-recentering - Add reset navigation FAB to re-fit map to user + selected shelter - Add offline map cache prompt (OK/Skip) with warning banner and retry - Rewrite MapCacheManager to use passive tile caching via map panning - Respect system status bar with fitsSystemWindows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
27cad094e7
commit
92531ee971
9 changed files with 373 additions and 112 deletions
|
|
@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import no.naiv.tilfluktsrom.data.MapCacheManager
|
||||
|
|
@ -56,11 +57,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
private var currentLocation: Location? = null
|
||||
private var allShelters: List<Shelter> = emptyList()
|
||||
private var nearestShelters: List<ShelterWithDistance> = emptyList()
|
||||
private var selectedShelterIndex = 0
|
||||
private var deviceHeading = 0f
|
||||
private var isCompassMode = false
|
||||
private var locationJob: Job? = null
|
||||
private var shelterMarkers: MutableList<Marker> = mutableListOf()
|
||||
private var cachingJob: Job? = null
|
||||
// Map from shelter lokalId to its map marker, for icon swapping on selection
|
||||
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf()
|
||||
private var highlightedMarkerId: String? = null
|
||||
|
||||
// The currently selected shelter — can be any shelter, not just one from nearestShelters
|
||||
private var selectedShelter: ShelterWithDistance? = null
|
||||
// When true, location updates won't auto-select the nearest shelter
|
||||
private var userSelectedShelter = false
|
||||
// When true, location updates won't auto-zoom the map
|
||||
private var userHasInteractedWithMap = false
|
||||
|
||||
private val locationPermissionRequest = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
|
|
@ -99,6 +109,16 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
// Default center: roughly central Norway
|
||||
controller.setCenter(GeoPoint(59.9, 10.7))
|
||||
|
||||
// Detect user touch interaction (pan/zoom) to suppress auto-zoom
|
||||
setOnTouchListener { v, _ ->
|
||||
if (!userHasInteractedWithMap) {
|
||||
userHasInteractedWithMap = true
|
||||
binding.resetNavigationFab.visibility = View.VISIBLE
|
||||
}
|
||||
v.performClick()
|
||||
false // Don't consume the event
|
||||
}
|
||||
|
||||
// Add user location overlay
|
||||
myLocationOverlay = MyLocationNewOverlay(
|
||||
GpsMyLocationProvider(this@MainActivity), this
|
||||
|
|
@ -110,12 +130,10 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
private fun setupShelterList() {
|
||||
shelterAdapter = ShelterListAdapter { selected ->
|
||||
val idx = nearestShelters.indexOf(selected)
|
||||
if (idx >= 0) {
|
||||
selectedShelterIndex = idx
|
||||
updateSelectedShelter()
|
||||
}
|
||||
shelterAdapter = ShelterListAdapter { swd ->
|
||||
userSelectedShelter = true
|
||||
userHasInteractedWithMap = false
|
||||
selectShelter(swd)
|
||||
}
|
||||
|
||||
binding.shelterList.apply {
|
||||
|
|
@ -138,9 +156,26 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
}
|
||||
|
||||
// Reset to navigation: re-fit map to show user + selected shelter
|
||||
binding.resetNavigationFab.setOnClickListener {
|
||||
userHasInteractedWithMap = false
|
||||
binding.resetNavigationFab.visibility = View.GONE
|
||||
selectedShelter?.let { highlightShelterOnMap(it) }
|
||||
}
|
||||
|
||||
binding.refreshButton.setOnClickListener {
|
||||
forceRefresh()
|
||||
}
|
||||
|
||||
binding.cacheRetryButton.setOnClickListener {
|
||||
currentLocation?.let { loc ->
|
||||
if (isNetworkAvailable()) {
|
||||
startCaching(loc.latitude, loc.longitude)
|
||||
} else {
|
||||
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
|
|
@ -231,16 +266,87 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
allShelters, location.latitude, location.longitude, NEAREST_COUNT
|
||||
)
|
||||
|
||||
// Highlight which nearest-list item matches the current selection
|
||||
val selectedIdx = if (selectedShelter != null) {
|
||||
nearestShelters.indexOfFirst { it.shelter.lokalId == selectedShelter!!.shelter.lokalId }
|
||||
} else -1
|
||||
|
||||
shelterAdapter.submitList(nearestShelters)
|
||||
selectedShelterIndex = 0
|
||||
shelterAdapter.selectPosition(0)
|
||||
updateSelectedShelter()
|
||||
shelterAdapter.selectPosition(selectedIdx)
|
||||
|
||||
if (userSelectedShelter && selectedShelter != null) {
|
||||
// Recalculate distance/bearing for the user's picked shelter
|
||||
refreshSelectedShelterDistance(location)
|
||||
} else {
|
||||
// Auto-select nearest
|
||||
if (nearestShelters.isNotEmpty()) {
|
||||
selectShelter(nearestShelters[0])
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedShelterUI()
|
||||
}
|
||||
|
||||
private fun updateSelectedShelter() {
|
||||
if (nearestShelters.isEmpty()) return
|
||||
/**
|
||||
* Select a specific shelter (from list tap, marker tap, or auto-select).
|
||||
* Recalculates distance/bearing from current location.
|
||||
*/
|
||||
private fun selectShelter(swd: ShelterWithDistance) {
|
||||
selectedShelter = swd
|
||||
currentLocation?.let { refreshSelectedShelterDistance(it) }
|
||||
|
||||
val selected = nearestShelters[selectedShelterIndex]
|
||||
// Update list highlight
|
||||
val idx = nearestShelters.indexOfFirst { it.shelter.lokalId == swd.shelter.lokalId }
|
||||
shelterAdapter.selectPosition(idx)
|
||||
|
||||
updateSelectedShelterUI()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a shelter by its data object (e.g. from a marker tap).
|
||||
* Computes a fresh ShelterWithDistance from the current location.
|
||||
*/
|
||||
private fun selectShelterByData(shelter: Shelter) {
|
||||
val loc = currentLocation
|
||||
val swd = if (loc != null) {
|
||||
ShelterWithDistance(
|
||||
shelter = shelter,
|
||||
distanceMeters = DistanceUtils.distanceMeters(
|
||||
loc.latitude, loc.longitude, shelter.latitude, shelter.longitude
|
||||
),
|
||||
bearingDegrees = DistanceUtils.bearingDegrees(
|
||||
loc.latitude, loc.longitude, shelter.latitude, shelter.longitude
|
||||
)
|
||||
)
|
||||
} else {
|
||||
ShelterWithDistance(shelter = shelter, distanceMeters = 0.0, bearingDegrees = 0.0)
|
||||
}
|
||||
|
||||
userSelectedShelter = true
|
||||
userHasInteractedWithMap = false
|
||||
binding.resetNavigationFab.visibility = View.GONE
|
||||
selectShelter(swd)
|
||||
}
|
||||
|
||||
/** Recalculate distance/bearing for the currently selected shelter. */
|
||||
private fun refreshSelectedShelterDistance(location: Location) {
|
||||
val current = selectedShelter ?: return
|
||||
selectedShelter = ShelterWithDistance(
|
||||
shelter = current.shelter,
|
||||
distanceMeters = DistanceUtils.distanceMeters(
|
||||
location.latitude, location.longitude,
|
||||
current.shelter.latitude, current.shelter.longitude
|
||||
),
|
||||
bearingDegrees = DistanceUtils.bearingDegrees(
|
||||
location.latitude, location.longitude,
|
||||
current.shelter.latitude, current.shelter.longitude
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Update all UI elements for the currently selected shelter. */
|
||||
private fun updateSelectedShelterUI() {
|
||||
val selected = selectedShelter ?: return
|
||||
val distanceText = DistanceUtils.formatDistance(selected.distanceMeters)
|
||||
|
||||
// Update bottom sheet
|
||||
|
|
@ -260,28 +366,61 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
binding.compassAddressText.text = selected.shelter.adresse
|
||||
binding.directionArrow.setDirection(bearing - deviceHeading)
|
||||
|
||||
// Center map on shelter if in map mode
|
||||
if (!isCompassMode) {
|
||||
// Emphasize the selected marker on the map
|
||||
highlightSelectedMarker(selected.shelter.lokalId)
|
||||
|
||||
// Only auto-zoom the map if the user hasn't manually panned/zoomed
|
||||
if (!isCompassMode && !userHasInteractedWithMap) {
|
||||
highlightShelterOnMap(selected)
|
||||
}
|
||||
}
|
||||
|
||||
/** Swap marker icons so the selected shelter stands out. */
|
||||
private fun highlightSelectedMarker(lokalId: String) {
|
||||
if (lokalId == highlightedMarkerId) return
|
||||
|
||||
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
|
||||
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
|
||||
|
||||
// Reset previous
|
||||
highlightedMarkerId?.let { prevId ->
|
||||
shelterMarkerMap[prevId]?.icon = normalIcon
|
||||
}
|
||||
|
||||
// Highlight new
|
||||
shelterMarkerMap[lokalId]?.icon = selectedIcon
|
||||
highlightedMarkerId = lokalId
|
||||
|
||||
binding.mapView.invalidate()
|
||||
}
|
||||
|
||||
private fun updateShelterMarkers() {
|
||||
// Remove old markers
|
||||
shelterMarkers.forEach { binding.mapView.overlays.remove(it) }
|
||||
shelterMarkers.clear()
|
||||
shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) }
|
||||
shelterMarkerMap.clear()
|
||||
highlightedMarkerId = null
|
||||
|
||||
// Add markers for all shelters
|
||||
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
|
||||
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
|
||||
val currentSelectedId = selectedShelter?.shelter?.lokalId
|
||||
|
||||
// Add markers for all shelters — tapping any marker selects it
|
||||
allShelters.forEach { shelter ->
|
||||
val isSelected = shelter.lokalId == currentSelectedId
|
||||
val marker = Marker(binding.mapView).apply {
|
||||
position = GeoPoint(shelter.latitude, shelter.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
title = shelter.adresse
|
||||
snippet = getString(R.string.shelter_capacity, shelter.plasser) +
|
||||
" - " + getString(R.string.shelter_room_nr, shelter.romnr)
|
||||
icon = ContextCompat.getDrawable(this@MainActivity, R.drawable.ic_shelter)
|
||||
icon = if (isSelected) selectedIcon else normalIcon
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
selectShelterByData(shelter)
|
||||
true
|
||||
}
|
||||
}
|
||||
shelterMarkers.add(marker)
|
||||
if (isSelected) highlightedMarkerId = shelter.lokalId
|
||||
shelterMarkerMap[shelter.lokalId] = marker
|
||||
binding.mapView.overlays.add(marker)
|
||||
}
|
||||
|
||||
|
|
@ -305,18 +444,42 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
private fun cacheMapTiles(latitude: Double, longitude: Double) {
|
||||
lifecycleScope.launch {
|
||||
binding.statusText.text = getString(R.string.status_caching_map)
|
||||
// Show prompt with OK / Skip choice
|
||||
showLoading(getString(R.string.loading_map_explanation))
|
||||
binding.loadingProgress.visibility = View.GONE
|
||||
binding.loadingButtonRow.visibility = View.VISIBLE
|
||||
|
||||
binding.loadingOkButton.setOnClickListener {
|
||||
startCaching(latitude, longitude)
|
||||
}
|
||||
|
||||
binding.loadingSkipButton.setOnClickListener {
|
||||
hideLoading()
|
||||
showNoCacheBanner()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCaching(latitude: Double, longitude: Double) {
|
||||
binding.noCacheBanner.visibility = View.GONE
|
||||
showLoading(getString(R.string.loading_map))
|
||||
binding.loadingButtonRow.visibility = View.GONE
|
||||
|
||||
cachingJob = lifecycleScope.launch {
|
||||
mapCacheManager.cacheMapArea(
|
||||
binding.mapView, latitude, longitude
|
||||
) { progress ->
|
||||
binding.statusText.text = getString(R.string.status_caching_map) +
|
||||
binding.loadingText.text = getString(R.string.loading_map) +
|
||||
" (${(progress * 100).toInt()}%)"
|
||||
}
|
||||
hideLoading()
|
||||
binding.statusText.text = getString(R.string.status_shelters_loaded, allShelters.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNoCacheBanner() {
|
||||
binding.noCacheBanner.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun forceRefresh() {
|
||||
if (!isNetworkAvailable()) {
|
||||
Toast.makeText(this, R.string.error_download_failed, Toast.LENGTH_SHORT).show()
|
||||
|
|
@ -337,6 +500,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
private fun showLoading(message: String) {
|
||||
binding.loadingOverlay.visibility = View.VISIBLE
|
||||
binding.loadingText.text = message
|
||||
binding.loadingProgress.visibility = View.VISIBLE
|
||||
binding.loadingButtonRow.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
|
|
@ -413,8 +578,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
|
|||
}
|
||||
|
||||
private fun updateDirectionArrows() {
|
||||
if (nearestShelters.isEmpty()) return
|
||||
val selected = nearestShelters[selectedShelterIndex]
|
||||
val selected = selectedShelter ?: return
|
||||
val bearing = selected.bearingDegrees.toFloat()
|
||||
val arrowAngle = bearing - deviceHeading
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,20 @@ package no.naiv.tilfluktsrom.data
|
|||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.osmdroid.tileprovider.cachemanager.CacheManager
|
||||
import org.osmdroid.tileprovider.modules.SqlTileWriter
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
|
||||
/**
|
||||
* Manages offline map tile caching for the surrounding area.
|
||||
*
|
||||
* On first launch, downloads map tiles for a region around the user's location.
|
||||
* On subsequent launches, checks if the current location is within the cached area.
|
||||
* OSMDroid's SqlTileWriter automatically caches every tile the MapView loads.
|
||||
* We exploit this by programmatically panning the map across the surrounding area
|
||||
* at multiple zoom levels, which causes tiles to be fetched and cached passively.
|
||||
*
|
||||
* This approach respects OSM's tile usage policy (no bulk download) while still
|
||||
* building up an offline cache for the user's area.
|
||||
*/
|
||||
class MapCacheManager(private val context: Context) {
|
||||
|
||||
|
|
@ -26,10 +28,13 @@ class MapCacheManager(private val context: Context) {
|
|||
private const val KEY_CACHE_RADIUS = "cache_radius_km"
|
||||
private const val KEY_CACHE_COMPLETE = "cache_complete"
|
||||
|
||||
// Cache tiles for ~15km radius at useful zoom levels
|
||||
private const val CACHE_RADIUS_DEGREES = 0.15 // ~15km
|
||||
private const val MIN_ZOOM = 10
|
||||
private const val MAX_ZOOM = 16
|
||||
|
||||
// Zoom levels to cache: overview down to street level
|
||||
private val CACHE_ZOOM_LEVELS = intArrayOf(10, 12, 14, 16)
|
||||
|
||||
// Grid points per axis per zoom level for panning
|
||||
private const val GRID_SIZE = 3
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
|
@ -46,14 +51,15 @@ class MapCacheManager(private val context: Context) {
|
|||
|
||||
if (radius == 0.0) return false
|
||||
|
||||
// Check if current location is within the cached region (with some margin)
|
||||
val margin = radius * 0.3
|
||||
return Math.abs(latitude - cachedLat) < (radius - margin) &&
|
||||
Math.abs(longitude - cachedLon) < (radius - margin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download map tiles for the area around the given location.
|
||||
* Seed the tile cache by panning the map across the area around the location.
|
||||
* OSMDroid's built-in SqlTileWriter caches each tile as it's loaded.
|
||||
*
|
||||
* Reports progress via callback (0.0 to 1.0).
|
||||
*/
|
||||
suspend fun cacheMapArea(
|
||||
|
|
@ -63,93 +69,51 @@ class MapCacheManager(private val context: Context) {
|
|||
onProgress: (Float) -> Unit = {}
|
||||
): Boolean = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
Log.i(TAG, "Starting map tile cache for area around $latitude, $longitude")
|
||||
Log.i(TAG, "Seeding tile cache for area around $latitude, $longitude")
|
||||
|
||||
val boundingBox = BoundingBox(
|
||||
latitude + CACHE_RADIUS_DEGREES,
|
||||
longitude + CACHE_RADIUS_DEGREES,
|
||||
latitude - CACHE_RADIUS_DEGREES,
|
||||
longitude - CACHE_RADIUS_DEGREES
|
||||
)
|
||||
val totalSteps = CACHE_ZOOM_LEVELS.size * GRID_SIZE * GRID_SIZE
|
||||
var step = 0
|
||||
|
||||
val cacheManager = CacheManager(mapView)
|
||||
var complete = false
|
||||
var success = false
|
||||
for (zoom in CACHE_ZOOM_LEVELS) {
|
||||
mapView.controller.setZoom(zoom.toDouble())
|
||||
|
||||
cacheManager.downloadAreaAsync(
|
||||
context,
|
||||
boundingBox,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
object : CacheManager.CacheManagerCallback {
|
||||
override fun onTaskComplete() {
|
||||
Log.i(TAG, "Map cache download complete")
|
||||
success = true
|
||||
complete = true
|
||||
// Pan across a grid of points covering the area
|
||||
for (row in 0 until GRID_SIZE) {
|
||||
for (col in 0 until GRID_SIZE) {
|
||||
val lat = latitude - CACHE_RADIUS_DEGREES +
|
||||
(2 * CACHE_RADIUS_DEGREES * row) / (GRID_SIZE - 1)
|
||||
val lon = longitude - CACHE_RADIUS_DEGREES +
|
||||
(2 * CACHE_RADIUS_DEGREES * col) / (GRID_SIZE - 1)
|
||||
|
||||
mapView.controller.setCenter(GeoPoint(lat, lon))
|
||||
// Force a layout pass so tiles are requested
|
||||
mapView.invalidate()
|
||||
|
||||
step++
|
||||
onProgress(step.toFloat() / totalSteps)
|
||||
|
||||
// Brief delay to allow tile loading to start
|
||||
delay(300)
|
||||
}
|
||||
|
||||
override fun onTaskFailed(errors: Int) {
|
||||
Log.w(TAG, "Map cache download completed with $errors errors")
|
||||
// Consider partial success if most tiles downloaded
|
||||
success = errors < 50
|
||||
complete = true
|
||||
}
|
||||
|
||||
override fun updateProgress(
|
||||
progress: Int,
|
||||
currentZoomLevel: Int,
|
||||
zoomMin: Int,
|
||||
zoomMax: Int
|
||||
) {
|
||||
val totalZoomLevels = zoomMax - zoomMin + 1
|
||||
val zoomProgress = (currentZoomLevel - zoomMin).toFloat() / totalZoomLevels
|
||||
onProgress(zoomProgress + (progress / 100f) / totalZoomLevels)
|
||||
}
|
||||
|
||||
override fun downloadStarted() {
|
||||
Log.i(TAG, "Map cache download started")
|
||||
}
|
||||
|
||||
override fun setPossibleTilesInArea(total: Int) {
|
||||
Log.i(TAG, "Total tiles to download: $total")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Wait for completion (the async callback runs on main thread)
|
||||
withContext(Dispatchers.IO) {
|
||||
while (!complete) {
|
||||
Thread.sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
prefs.edit()
|
||||
.putLong(KEY_CACHED_LAT, latitude.toBits())
|
||||
.putLong(KEY_CACHED_LON, longitude.toBits())
|
||||
.putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat())
|
||||
.putBoolean(KEY_CACHE_COMPLETE, true)
|
||||
.apply()
|
||||
}
|
||||
// Restore to user's location
|
||||
mapView.controller.setZoom(14.0)
|
||||
mapView.controller.setCenter(GeoPoint(latitude, longitude))
|
||||
|
||||
success
|
||||
prefs.edit()
|
||||
.putLong(KEY_CACHED_LAT, latitude.toBits())
|
||||
.putLong(KEY_CACHED_LON, longitude.toBits())
|
||||
.putFloat(KEY_CACHE_RADIUS, CACHE_RADIUS_DEGREES.toFloat())
|
||||
.putBoolean(KEY_CACHE_COMPLETE, true)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Tile cache seeding complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cache map tiles", e)
|
||||
Log.e(TAG, "Failed to seed tile cache", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the approximate number of cached tiles.
|
||||
*/
|
||||
fun getCachedTileCount(): Long {
|
||||
return try {
|
||||
val writer = SqlTileWriter()
|
||||
val count = writer.getRowCount(null)
|
||||
writer.onDetach()
|
||||
count
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue