diff --git a/build.gradle.kts b/build.gradle.kts index 0672c48..8dc0c42 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,7 @@ repositories { } dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(compose.desktop.currentOs) api(compose.foundation) api(compose.animation) @@ -22,6 +23,7 @@ dependencies { implementation("org.jetbrains.compose.material3:material3-desktop:1.6.11") implementation("dev.icerock.moko:mvvm-livedata-compose:0.16.1") implementation("com.mikepenz:multiplatform-markdown-renderer:0.8.0") + implementation("org.slf4j:slf4j-log4j12:2.0.9") } compose.desktop { @@ -30,6 +32,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + modules("java.sql") packageName = "LibreSpeed" packageVersion = "1.1.0" val iconsRoot = project.file("src/main/resources") diff --git a/libs/sqlite-jdbc-3.46.1.3.jar b/libs/sqlite-jdbc-3.46.1.3.jar new file mode 100644 index 0000000..c2068c0 Binary files /dev/null and b/libs/sqlite-jdbc-3.46.1.3.jar differ diff --git a/src/main/kotlin/LibreSpeed.kt b/src/main/kotlin/LibreSpeed.kt index a769a2c..83f9289 100644 --- a/src/main/kotlin/LibreSpeed.kt +++ b/src/main/kotlin/LibreSpeed.kt @@ -2,7 +2,9 @@ import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.platform.LocalDensity @@ -12,15 +14,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application +import core.Database import core.Service import moe.tlaster.precompose.PreComposeApp import moe.tlaster.precompose.navigation.NavHost import moe.tlaster.precompose.navigation.rememberNavigator import moe.tlaster.precompose.navigation.transition.NavTransition import routes.Route +import routes.scenes.HistoryScene import routes.scenes.HomeScene import routes.scenes.SplashScene import theme.AppRippleTheme +import theme.ColorBox import theme.Fonts import java.awt.Dimension @@ -36,6 +41,7 @@ fun App() { ) { CompositionLocalProvider(LocalRippleTheme provides AppRippleTheme) { NavHost( + modifier = Modifier.background(ColorBox.primaryDark), navigator = navigator, navTransition = NavTransition(), initialRoute = Route.SPLASH, @@ -52,6 +58,12 @@ fun App() { ) { HomeScene(navigator) } + scene( + route = Route.HISTORY, + navTransition = NavTransition() + ) { + HistoryScene(navigator) + } } } } @@ -60,6 +72,9 @@ fun App() { } fun main() = application { + LaunchedEffect(Unit) { + Database.initDB() + } Window( onCloseRequest = ::exitApplication, resizable = true, diff --git a/src/main/kotlin/components/TableView.kt b/src/main/kotlin/components/TableView.kt new file mode 100644 index 0000000..f29ce34 --- /dev/null +++ b/src/main/kotlin/components/TableView.kt @@ -0,0 +1,106 @@ +package components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import theme.ColorBox +import theme.Fonts + +@Composable +fun TableView( + tableRows: List, + columnCount : Int, + modifier: Modifier, + itemContent: @Composable (column: Int,row : Int) -> String +) { + + val scrollState = rememberLazyListState() + + Column(modifier = modifier) { + TableItem( + modifier = Modifier.fillMaxWidth().height(64.dp).background(ColorBox.primary.copy(0.2f)).padding(start = 20.dp, end = 20.dp), + textStyle = MaterialTheme.typography.labelSmall.copy(fontFamily = Fonts.open_sans), + textColor = ColorBox.text, + list = tableRows.map { Triple(it.title,it.weight,it.textAlign) } + ) + Box(Modifier.fillMaxWidth()) { + LazyColumn(Modifier.fillMaxWidth(), state = scrollState) { + items(columnCount) { column -> + val bg = if (column % 2 == 0) ColorBox.text.copy(0.03f) else Color.Transparent + TableItem( + modifier = Modifier.fillMaxWidth().height(58.dp).background(bg).padding(start = 20.dp, end = 20.dp), + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = Fonts.open_sans), + textColor = ColorBox.text, + list = tableRows.mapIndexed { index, tableItemRow -> Triple(itemContent(column,index),tableItemRow.weight,tableItemRow.textAlign) } + ) + } + } + if (scrollState.canScrollForward || scrollState.canScrollBackward) { + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + style = LocalScrollbarStyle.current + .copy(thickness = 5.dp, hoverColor = ColorBox.text.copy(0.6f), unhoverColor = ColorBox.text.copy(0.1f)), + adapter = rememberScrollbarAdapter(scrollState) + ) + } + } + } + +} + +data class TableItemRow ( + var weight : Float, + var title : String, + var textAlign: TextAlign +) + +@Composable +private fun TableItem (modifier: Modifier,textColor: Color,textStyle : TextStyle,list: List>) { + val weights = remember { mutableStateListOf() } + var totalWeight by remember { mutableStateOf(0f) } + val textMeasurer = rememberTextMeasurer() + LaunchedEffect(list) { + weights.clear() + totalWeight = 0f + weights.addAll(list.map { + totalWeight += it.second + it.second + }) + } + Canvas(modifier) { + if (weights.isNotEmpty()) { + var currentX = 0f + for (i in list.indices) { + val childWidth = (size.width * (weights[i] / totalWeight)) + val textLayoutResult = textMeasurer.measure( + text = list[i].first, + style = textStyle.copy(textAlign = list[i].third), + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Clip, + constraints = Constraints(0,childWidth.toInt(),0,size.height.toInt()) + ) + drawText( + textLayoutResult = textLayoutResult, + topLeft = Offset(currentX,size.height / 2 - textLayoutResult.size.height / 2), + color = textColor + ) + currentX += childWidth + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/core/Database.kt b/src/main/kotlin/core/Database.kt new file mode 100644 index 0000000..02ec949 --- /dev/null +++ b/src/main/kotlin/core/Database.kt @@ -0,0 +1,100 @@ +package core + +import java.io.File +import java.nio.file.Paths +import java.sql.Connection +import java.sql.DriverManager +import java.util.* + +object Database { + + private const val APP_NAME: String = "librespeed-desktop" + private fun getDatabasePath(): String { + val osName = System.getProperty("os.name").lowercase(Locale.getDefault()) + return if (osName.contains("linux")) { + Paths.get(System.getProperty("user.home"), ".local", "share", APP_NAME).toString() + } else if (osName.contains("windows")) { + System.getProperty("user.home") + File.separator + "AppData" + File.separator + "Roaming" + File.separator + APP_NAME + } else if (osName.contains("mac")) { + Paths.get(System.getProperty("user.home"), "Library", "Application Support", APP_NAME).toString() + } else { + throw UnsupportedOperationException("Unsupported operating system") + } + } + + private lateinit var connection: Connection + fun initDB() { + File(getDatabasePath()).mkdirs() + val dbFile = File(getDatabasePath(), "librespeed.db") + Class.forName("org.sqlite.JDBC") + connection = DriverManager.getConnection("jdbc:sqlite:$dbFile") + createTables() + } + + private fun createTables() { + val statement = connection.createStatement() + try { + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS history (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "netAdapter TEXT," + + "ping REAL," + + "jitter REAL," + + "download REAL," + + "upload REAL," + + "ispInfo TEXT," + + "testPoint TEXT," + + "date INTEGER" + + ")" + ) + } catch (_: Exception) { + } finally { + statement.close() + } + } + fun saveHistory(model: ModelHistory) { + val statement = connection.prepareStatement("INSERT INTO history (netAdapter,ping,jitter,download,upload,ispInfo,testPoint,date) VALUES (?,?,?,?,?,?,?,?)") + try { + statement.setString(1, model.netAdapter) + statement.setDouble(2, model.ping) + statement.setDouble(3, model.jitter) + statement.setDouble(4, model.download) + statement.setDouble(5, model.upload) + statement.setString(6, model.ispInfo) + statement.setString(7, model.testPoint) + statement.setLong(8, model.date) + statement.executeUpdate() + } catch (_ : Exception) {} finally { + statement.close() + } + } + fun readHistory() : MutableList { + val statement = connection.prepareStatement("SELECT * FROM history ORDER BY id DESC") + val resultSet = statement.executeQuery() + val result = mutableListOf() + while (resultSet.next()) { + result.add( + ModelHistory( + id = resultSet.getInt("id"), + netAdapter = resultSet.getString("netAdapter"), + ping = resultSet.getDouble("ping"), + jitter = resultSet.getDouble("jitter"), + download = resultSet.getDouble("download"), + upload = resultSet.getDouble("upload"), + ispInfo = resultSet.getString("ispInfo"), + testPoint = resultSet.getString("testPoint"), + date = resultSet.getLong("date")) + ) + } + return result + } + fun clearHistory() { + val statement = connection.createStatement() + try { + statement.executeUpdate("DELETE FROM history") + } catch (_ : Exception) {} finally { + statement.close() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/core/ModelHistory.kt b/src/main/kotlin/core/ModelHistory.kt new file mode 100644 index 0000000..7fed846 --- /dev/null +++ b/src/main/kotlin/core/ModelHistory.kt @@ -0,0 +1,13 @@ +package core + +data class ModelHistory( + var id : Int = 0, + var netAdapter : String, + var ping : Double, + var jitter : Double, + var download : Double, + var upload : Double, + var ispInfo : String, + var testPoint : String, + var date : Long +) diff --git a/src/main/kotlin/core/Service.kt b/src/main/kotlin/core/Service.kt index 8fd3f65..94f764a 100644 --- a/src/main/kotlin/core/Service.kt +++ b/src/main/kotlin/core/Service.kt @@ -7,6 +7,7 @@ import dev.icerock.moko.mvvm.livedata.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import util.NetUtils import util.Utils.roundPlace import util.Utils.toMegabyte import util.Utils.validate @@ -19,6 +20,7 @@ object Service { const val UNIT_MBIT = "mbps" val unitSetting = MutableLiveData(UNIT_MBIT) + val networkAdapter = MutableLiveData("") val currentStep = MutableLiveData("") val currentCalValue = MutableLiveData(0.0) @@ -103,6 +105,12 @@ object Service { } else { running.value = true CoroutineScope(Dispatchers.IO).launch { + val netInterface = NetUtils.getDefaultNetworkInterface() + if (netInterface != null) { + networkAdapter.value = "${netInterface.name} (${NetUtils.parseMacAddress(netInterface.hardwareAddress)})" + } else { + networkAdapter.value = "Unknown" + } speedTestHandler.startTest(this@Service.testPoint.value,object : LibreSpeed.SpeedtestHandler() { override fun onDownloadUpdate(dl: Double, progress: Double) { currentStep.value = "DOWNLOAD" @@ -150,7 +158,21 @@ object Service { } override fun onEnd() { currentStep.value = "ENDED" - if (!running.value) reset() else goToResult.invoke() + if (!running.value) reset() else { + goToResult.invoke() + Database.saveHistory( + ModelHistory( + netAdapter = networkAdapter.value, + ping = ping.value.toDouble(), + jitter = jitter.value.toDouble(), + download = download.value, + upload = upload.value, + ispInfo = ipInfo.value, + testPoint = testPoint.value?.name.toString(), + date = System.currentTimeMillis() + ) + ) + } running.value = false } override fun onCriticalFailure(err: String?) { diff --git a/src/main/kotlin/routes/Route.kt b/src/main/kotlin/routes/Route.kt index fbd1adc..9ff9884 100644 --- a/src/main/kotlin/routes/Route.kt +++ b/src/main/kotlin/routes/Route.kt @@ -4,5 +4,6 @@ object Route { const val SPLASH = "splash" const val HOME = "home" + const val HISTORY = "history" } \ No newline at end of file diff --git a/src/main/kotlin/routes/dialogs/DialogDelete.kt b/src/main/kotlin/routes/dialogs/DialogDelete.kt new file mode 100644 index 0000000..49c4f2f --- /dev/null +++ b/src/main/kotlin/routes/dialogs/DialogDelete.kt @@ -0,0 +1,61 @@ +package routes.dialogs + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import components.SimpleButton +import theme.ColorBox + +@Composable +fun DialogDelete( + title : String, + description : String, + show : Boolean, + onDismiss: () -> Unit, + onOk: () -> Unit, +) { + + BaseDialog( + expanded = show, + onDismissRequest = onDismiss + ) { + Column(modifier = Modifier.width(350.dp),horizontalAlignment = Alignment.CenterHorizontally) { + Text( + modifier = Modifier.padding(20.dp), + text = title, + color = ColorBox.text, + style = MaterialTheme.typography.titleLarge + ) + Text( + modifier = Modifier.padding(start = 20.dp, bottom = 16.dp, end = 20.dp), + text = description, + color = ColorBox.text.copy(0.7f), + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center), + ) + Row(modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp).fillMaxWidth()) { + SimpleButton( + modifier = Modifier.weight(1f).padding(end = 10.dp), + text = "Close", + onClick = { + onDismiss.invoke() + } + ) + SimpleButton( + modifier = Modifier.weight(1f).padding(start = 10.dp), + backgroundColor = ColorBox.error.copy(0.1f), + textColor = ColorBox.error, + text = "Clear", + onClick = { + onOk.invoke() + } + ) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/routes/scenes/HistoryScene.kt b/src/main/kotlin/routes/scenes/HistoryScene.kt new file mode 100644 index 0000000..8abb566 --- /dev/null +++ b/src/main/kotlin/routes/scenes/HistoryScene.kt @@ -0,0 +1,186 @@ +package routes.scenes + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import components.MyIconButton +import components.SwitchUnit +import components.TableItemRow +import components.TableView +import core.Database +import core.ModelHistory +import core.Service +import core.Service.toValidString +import dev.icerock.moko.mvvm.livedata.compose.observeAsState +import moe.tlaster.precompose.navigation.Navigator +import routes.dialogs.DialogDelete +import theme.ColorBox +import theme.Fonts +import util.Utils +import util.Utils.formatToDate +import util.Utils.suffixItems + +@Composable +fun HistoryScene(navigator: Navigator) { + + val historyList = remember { SnapshotStateList() } + val unitSetting = Service.unitSetting.observeAsState() + + var showClearDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + historyList.clear() + historyList.addAll(Database.readHistory()) + } + + Box(modifier = Modifier.fillMaxSize().background(ColorBox.primaryDark), contentAlignment = Alignment.Center) { + Column(modifier = Modifier.widthIn(max = 1200.dp).fillMaxSize().background(ColorBox.primaryDark)) { + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp).fillMaxWidth().height(72.dp), verticalAlignment = Alignment.CenterVertically) { + MyIconButton( + padding = PaddingValues(start = 6.dp), + icon = "icons/arrow-left.svg", + onClick = { + navigator.goBack() + } + ) + Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) { + Text( + text = "History", + color = ColorBox.text, + style = MaterialTheme.typography.titleLarge.copy(fontFamily = Fonts.open_sans) + ) + Text( + text = historyList.size.suffixItems(), + color = ColorBox.text.copy(0.7f), + style = MaterialTheme.typography.labelMedium.copy(fontFamily = Fonts.open_sans) + ) + } + MyIconButton( + icon = "icons/export.svg", + enabled = historyList.isNotEmpty(), + onClick = { + Utils.exportHistoryToCSV(historyList) + } + ) + MyIconButton( + padding = PaddingValues(end = 8.dp), + icon = "icons/trash.svg", + enabled = historyList.isNotEmpty(), + onClick = { + showClearDialog = true + } + ) + SwitchUnit( + modifier = Modifier.padding(end = 16.dp), + isMbps = unitSetting.value == Service.UNIT_MBIT, + onClicked = { + Service.unitSetting.value = if (it == 1) Service.UNIT_MBYTE else Service.UNIT_MBIT + } + ) + } + if (historyList.isEmpty()) { + val emptyIcon = painterResource("icons/history.svg") + val emptyText = rememberTextMeasurer().measure("History is empty !", style = MaterialTheme.typography.headlineSmall.copy(fontFamily = Fonts.open_sans)) + Canvas(Modifier.fillMaxSize()) { + translate(size.width / 2 - 55f.dp.toPx(), size.height / 2 - 75f.dp.toPx() - emptyText.size.height) { + with(emptyIcon) { + draw(Size(110f.dp.toPx(), 110f.dp.toPx()), colorFilter = ColorFilter.tint(ColorBox.text.copy(0.7f))) + } + } + drawText( + textLayoutResult = emptyText, + topLeft = Offset(size.width / 2 - emptyText.size.width / 2, size.height / 2 + 38f.dp.toPx()), + color = ColorBox.text.copy(0.7f) + ) + } + } else { + TableView( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 20.dp).fillMaxSize().clip(RoundedCornerShape(12.dp)), + tableRows = listOf( + TableItemRow( + weight = 1f, + title = "Net Adapter", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = .6f, + title = "Ping (ms)", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = .6f, + title = "Jitter (ms)", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = .6f, + title = "Download\n(${unitSetting.value})", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = .6f, + title = "Upload\n(${unitSetting.value})", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = 1f, + title = "Test Point", + textAlign = TextAlign.Start, + ), + TableItemRow( + weight = .6f, + title = "Date", + textAlign = TextAlign.Start, + ) + ), + columnCount = historyList.size + ) { column, row -> + val item = historyList[column] + return@TableView when(row) { + 0 -> item.netAdapter + 1 -> item.ping.toString() + 2 -> item.jitter.toString() + 3 -> item.download.toValidString() + 4 -> item.upload.toValidString() + 5 -> item.testPoint + 6 -> item.date.formatToDate() + else -> "" + } + } + } + + } + } + + DialogDelete( + title = "Clear History !", + description = "Are you sure to want to clear history ?", + show = showClearDialog, + onDismiss = { + showClearDialog = false + }, + onOk = { + showClearDialog = false + Database.clearHistory() + historyList.clear() + } + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/routes/scenes/HomeScene.kt b/src/main/kotlin/routes/scenes/HomeScene.kt index 2df2a5d..2776e07 100644 --- a/src/main/kotlin/routes/scenes/HomeScene.kt +++ b/src/main/kotlin/routes/scenes/HomeScene.kt @@ -2,7 +2,9 @@ package routes.scenes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import components.CustomBottomSheet @@ -12,6 +14,7 @@ import components.rememberBottomSheetState import core.Service import kotlinx.coroutines.launch import moe.tlaster.precompose.navigation.Navigator +import routes.Route import routes.sections.HomeBottomSheet import routes.sections.home.Appbar import routes.sections.home.ResultStage @@ -24,14 +27,14 @@ enum class Stage { Result } +var selectedStage = mutableStateOf(Stage.Start) + @Composable fun HomeScene(navigator: Navigator) { val bottomSheetState = rememberBottomSheetState() val scope = rememberCoroutineScope() - var selectedStage by remember { mutableStateOf(Stage.Start) } - CustomBottomSheet( scope = scope, state = bottomSheetState, @@ -46,16 +49,18 @@ fun HomeScene(navigator: Navigator) { }, content = { Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { - Appbar() + Appbar(onHistoryClicked = { + navigator.navigate(Route.HISTORY) + }) TabSwitcher( modifier = Modifier.fillMaxSize(), - selectedTab = selectedStage.name, + selectedTab = selectedStage.value.name, tabs = arrayOf( Tab(name = Stage.Start.name) { StartStage( onStartClicked = { Service.startTesting() - selectedStage = Stage.Test + selectedStage.value = Stage.Test }, onChooseServerClicked = { scope.launch { @@ -68,10 +73,10 @@ fun HomeScene(navigator: Navigator) { TestStage( onCancel = { Service.reset() - selectedStage = Stage.Start + selectedStage.value = Stage.Start }, goToResult = { - selectedStage = Stage.Result + selectedStage.value = Stage.Result } ) }, @@ -79,7 +84,7 @@ fun HomeScene(navigator: Navigator) { ResultStage( newTestClicked = { Service.reset() - selectedStage = Stage.Start + selectedStage.value = Stage.Start } ) } diff --git a/src/main/kotlin/routes/sections/home/Appbar.kt b/src/main/kotlin/routes/sections/home/Appbar.kt index e8f80d6..b3fcfe0 100644 --- a/src/main/kotlin/routes/sections/home/Appbar.kt +++ b/src/main/kotlin/routes/sections/home/Appbar.kt @@ -22,7 +22,7 @@ import routes.dialogs.DialogPrivacy import theme.ColorBox @Composable -fun Appbar() { +fun Appbar(onHistoryClicked : () -> Unit) { var showPrivacyDialog by remember { mutableStateOf(false) } val unitSetting = Service.unitSetting.observeAsState() @@ -39,6 +39,12 @@ fun Appbar() { color = ColorBox.text, style = MaterialTheme.typography.titleMedium ) + MyIconButton( + icon = "icons/history.svg", + onClick = { + onHistoryClicked.invoke() + } + ) DayNightAnimationIcon( modifier = Modifier.size(48.dp).clip(RoundedCornerShape(50)).clickable { ColorBox.switchTheme() diff --git a/src/main/kotlin/util/NetUtils.kt b/src/main/kotlin/util/NetUtils.kt new file mode 100644 index 0000000..d9b99bb --- /dev/null +++ b/src/main/kotlin/util/NetUtils.kt @@ -0,0 +1,35 @@ +package util + +import java.net.* + +object NetUtils { + + fun parseMacAddress(mac: ByteArray): String { + val sb = StringBuilder() + for (i in mac.indices) { + sb.append(String.format("%02X%s", mac[i], if ((i < mac.size - 1)) ":" else "")) + } + return sb.toString() + } + + fun getDefaultNetworkInterface(): NetworkInterface? { + val globalHost = "a.root-servers.net" + var result: NetworkInterface? = null + var remoteAddress: InetAddress? = null + try { + remoteAddress = InetAddress.getByName(globalHost) + } catch (ignored: UnknownHostException) { + } + if (remoteAddress != null) { + try { + DatagramSocket().use { s -> + s.connect(remoteAddress, 80) + result = NetworkInterface.getByInetAddress(s.localAddress) + } + } catch (ignored: SocketException) { + } + } + return result + } + +} \ No newline at end of file diff --git a/src/main/kotlin/util/Utils.kt b/src/main/kotlin/util/Utils.kt index 877c606..e64352b 100644 --- a/src/main/kotlin/util/Utils.kt +++ b/src/main/kotlin/util/Utils.kt @@ -4,9 +4,18 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier +import core.ModelHistory +import core.Service.toValidString +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter import java.math.BigDecimal import java.math.RoundingMode +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* object Utils { @@ -22,14 +31,40 @@ object Utils { return bd.toFloat() } + fun Int.suffixItems() : String { + return if (this == 0) "No Items" else if (this == 1) "1 Item" else "$this items" + } + fun Float.validate () : Float = if (this > 1f) 1f else this fun Double.toMegabyte(): Double = this * .125 + fun Long.formatToDate(pattern: String = "dd MMM yyyy\nHH:mm:ss"): String { + val simple: DateFormat = SimpleDateFormat(pattern) + val result = Date(this) + return simple.format(result) + } + @Composable fun Modifier.clickable() = this.then( clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} ) + private fun String.escapeCsvValue(): String = "\"${replace("\"", "\"\"")}\"" + fun exportHistoryToCSV(input : SnapshotStateList) { + val exportFile = File("${System.getProperty("user.home")}/Downloads","librespeed-history.csv") + exportFile.parentFile.mkdirs() + exportFile.createNewFile() + BufferedWriter(FileWriter(exportFile)).use { + it.write("id,netAdapter,ping,jitter,download,upload,ispInfo,testPoint,date\n") + for (model in input) { + it.write("${model.id},${model.netAdapter.escapeCsvValue()}," + + "${model.ping},${model.jitter},${model.download.toValidString()}," + + "${model.upload.toValidString()},${model.ispInfo.escapeCsvValue()}," + + "${model.testPoint.escapeCsvValue()},${model.date.formatToDate("dd-MMM-yyyy HH:mm:ss")}\n") + } + } + } + } diff --git a/src/main/resources/icons/export.svg b/src/main/resources/icons/export.svg new file mode 100644 index 0000000..0bd6571 --- /dev/null +++ b/src/main/resources/icons/export.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/history.svg b/src/main/resources/icons/history.svg new file mode 100644 index 0000000..2a30581 --- /dev/null +++ b/src/main/resources/icons/history.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/trash.svg b/src/main/resources/icons/trash.svg new file mode 100644 index 0000000..85fa343 --- /dev/null +++ b/src/main/resources/icons/trash.svg @@ -0,0 +1,5 @@ + + + + +