Skip to content

Commit 29be208

Browse files
Add network functionality to fetch link metadata and update UI components (#93)
1 parent 77c5719 commit 29be208

File tree

8 files changed

+227
-42
lines changed

8 files changed

+227
-42
lines changed

app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ dependencies {
112112
ktlint(libs.ktlint)
113113
implementation("dev.chrisbanes.haze:haze:1.6.9")
114114
implementation("dev.chrisbanes.haze:haze-materials:1.6.9")
115+
implementation(libs.jsoup)
116+
implementation(libs.ktor.client.core)
117+
implementation(libs.ktor.client.cio)
118+
implementation(libs.coil.compose)
119+
implementation(libs.coil.network.ktor3)
120+
implementation(libs.ktor.client.android)
115121
}
116122

117123
kotlinter {

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
</intent>
1717
</queries>
1818

19+
20+
<uses-permission android:name="android.permission.INTERNET" />
21+
1922
<application
2023
android:name=".DeeprApplication"
2124
android:allowBackup="true"

app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ import com.yogeshpaliyal.deepr.backup.ExportRepository
99
import com.yogeshpaliyal.deepr.backup.ExportRepositoryImpl
1010
import com.yogeshpaliyal.deepr.backup.ImportRepository
1111
import com.yogeshpaliyal.deepr.backup.ImportRepositoryImpl
12+
import com.yogeshpaliyal.deepr.data.HtmlParser
13+
import com.yogeshpaliyal.deepr.data.NetworkRepository
1214
import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore
1315
import com.yogeshpaliyal.deepr.sync.SyncRepository
1416
import com.yogeshpaliyal.deepr.sync.SyncRepositoryImpl
1517
import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel
18+
import io.ktor.client.HttpClient
19+
import io.ktor.client.engine.cio.CIO
1620
import org.koin.android.ext.koin.androidContext
1721
import org.koin.core.context.startKoin
1822
import org.koin.core.module.dsl.viewModel
@@ -57,7 +61,19 @@ class DeeprApplication : Application() {
5761

5862
single<SyncRepository> { SyncRepositoryImpl(androidContext(), get(), get()) }
5963

60-
viewModel { AccountViewModel(get(), get(), get(), get()) }
64+
single {
65+
HttpClient(CIO)
66+
}
67+
68+
viewModel { AccountViewModel(get(), get(), get(), get(), get()) }
69+
70+
single {
71+
HtmlParser()
72+
}
73+
74+
single {
75+
NetworkRepository(get(), get())
76+
}
6177
}
6278

6379
startKoin {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.yogeshpaliyal.deepr.data
2+
3+
import org.jsoup.Jsoup
4+
import org.jsoup.nodes.Document
5+
6+
class HtmlParser {
7+
// Get Open Graph content (og:title, og:image)
8+
private fun getOgContent(
9+
doc: Document,
10+
property: String,
11+
): String? {
12+
// Try both property and name attributes for compatibility
13+
return doc.selectFirst("meta[property=$property]")?.attr("content")
14+
?: doc.selectFirst("meta[name=$property]")?.attr("content")
15+
}
16+
17+
// Get title and image from Open Graph, or fallback as described
18+
fun getTitleAndImageFromHtml(html: String): LinkInfo {
19+
val doc = Jsoup.parse(html)
20+
21+
// 1. Try og:title and og:image from <meta>
22+
val ogTitle = getOgContent(doc, "og:title")
23+
val ogDescription = getOgContent(doc, "og:description")
24+
val ogImage = getOgContent(doc, "og:image")
25+
26+
// 2. Fallback for title: biggest heading in document
27+
val headingTags = listOf("h1", "h2", "h3", "h4", "h5", "h6")
28+
val fallbackTitle =
29+
if (ogTitle.isNullOrBlank()) {
30+
headingTags.firstNotNullOfOrNull { tag ->
31+
doc.selectFirst(tag)?.text()?.takeIf { it.isNotBlank() }
32+
}
33+
} else {
34+
ogTitle
35+
}
36+
37+
// 3. Fallback for image: first img in document
38+
val fallbackImage =
39+
if (ogImage.isNullOrBlank()) {
40+
doc.selectFirst("img")?.absUrl("src")?.takeIf { it.isNotBlank() }
41+
} else {
42+
ogImage
43+
}
44+
45+
return LinkInfo(fallbackTitle, ogDescription, fallbackImage)
46+
}
47+
}
48+
49+
data class LinkInfo(
50+
val title: String?,
51+
val description: String?,
52+
val image: String?,
53+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.yogeshpaliyal.deepr.data
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.request.get
5+
import io.ktor.client.statement.bodyAsText
6+
7+
class NetworkRepository(
8+
val httpClient: HttpClient,
9+
val httpParser: HtmlParser,
10+
) {
11+
suspend fun getLinkInfo(url: String): Result<LinkInfo> {
12+
try {
13+
val response = httpClient.get(url)
14+
if (response.status.value != 200) {
15+
return Result.failure(Exception("Failed to fetch data from $url, status code: ${response.status.value}"))
16+
}
17+
// Return the response body as text
18+
return Result.success(httpParser.getTitleAndImageFromHtml(response.bodyAsText()))
19+
} catch (e: Exception) {
20+
return Result.failure(Exception("Error fetching data from $url: ${e.message}", e))
21+
}
22+
}
23+
}

app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/HomeBottomContent.kt

Lines changed: 98 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
2+
13
package com.yogeshpaliyal.deepr.ui.screens.home
24

35
import android.widget.Toast
@@ -11,9 +13,12 @@ import androidx.compose.foundation.layout.height
1113
import androidx.compose.foundation.layout.padding
1214
import androidx.compose.foundation.layout.size
1315
import androidx.compose.foundation.layout.width
16+
import androidx.compose.foundation.rememberScrollState
1417
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.foundation.verticalScroll
1519
import androidx.compose.material3.Button
1620
import androidx.compose.material3.ExperimentalMaterial3Api
21+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
1722
import androidx.compose.material3.Icon
1823
import androidx.compose.material3.IconButton
1924
import androidx.compose.material3.InputChip
@@ -51,10 +56,6 @@ import compose.icons.TablerIcons
5156
import compose.icons.tablericons.X
5257
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
5358
import org.koin.compose.koinInject
54-
import kotlin.collections.addAll
55-
import kotlin.collections.remove
56-
import kotlin.compareTo
57-
import kotlin.text.clear
5859

5960
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3Api::class)
6061
@Composable
@@ -102,6 +103,17 @@ fun HomeBottomContent(
102103
}
103104
}
104105

106+
LaunchedEffect(Unit) {
107+
deeprInfo.link?.let {
108+
viewModel.fetchMetaData(it) {
109+
if (it != null) {
110+
deeprInfo = deeprInfo.copy(name = it.title ?: "")
111+
isNameError = false
112+
}
113+
}
114+
}
115+
}
116+
105117
val save: (executeAfterSave: Boolean) -> Unit = { executeAfterSave ->
106118
// Remove unselected tags
107119
val initialTagIds = initialSelectedTags.map { it.id }.toSet()
@@ -141,50 +153,79 @@ fun HomeBottomContent(
141153
Column(
142154
modifier =
143155
modifier
156+
.verticalScroll(rememberScrollState())
144157
.clip(
145158
RoundedCornerShape(
146159
topStart = 12.dp,
147160
),
148161
).fillMaxWidth(),
149162
) {
163+
Text(
164+
text = if (isCreate) "Create" else "Edit",
165+
modifier = Modifier.padding(8.dp),
166+
style = MaterialTheme.typography.headlineMedium,
167+
)
150168
Column(
151169
modifier =
152170
Modifier
153171
.padding(8.dp),
154172
) {
155173
TextField(
156-
value = deeprInfo.name,
174+
value = deeprInfo.link,
157175
onValueChange = {
158-
deeprInfo = deeprInfo.copy(name = it)
159-
isNameError = false
176+
deeprInfo = deeprInfo.copy(link = it)
177+
isError = false
160178
},
161179
modifier =
162180
Modifier
163-
.fillMaxWidth()
164-
.padding(bottom = 8.dp),
165-
label = { Text(stringResource(R.string.enter_link_name)) },
181+
.fillMaxWidth(),
182+
label = { Text(stringResource(R.string.enter_deeplink_command)) },
183+
isError = isError,
166184
supportingText = {
167-
if (isNameError) {
168-
Text(text = stringResource(R.string.enter_link_name_error))
185+
if (isError) {
186+
Text(text = stringResource(R.string.invalid_empty_deeplink))
169187
}
170188
},
171189
)
172190

191+
OutlinedButton(
192+
modifier = Modifier.fillMaxWidth(),
193+
enabled = deeprInfo.link.isNotBlank(),
194+
onClick = {
195+
viewModel.fetchMetaData(deeprInfo.link) {
196+
if (it != null) {
197+
deeprInfo = deeprInfo.copy(name = it.title ?: "")
198+
isNameError = false
199+
} else {
200+
Toast
201+
.makeText(
202+
context,
203+
"Failed to fetch metadata",
204+
Toast.LENGTH_SHORT,
205+
).show()
206+
}
207+
}
208+
},
209+
) {
210+
Text("Fetch name from link")
211+
}
212+
213+
Spacer(modifier = Modifier.height(8.dp))
214+
173215
TextField(
174-
value = deeprInfo.link,
216+
value = deeprInfo.name,
175217
onValueChange = {
176-
deeprInfo = deeprInfo.copy(link = it)
177-
isError = false
218+
deeprInfo = deeprInfo.copy(name = it)
219+
isNameError = false
178220
},
179221
modifier =
180222
Modifier
181223
.fillMaxWidth()
182224
.padding(bottom = 8.dp),
183-
label = { Text(stringResource(R.string.enter_deeplink_command)) },
184-
isError = isError,
225+
label = { Text(stringResource(R.string.enter_link_name)) },
185226
supportingText = {
186-
if (isError) {
187-
Text(text = stringResource(R.string.invalid_empty_deeplink))
227+
if (isNameError) {
228+
Text(text = stringResource(R.string.enter_link_name_error))
188229
}
189230
},
190231
)
@@ -294,35 +335,51 @@ fun HomeBottomContent(
294335
}
295336

296337
Spacer(modifier = Modifier.height(16.dp))
297-
298338
Row(
299339
modifier =
300340
Modifier
301341
.fillMaxWidth(),
302342
horizontalArrangement = Arrangement.SpaceAround,
303343
) {
304-
OutlinedButton(modifier = Modifier.then(if (isCreate) Modifier else Modifier.fillMaxWidth()), onClick = {
305-
if (isValidDeeplink(deeprInfo.link)) {
306-
if (isCreate &&
307-
deeprQueries
308-
.getDeeprByLink(deeprInfo.link)
309-
.executeAsList()
310-
.isNotEmpty()
311-
) {
312-
Toast
313-
.makeText(
314-
context,
315-
"Deeplink already exists",
316-
Toast.LENGTH_SHORT,
317-
).show()
318-
} else {
319-
save(false)
320-
}
321-
} else {
322-
isError = true
344+
if (!isCreate) {
345+
Button(
346+
onClick = {
347+
if (isValidDeeplink(deeprInfo.link)) {
348+
save(false)
349+
} else {
350+
isError = true
351+
}
352+
},
353+
modifier = Modifier.fillMaxWidth(),
354+
) {
355+
Text(stringResource(R.string.save))
356+
}
357+
} else {
358+
OutlinedButton(
359+
modifier = Modifier,
360+
onClick = {
361+
if (isValidDeeplink(deeprInfo.link)) {
362+
if (deeprQueries
363+
.getDeeprByLink(deeprInfo.link)
364+
.executeAsList()
365+
.isNotEmpty()
366+
) {
367+
Toast
368+
.makeText(
369+
context,
370+
"Deeplink already exists",
371+
Toast.LENGTH_SHORT,
372+
).show()
373+
} else {
374+
save(false)
375+
}
376+
} else {
377+
isError = true
378+
}
379+
},
380+
) {
381+
Text(stringResource(R.string.save))
323382
}
324-
}) {
325-
Text(stringResource(R.string.save))
326383
}
327384

328385
if (isCreate) {

app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/AccountViewModel.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import com.yogeshpaliyal.deepr.GetLinksAndTags
1111
import com.yogeshpaliyal.deepr.Tags
1212
import com.yogeshpaliyal.deepr.backup.ExportRepository
1313
import com.yogeshpaliyal.deepr.backup.ImportRepository
14+
import com.yogeshpaliyal.deepr.data.LinkInfo
15+
import com.yogeshpaliyal.deepr.data.NetworkRepository
1416
import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore
1517
import com.yogeshpaliyal.deepr.sync.SyncRepository
1618
import com.yogeshpaliyal.deepr.util.RequestResult
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.flatMapLatest
2628
import kotlinx.coroutines.flow.receiveAsFlow
2729
import kotlinx.coroutines.flow.stateIn
2830
import kotlinx.coroutines.launch
31+
import kotlinx.coroutines.withContext
2932
import org.koin.core.component.KoinComponent
3033
import org.koin.core.component.get
3134

@@ -63,6 +66,7 @@ class AccountViewModel(
6366
private val exportRepository: ExportRepository,
6467
private val importRepository: ImportRepository,
6568
private val syncRepository: SyncRepository,
69+
private val networkRepository: NetworkRepository,
6670
) : ViewModel(),
6771
KoinComponent {
6872
private val preferenceDataStore: AppPreferenceDataStore = get()
@@ -147,6 +151,19 @@ class AccountViewModel(
147151
}
148152
}
149153

154+
fun fetchMetaData(
155+
link: String,
156+
onLinkMetaDataFound: (LinkInfo?) -> Unit,
157+
) {
158+
viewModelScope.launch(Dispatchers.IO) {
159+
networkRepository.getLinkInfo(link).getOrNull().let {
160+
withContext(Dispatchers.Main) {
161+
onLinkMetaDataFound(it)
162+
}
163+
}
164+
}
165+
}
166+
150167
@OptIn(ExperimentalCoroutinesApi::class)
151168
val accounts: StateFlow<List<GetLinksAndTags>?> =
152169
combine(searchQuery, sortOrder, selectedTagFilter) { query, sorting, tag ->

0 commit comments

Comments
 (0)