first commit

This commit is contained in:
2026-01-11 19:47:49 -05:00
commit 1438003f94
56 changed files with 4829 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ShaarItApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ShaarIt"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ShaarIt.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,62 @@
package com.shaarit
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Install splash screen before super.onCreate
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
setContent {
ShaarItTheme {
// A surface container using the 'background' color from the theme
val context = LocalContext.current
var shareUrl: String? = null
var shareTitle: String? = null
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
if (intent?.action == android.content.Intent.ACTION_SEND &&
intent.type == "text/plain"
) {
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) { AppNavGraph(shareUrl = shareUrl, shareTitle = shareTitle) }
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(text = "Hello $name!", modifier = modifier)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ShaarItTheme { Greeting("Android") }
}
@@ -0,0 +1,6 @@
package com.shaarit
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp class ShaarItApp : Application()
@@ -0,0 +1,16 @@
package com.shaarit.core.di
import com.shaarit.core.storage.TokenManager
import com.shaarit.core.storage.TokenManagerImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AppModule {
@Binds @Singleton abstract fun bindTokenManager(impl: TokenManagerImpl): TokenManager
}
@@ -0,0 +1,54 @@
package com.shaarit.core.di
import com.shaarit.core.network.AuthInterceptor
import com.shaarit.core.network.HostSelectionInterceptor
import com.shaarit.data.api.ShaarliApi
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton fun provideMoshi(): Moshi = Moshi.Builder().build()
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
hostSelectionInterceptor: HostSelectionInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
return OkHttpClient.Builder()
.addInterceptor(hostSelectionInterceptor) // Host selection first
.addInterceptor(authInterceptor) // Auth header second
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
// Initial dummy URL. HostSelectionInterceptor will swap it.
// Must end with /
return Retrofit.Builder()
.baseUrl("http://localhost/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideShaarliApi(retrofit: Retrofit): ShaarliApi {
return retrofit.create(ShaarliApi::class.java)
}
}
@@ -0,0 +1,20 @@
package com.shaarit.core.di
import com.shaarit.data.repository.AuthRepositoryImpl
import com.shaarit.data.repository.LinkRepositoryImpl
import com.shaarit.domain.repository.AuthRepository
import com.shaarit.domain.repository.LinkRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds @Singleton abstract fun bindLinkRepository(impl: LinkRepositoryImpl): LinkRepository
}
@@ -0,0 +1,24 @@
package com.shaarit.core.network
import com.shaarit.core.storage.TokenManager
import com.shaarit.core.util.JwtGenerator
import javax.inject.Inject
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor @Inject constructor(private val tokenManager: TokenManager) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val builder = original.newBuilder()
// Shaarli requires a fresh JWT token generated from the API secret for each request
// The token is valid for 9 minutes, so we generate a new one for each request
val apiSecret = tokenManager.getApiSecret()
if (!apiSecret.isNullOrBlank()) {
val jwtToken = JwtGenerator.generateToken(apiSecret)
builder.header("Authorization", "Bearer $jwtToken")
}
return chain.proceed(builder.build())
}
}
@@ -0,0 +1,90 @@
package com.shaarit.core.network
import com.shaarit.core.storage.TokenManager
import javax.inject.Inject
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
class HostSelectionInterceptor @Inject constructor(private val tokenManager: TokenManager) :
Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val savedBaseUrl = tokenManager.getBaseUrl()
if (!savedBaseUrl.isNullOrBlank()) {
val newUrl = savedBaseUrl.toHttpUrlOrNull()
newUrl?.let {
// Delegate to createNewUrl helper
// If the user's base URL has a path (e.g. example.com/shaarli), we need to prefix
// the request path
// But Retrofit annotations usually define the relative path.
// If Retrofit was built with a dummy URL "http://localhost/", a request to
// "/api/v1/links" becomes "http://localhost/api/v1/links"
// If we perform a pure host swap, it becomes "https://user-host.com/api/v1/links"
// This loses the "/shaarli" part if the user setup is
// "https://user-host.com/shaarli"
// So we need to reconstruct the path correctly.
// Correction:
// If savedBaseUrl is "https://myserver.com/shaarli", and request is to
// "/api/v1/links" (relative)
// We want "https://myserver.com/shaarli/api/v1/links"
// We shouldn't rely on HostSelectionInterceptor for path rewriting if possible,
// but if we must, we should check if we need to prepend the path segments of the
// base URL.
// Simpler approach for MVP:
// Just use the scheme/host/port swap and assume the API handles the rest or User
// enters root.
// BUT Shaarli is almost always in a subdir.
// BETTER STRATEGY:
// Don't use HostSelectionInterceptor for Path.
// Just use it for Host/Scheme/Port, and assume the user's BaseURL is the ROOT of
// the server.
// WAIT, Shaarli API doc says endpoint is /api/v1/links.
// If the user installed at /shaarli/, the endpoint is /shaarli/api/v1/links
// We will implement a proper Re-construction based on the saved URL.
// Or simpler: We will configure Retrofit with the correct BaseURL when the app
// starts,
// and if it changes, we force a restart or recreate the graph.
// But Hilt singletons are hard to recreate.
// So HostSelectionInterceptor IS the way, but we must handle path.
// Let's implement a robust path join.
request = request.newBuilder().url(createNewUrl(request.url, it)).build()
}
}
return chain.proceed(request)
}
private fun createNewUrl(currentUrl: HttpUrl, baseUrl: HttpUrl): HttpUrl {
// currentUrl: http://localhost/api/v1/links (from Retrofit dummy)
// baseUrl: https://myserver.com/shaarli (from User)
val builder =
currentUrl.newBuilder().scheme(baseUrl.scheme).host(baseUrl.host).port(baseUrl.port)
// Clear current path
builder.encodedPath("/")
// Add base segments, filtering out empty ones (e.g. from trailing slash)
baseUrl.encodedPathSegments.filter { it.isNotEmpty() }.forEach { segment ->
builder.addEncodedPathSegment(segment)
}
// Add current segments
currentUrl.encodedPathSegments.filter { it.isNotEmpty() }.forEach { segment ->
builder.addEncodedPathSegment(segment)
}
return builder.build()
}
}
@@ -0,0 +1,78 @@
package com.shaarit.core.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
interface TokenManager {
fun saveToken(token: String)
fun getToken(): String?
fun clearToken()
fun saveBaseUrl(url: String)
fun getBaseUrl(): String?
fun saveApiSecret(secret: String)
fun getApiSecret(): String?
fun clearApiSecret()
}
@Singleton
class TokenManagerImpl @Inject constructor(@ApplicationContext private val context: Context) :
TokenManager {
private val sharedPreferences: SharedPreferences by lazy {
val masterKey =
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
EncryptedSharedPreferences.create(
context,
"secret_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override fun saveToken(token: String) {
sharedPreferences.edit().putString(KEY_TOKEN, token).apply()
}
override fun getToken(): String? {
return sharedPreferences.getString(KEY_TOKEN, null)
}
override fun clearToken() {
sharedPreferences.edit().remove(KEY_TOKEN).apply()
}
override fun saveBaseUrl(url: String) {
// Remove trailing slash if present for consistency, though better done in logic
val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url
sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply()
}
override fun getBaseUrl(): String? {
return sharedPreferences.getString(KEY_BASE_URL, null)
}
override fun saveApiSecret(secret: String) {
sharedPreferences.edit().putString(KEY_API_SECRET, secret).apply()
}
override fun getApiSecret(): String? {
return sharedPreferences.getString(KEY_API_SECRET, null)
}
override fun clearApiSecret() {
sharedPreferences.edit().remove(KEY_API_SECRET).apply()
}
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url"
private const val KEY_API_SECRET = "api_secret"
}
}
@@ -0,0 +1,56 @@
package com.shaarit.core.util
import android.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Generates JWT tokens for Shaarli API authentication. Shaarli uses HS512 algorithm and requires:
* - header: {"typ":"JWT","alg":"HS512"}
* - payload: {"iat": <unix_timestamp>}
* - signature: HMAC-SHA512(base64url(header) + "." + base64url(payload), apiSecret)
*/
object JwtGenerator {
private const val ALGORITHM = "HmacSHA512"
/**
* Generates a JWT token for Shaarli API.
* @param apiSecret The API secret from Shaarli settings
* @return The JWT token string
*/
fun generateToken(apiSecret: String): String {
// Header: {"typ":"JWT","alg":"HS512"}
val header = """{"typ":"JWT","alg":"HS512"}"""
// Payload: {"iat": unix_timestamp}
// Use timestamp 60 seconds in the past to avoid clock skew issues
val iat = (System.currentTimeMillis() / 1000) - 60
val payload = """{"iat":$iat}"""
// Base64URL encode header and payload
val encodedHeader = base64UrlEncode(header.toByteArray(Charsets.UTF_8))
val encodedPayload = base64UrlEncode(payload.toByteArray(Charsets.UTF_8))
// Create signature input
val signatureInput = "$encodedHeader.$encodedPayload"
// Sign with HMAC-SHA512
val signature = hmacSha512(signatureInput, apiSecret)
val encodedSignature = base64UrlEncode(signature)
return "$encodedHeader.$encodedPayload.$encodedSignature"
}
private fun base64UrlEncode(data: ByteArray): String {
// Use URL-safe Base64 encoding without padding
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
}
private fun hmacSha512(data: String, secret: String): ByteArray {
val mac = Mac.getInstance(ALGORITHM)
val secretKey = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), ALGORITHM)
mac.init(secretKey)
return mac.doFinal(data.toByteArray(Charsets.UTF_8))
}
}
@@ -0,0 +1,50 @@
package com.shaarit.data.api
import com.shaarit.data.dto.CreateLinkDto
import com.shaarit.data.dto.InfoDto
import com.shaarit.data.dto.LinkDto
import com.shaarit.data.dto.TagDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface ShaarliApi {
/** Get Shaarli instance info. Requires authentication - used to verify credentials. */
@GET("/api/v1/info") suspend fun getInfo(): InfoDto
@GET("/api/v1/links")
suspend fun getLinks(
@Query("offset") offset: Int,
@Query("limit") limit: Int,
@Query("searchterm") searchTerm: String? = null,
@Query("searchtags") searchTags: String? = null
): List<LinkDto>
@POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): Response<LinkDto>
@PUT("/api/v1/links/{id}")
suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): Response<LinkDto>
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
/** Get all tags with their occurrence count. */
@GET("/api/v1/tags")
suspend fun getTags(
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 100
): List<TagDto>
/** Get links filtered by tags. */
@GET("/api/v1/links")
suspend fun getLinksByTag(
@Query("searchtags") tag: String,
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 20
): List<LinkDto>
}
@@ -0,0 +1,49 @@
package com.shaarit.data.dto
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LoginRequestDto(@Json(name = "secret") val secret: String)
@JsonClass(generateAdapter = true)
data class LoginResponseDto(@Json(name = "token") val token: String)
@JsonClass(generateAdapter = true)
data class LinkDto(
@Json(name = "id") val id: Int,
@Json(name = "url") val url: String,
@Json(name = "shorturl") val shortUrl: String?,
@Json(name = "title") val title: String?,
@Json(name = "description") val description: String?,
@Json(name = "tags") val tags: List<String>?,
@Json(name = "private") val isPrivate: Boolean,
@Json(name = "created") val created: String?,
@Json(name = "updated") val updated: String?
)
@JsonClass(generateAdapter = true)
data class CreateLinkDto(
@Json(name = "url") val url: String,
@Json(name = "title") val title: String? = null,
@Json(name = "description") val description: String? = null,
@Json(name = "tags") val tags: List<String>? = null,
@Json(name = "private") val isPrivate: Boolean = false
)
/** Shaarli instance information returned by /api/v1/info */
@JsonClass(generateAdapter = true)
data class InfoDto(
@Json(name = "global_counter") val globalCounter: Int? = null,
@Json(name = "private_counter") val privateCounter: Int? = null,
@Json(name = "settings") val settings: InfoSettingsDto? = null
)
@JsonClass(generateAdapter = true)
data class InfoSettingsDto(
@Json(name = "title") val title: String? = null,
@Json(name = "header_link") val headerLink: String? = null,
@Json(name = "timezone") val timezone: String? = null,
@Json(name = "enabled_plugins") val enabledPlugins: List<String>? = null,
@Json(name = "default_private_links") val defaultPrivateLinks: Boolean? = null
)
@@ -0,0 +1,10 @@
package com.shaarit.data.dto
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TagDto(
@Json(name = "name") val name: String,
@Json(name = "occurrences") val occurrences: Int
)
@@ -0,0 +1,26 @@
package com.shaarit.data.mapper
import com.shaarit.data.dto.LinkDto
import com.shaarit.data.dto.TagDto
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
object LinkMapper {
fun toDomain(dto: LinkDto): ShaarliLink {
return ShaarliLink(
id = dto.id,
url = dto.url,
title = dto.title ?: dto.url,
description = dto.description ?: "",
tags = dto.tags ?: emptyList(),
isPrivate = dto.isPrivate,
date = dto.created ?: ""
)
}
}
object TagMapper {
fun toDomain(dto: TagDto): ShaarliTag {
return ShaarliTag(name = dto.name, occurrences = dto.occurrences)
}
}
@@ -0,0 +1,68 @@
package com.shaarit.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.domain.model.ShaarliLink
import java.io.IOException
import retrofit2.HttpException
class LinkPagingSource(
private val api: ShaarliApi,
private val searchTerm: String? = null,
private val searchTags: String? = null
) : PagingSource<Int, ShaarliLink>() {
override fun getRefreshKey(state: PagingState<Int, ShaarliLink>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ShaarliLink> {
val position = params.key ?: 0
// Shaarli V1 API uses offset and limit
// Assuming we pass page index as key? No, offset is better for generic APIs, but here we
// can manage offset.
// Wait, params.key is usually page index if we set it so.
// Or we can use offset as the key.
// Let's use Offset as Key. Initial key = 0.
val offset = position
val limit = params.loadSize
return try {
val dtos =
api.getLinks(
offset = offset,
limit = limit,
searchTerm = searchTerm,
searchTags = searchTags
)
val links = dtos.map { LinkMapper.toDomain(it) }
val nextKey =
if (links.isEmpty()) {
null
} else {
// If we got less than requested, we are at the end?
// Shaarli doesn't return total count easily in v1 (maybe headers).
// If detailed list is empty or < limit, next is null.
if (links.size < limit) null else offset + limit
}
LoadResult.Page(
data = links,
prevKey = if (offset == 0) null else offset - limit,
nextKey = nextKey
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
}
@@ -0,0 +1,49 @@
package com.shaarit.data.repository
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.api.ShaarliApi
import com.shaarit.domain.model.Credentials
import com.shaarit.domain.repository.AuthRepository
import javax.inject.Inject
class AuthRepositoryImpl
@Inject
constructor(private val api: ShaarliApi, private val tokenManager: TokenManager) : AuthRepository {
override suspend fun login(credentials: Credentials, serverUrl: String): Result<Boolean> {
// 1. Save Base URL first so HostSelectionInterceptor picks it up
tokenManager.saveBaseUrl(serverUrl)
// 2. Save the API secret so AuthInterceptor can generate JWT tokens
tokenManager.saveApiSecret(credentials.secret)
// 3. Verify credentials by calling the API info endpoint
// This endpoint requires valid authentication
return try {
val info = api.getInfo()
// If we get here, authentication worked
// The info contains Shaarli version and settings
Result.success(true)
} catch (e: Exception) {
// Authentication failed - clear the secret
tokenManager.clearApiSecret()
e.printStackTrace()
Result.failure(e)
}
}
override fun isLoggedIn(): Boolean {
// We're logged in if we have both base URL and API secret
return !tokenManager.getApiSecret().isNullOrBlank() &&
!tokenManager.getBaseUrl().isNullOrBlank()
}
override fun logout() {
tokenManager.clearToken()
tokenManager.clearApiSecret()
}
override fun getBaseUrl(): String? {
return tokenManager.getBaseUrl()
}
}
@@ -0,0 +1,172 @@
package com.shaarit.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CreateLinkDto
import com.shaarit.data.dto.LinkDto
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.data.mapper.TagMapper
import com.shaarit.data.paging.LinkPagingSource
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.AddLinkResult
import com.shaarit.domain.repository.LinkRepository
import com.squareup.moshi.Moshi
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import retrofit2.HttpException
class LinkRepositoryImpl
@Inject
constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkRepository {
override fun getLinksStream(
searchTerm: String?,
searchTags: String?
): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) }
)
.flow
}
override suspend fun addLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
return try {
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addOrUpdateLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean,
forceUpdate: Boolean,
existingLinkId: Int?
): AddLinkResult {
return try {
if (forceUpdate && existingLinkId != null) {
// Force update existing link
val response =
api.updateLink(
existingLinkId,
CreateLinkDto(url, title, description, tags, isPrivate)
)
if (response.isSuccessful) {
AddLinkResult.Success
} else {
AddLinkResult.Error("Update failed: ${response.code()}")
}
} else {
// Try to add new link
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
AddLinkResult.Success
} else if (response.code() == 409) {
// Conflict - link already exists
// Try to parse the existing link from response body
val errorBody = response.errorBody()?.string()
val existingLink = parseExistingLink(errorBody)
AddLinkResult.Conflict(
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
)
} else {
AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}")
}
}
} catch (e: HttpException) {
if (e.code() == 409) {
val errorBody = e.response()?.errorBody()?.string()
val existingLink = parseExistingLink(errorBody)
AddLinkResult.Conflict(
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
)
} else {
AddLinkResult.Error(e.message ?: "HTTP Error ${e.code()}")
}
} catch (e: Exception) {
AddLinkResult.Error(e.message ?: "Unknown error")
}
}
private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null
return try {
val adapter = moshi.adapter(LinkDto::class.java)
adapter.fromJson(errorBody)
} catch (e: Exception) {
null
}
}
override suspend fun updateLink(
id: Int,
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
return try {
val response =
api.updateLink(id, CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Update failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deleteLink(id: Int): Result<Unit> {
return try {
val response = api.deleteLink(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getTags(): Result<List<ShaarliTag>> {
return try {
val tags = api.getTags(offset = 0, limit = 500)
Result.success(tags.map { TagMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.map { LinkMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,13 @@
package com.shaarit.domain.model
data class Credentials(val url: String, val secret: String)
data class ShaarliLink(
val id: Int,
val url: String,
val title: String,
val description: String,
val tags: List<String>,
val isPrivate: Boolean,
val date: String
)
@@ -0,0 +1,3 @@
package com.shaarit.domain.model
data class ShaarliTag(val name: String, val occurrences: Int)
@@ -0,0 +1,10 @@
package com.shaarit.domain.repository
import com.shaarit.domain.model.Credentials
interface AuthRepository {
suspend fun login(credentials: Credentials, serverUrl: String): Result<Boolean>
fun isLoggedIn(): Boolean
fun logout()
fun getBaseUrl(): String?
}
@@ -0,0 +1,52 @@
package com.shaarit.domain.repository
import androidx.paging.PagingData
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
import kotlinx.coroutines.flow.Flow
sealed class AddLinkResult {
object Success : AddLinkResult()
data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkResult()
data class Error(val message: String) : AddLinkResult()
}
interface LinkRepository {
fun getLinksStream(
searchTerm: String? = null,
searchTags: String? = null
): Flow<PagingData<ShaarliLink>>
suspend fun addLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit>
suspend fun addOrUpdateLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean,
forceUpdate: Boolean = false,
existingLinkId: Int? = null
): AddLinkResult
suspend fun updateLink(
id: Int,
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit>
suspend fun deleteLink(id: Int): Result<Unit>
suspend fun getTags(): Result<List<ShaarliTag>>
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
}
@@ -0,0 +1,14 @@
package com.shaarit.domain.usecase
import com.shaarit.domain.model.Credentials
import com.shaarit.domain.repository.AuthRepository
import javax.inject.Inject
class LoginUseCase @Inject constructor(private val repository: AuthRepository) {
suspend operator fun invoke(url: String, secret: String): Result<Boolean> {
if (url.isBlank() || secret.isBlank()) {
return Result.failure(IllegalArgumentException("URL and Secret cannot be empty"))
}
return repository.login(Credentials(url, secret), url)
}
}
@@ -0,0 +1,371 @@
package com.shaarit.presentation.add
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.GradientButton
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.SectionHeader
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkScreen(
onNavigateBack: () -> Unit,
onShareSuccess: (() -> Unit)? = null,
viewModel: AddLinkViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val url by viewModel.url.collectAsState()
val title by viewModel.title.collectAsState()
val description by viewModel.description.collectAsState()
val selectedTags by viewModel.selectedTags.collectAsState()
val newTagInput by viewModel.newTagInput.collectAsState()
val availableTags by viewModel.availableTags.collectAsState()
val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState) {
when (val state = uiState) {
is AddLinkUiState.Success -> {
// If this was a share intent, finish the activity to return to source app
if (onShareSuccess != null) {
onShareSuccess()
} else {
onNavigateBack()
}
}
is AddLinkUiState.Error -> {
snackbarHostState.showSnackbar(state.message)
}
is AddLinkUiState.Conflict -> {
// Show conflict dialog - handled in AlertDialog below
}
else -> {}
}
}
// Conflict Dialog
if (uiState is AddLinkUiState.Conflict) {
val conflict = uiState as AddLinkUiState.Conflict
AlertDialog(
onDismissRequest = { viewModel.dismissConflict() },
title = {
Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary)
},
text = {
Column {
Text("A link with this URL already exists:", color = TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Text(
conflict.existingTitle ?: "Untitled",
color = CyanPrimary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Would you like to update the existing link instead?",
color = TextSecondary
)
}
},
confirmButton = {
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
Text("Update", color = CyanPrimary)
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissConflict() }) {
Text("Cancel", color = TextMuted)
}
},
containerColor = CardBackground,
titleContentColor = TextPrimary,
textContentColor = TextSecondary
)
}
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"Add Link",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = TextPrimary
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor =
android.graphics.Color.TRANSPARENT.let {
androidx.compose.ui.graphics.Color.Transparent
}
) { paddingValues ->
Column(
modifier =
Modifier.padding(paddingValues)
.padding(16.dp)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// URL Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "URL", subtitle = "Required")
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "https://example.com",
leadingIcon = {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = CyanPrimary
)
}
)
}
}
// Title Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(
title = "Title",
subtitle = "Optional - auto-fetched if empty"
)
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = title,
onValueChange = { viewModel.title.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Page title"
)
}
}
// Description Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "Description", subtitle = "Optional")
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = description,
onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Add a description...",
singleLine = false,
minLines = 3
)
}
}
// Tags Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "Tags", subtitle = "Organize your links")
Spacer(modifier = Modifier.height(12.dp))
// Selected tags
if (selectedTags.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
items(selectedTags) { tag ->
TagChip(
tag = tag,
isSelected = true,
onClick = { viewModel.removeTag(tag) }
)
}
}
}
// New tag input
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
PremiumTextField(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f),
placeholder = "Add tag..."
)
IconButton(
onClick = { viewModel.addNewTag() },
enabled = newTagInput.isNotBlank()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add tag",
tint =
if (newTagInput.isNotBlank()) CyanPrimary
else TextMuted
)
}
}
// Tag suggestions
AnimatedVisibility(
visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Suggestions",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(tagSuggestions.take(10)) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
)
}
}
}
}
// Popular tags from existing
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Popular tags",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(
availableTags
.filter { it.name !in selectedTags }
.take(10)
) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
)
}
}
}
}
}
}
// Privacy Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Private",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
Text(
"Only you can see this link",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
colors =
SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceVariant
)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Save Button
GradientButton(
text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link",
onClick = { viewModel.addLink() },
modifier = Modifier.fillMaxWidth(),
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
)
if (uiState is AddLinkUiState.Loading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = CyanPrimary,
trackColor = SurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@@ -0,0 +1,208 @@
package com.shaarit.presentation.add
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.AddLinkResult
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import java.net.URLDecoder
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class AddLinkViewModel
@Inject
constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedStateHandle) :
ViewModel() {
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
private val initialUrl: String? = savedStateHandle["url"]
private val initialTitle: String? = savedStateHandle["title"]
private val _uiState = MutableStateFlow<AddLinkUiState>(AddLinkUiState.Idle)
val uiState = _uiState.asStateFlow()
var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "")
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false)
// New tag management
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
val selectedTags = _selectedTags.asStateFlow()
private val _newTagInput = MutableStateFlow("")
val newTagInput = _newTagInput.asStateFlow()
private val _availableTags = MutableStateFlow<List<ShaarliTag>>(emptyList())
val availableTags = _availableTags.asStateFlow()
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
// For conflict handling
private var conflictLinkId: Int? = null
init {
loadAvailableTags()
}
/** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */
private fun decodeUrlParam(param: String?): String? {
if (param.isNullOrBlank()) return null
return try {
// First decode URL encoding, then replace + with spaces
// The + signs appear because URLEncoder uses + for spaces
URLDecoder.decode(param, "UTF-8").replace("+", " ").trim()
} catch (e: Exception) {
// If decoding fails, just replace + with spaces
param.replace("+", " ").trim()
}
}
private fun loadAvailableTags() {
viewModelScope.launch {
linkRepository
.getTags()
.fold(
onSuccess = { tags ->
_availableTags.value = tags.sortedByDescending { it.occurrences }
},
onFailure = {
// Silently fail - tags are optional
}
)
}
}
fun onNewTagInputChanged(input: String) {
_newTagInput.value = input
updateTagSuggestions(input)
}
private fun updateTagSuggestions(query: String) {
if (query.isBlank()) {
_tagSuggestions.value = emptyList()
return
}
val queryLower = query.lowercase()
_tagSuggestions.value =
_availableTags
.value
.filter {
it.name.lowercase().contains(queryLower) &&
it.name !in _selectedTags.value
}
.take(10)
}
fun addTag(tag: String) {
val cleanTag = tag.trim().lowercase()
if (cleanTag.isNotBlank() && cleanTag !in _selectedTags.value) {
_selectedTags.value = _selectedTags.value + cleanTag
_newTagInput.value = ""
_tagSuggestions.value = emptyList()
}
}
fun addNewTag() {
addTag(_newTagInput.value)
}
fun removeTag(tag: String) {
_selectedTags.value = _selectedTags.value - tag
}
fun addLink() {
viewModelScope.launch {
_uiState.value = AddLinkUiState.Loading
// Basic validation
val currentUrl = url.value
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required")
return@launch
}
val result =
linkRepository.addOrUpdateLink(
url = currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = false,
existingLinkId = null
)
when (result) {
is AddLinkResult.Success -> {
_uiState.value = AddLinkUiState.Success
}
is AddLinkResult.Conflict -> {
conflictLinkId = result.existingLinkId
_uiState.value =
AddLinkUiState.Conflict(
existingLinkId = result.existingLinkId,
existingTitle = result.existingTitle
)
}
is AddLinkResult.Error -> {
_uiState.value = AddLinkUiState.Error(result.message)
}
}
}
}
fun forceUpdateExistingLink() {
val linkId = conflictLinkId ?: return
viewModelScope.launch {
_uiState.value = AddLinkUiState.Loading
val result =
linkRepository.addOrUpdateLink(
url = url.value,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = true,
existingLinkId = linkId
)
when (result) {
is AddLinkResult.Success -> {
_uiState.value = AddLinkUiState.Success
}
is AddLinkResult.Error -> {
_uiState.value = AddLinkUiState.Error(result.message)
}
else -> {
_uiState.value = AddLinkUiState.Error("Unexpected error")
}
}
}
}
fun dismissConflict() {
conflictLinkId = null
_uiState.value = AddLinkUiState.Idle
}
// Legacy compatibility for old comma-separated tags input
@Deprecated("Use selectedTags instead") var tags = MutableStateFlow("")
}
sealed class AddLinkUiState {
object Idle : AddLinkUiState()
object Loading : AddLinkUiState()
object Success : AddLinkUiState()
data class Error(val message: String) : AddLinkUiState()
data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkUiState()
}
@@ -0,0 +1,202 @@
package com.shaarit.presentation.auth
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.GradientButton
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.theme.*
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var url by remember { mutableStateOf("") }
var secret by remember { mutableStateOf("") }
var showSecret by remember { mutableStateOf(false) }
// Animated background effect
val infiniteTransition = rememberInfiniteTransition()
val backgroundAlpha by
infiniteTransition.animateFloat(
initialValue = 0.1f,
targetValue = 0.2f,
animationSpec =
infiniteRepeatable(
animation = tween(3000, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse
)
)
LaunchedEffect(uiState) {
if (uiState is LoginUiState.Success) {
onLoginSuccess()
}
if (uiState is LoginUiState.Error) {
snackbarHostState.showSnackbar((uiState as LoginUiState.Error).message)
}
}
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
) {
// Subtle gradient overlay
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.radialGradient(
colors =
listOf(
CyanPrimary.copy(
alpha =
backgroundAlpha
),
android.graphics.Color
.TRANSPARENT
.let {
androidx.compose
.ui
.graphics
.Color
.Transparent
}
),
radius = 800f
)
)
)
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
Column(
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo / Title
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(bottom = 48.dp)
) {
Text(text = "🔗", fontSize = 64.sp)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ShaarIt",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your personal bookmark manager",
style = MaterialTheme.typography.bodyLarge,
color = TextSecondary,
textAlign = TextAlign.Center
)
}
// Login Card
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text(
text = "Connect to Shaarli",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
PremiumTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = "Server URL",
placeholder = "https://your-shaarli.com"
)
PremiumTextField(
value = secret,
onValueChange = { secret = it },
modifier = Modifier.fillMaxWidth(),
label = "API Secret",
placeholder = "Enter your API secret",
isPassword = true,
passwordVisible = showSecret,
leadingIcon = {
Icon(
Icons.Default.Lock,
contentDescription = null,
tint = TextMuted
)
},
trailingIcon = {
TextButton(onClick = { showSecret = !showSecret }) {
Text(
if (showSecret) "Hide" else "Show",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
if (uiState is LoginUiState.Loading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
} else {
GradientButton(
text = "Connect",
onClick = { viewModel.login(url, secret) },
modifier = Modifier.fillMaxWidth(),
enabled = url.isNotBlank() && secret.isNotBlank()
)
}
}
}
// Help text
Spacer(modifier = Modifier.height(24.dp))
Text(
text =
"Find your API secret in Shaarli's\n\"Tools\" → \"Configure your Shaarli\"",
style = MaterialTheme.typography.bodySmall,
color = TextMuted,
textAlign = TextAlign.Center,
modifier = Modifier.alpha(0.7f)
)
}
}
}
}
@@ -0,0 +1,59 @@
package com.shaarit.presentation.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.domain.repository.AuthRepository
import com.shaarit.domain.usecase.LoginUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class LoginViewModel
@Inject
constructor(
private val loginUseCase: LoginUseCase,
private val authRepository: AuthRepository // To check login state
) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val uiState = _uiState.asStateFlow()
init {
checkLoginStatus()
}
private fun checkLoginStatus() {
if (authRepository.isLoggedIn()) {
_uiState.value = LoginUiState.Success
} else {
// Pre-fill URL if available
val savedUrl = authRepository.getBaseUrl()
if (!savedUrl.isNullOrBlank()) {
_uiState.value = LoginUiState.Idle // Could verify savedUrl
}
}
}
fun login(url: String, secret: String) {
viewModelScope.launch {
_uiState.value = LoginUiState.Loading
val result = loginUseCase(url, secret)
result.fold(
onSuccess = { _uiState.value = LoginUiState.Success },
onFailure = {
_uiState.value = LoginUiState.Error(it.message ?: "Unknown Error")
}
)
}
}
}
sealed class LoginUiState {
object Idle : LoginUiState()
object Loading : LoginUiState()
object Success : LoginUiState()
data class Error(val message: String) : LoginUiState()
}
@@ -0,0 +1,398 @@
package com.shaarit.presentation.feed
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedScreen(
onNavigateToAdd: () -> Unit,
onNavigateToTags: () -> Unit = {},
initialTagFilter: String? = null,
viewModel: FeedViewModel = hiltViewModel()
) {
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
val searchQuery by viewModel.searchQuery.collectAsState()
val searchTags by viewModel.searchTags.collectAsState()
val context = LocalContext.current
// Set initial tag filter
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
) {
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
Text(
"ShaarIt",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
},
actions = {
// Tags button - using # symbol which represents tags
TextButton(onClick = onNavigateToTags) {
Text(
text = "#",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TealSecondary
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
// Search Bar or Tag Filter
AnimatedContent(
targetState = searchTags != null,
transitionSpec = {
fadeIn() + slideInVertically() togetherWith
fadeOut() + slideOutVertically()
}
) { hasTagFilter ->
if (hasTagFilter && searchTags != null) {
// Tag filter chip
Row(
modifier =
Modifier.fillMaxWidth()
.background(DarkNavy)
.padding(
horizontal = 16.dp,
vertical = 12.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Filtering by:",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
TagChip(
tag = searchTags!!,
isSelected = true,
onClick = { viewModel.clearTagFilter() }
)
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = { viewModel.clearTagFilter() }) {
Icon(
Icons.Default.Close,
contentDescription = "Clear filter",
tint = TextMuted,
modifier = Modifier.size(20.dp)
)
}
}
} else {
// Search Bar
PremiumTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
modifier =
Modifier.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 8.dp
),
placeholder = "Search links...",
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = TextMuted
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = {
viewModel.onSearchQueryChanged("")
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = TextMuted
)
}
}
}
)
}
}
}
},
floatingActionButton = {
FloatingActionButton(
onClick = onNavigateToAdd,
containerColor = CyanPrimary,
contentColor = DeepNavy
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
},
containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
when {
pagingItems.loadState.refresh is LoadState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
}
pagingItems.loadState.refresh is LoadState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load links",
style = MaterialTheme.typography.titleMedium,
color = ErrorRed
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { pagingItems.refresh() }) {
Text("Retry", color = CyanPrimary)
}
}
}
}
pagingItems.itemCount == 0 -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
if (searchQuery.isNotBlank() || searchTags != null)
"No links found"
else "No links yet",
style = MaterialTheme.typography.titleMedium,
color = TextSecondary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
if (searchQuery.isNotBlank() || searchTags != null)
"Try a different search"
else "Add your first link!",
style = MaterialTheme.typography.bodyMedium,
color = TextMuted
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index]
if (link != null) {
LinkItem(
link = link,
onTagClick = viewModel::onTagClicked,
onLinkClick = { url ->
val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onDeleteClick = { viewModel.deleteLink(link.id) }
)
}
}
if (pagingItems.loadState.append is LoadState.Loading) {
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.padding(16.dp),
color = CyanPrimary
)
}
}
}
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LinkItem(
link: ShaarliLink,
onTagClick: (String) -> Unit,
onLinkClick: (String) -> Unit,
onDeleteClick: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Link", fontWeight = FontWeight.Bold, color = TextPrimary) },
text = {
Column {
Text("Are you sure you want to delete this link?", color = TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Text(
link.title,
color = CyanPrimary,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
},
confirmButton = {
TextButton(
onClick = {
onDeleteClick()
showDeleteDialog = false
}
) { Text("Delete", color = ErrorRed) }
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel", color = TextMuted)
}
},
containerColor = CardBackground,
titleContentColor = TextPrimary,
textContentColor = TextSecondary
)
}
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = link.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = TealSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = ErrorRed.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
}
}
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = link.description,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
if (link.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(link.tags) { tag ->
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = link.date,
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
if (link.isPrivate) {
Text(
text = "🔒 Private",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
}
}
}
}
}
@@ -0,0 +1,81 @@
package com.shaarit.presentation.feed
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@HiltViewModel
class FeedViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
private val _searchTags = MutableStateFlow<String?>(null)
val searchTags = _searchTags.asStateFlow()
private val _refreshTrigger = MutableStateFlow(0)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> =
combine(_searchQuery, _searchTags, _refreshTrigger) { query, tags, _ ->
Pair(query, tags)
}
.debounce(300) // Debounce for 300ms
.flatMapLatest { (query, tags) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
searchTags = tags
)
}
.cachedIn(viewModelScope)
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
fun onTagClicked(tag: String) {
// Toggle or set? User said "n'afficher que les liens de ce tag"
// If clicking same tag, maybe clear?
if (_searchTags.value == tag) {
_searchTags.value = null
} else {
_searchTags.value = tag
}
}
fun setInitialTagFilter(tag: String?) {
if (tag != null && _searchTags.value == null) {
_searchTags.value = tag
}
}
fun clearTagFilter() {
_searchTags.value = null
}
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)
// Trigger refresh
refresh()
}
}
fun refresh() {
_refreshTrigger.value++
}
}
@@ -0,0 +1,117 @@
package com.shaarit.presentation.nav
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import java.net.URLEncoder
sealed class Screen(val route: String) {
object Login : Screen("login")
object Feed : Screen("feed?tag={tag}") {
fun createRoute(tag: String? = null): String {
return if (tag != null) "feed?tag=$tag" else "feed"
}
}
object Add : Screen("add?url={url}&title={title}&isShare={isShare}")
object Tags : Screen("tags")
}
@Composable
fun AppNavGraph(
startDestination: String = Screen.Login.route,
shareUrl: String? = null,
shareTitle: String? = null
) {
val navController = rememberNavController()
val context = LocalContext.current
val isShareIntent = shareUrl != null
NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Login.route) {
com.shaarit.presentation.auth.LoginScreen(
onLoginSuccess = {
if (shareUrl != null) {
// Use proper URL encoding that handles spaces correctly
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
val encodedTitle =
if (shareTitle != null) {
URLEncoder.encode(shareTitle, "UTF-8")
} else ""
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
popUpTo(Screen.Login.route) { inclusive = true }
}
} else {
navController.navigate(Screen.Feed.createRoute()) {
popUpTo(Screen.Login.route) { inclusive = true }
}
}
}
)
}
composable(
route = "feed?tag={tag}",
arguments =
listOf(
navArgument("tag") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val tag = backStackEntry.arguments?.getString("tag")
com.shaarit.presentation.feed.FeedScreen(
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
initialTagFilter = tag
)
}
composable(
route = "add?url={url}&title={title}&isShare={isShare}",
arguments =
listOf(
navArgument("url") {
type = NavType.StringType
defaultValue = ""
nullable = true
},
navArgument("title") {
type = NavType.StringType
defaultValue = ""
nullable = true
},
navArgument("isShare") {
type = NavType.BoolType
defaultValue = false
}
)
) { backStackEntry ->
val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false
com.shaarit.presentation.add.AddLinkScreen(
onNavigateBack = { navController.popBackStack() },
onShareSuccess = if (isShare) {
{ (context as? Activity)?.finish() }
} else null
)
}
composable(Screen.Tags.route) {
com.shaarit.presentation.tags.TagsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToFeedWithTag = { tag ->
navController.navigate(Screen.Feed.createRoute(tag)) {
popUpTo(Screen.Tags.route) { inclusive = true }
}
}
)
}
}
}
@@ -0,0 +1,314 @@
package com.shaarit.presentation.tags
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
onNavigateBack: () -> Unit,
onNavigateToFeedWithTag: (String) -> Unit,
viewModel: TagsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val selectedTag by viewModel.selectedTag.collectAsState()
val tagLinks by viewModel.tagLinks.collectAsState()
val isLoadingLinks by viewModel.isLoadingLinks.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val context = LocalContext.current
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top App Bar
TopAppBar(
title = {
Text(
"Tags",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = TextPrimary
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
// Search Bar
PremiumTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = "Search tags...",
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.onSearchQueryChanged("") }) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = TextMuted
)
}
}
}
)
when (val state = uiState) {
is TagsUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CyanPrimary)
}
}
is TagsUiState.Error -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Failed to load tags",
style = MaterialTheme.typography.titleMedium,
color = ErrorRed
)
Spacer(modifier = Modifier.height(8.dp))
Text(
state.message,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
}
is TagsUiState.Success -> {
val filteredTags = viewModel.getFilteredTags()
if (selectedTag != null) {
// Show links for selected tag
TagLinksView(
tag = selectedTag!!,
links = tagLinks,
isLoading = isLoadingLinks,
onBack = { viewModel.clearTagSelection() },
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onViewInFeed = { onNavigateToFeedWithTag(selectedTag!!.name) }
)
} else {
// Show tags grid
TagsGridView(tags = filteredTags, onTagClick = viewModel::onTagSelected)
}
}
}
}
}
}
@Composable
private fun TagsGridView(tags: List<ShaarliTag>, onTagClick: (ShaarliTag) -> Unit) {
if (tags.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tags found", style = MaterialTheme.typography.bodyLarge, color = TextSecondary)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { items(tags) { tag -> TagGridItem(tag = tag, onClick = { onTagClick(tag) }) } }
}
}
@Composable
private fun TagGridItem(tag: ShaarliTag, onClick: () -> Unit) {
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = onClick, glowColor = TealSecondary) {
Column(modifier = Modifier.padding(4.dp)) {
Text(
text = "#${tag.name}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${tag.occurrences} links",
style = MaterialTheme.typography.labelMedium,
color = TextSecondary
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagLinksView(
tag: ShaarliTag,
links: List<ShaarliLink>,
isLoading: Boolean,
onBack: () -> Unit,
onLinkClick: (String) -> Unit,
onViewInFeed: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
// Tag header
GlassCard(modifier = Modifier.fillMaxWidth().padding(16.dp), glowColor = CyanPrimary) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = onBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = TextSecondary
)
}
Text(
text = "#${tag.name}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = CyanPrimary
)
}
Text(
text = "${tag.occurrences} links",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 48.dp)
)
}
TextButton(onClick = onViewInFeed) { Text("View in Feed", color = TealSecondary) }
}
}
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
} else if (links.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No links found for this tag",
style = MaterialTheme.typography.bodyLarge,
color = TextSecondary
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(links) { link ->
TagLinkItem(link = link, onClick = { onLinkClick(link.url) })
}
}
}
}
}
@Composable
private fun TagLinkItem(link: ShaarliLink, onClick: () -> Unit) {
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Column {
Text(
text = link.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = TealSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = link.description,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
if (link.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(link.tags) { tag -> TagChip(tag = tag, isSelected = false, onClick = {}) }
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = link.date, style = MaterialTheme.typography.labelSmall, color = TextMuted)
}
}
}
@@ -0,0 +1,100 @@
package com.shaarit.presentation.tags
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class TagsViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() {
private val _uiState = MutableStateFlow<TagsUiState>(TagsUiState.Loading)
val uiState = _uiState.asStateFlow()
private val _selectedTag = MutableStateFlow<ShaarliTag?>(null)
val selectedTag = _selectedTag.asStateFlow()
private val _tagLinks = MutableStateFlow<List<ShaarliLink>>(emptyList())
val tagLinks = _tagLinks.asStateFlow()
private val _isLoadingLinks = MutableStateFlow(false)
val isLoadingLinks = _isLoadingLinks.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
init {
loadTags()
}
fun loadTags() {
viewModelScope.launch {
_uiState.value = TagsUiState.Loading
linkRepository
.getTags()
.fold(
onSuccess = { tags ->
_uiState.value =
TagsUiState.Success(
tags = tags.sortedByDescending { it.occurrences }
)
},
onFailure = { error ->
_uiState.value =
TagsUiState.Error(error.message ?: "Failed to load tags")
}
)
}
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
fun getFilteredTags(): List<ShaarliTag> {
val state = _uiState.value
if (state !is TagsUiState.Success) return emptyList()
val query = _searchQuery.value.lowercase()
return if (query.isBlank()) {
state.tags
} else {
state.tags.filter { it.name.lowercase().contains(query) }
}
}
fun onTagSelected(tag: ShaarliTag) {
_selectedTag.value = tag
loadLinksForTag(tag.name)
}
fun clearTagSelection() {
_selectedTag.value = null
_tagLinks.value = emptyList()
}
private fun loadLinksForTag(tagName: String) {
viewModelScope.launch {
_isLoadingLinks.value = true
linkRepository
.getLinksByTag(tagName)
.fold(
onSuccess = { links -> _tagLinks.value = links },
onFailure = { _tagLinks.value = emptyList() }
)
_isLoadingLinks.value = false
}
}
}
sealed class TagsUiState {
object Loading : TagsUiState()
data class Success(val tags: List<ShaarliTag>) : TagsUiState()
data class Error(val message: String) : TagsUiState()
}
@@ -0,0 +1,320 @@
package com.shaarit.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.shaarit.ui.theme.*
/** A glassmorphism-styled card with subtle border glow effect */
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
glowColor: Color = CyanPrimary,
content: @Composable ColumnScope.() -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val animatedElevation by
animateDpAsState(
targetValue = if (isPressed) 2.dp else 8.dp,
animationSpec = tween(150)
)
val animatedScale by
animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(150)
)
val borderColor by
animateColorAsState(
targetValue =
if (isPressed) glowColor.copy(alpha = 0.6f)
else glowColor.copy(alpha = 0.2f),
animationSpec = tween(150)
)
val cardModifier =
modifier
.graphicsLayer {
scaleX = animatedScale
scaleY = animatedScale
}
.shadow(
elevation = animatedElevation,
shape = RoundedCornerShape(16.dp),
ambientColor = glowColor.copy(alpha = 0.1f),
spotColor = glowColor.copy(alpha = 0.2f)
)
.clip(RoundedCornerShape(16.dp))
.background(
brush =
Brush.verticalGradient(
colors =
listOf(
CardBackground.copy(alpha = 0.95f),
CardBackgroundElevated.copy(
alpha = 0.9f
)
)
)
)
.border(
width = 1.dp,
brush =
Brush.linearGradient(
colors =
listOf(
borderColor,
borderColor.copy(alpha = 0.1f)
)
),
shape = RoundedCornerShape(16.dp)
)
val finalModifier =
if (onClick != null) {
cardModifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
} else {
cardModifier
}
Column(modifier = finalModifier.padding(16.dp), content = content)
}
/** Premium gradient button with glow effect */
@Composable
fun GradientButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val animatedScale by
animateFloatAsState(
targetValue = if (isPressed) 0.96f else 1f,
animationSpec = tween(100)
)
val gradient =
Brush.horizontalGradient(
colors =
if (enabled) {
listOf(TealSecondary, CyanPrimary)
} else {
listOf(TextMuted, TextMuted)
}
)
Box(
modifier =
modifier
.graphicsLayer {
scaleX = animatedScale
scaleY = animatedScale
}
.shadow(
elevation = if (isPressed) 4.dp else 12.dp,
shape = RoundedCornerShape(12.dp),
ambientColor = CyanPrimary.copy(alpha = 0.3f),
spotColor = CyanPrimary.copy(alpha = 0.4f)
)
.clip(RoundedCornerShape(12.dp))
.background(gradient)
.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled,
onClick = onClick
)
.padding(horizontal = 24.dp, vertical = 14.dp),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
icon?.invoke()
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = DeepNavy
)
}
}
}
/** Tag chip with selection state */
@Composable
fun TagChip(
tag: String,
isSelected: Boolean = false,
onClick: () -> Unit,
count: Int? = null,
modifier: Modifier = Modifier
) {
val backgroundColor by
animateColorAsState(
targetValue =
if (isSelected) CyanPrimary.copy(alpha = 0.2f) else CardBackground,
animationSpec = tween(200)
)
val borderColor by
animateColorAsState(
targetValue = if (isSelected) CyanPrimary else TextMuted.copy(alpha = 0.3f),
animationSpec = tween(200)
)
val textColor by
animateColorAsState(
targetValue = if (isSelected) CyanPrimary else TextSecondary,
animationSpec = tween(200)
)
Row(
modifier =
modifier.clip(RoundedCornerShape(20.dp))
.background(backgroundColor)
.border(1.dp, borderColor, RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "#$tag",
style = MaterialTheme.typography.labelMedium,
color = textColor,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
)
if (count != null) {
Text(
text = count.toString(),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f)
)
}
}
}
/** Premium styled text field */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PremiumTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
singleLine: Boolean = true,
minLines: Int = 1,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
isError: Boolean = false,
isPassword: Boolean = false,
passwordVisible: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = label?.let { { Text(it) } },
placeholder = placeholder?.let { { Text(it, color = TextMuted) } },
singleLine = singleLine,
minLines = minLines,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
isError = isError,
visualTransformation = if (isPassword && !passwordVisible) {
androidx.compose.ui.text.input.PasswordVisualTransformation()
} else {
androidx.compose.ui.text.input.VisualTransformation.None
},
keyboardOptions = if (isPassword) {
androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Password
)
} else {
androidx.compose.foundation.text.KeyboardOptions.Default
},
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = CyanPrimary,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = CyanPrimary,
unfocusedLabelColor = TextSecondary,
cursorColor = CyanPrimary,
focusedContainerColor = CardBackground.copy(alpha = 0.5f),
unfocusedContainerColor = CardBackground.copy(alpha = 0.3f),
errorBorderColor = ErrorRed,
errorLabelColor = ErrorRed
),
shape = RoundedCornerShape(12.dp)
)
}
/** Section header with optional action */
@Composable
fun SectionHeader(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
action: @Composable (() -> Unit)? = null
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary
)
}
}
action?.invoke()
}
}
@@ -0,0 +1,112 @@
package com.shaarit.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Premium Dark Theme Colors - Inspired by modern SaaS interfaces
val DeepNavy = Color(0xFF0A1628)
val DarkNavy = Color(0xFF0D1B2A)
val CardBackground = Color(0xFF1B2838)
val CardBackgroundElevated = Color(0xFF243447)
val SurfaceVariant = Color(0xFF2A3F54)
// Accent Colors
val CyanPrimary = Color(0xFF00D4AA)
val CyanLight = Color(0xFF4EECC4)
val TealSecondary = Color(0xFF0EA5E9)
val TealLight = Color(0xFF38BDF8)
// Text Colors
val TextPrimary = Color(0xFFE2E8F0)
val TextSecondary = Color(0xFF94A3B8)
val TextMuted = Color(0xFF64748B)
// Status Colors
val SuccessGreen = Color(0xFF10B981)
val WarningAmber = Color(0xFFF59E0B)
val ErrorRed = Color(0xFFEF4444)
// Gradient Colors (for reference in custom components)
val GradientStart = Color(0xFF0EA5E9)
val GradientEnd = Color(0xFF00D4AA)
private val DarkColorScheme =
darkColorScheme(
primary = CyanPrimary,
onPrimary = DeepNavy,
primaryContainer = CardBackgroundElevated,
onPrimaryContainer = CyanLight,
secondary = TealSecondary,
onSecondary = DeepNavy,
secondaryContainer = SurfaceVariant,
onSecondaryContainer = TealLight,
tertiary = CyanLight,
onTertiary = DeepNavy,
background = DeepNavy,
onBackground = TextPrimary,
surface = DarkNavy,
onSurface = TextPrimary,
surfaceVariant = CardBackground,
onSurfaceVariant = TextSecondary,
outline = TextMuted,
outlineVariant = SurfaceVariant,
error = ErrorRed,
onError = Color.White,
errorContainer = Color(0xFF450A0A),
onErrorContainer = Color(0xFFFCA5A5)
)
private val LightColorScheme =
lightColorScheme(
primary = Color(0xFF0891B2),
onPrimary = Color.White,
primaryContainer = Color(0xFFCFFAFE),
onPrimaryContainer = Color(0xFF164E63),
secondary = Color(0xFF0284C7),
onSecondary = Color.White,
background = Color(0xFFF8FAFC),
onBackground = Color(0xFF0F172A),
surface = Color.White,
onSurface = Color(0xFF0F172A),
surfaceVariant = Color(0xFFF1F5F9),
onSurfaceVariant = Color(0xFF475569)
)
@Composable
fun ShaarItTheme(
darkTheme: Boolean = true, // Default to dark theme for premium look
dynamicColor: Boolean = false, // Disable dynamic color to maintain brand
content: @Composable () -> Unit
) {
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
else lightColorScheme()
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = DeepNavy.toArgb()
window.navigationBarColor = DeepNavy.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}
@@ -0,0 +1,19 @@
package com.shaarit.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Deep Navy Background -->
<path
android:fillColor="#0A1628"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Bookmark shape with gradient -->
<path
android:pathData="M36,24 L36,78 L54,66 L72,78 L72,24 C72,21.79 70.21,20 68,20 L40,20 C37.79,20 36,21.79 36,24 Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="36"
android:startY="20"
android:endX="72"
android:endY="78"
android:type="linear">
<item android:offset="0" android:color="#FF00D4AA"/>
<item android:offset="1" android:color="#FF0EA5E9"/>
</gradient>
</aapt:attr>
</path>
<!-- Chain link overlay - top part -->
<path
android:pathData="M58,32 C58,28.69 60.69,26 64,26 L70,26 C73.31,26 76,28.69 76,32 L76,38 C76,41.31 73.31,44 70,44 L64,44"
android:strokeWidth="3"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:fillColor="#00000000"/>
<!-- Chain link overlay - bottom part -->
<path
android:pathData="M50,44 C50,47.31 47.31,50 44,50 L38,50 C34.69,50 32,47.31 32,44 L32,38 C32,34.69 34.69,32 38,32 L44,32"
android:strokeWidth="3"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:fillColor="#00000000"/>
<!-- Connecting diagonal -->
<path
android:pathData="M50,44 L58,32"
android:strokeWidth="3"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ShaarIt</string>
</resources>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base app theme -->
<style name="Theme.ShaarIt" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">#0A1628</item>
<item name="android:navigationBarColor">#0A1628</item>
<item name="android:windowBackground">#0A1628</item>
</style>
<!-- Splash screen theme -->
<style name="Theme.ShaarIt.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#0A1628</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">800</item>
<item name="postSplashScreenTheme">@style/Theme.ShaarIt</item>
</style>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<xml xmlns:android="http://schemas.android.com/apk/res/android">
<backup-rules>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</backup-rules>
</xml>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
</device-transfer>
</data-extraction-rules>