Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ddcfcf40 authored by Ellen Poe's avatar Ellen Poe
Browse files

Merge branch 'ellenhp/recent_searches' into 'main'

Display recent searches in search screen instead of saved POIs

Closes #2

See merge request e/os/cardinal!4
parents d63856d5 dc124d1d
Loading
Loading
Loading
Loading
+28 −2
Original line number Diff line number Diff line
@@ -28,8 +28,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import earth.maps.cardinal.data.DownloadStatusConverter

@Database(
    entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class],
    version = 10,
    entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class],
    version = 11,
    exportSchema = false
)
@TypeConverters(TileTypeConverter::class, DownloadStatusConverter::class, ItemTypeConverter::class)
@@ -40,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() {
    abstract fun savedListDao(): SavedListDao
    abstract fun savedPlaceDao(): SavedPlaceDao
    abstract fun listItemDao(): ListItemDao
    abstract fun recentSearchDao(): RecentSearchDao

    companion object {
        @Volatile
@@ -190,6 +191,30 @@ abstract class AppDatabase : RoomDatabase() {
            }
        }

        private val MIGRATION_10_11 = object : Migration(10, 11) {
            override fun migrate(db: SupportSQLiteDatabase) {
                db.execSQL(
                    """
                    CREATE TABLE IF NOT EXISTS recent_searches (
                        id TEXT PRIMARY KEY NOT NULL,
                        name TEXT NOT NULL,
                        description TEXT NOT NULL,
                        icon TEXT NOT NULL,
                        latitude REAL NOT NULL,
                        longitude REAL NOT NULL,
                        houseNumber TEXT,
                        road TEXT,
                        city TEXT,
                        state TEXT,
                        postcode TEXT,
                        country TEXT,
                        countryCode TEXT,
                        tappedAt INTEGER NOT NULL
                    )
                """.trimIndent()
                )
            }
        }

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
@@ -204,6 +229,7 @@ abstract class AppDatabase : RoomDatabase() {
                    MIGRATION_7_8,
                    MIGRATION_8_9,
                    MIGRATION_9_10,
                    MIGRATION_10_11,
                ).build()
                INSTANCE = instance
                instance
+66 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.room

import androidx.room.Entity
import androidx.room.PrimaryKey
import earth.maps.cardinal.data.Place
import java.util.UUID

@Entity(tableName = "recent_searches")
data class RecentSearch(
    @PrimaryKey val id: String,  // UUID string
    val name: String,
    val description: String,
    val icon: String,
    val latitude: Double,
    val longitude: Double,
    // Address fields
    val houseNumber: String? = null,
    val road: String? = null,
    val city: String? = null,
    val state: String? = null,
    val postcode: String? = null,
    val country: String? = null,
    val countryCode: String? = null,
    val tappedAt: Long
) {
    companion object {
        fun fromPlace(place: Place): RecentSearch {
            val timestamp = System.currentTimeMillis()

            return RecentSearch(
                id = UUID.randomUUID().toString(),
                name = place.name,
                description = place.description,
                icon = place.icon,
                latitude = place.latLng.latitude,
                longitude = place.latLng.longitude,
                houseNumber = place.address?.houseNumber,
                road = place.address?.road,
                city = place.address?.city,
                state = place.address?.state,
                postcode = place.address?.postcode,
                country = place.address?.country,
                countryCode = place.address?.countryCode,
                tappedAt = timestamp,
            )
        }
    }
}
+43 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.room

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface RecentSearchDao {
    @Query("SELECT * FROM recent_searches ORDER BY tappedAt DESC")
    fun getRecentSearches(): Flow<List<RecentSearch>>

    @Insert
    suspend fun insertSearch(search: RecentSearch)

    @Delete
    suspend fun deleteSearch(search: RecentSearch)

    @Query("DELETE FROM recent_searches WHERE id NOT IN (SELECT id FROM recent_searches ORDER BY tappedAt DESC LIMIT :keepCount)")
    suspend fun deleteOldSearches(keepCount: Int)

    @Query("DELETE FROM recent_searches")
    suspend fun clearAllSearches()
}
+107 −0
Original line number Diff line number Diff line
/*
 *     Cardinal Maps
 *     Copyright (C) 2025 Cardinal Maps Authors
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU General Public License for more details.
 *
 *     You should have received a copy of the GNU General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package earth.maps.cardinal.data.room

import earth.maps.cardinal.data.Address
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.Place
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class RecentSearchRepository @Inject constructor(
    database: AppDatabase,
) {
    private val searchDao = database.recentSearchDao()

    companion object {
        const val MAX_RECENT_SEARCHES = 20
    }

    /**
     * Adds a recent search and ensures we don't exceed the maximum count.
     */
    suspend fun addRecentSearch(place: Place): Result<Unit> = withContext(Dispatchers.IO) {
        try {
            val recentSearch = RecentSearch.fromPlace(place)
            searchDao.insertSearch(recentSearch)
            searchDao.deleteOldSearches(MAX_RECENT_SEARCHES)
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    /**
     * Converts a RecentSearch back to a Place for UI consumption.
     */
    fun toPlace(recentSearch: RecentSearch): Place {
        return Place(
            id = recentSearch.id,
            name = recentSearch.name,
            description = recentSearch.description,
            icon = recentSearch.icon,
            latLng = LatLng(
                latitude = recentSearch.latitude, longitude = recentSearch.longitude
            ),
            address = if (recentSearch.houseNumber != null || recentSearch.road != null || recentSearch.city != null || recentSearch.state != null || recentSearch.postcode != null || recentSearch.country != null || recentSearch.countryCode != null) {
                Address(
                    houseNumber = recentSearch.houseNumber,
                    road = recentSearch.road,
                    city = recentSearch.city,
                    state = recentSearch.state,
                    postcode = recentSearch.postcode,
                    country = recentSearch.country,
                    countryCode = recentSearch.countryCode
                )
            } else {
                null
            }
        )
    }

    /**
     * Gets recent searches with an optional limit (defaults to 10 for UI display).
     */
    fun getRecentSearches(limit: Int = 10): Flow<List<RecentSearch>> {
        return searchDao.getRecentSearches().map { list ->
            list.distinctBy { it.copy(id = "", tappedAt = 0) }.take(limit)
        }
    }

    /**
     * Remove a a RecentSearch from the database, along with all duplicates that may have different IDs or timestamps.
     */
    suspend fun removeRecentSearch(searchToDelete: RecentSearch) {
        searchDao.deleteSearch(searchToDelete)

        // A subtle point: There may be duplicates filtered out by the
        // distinctBy logic above, and they should be removed too.
        searchDao.getRecentSearches().firstOrNull()?.filter {
            it.copy(id = "", tappedAt = 0) == searchToDelete.copy(id = "", tappedAt = 0)
        }?.forEach {
            searchDao.deleteSearch(it)
        }
    }
}
+6 −0
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ import earth.maps.cardinal.data.room.AppDatabase
import earth.maps.cardinal.data.room.DownloadedTileDao
import earth.maps.cardinal.data.room.ListItemDao
import earth.maps.cardinal.data.room.OfflineAreaDao
import earth.maps.cardinal.data.room.RecentSearchDao
import earth.maps.cardinal.data.room.SavedListDao
import earth.maps.cardinal.data.room.SavedPlaceDao
import javax.inject.Singleton
@@ -66,4 +67,9 @@ object DatabaseModule {
    fun provideListItemDao(appDatabase: AppDatabase): ListItemDao {
        return appDatabase.listItemDao()
    }

    @Provides
    fun provideRecentSearchDao(appDatabase: AppDatabase): RecentSearchDao {
        return appDatabase.recentSearchDao()
    }
}
Loading