From 1438003f941f0cf2a518eac53c003cf39f3fca07 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 11 Jan 2026 19:47:49 -0500 Subject: [PATCH] first commit --- .gitignore | 66 +++ README.md | 114 ++++ RELEASE_BUILD.md | 94 +++ app/build.gradle.kts | 132 +++++ app/proguard-rules.pro | 62 ++ app/src/main/AndroidManifest.xml | 39 ++ app/src/main/java/com/shaarit/MainActivity.kt | 62 ++ app/src/main/java/com/shaarit/ShaarItApp.kt | 6 + .../java/com/shaarit/core/di/AppModule.kt | 16 + .../java/com/shaarit/core/di/NetworkModule.kt | 54 ++ .../com/shaarit/core/di/RepositoryModule.kt | 20 + .../shaarit/core/network/AuthInterceptor.kt | 24 + .../core/network/HostSelectionInterceptor.kt | 90 +++ .../com/shaarit/core/storage/TokenManager.kt | 78 +++ .../com/shaarit/core/util/JwtGenerator.kt | 56 ++ .../java/com/shaarit/data/api/ShaarliApi.kt | 50 ++ .../main/java/com/shaarit/data/dto/Dtos.kt | 49 ++ .../main/java/com/shaarit/data/dto/TagDto.kt | 10 + .../com/shaarit/data/mapper/LinkMapper.kt | 26 + .../shaarit/data/paging/LinkPagingSource.kt | 68 +++ .../data/repository/AuthRepositoryImpl.kt | 49 ++ .../data/repository/LinkRepositoryImpl.kt | 172 ++++++ .../java/com/shaarit/domain/model/Models.kt | 13 + .../com/shaarit/domain/model/ShaarliTag.kt | 3 + .../domain/repository/AuthRepository.kt | 10 + .../domain/repository/LinkRepository.kt | 52 ++ .../shaarit/domain/usecase/LoginUseCase.kt | 14 + .../shaarit/presentation/add/AddLinkScreen.kt | 371 ++++++++++++ .../presentation/add/AddLinkViewModel.kt | 208 +++++++ .../shaarit/presentation/auth/LoginScreen.kt | 202 +++++++ .../presentation/auth/LoginViewModel.kt | 59 ++ .../shaarit/presentation/feed/FeedScreen.kt | 398 +++++++++++++ .../presentation/feed/FeedViewModel.kt | 81 +++ .../com/shaarit/presentation/nav/NavGraph.kt | 117 ++++ .../shaarit/presentation/tags/TagsScreen.kt | 314 ++++++++++ .../presentation/tags/TagsViewModel.kt | 100 ++++ .../ui/components/PremiumComponents.kt | 320 ++++++++++ .../main/java/com/shaarit/ui/theme/Theme.kt | 112 ++++ .../main/java/com/shaarit/ui/theme/Type.kt | 19 + .../res/drawable/ic_launcher_background.xml | 11 + .../res/drawable/ic_launcher_foreground.xml | 49 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 17 + app/src/main/res/xml/backup_rules.xml | 7 + .../main/res/xml/data_extraction_rules.xml | 11 + build.gradle.kts | 7 + gradle.properties | 3 + gradle/libs.versions.toml | 58 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++ gradlew.bat | 93 +++ settings.gradle.kts | 19 + test_shaarli.py | 555 ++++++++++++++++++ 56 files changed, 4829 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 RELEASE_BUILD.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/shaarit/MainActivity.kt create mode 100644 app/src/main/java/com/shaarit/ShaarItApp.kt create mode 100644 app/src/main/java/com/shaarit/core/di/AppModule.kt create mode 100644 app/src/main/java/com/shaarit/core/di/NetworkModule.kt create mode 100644 app/src/main/java/com/shaarit/core/di/RepositoryModule.kt create mode 100644 app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt create mode 100644 app/src/main/java/com/shaarit/core/network/HostSelectionInterceptor.kt create mode 100644 app/src/main/java/com/shaarit/core/storage/TokenManager.kt create mode 100644 app/src/main/java/com/shaarit/core/util/JwtGenerator.kt create mode 100644 app/src/main/java/com/shaarit/data/api/ShaarliApi.kt create mode 100644 app/src/main/java/com/shaarit/data/dto/Dtos.kt create mode 100644 app/src/main/java/com/shaarit/data/dto/TagDto.kt create mode 100644 app/src/main/java/com/shaarit/data/mapper/LinkMapper.kt create mode 100644 app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt create mode 100644 app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt create mode 100644 app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt create mode 100644 app/src/main/java/com/shaarit/domain/model/Models.kt create mode 100644 app/src/main/java/com/shaarit/domain/model/ShaarliTag.kt create mode 100644 app/src/main/java/com/shaarit/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt create mode 100644 app/src/main/java/com/shaarit/domain/usecase/LoginUseCase.kt create mode 100644 app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/auth/LoginViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt create mode 100644 app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt create mode 100644 app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt create mode 100644 app/src/main/java/com/shaarit/presentation/tags/TagsViewModel.kt create mode 100644 app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt create mode 100644 app/src/main/java/com/shaarit/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/shaarit/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 test_shaarli.py 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 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 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