first commit

This commit is contained in:
Bruno Charest 2026-01-11 19:47:49 -05:00
commit 1438003f94
56 changed files with 4829 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@ -0,0 +1,66 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/codeStyles
# Keystore files (NEVER commit these!)
*.jks
*.keystore
keystore.properties
# External native build folder
.externalNativeBuild
.cxx/
# macOS
.DS_Store
# Windows
Thumbs.db
# Build output
build_errors.txt

114
README.md Normal file
View File

@ -0,0 +1,114 @@
# ShaarIt
**ShaarIt** is a native Android client for [Shaarli](https://github.com/shaarli/Shaarli), the self-hosted bookmark manager. Built with modern Android technologies, it provides a seamless mobile experience for managing your links.
## Features
* **Authentication**: Secure login to your self-hosted Shaarli instance (API v1).
* **Feed**: Infinite scroll browsing of your bookmarks with infinite loading.
* **Search & Filter**: Server-side search by terms and tag filtering.
* **Management**: Add new private/public links and delete existing ones.
* **Share Intent**: Quick-save links from other apps (browser, YouTube, etc.) via the Android Share menu.
## Tech Stack
* **Language**: Kotlin
* **UI**: Jetpack Compose (Material Design 3)
* **Architecture**: Clean Architecture + MVVM
* **Dependency Injection**: Dagger Hilt
* **Network**: Retrofit + Moshi + OkHttp
* **Concurrency**: Coroutines & Flow
* **Pagination**: Paging 3
## Prerequisites
Before building via the command line, ensure you have:
1. **JDK 17** (or newer) installed.
2. **Android SDK** installed.
3. **Gradle** (v8.0+) installed (only required if `gradlew` is missing).
* *Note for Windows users with Scoop:* `scoop install gradle`
## First Time Setup (If gradlew is missing)
If the `./gradlew` file is missing (e.g. fresh project generation), you need to generate it using a local Gradle installation:
```bash
gradle wrapper
```
This will create `gradlew`, `gradlew.bat` and the `gradle/wrapper` folder.
## Environment Setup (SDK)
Ensure you have the required Android SDK components installed (Platform API 34).
If you see a silent failure or "missing target" error, run:
```powershell
# Adjust path to your sdkmanager if needed
$SDK_MANAGER = "$env:ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager"
& $SDK_MANAGER "platforms;android-34" "build-tools;34.0.0"
```
## Compilation Instructions (Command Line)
You can build the project without opening Android Studio by using the Gradle Wrapper included in the project.
### 1. Configure SDK Location
If your `ANDROID_HOME` environment variable is not set, create a `local.properties` file in the project root:
```bash
# Windows
echo sdk.dir=C:\\Users\\<Username>\\AppData\\Local\\Android\\Sdk > local.properties
# Linux/macOS
echo sdk.dir=/home/<username>/Android/Sdk > local.properties
```
### 2. Build the APK
Open your terminal in the project root folder.
**Windows (PowerShell/CMD):**
```powershell
./gradlew assembleDebug
```
**Linux/macOS:**
```bash
chmod +x gradlew
./gradlew assembleDebug
```
*The build process may take a few minutes as it downloads dependencies.*
### 3. Locate the APK
Once the build is successful, the APK file will be located at:
`app/build/outputs/apk/debug/app-debug.apk`
## Installation
To install the app on a connected device or emulator via command line:
```bash
# Ensure your device is connected and visible
adb devices
# Install the Debug APK
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
## Running Tests
To run unit tests:
```bash
./gradlew test
```
## Usage
1. Open the **ShaarIt** app.
2. Enter your **Shaarli Instance URL** (e.g., `https://myserver.com/shaarli`).
3. Enter your **API Secret** (found in your Shaarli admin settings).
4. Click **Connect**.

94
RELEASE_BUILD.md Normal file
View File

@ -0,0 +1,94 @@
# ShaarIt - Instructions de Build Release
## Prérequis
- Android Studio ou Gradle CLI
- Java JDK 8+ installé
## Création du Keystore (à faire une seule fois)
### Via ligne de commande:
```bash
keytool -genkey -v -keystore shaarit-release.keystore -alias shaarit -keyalg RSA -keysize 2048 -validity 10000
```
### Informations à fournir:
- **Mot de passe keystore**: (notez-le précieusement!)
- **Nom et prénom**: Votre nom
- **Unité organisationnelle**: (ex: Development)
- **Organisation**: Votre entreprise
- **Ville**: Votre ville
- **Province/État**: Votre province
- **Code pays**: CA (pour Canada)
## Configuration de la signature
### Option 1: Variables d'environnement (recommandé)
Créez un fichier `local.properties` (ne pas commiter!) avec:
```properties
SHAARIT_KEYSTORE_PATH=../shaarit-release.keystore
SHAARIT_KEYSTORE_PASSWORD=votre_mot_de_passe
SHAARIT_KEY_ALIAS=shaarit
SHAARIT_KEY_PASSWORD=votre_mot_de_passe_cle
```
### Option 2: Fichier séparé
Créez `keystore.properties` (ne pas commiter!) avec:
```properties
storeFile=../shaarit-release.keystore
storePassword=votre_mot_de_passe
keyAlias=shaarit
keyPassword=votre_mot_de_passe_cle
```
## Build Release
### Debug build (pour tester):
```bash
./gradlew assembleDebug
```
L'APK sera dans: `app/build/outputs/apk/debug/app-debug.apk`
### Release build (pour production):
```bash
./gradlew assembleRelease
```
L'APK sera dans: `app/build/outputs/apk/release/app-release.apk`
### Bundle AAB (pour Google Play Store):
```bash
./gradlew bundleRelease
```
Le bundle sera dans: `app/build/outputs/bundle/release/app-release.aab`
## Vérification du build
Vérifiez l'APK signé:
```bash
jarsigner -verify -verbose -certs app/build/outputs/apk/release/app-release.apk
```
## Checklist avant publication
- [ ] Version code incrémenté dans `build.gradle.kts`
- [ ] Version name mise à jour
- [ ] Tests passés
- [ ] ProGuard configuré et testé
- [ ] APK signé et vérifié
- [ ] Captures d'écran prêtes pour le store
- [ ] Description de l'app rédigée
- [ ] Icône et assets graphiques finalisés
## Notes de sécurité
⚠️ **IMPORTANT**: Ne jamais commiter ces fichiers:
- `*.keystore`
- `*.jks`
- `keystore.properties`
- `local.properties` (sauf template)
Ajoutez au `.gitignore`:
```
*.keystore
*.jks
keystore.properties
```

132
app/build.gradle.kts Normal file
View File

@ -0,0 +1,132 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.shaarit"
compileSdk = 34
defaultConfig {
applicationId = "com.shaarit"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
// Navigation
implementation(libs.androidx.navigation.compose)
// Paging
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
// Hilt
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.compiler)
// Retrofit & Network
implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// Moshi
implementation(libs.moshi)
ksp(libs.moshi.kotlin.codegen)
// Security
implementation(libs.androidx.security.crypto)
// Splash Screen
implementation("androidx.core:core-splashscreen:1.0.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

62
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,62 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Keep classes used for serialization
-keepattributes *Annotation*,EnclosingMethod,InnerClasses
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
# Retrofit
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Moshi
-keep class com.squareup.moshi.** { *; }
-keep interface com.squareup.moshi.** { *; }
-keep class com.shaarit.data.dto.** { *; }
-keepclassmembers class com.shaarit.data.dto.** { *; }
# Keep Kotlin Metadata
-keep class kotlin.Metadata { *; }
# Hilt
-keepclasseswithmembers class * {
@dagger.* <methods>;
}
-keepclasseswithmembers class * {
@javax.inject.* <fields>;
@javax.inject.* <methods>;
}
-keep class dagger.* { *; }
-keep class javax.inject.* { *; }
-dontwarn dagger.internal.codegen.**
# JWT (io.jsonwebtoken)
-keep class io.jsonwebtoken.** { *; }
# Compose
-keep class androidx.compose.** { *; }
# Application classes
-keep class com.shaarit.domain.model.** { *; }
-keep class com.shaarit.data.api.** { *; }

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>

View File

@ -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") }
}

View File

@ -0,0 +1,6 @@
package com.shaarit
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp class ShaarItApp : Application()

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -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()
}
}

