commit 1438003f941f0cf2a518eac53c003cf39f3fca07 Author: Bruno Charest Date: Sun Jan 11 19:47:49 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9055a01 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f7ccee --- /dev/null +++ b/README.md @@ -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\\\\AppData\\Local\\Android\\Sdk > local.properties + +# Linux/macOS +echo sdk.dir=/home//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**. diff --git a/RELEASE_BUILD.md b/RELEASE_BUILD.md new file mode 100644 index 0000000..f578002 --- /dev/null +++ b/RELEASE_BUILD.md @@ -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 +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f2528bb --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..b6452e5 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.* ; +} +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* +-if interface * { @retrofit2.http.* ; } +-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.* ; +} +-keepclasseswithmembers class * { + @javax.inject.* ; + @javax.inject.* ; +} +-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.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d17f3e5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/shaarit/MainActivity.kt b/app/src/main/java/com/shaarit/MainActivity.kt new file mode 100644 index 0000000..1c46b50 --- /dev/null +++ b/app/src/main/java/com/shaarit/MainActivity.kt @@ -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") } +} + diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt new file mode 100644 index 0000000..fe8a090 --- /dev/null +++ b/app/src/main/java/com/shaarit/ShaarItApp.kt @@ -0,0 +1,6 @@ +package com.shaarit + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp class ShaarItApp : Application() diff --git a/app/src/main/java/com/shaarit/core/di/AppModule.kt b/app/src/main/java/com/shaarit/core/di/AppModule.kt new file mode 100644 index 0000000..11ef47a --- /dev/null +++ b/app/src/main/java/com/shaarit/core/di/AppModule.kt @@ -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 +} diff --git a/app/src/main/java/com/shaarit/core/di/NetworkModule.kt b/app/src/main/java/com/shaarit/core/di/NetworkModule.kt new file mode 100644 index 0000000..4e58445 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/di/NetworkModule.kt @@ -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) + } +} diff --git a/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt new file mode 100644 index 0000000..c2f8d19 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/di/RepositoryModule.kt @@ -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 +} diff --git a/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt new file mode 100644 index 0000000..ef7286c --- /dev/null +++ b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt @@ -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()) + } +} diff --git a/app/src/main/java/com/shaarit/core/network/HostSelectionInterceptor.kt b/app/src/main/java/com/shaarit/core/network/HostSelectionInterceptor.kt new file mode 100644 index 0000000..acb31ed --- /dev/null +++ b/app/src/main/java/com/shaarit/core/network/HostSelectionInterceptor.kt @@ -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() + } +} diff --git a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt new file mode 100644 index 0000000..2c5ede0 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt b/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt new file mode 100644 index 0000000..de3e188 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/util/JwtGenerator.kt @@ -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": } + * - 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)) + } +} diff --git a/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt new file mode 100644 index 0000000..d6013bf --- /dev/null +++ b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt @@ -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 + + @POST("/api/v1/links") suspend fun addLink(@Body link: CreateLinkDto): Response + + @PUT("/api/v1/links/{id}") + suspend fun updateLink(@Path("id") id: Int, @Body link: CreateLinkDto): Response + + @DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response + + /** 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 + + /** 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 +} diff --git a/app/src/main/java/com/shaarit/data/dto/Dtos.kt b/app/src/main/java/com/shaarit/data/dto/Dtos.kt new file mode 100644 index 0000000..1f247e3 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/dto/Dtos.kt @@ -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?, + @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? = 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? = null, + @Json(name = "default_private_links") val defaultPrivateLinks: Boolean? = null +) diff --git a/app/src/main/java/com/shaarit/data/dto/TagDto.kt b/app/src/main/java/com/shaarit/data/dto/TagDto.kt new file mode 100644 index 0000000..bce682f --- /dev/null +++ b/app/src/main/java/com/shaarit/data/dto/TagDto.kt @@ -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 +) diff --git a/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt b/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt new file mode 100644 index 0000000..9035e19 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt @@ -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) + } +} diff --git a/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt b/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt new file mode 100644 index 0000000..6b3998f --- /dev/null +++ b/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt @@ -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() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + 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) + } + } +} diff --git a/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..e7ab5f8 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt @@ -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 { + // 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() + } +} diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt new file mode 100644 index 0000000..da940f5 --- /dev/null +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -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> { + 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?, + isPrivate: Boolean + ): Result { + 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?, + 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?, + isPrivate: Boolean + ): Result { + 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 { + 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> { + 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> { + 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) + } + } +} diff --git a/app/src/main/java/com/shaarit/domain/model/Models.kt b/app/src/main/java/com/shaarit/domain/model/Models.kt new file mode 100644 index 0000000..b5e73f6 --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/Models.kt @@ -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, + val isPrivate: Boolean, + val date: String +) diff --git a/app/src/main/java/com/shaarit/domain/model/ShaarliTag.kt b/app/src/main/java/com/shaarit/domain/model/ShaarliTag.kt new file mode 100644 index 0000000..6e8c7ad --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/ShaarliTag.kt @@ -0,0 +1,3 @@ +package com.shaarit.domain.model + +data class ShaarliTag(val name: String, val occurrences: Int) diff --git a/app/src/main/java/com/shaarit/domain/repository/AuthRepository.kt b/app/src/main/java/com/shaarit/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..ce9d67a --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/repository/AuthRepository.kt @@ -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 + fun isLoggedIn(): Boolean + fun logout() + fun getBaseUrl(): String? +} diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt new file mode 100644 index 0000000..56324bf --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt @@ -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> + + suspend fun addLink( + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean + ): Result + + suspend fun addOrUpdateLink( + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean, + forceUpdate: Boolean = false, + existingLinkId: Int? = null + ): AddLinkResult + + suspend fun updateLink( + id: Int, + url: String, + title: String?, + description: String?, + tags: List?, + isPrivate: Boolean + ): Result + + suspend fun deleteLink(id: Int): Result + + suspend fun getTags(): Result> + + suspend fun getLinksByTag(tag: String): Result> +} diff --git a/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..55cdd9e --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt @@ -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 { + if (url.isBlank() || secret.isBlank()) { + return Result.failure(IllegalArgumentException("URL and Secret cannot be empty")) + } + return repository.login(Credentials(url, secret), url) + } +} diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt new file mode 100644 index 0000000..d6349b2 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt new file mode 100644 index 0000000..b498473 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -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.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>(emptyList()) + val selectedTags = _selectedTags.asStateFlow() + + private val _newTagInput = MutableStateFlow("") + val newTagInput = _newTagInput.asStateFlow() + + private val _availableTags = MutableStateFlow>(emptyList()) + val availableTags = _availableTags.asStateFlow() + + private val _tagSuggestions = MutableStateFlow>(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() +} diff --git a/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt b/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt new file mode 100644 index 0000000..53cc4db --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt b/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt new file mode 100644 index 0000000..b0edb36 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt @@ -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.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() +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt new file mode 100644 index 0000000..b614f5e --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt new file mode 100644 index 0000000..c918e62 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt @@ -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(null) + val searchTags = _searchTags.asStateFlow() + + private val _refreshTrigger = MutableStateFlow(0) + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val pagedLinks: Flow> = + 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++ + } +} diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt new file mode 100644 index 0000000..a483771 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -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 } + } + } + ) + } + } +} + diff --git a/app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt b/app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt new file mode 100644 index 0000000..751b1c5 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt @@ -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, 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, + 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) + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/tags/TagsViewModel.kt b/app/src/main/java/com/shaarit/presentation/tags/TagsViewModel.kt new file mode 100644 index 0000000..968f861 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/tags/TagsViewModel.kt @@ -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.Loading) + val uiState = _uiState.asStateFlow() + + private val _selectedTag = MutableStateFlow(null) + val selectedTag = _selectedTag.asStateFlow() + + private val _tagLinks = MutableStateFlow>(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 { + 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) : TagsUiState() + data class Error(val message: String) : TagsUiState() +} diff --git a/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt b/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt new file mode 100644 index 0000000..058401a --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt @@ -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() + } +} diff --git a/app/src/main/java/com/shaarit/ui/theme/Theme.kt b/app/src/main/java/com/shaarit/ui/theme/Theme.kt new file mode 100644 index 0000000..596e5b3 --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/java/com/shaarit/ui/theme/Type.kt b/app/src/main/java/com/shaarit/ui/theme/Type.kt new file mode 100644 index 0000000..a69e669 --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/theme/Type.kt @@ -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 + ) + ) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..de8684a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8e635c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d3dd294 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + ShaarIt + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..957da89 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..6b544f5 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..79b9958 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1655225 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cc18a0c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..507cfad --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..44c3650 --- /dev/null +++ b/settings.gradle.kts @@ -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") diff --git a/test_shaarli.py b/test_shaarli.py new file mode 100644 index 0000000..93d10b7 --- /dev/null +++ b/test_shaarli.py @@ -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() \ No newline at end of file