mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
feat(logbook): Room persistence, UP/QSL markers, one-click upload, swipe-delete, calculator-frequency logging
This commit is contained in:
1 parent
3be2e4b9e8
commit
a769f7dea7
18 files changed
+454
-50
No files matched your search
@@ -44,4 +44,7 @@ interface QsoDao {
|
||||
|
||||
@Query("DELETE FROM qso_records WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,14 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 1, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
|
||||
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
|
||||
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ data class QsoEntity(
|
||||
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
|
||||
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -96,7 +97,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB").build()
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
||||
|
||||
@@ -57,6 +57,11 @@ class QsoRepository(
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
val records = dao.getAll()
|
||||
.asSequence()
|
||||
@@ -143,6 +148,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
@@ -178,6 +184,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
dedupeKey = stableQsoKey(this),
|
||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
|
||||
@@ -17,6 +17,7 @@ interface IQsoRepository {
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
@@ -35,6 +35,7 @@ data class QsoRecord(
|
||||
val status: QsoStatus = QsoStatus.DRAFT,
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
val lotwUploaded: Boolean = false,
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
|
||||
+150
@@ -17,9 +17,15 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MarqueeSpacing
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -447,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
fun Modifier.infiniteMarquee(): Modifier =
|
||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||
|
||||
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
|
||||
class SwipeController {
|
||||
var openKey by mutableStateOf<String?>(null)
|
||||
private set
|
||||
fun open(key: String) {
|
||||
openKey = key
|
||||
}
|
||||
fun close() {
|
||||
openKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
|
||||
|
||||
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
|
||||
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
|
||||
@Composable
|
||||
fun SwipeRevealRow(
|
||||
key: String,
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.error),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val revealPx = with(density) { revealWidth.toPx() }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val isOpen = controller.openKey == key
|
||||
|
||||
fun settle(target: Float) {
|
||||
settleJob?.cancel()
|
||||
settleJob = scope.launch {
|
||||
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
|
||||
offsetX = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 另一行展开(或全部收回)时,本行随之收回。
|
||||
LaunchedEffect(controller.openKey) {
|
||||
if (!isOpen && offsetX < 0f) settle(0f)
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxWidth().clipToBounds()) {
|
||||
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
|
||||
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
|
||||
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(revealWidth)
|
||||
.offset(x = revealWidth)
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.clickable {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
controller.close()
|
||||
settle(0f)
|
||||
revealAction()
|
||||
}
|
||||
) { revealButton() }
|
||||
// 前景内容:右划平移露出按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
if (isOpen) {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.layoutPadding(): Modifier {
|
||||
val spacing = LocalSpacing.current.extraSmall
|
||||
|
||||
Reference in new issue
Block a user