View File

@ -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"
}
}

View File

@ -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))
}
}

View File

@ -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>
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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
)

View File

@ -0,0 +1,3 @@
package com.shaarit.domain.model
data class ShaarliTag(val name: String, val occurrences: Int)

View File

@ -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?
}

View File

@ -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>>
}

View File

@ -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)
}
}

View File

@ -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))
}
}
}
}

View File

@ -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()
}

View File

@ -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)
)
}
}
}
}

View File

@ -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()
}

View File

@ -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
)
}
}
}
}
}

View File

@ -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++
}
}

View File

@ -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 }
}
}
)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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
)
)

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ShaarIt</string>
</resources>

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>

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>

View File

@ -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>

7
build.gradle.kts Normal file
View File

@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

3
gradle.properties Normal file
View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true

58
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,58 @@
[versions]
agp = "8.2.0"
kotlin = "1.9.20"
coreKtx = "1.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.8.2"
composeBom = "2023.08.00"
hilt = "2.48.1"
retrofit = "2.9.0"
moshi = "1.15.0"
okhttp = "4.12.0"
navigationCompose = "2.7.6"
securityCrypto = "1.1.0-alpha06"
ksp = "1.9.20-1.0.14"
paging = "3.2.1"
pagingCompose = "3.2.1"
material = "1.11.0"
[libraries]
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.1.0" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Normal file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

19
settings.gradle.kts Normal file
View File

@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ShaarIt"
include(":app")

555
test_shaarli.py Normal file
View File

@ -0,0 +1,555 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Shaarli API Complete Test Script
Tests all available endpoints of the Shaarli REST API
"""
import jwt
import time
import requests
import json
import sys
import io
from datetime import datetime
from typing import Optional, Dict, Any, List
# Fix Windows console encoding
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
# --- CONFIGURATION ---
SHAARLI_URL = "https://bm.dracodev.net"
API_SECRET = "Chab30017405"
# ---------------------
class ShaarliAPITester:
"""Complete Shaarli API Tester"""
def __init__(self, base_url: str, api_secret: str):
self.base_url = base_url.rstrip('/')
self.api_secret = api_secret
self.session = requests.Session()
self.test_link_id: Optional[int] = None
def generate_token(self) -> str:
"""Generate JWT HS512 token required by Shaarli"""
# iat must be slightly in the past to avoid clock skew issues
payload = {'iat': int(time.time()) - 60}
token = jwt.encode(payload, self.api_secret, algorithm='HS512')
if isinstance(token, bytes):
token = token.decode('utf-8')
return token
def get_headers(self) -> Dict[str, str]:
"""Get request headers with fresh JWT token"""
return {
'Authorization': f'Bearer {self.generate_token()}',
'Content-Type': 'application/json'
}
def api_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> requests.Response:
"""Make an API request"""
url = f"{self.base_url}/api/v1{endpoint}"
headers = self.get_headers()
if method.upper() == 'GET':
return self.session.get(url, headers=headers, params=data)
elif method.upper() == 'POST':
return self.session.post(url, headers=headers, json=data)
elif method.upper() == 'PUT':
return self.session.put(url, headers=headers, json=data)
elif method.upper() == 'DELETE':
return self.session.delete(url, headers=headers)
else:
raise ValueError(f"Unknown method: {method}")
def print_header(self, title: str):
"""Print a formatted section header"""
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
def print_subheader(self, title: str):
"""Print a formatted subsection header"""
print(f"\n--- {title} ---")
def print_success(self, message: str):
print(f"[OK] {message}")
def print_error(self, message: str):
print(f"[FAIL] {message}")
def print_info(self, key: str, value: Any):
print(f" > {key}: {value}")
def print_item(self, message: str):
print(f" * {message}")
# ==================== INFO ENDPOINT ====================
def test_info(self) -> bool:
"""Test GET /info - Instance information"""
self.print_subheader("GET /info - Instance Information")
try:
response = self.api_request('GET', '/info')
if response.status_code == 200:
data = response.json()
self.print_success("Instance information retrieved!")
self.print_info("Global Links Count", data.get('global_counter', 'N/A'))
self.print_info("Private Links Count", data.get('private_counter', 'N/A'))
settings = data.get('settings', {})
if settings:
self.print_info("Title", settings.get('title', 'N/A'))
self.print_info("Header Link", settings.get('header_link', 'N/A'))
self.print_info("Timezone", settings.get('timezone', 'N/A'))
self.print_info("Default Private", settings.get('default_private_links', 'N/A'))
plugins = settings.get('enabled_plugins', [])
self.print_info("Enabled Plugins", ', '.join(plugins) if plugins else 'None')
return True
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
# ==================== LINKS ENDPOINTS ====================
def test_get_links(self, limit: int = 5) -> bool:
"""Test GET /links - Get links list"""
self.print_subheader(f"GET /links - Get Links (limit={limit})")
try:
response = self.api_request('GET', '/links', {'limit': limit})
if response.status_code == 200:
links = response.json()
self.print_success(f"Retrieved {len(links)} links")
for link in links[:5]: # Show max 5
title = link.get('title', 'No title')[:50]
url = link.get('url', '')[:40]
tags = ', '.join(link.get('tags', [])) or 'No tags'
private = "[PRIVATE]" if link.get('private') else "[PUBLIC]"
self.print_item(f"{private} [{link.get('id')}] {title}")
print(f" URL: {url}...")
print(f" Tags: {tags}")
return True
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_get_links_with_search(self, searchterm: str) -> bool:
"""Test GET /links with search parameter"""
self.print_subheader(f"GET /links?searchterm={searchterm}")
try:
response = self.api_request('GET', '/links', {'searchterm': searchterm, 'limit': 5})
if response.status_code == 200:
links = response.json()
self.print_success(f"Found {len(links)} links matching '{searchterm}'")
for link in links[:3]:
self.print_item(f"[{link.get('id')}] {link.get('title', 'No title')[:50]}")
return True
else:
self.print_error(f"Failed with status {response.status_code}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_get_links_visibility(self, visibility: str) -> bool:
"""Test GET /links with visibility filter"""
self.print_subheader(f"GET /links?visibility={visibility}")
try:
response = self.api_request('GET', '/links', {'visibility': visibility, 'limit': 5})
if response.status_code == 200:
links = response.json()
self.print_success(f"Found {len(links)} {visibility} links")
return True
else:
self.print_error(f"Failed with status {response.status_code}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_create_link(self) -> bool:
"""Test POST /links - Create a new link"""
self.print_subheader("POST /links - Create New Link")
test_link = {
'url': f'https://example.com/test-{int(time.time())}',
'title': f'Test Link from API - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
'description': 'This is a test link created by the Shaarli API tester script',
'tags': ['test', 'api', 'automated'],
'private': True
}
try:
response = self.api_request('POST', '/links', test_link)
if response.status_code in [200, 201]:
link = response.json()
self.test_link_id = link.get('id')
self.print_success(f"Link created successfully!")
self.print_info("ID", link.get('id'))
self.print_info("Title", link.get('title'))
self.print_info("URL", link.get('url'))
self.print_info("Short URL", link.get('shorturl'))
self.print_info("Private", link.get('private'))
self.print_info("Tags", ', '.join(link.get('tags', [])))
return True
elif response.status_code == 409:
self.print_error("Conflict - Link URL already exists")
return False
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_get_single_link(self, link_id: int) -> bool:
"""Test GET /links/{id} - Get a specific link"""
self.print_subheader(f"GET /links/{link_id} - Get Single Link")
try:
response = self.api_request('GET', f'/links/{link_id}')
if response.status_code == 200:
link = response.json()
self.print_success("Link retrieved successfully!")
self.print_info("ID", link.get('id'))
self.print_info("Title", link.get('title'))
self.print_info("URL", link.get('url'))
self.print_info("Created", link.get('created'))
self.print_info("Updated", link.get('updated'))
return True
elif response.status_code == 404:
self.print_error(f"Link {link_id} not found")
return False
else:
self.print_error(f"Failed with status {response.status_code}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_update_link(self, link_id: int) -> bool:
"""Test PUT /links/{id} - Update a link"""
self.print_subheader(f"PUT /links/{link_id} - Update Link")
# First get the current link
try:
response = self.api_request('GET', f'/links/{link_id}')
if response.status_code != 200:
self.print_error(f"Cannot get link to update: {response.status_code}")
return False
current_link = response.json()
# Update the link
updated_data = {
'url': current_link.get('url'),
'title': f"{current_link.get('title')} [UPDATED]",
'description': f"{current_link.get('description', '')} - Updated at {datetime.now().strftime('%H:%M:%S')}",
'tags': current_link.get('tags', []) + ['updated'],
'private': current_link.get('private', True)
}
response = self.api_request('PUT', f'/links/{link_id}', updated_data)
if response.status_code == 200:
link = response.json()
self.print_success("Link updated successfully!")
self.print_info("New Title", link.get('title'))
self.print_info("New Tags", ', '.join(link.get('tags', [])))
return True
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_delete_link(self, link_id: int) -> bool:
"""Test DELETE /links/{id} - Delete a link"""
self.print_subheader(f"DELETE /links/{link_id} - Delete Link")
try:
response = self.api_request('DELETE', f'/links/{link_id}')
if response.status_code in [200, 204]:
self.print_success(f"Link {link_id} deleted successfully!")
return True
elif response.status_code == 404:
self.print_error(f"Link {link_id} not found")
return False
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
# ==================== TAGS ENDPOINTS ====================
def test_get_tags(self, limit: int = 20) -> bool:
"""Test GET /tags - Get all tags"""
self.print_subheader(f"GET /tags - Get Tags (limit={limit})")
try:
response = self.api_request('GET', '/tags', {'limit': limit})
if response.status_code == 200:
tags = response.json()
self.print_success(f"Retrieved {len(tags)} tags")
# Show tags sorted by occurrences
for tag in tags[:15]:
name = tag.get('name', 'Unknown')
occurrences = tag.get('occurrences', 0)
bar = '#' * min(occurrences, 20)
self.print_item(f"{name}: {occurrences} {bar}")
if len(tags) > 15:
print(f" ... and {len(tags) - 15} more tags")
return True
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
def test_get_single_tag(self, tag_name: str) -> bool:
"""Test GET /tags/{tagName} - Get a specific tag"""
self.print_subheader(f"GET /tags/{tag_name}")
try:
response = self.api_request('GET', f'/tags/{tag_name}')
if response.status_code == 200:
tag = response.json()
self.print_success("Tag retrieved!")
self.print_info("Name", tag.get('name'))
self.print_info("Occurrences", tag.get('occurrences'))
return True
elif response.status_code == 404:
self.print_error(f"Tag '{tag_name}' not found")
return False
else:
self.print_error(f"Failed with status {response.status_code}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
# ==================== HISTORY ENDPOINT ====================
def test_get_history(self, limit: int = 10) -> bool:
"""Test GET /history - Get recent actions"""
self.print_subheader(f"GET /history - Recent Actions (limit={limit})")
try:
response = self.api_request('GET', '/history', {'limit': limit})
if response.status_code == 200:
history = response.json()
self.print_success(f"Retrieved {len(history)} history entries")
action_icons = {
'CREATED': '[+]',
'UPDATED': '[~]',
'DELETED': '[-]',
'SETTINGS': '[=]'
}
for entry in history[:10]:
action = entry.get('event', 'UNKNOWN')
icon = action_icons.get(action, '[?]')
link_id = entry.get('id', 'N/A')
datetime_str = entry.get('datetime', 'Unknown time')
self.print_item(f"{icon} {action} - Link #{link_id} at {datetime_str}")
return True
else:
self.print_error(f"Failed with status {response.status_code}: {response.text}")
return False
except Exception as e:
self.print_error(f"Error: {e}")
return False
# ==================== MAIN TEST RUNNER ====================
def run_all_tests(self, include_write_tests: bool = True):
"""Run all API tests"""
print(f"\n{'#'*60}")
print(f"# SHAARLI API COMPLETE TEST SUITE")
print(f"# Target: {self.base_url}")
print(f"# Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'#'*60}")
results = {
'passed': 0,
'failed': 0,
'skipped': 0
}
# ===== INFO =====
self.print_header("INSTANCE INFORMATION")
if self.test_info():
results['passed'] += 1
else:
results['failed'] += 1
# ===== LINKS (READ) =====
self.print_header("LINKS - READ OPERATIONS")
if self.test_get_links(limit=5):
results['passed'] += 1
else:
results['failed'] += 1
# Get first link ID for single link test
try:
response = self.api_request('GET', '/links', {'limit': 1})
if response.status_code == 200:
links = response.json()
if links:
first_link_id = links[0].get('id')
if self.test_get_single_link(first_link_id):
results['passed'] += 1
else:
results['failed'] += 1
except:
results['skipped'] += 1
if self.test_get_links_visibility('public'):
results['passed'] += 1
else:
results['failed'] += 1
if self.test_get_links_visibility('private'):
results['passed'] += 1
else:
results['failed'] += 1
if self.test_get_links_with_search('test'):
results['passed'] += 1
else:
results['failed'] += 1
# ===== TAGS =====
self.print_header("TAGS")
if self.test_get_tags():
results['passed'] += 1
else:
results['failed'] += 1
# Get first tag for single tag test
try:
response = self.api_request('GET', '/tags', {'limit': 1})
if response.status_code == 200:
tags = response.json()
if tags:
first_tag = tags[0].get('name')
if self.test_get_single_tag(first_tag):
results['passed'] += 1
else:
results['failed'] += 1
except:
results['skipped'] += 1
# ===== HISTORY =====
self.print_header("HISTORY")
if self.test_get_history():
results['passed'] += 1
else:
results['failed'] += 1
# ===== LINKS (WRITE) =====
if include_write_tests:
self.print_header("LINKS - WRITE OPERATIONS (CREATE/UPDATE/DELETE)")
print("WARNING: These tests will create, modify, and delete a test link")
# Create
if self.test_create_link():
results['passed'] += 1
if self.test_link_id:
# Update
if self.test_update_link(self.test_link_id):
results['passed'] += 1
else:
results['failed'] += 1
# Delete
if self.test_delete_link(self.test_link_id):
results['passed'] += 1
else:
results['failed'] += 1
else:
results['failed'] += 1
results['skipped'] += 2
else:
self.print_header("WRITE OPERATIONS - SKIPPED")
print(" (Use include_write_tests=True to test CREATE/UPDATE/DELETE)")
results['skipped'] += 3
# ===== SUMMARY =====
self.print_header("TEST SUMMARY")
total = results['passed'] + results['failed'] + results['skipped']
print(f"\n [OK] Passed: {results['passed']}")
print(f" [FAIL] Failed: {results['failed']}")
print(f" [SKIP] Skipped: {results['skipped']}")
print(f" ----------------")
print(f" Total: {total}")
if results['failed'] == 0:
print(f"\n SUCCESS! All tests passed! Your Shaarli API is working correctly.")
else:
print(f"\n WARNING: Some tests failed. Check the output above for details.")
return results
def main():
print("Starting Shaarli API Test Suite...")
tester = ShaarliAPITester(SHAARLI_URL, API_SECRET)
# Run all tests including write operations
# Set to False if you don't want to create/modify/delete test links
results = tester.run_all_tests(include_write_tests=True)
# Exit with error code if any tests failed
sys.exit(0 if results['failed'] == 0 else 1)
if __name__ == "__main__":
main()