diff --git a/README.md b/README.md index 1f7ccee..f49947e 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,365 @@ # 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. +**ShaarIt** est un client Android natif pour [Shaarli](https://github.com/shaarli/Shaarli), le gestionnaire de favoris auto-hébergé. Développé avec les technologies Android modernes, il offre une expérience mobile fluide pour gérer vos liens. -## Features +![Tech Stack](https://img.shields.io/badge/Kotlin-7F52FF?style=flat&logo=kotlin&logoColor=white) +![Android](https://img.shields.io/badge/Android-3DDC84?style=flat&logo=android&logoColor=white) +![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=flat&logo=jetpack-compose&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-yellow.svg) -* **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 +## 📱 Fonctionnalités -* **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 +### 🔐 Authentification +- Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1) +- Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences` +- Génération automatique de tokens JWT avec algorithme HS512 -## Prerequisites +### 📚 Gestion des Favoris +- **Flux infini** : Défilement continu avec chargement progressif (Paging 3) +- **Recherche côté serveur** : Recherche par termes et filtrage par tags +- **Ajout rapide** : Création de liens privés/publics avec description et tags +- **Édition** : Modification complète des liens existants +- **Suppression** : Gestion facile des favoris +- **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour -Before building via the command line, ensure you have: +### 🏷️ Gestion des Tags +- Vue dédiée pour parcourir tous les tags +- Compteur d'utilisation par tag +- Filtrage rapide du flux par tag -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` +### 🔗 Intégration système +- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (navigateur, YouTube, etc.) via le menu Partager +- Ouverture des liens dans le navigateur par défaut +- Support des URLs partagées avec titre pré-rempli -## First Time Setup (If gradlew is missing) +### 🎨 Interface Utilisateur +- **Design premium** : Thème sombre moderne avec dégradés cyan/bleu +- **Material Design 3** : Composants UI natifs Android +- **Animations fluides** : Transitions et effets visuels +- **Deux modes d'affichage** : Liste détaillée ou grille compacte +- **Pull-to-refresh** : Actualisation du flux par glissement -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: +--- + +## 🛠️ Stack Technique + +| Catégorie | Technologie | +|-----------|-------------| +| **Langage** | Kotlin 1.9.20 | +| **UI** | Jetpack Compose + Material Design 3 | +| **Architecture** | Clean Architecture + MVVM | +| **Injection de dépendances** | Dagger Hilt 2.48.1 | +| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 | +| **Pagination** | Paging 3 | +| **Concurrence** | Coroutines & Flow | +| **Stockage sécurisé** | AndroidX Security Crypto | +| **Navigation** | Navigation Compose | +| **Compilation** | Gradle 8.0+ avec KSP | + +### Compatibilité +- **minSdk**: 24 (Android 7.0) +- **targetSdk**: 34 (Android 14) +- **compileSdk**: 34 +- **JDK requis**: 17+ + +--- + +## 📥 Installation + +### Prérequis utilisateur +- Un serveur Shaarli auto-hébergé (v0.12+) avec l'API v1 activée +- Un appareil Android 7.0+ ou un émulateur + +### Obtenir l'APK + +#### Méthode 1 : Téléchargement direct +Récupérez le dernier APK depuis la section [Releases](../../releases). + +#### Méthode 2 : Compilation depuis les sources + +##### Prérequis de développement +1. **JDK 17** (ou plus récent) installé +2. **Android SDK** installé (Platform API 34) +3. **Gradle** 8.0+ (si `gradlew` est manquant) + +##### Étapes de compilation + +1. **Cloner le repository** + ```bash + git clone https://github.com/votre-username/ShaarIt.git + cd ShaarIt + ``` + +2. **Configurer l'emplacement du SDK Android** + + Si la variable `ANDROID_HOME` n'est pas définie, créez un fichier `local.properties` : + ```bash + # Windows + echo sdk.dir=C:\Users\\AppData\Local\Android\Sdk > local.properties + + # Linux/macOS + echo sdk.dir=/home//Android/Sdk > local.properties + ``` + +3. **Compiler l'APK Debug** + ```bash + # Windows + ./gradlew assembleDebug + + # Linux/macOS + chmod +x gradlew + ./gradlew assembleDebug + ``` + +4. **L'APK se trouve dans** : `app/build/outputs/apk/debug/app-debug.apk` + +5. **Installer sur l'appareil** + ```bash + adb install -r app/build/outputs/apk/debug/app-debug.apk + ``` + +### Configuration initiale de l'app + +1. Ouvrez l'application **ShaarIt** +2. Entrez l'**URL de votre instance Shaarli** (ex: `https://monserveur.com/shaarli`) +3. Entrez votre **Secret API** (trouvé dans les paramètres admin de Shaarli) +4. Cliquez sur **Connecter** + +--- + +## 🧑‍💻 Section Développement + +### Architecture du projet + +``` +app/src/main/java/com/shaarit/ +├── core/ # Infrastructure et utilitaires +│ ├── di/ # Modules Dagger Hilt (injection de dépendances) +│ │ ├── AppModule.kt # Fournisseurs d'applications +│ │ ├── NetworkModule.kt # Configuration Retrofit/OkHttp +│ │ └── RepositoryModule.kt # Liaisons repository +│ ├── network/ # Intercepteurs réseau +│ │ ├── AuthInterceptor.kt # Injection automatique du token JWT +│ │ └── HostSelectionInterceptor.kt # Changement dynamique d'hôte +│ ├── storage/ # Stockage local sécurisé +│ │ └── TokenManager.kt # Gestion chiffrée des tokens +│ └── util/ # Utilitaires +│ └── JwtGenerator.kt # Générateur JWT HS512 +├── data/ # Couche de données (Clean Architecture) +│ ├── api/ # Interface Retrofit +│ │ └── ShaarliApi.kt # Endpoints API v1 +│ ├── dto/ # Data Transfer Objects (Moshi) +│ │ ├── Dtos.kt # Login, Link, Info DTOs +│ │ └── TagDto.kt # Tag DTO +│ ├── mapper/ # Convertisseurs DTO ↔ Domain +│ │ └── LinkMapper.kt +│ ├── paging/ # Sources de pagination +│ │ └── LinkPagingSource.kt # Paging 3 pour le flux +│ └── repository/ # Implémentations des repositories +│ ├── AuthRepositoryImpl.kt +│ └── LinkRepositoryImpl.kt +├── domain/ # Couche métier (indépendante des frameworks) +│ ├── model/ # Modèles de domaine +│ │ ├── Models.kt # Credentials, ShaarliLink +│ │ ├── ShaarliTag.kt +│ │ └── ViewStyle.kt # Enum modes d'affichage +│ ├── repository/ # Interfaces de repository +│ │ ├── AuthRepository.kt +│ │ └── LinkRepository.kt +│ └── usecase/ # Cas d'utilisation +│ └── LoginUseCase.kt +├── presentation/ # Couche présentation (UI) +│ ├── auth/ # Écran de connexion +│ │ ├── LoginScreen.kt +│ │ └── LoginViewModel.kt +│ ├── feed/ # Flux principal +│ │ ├── FeedScreen.kt +│ │ ├── FeedViewModel.kt +│ │ └── LinkItemViews.kt # Composants de carte de lien +│ ├── add/ # Ajout de lien +│ │ ├── AddLinkScreen.kt +│ │ └── AddLinkViewModel.kt +│ ├── edit/ # Édition de lien +│ │ ├── EditLinkScreen.kt +│ │ └── EditLinkViewModel.kt +│ ├── tags/ # Gestion des tags +│ │ ├── TagsScreen.kt +│ │ └── TagsViewModel.kt +│ └── nav/ # Navigation Compose +│ └── NavGraph.kt # Routes et navigation +├── ui/ # Composants UI réutilisables +│ ├── components/ # Composants custom premium +│ │ └── PremiumComponents.kt # GlassCard, GradientButton, etc. +│ └── theme/ # Thème Material Design 3 +│ ├── Theme.kt # Couleurs et thème sombre +│ └── Type.kt # Typographie +├── MainActivity.kt # Point d'entrée avec gestion Share Intent +└── ShaarItApp.kt # Application Hilt +``` + +### Flux d'authentification JWT + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Utilisateur │────▶│ LoginScreen │────▶│ LoginViewModel │ +└─────────────┘ └──────────────┘ └─────────────────┘ + │ + ┌───────────────────────────────┘ + ▼ +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ TokenManager│◀────│AuthRepository│◀────│ LoginUseCase │ +└─────────────┘ └──────────────┘ └─────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ EncryptedSharedPreferences│ (AES256_GCM) +└─────────────────────────┘ +``` + +Le token JWT est généré localement avec l'algorithme **HS512** : +- **Header** : `{"typ":"JWT","alg":"HS512"}` +- **Payload** : `{"iat": }` +- **Signature** : HMAC-SHA512(base64url(header) + "." + base64url(payload), apiSecret) + +### Pagination avec Paging 3 + +``` +┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ FeedScreen │────▶│ FeedViewModel │────▶│ LinkRepository │ +└─────────────┘ └─────────────────┘ └─────────────────┘ + │ + ┌──────────────────────────────────┘ + ▼ +┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ +│ lazyPagingItems│◀──│ Pager │◀────│ LinkPagingSource│ +└─────────────┘ └──────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────┐ + │ ShaarliApi │ + └──────────────┘ +``` + +### Configuration réseau dynamique + +L'application utilise un pattern d'intercepteur pour gérer le changement d'URL serveur sans recréer le client Retrofit : + +```kotlin +// HostSelectionInterceptor permet de changer l'hôte à la volée +class HostSelectionInterceptor : Interceptor { + @Volatile private var host: HttpUrl? = null + + fun setHost(url: String) { + host = url.toHttpUrlOrNull() + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val newUrl = host?.let { + request.url.newBuilder() + .scheme(it.scheme) + .host(it.host) + .port(it.port) + .build() + } ?: request.url + + return chain.proceed(request.newBuilder().url(newUrl).build()) + } +} +``` + +### Exécution des tests ```bash +# Tests unitaires ./gradlew test + +# Tests instrumentés +./gradlew connectedAndroidTest ``` -## Usage +### Build de release -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**. +1. **Créer un keystore** (si premier build) + ```bash + keytool -genkey -v -keystore shaarit.keystore -alias shaarit \ + -keyalg RSA -keysize 2048 -validity 10000 + ``` + +2. **Créer `keystore.properties`** dans le répertoire racine : + ```properties + storeFile=shaarit.keystore + storePassword=votre_password + keyAlias=shaarit + keyPassword=votre_password + ``` + +3. **Compiler** + ```bash + ./gradlew assembleRelease + ``` + +4. **L'APK signé se trouve dans** : `app/build/outputs/apk/release/app-release.apk` + +### Dépendances principales + +```toml +[versions] +agp = "8.13.2" +kotlin = "1.9.20" +hilt = "2.48.1" +retrofit = "2.9.0" +moshi = "1.15.0" +okhttp = "4.12.0" +paging = "3.2.1" +composeBom = "2023.08.00" + +[libraries] +# Injection de dépendances +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } + +# Réseau +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } + +# Pagination +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } + +# Sécurité +androidx-security-crypto = { module = "androidx.security:security-crypto", version = "1.1.0-alpha06" } +``` + +### Contribution + +1. Forker le projet +2. Créer une branche feature (`git checkout -b feature/amazing-feature`) +3. Committer vos changements (`git commit -m 'Add amazing feature'`) +4. Pusher sur la branche (`git push origin feature/amazing-feature`) +5. Ouvrir une Pull Request + +--- + +## 📄 Licence + +Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails. + +--- + +## 🙏 Remerciements + +- [Shaarli](https://github.com/shaarli/Shaarli) - Le gestionnaire de favoris auto-hébergé +- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit moderne Android +- [Material Design 3](https://m3.material.io/) - Système de design Google + +--- + +## 📧 Contact + +Pour toute question ou suggestion, n'hésitez pas à ouvrir une [issue](../../issues). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2528bb..265103d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,8 +48,8 @@ android { buildTypes { release { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -63,11 +63,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { compose = true @@ -87,12 +87,15 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.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) + implementation("androidx.compose.material:material-icons-extended") + implementation(libs.androidx.compose.material) // Navigation implementation(libs.androidx.navigation.compose) @@ -121,6 +124,9 @@ dependencies { // Splash Screen implementation("androidx.core:core-splashscreen:1.0.1") + + // Markdown + implementation(libs.compose.markdown) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b6452e5..d8d8dd9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -33,13 +33,37 @@ # Moshi -keep class com.squareup.moshi.** { *; } -keep interface com.squareup.moshi.** { *; } --keep class com.shaarit.data.dto.** { *; } --keepclassmembers class com.shaarit.data.dto.** { *; } + +# Keep Moshi @JsonClass annotated classes and their generated adapters +-keep @com.squareup.moshi.JsonClass class * { *; } +-keep class **JsonAdapter { *; } +-keep class **$JsonAdapter { *; } +-keepnames @com.squareup.moshi.JsonClass class * + +# Keep Moshi-generated adapter factories +-keep class *JsonAdapterFactory { *; } + +# Keep all DTOs with their fields and constructors +-keep class com.shaarit.data.dto.** { + (...); + ; +} +-keepclassmembers class com.shaarit.data.dto.** { + (...); + ; +} + +# Keep Kotlin data class component functions and copy methods +-keepclassmembers class com.shaarit.data.dto.** { + public ** component*(); + public ** copy(...); +} # Keep Kotlin Metadata -keep class kotlin.Metadata { *; } +-keepattributes RuntimeVisibleAnnotations -# Hilt +# Hilt / Dagger -keepclasseswithmembers class * { @dagger.* ; } @@ -49,14 +73,44 @@ } -keep class dagger.* { *; } -keep class javax.inject.* { *; } +-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; } +-keep class * implements dagger.hilt.internal.GeneratedComponent { *; } +-keep class * implements dagger.hilt.internal.GeneratedComponentManager { *; } +-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class * +-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EntryPoint class * +-keep,allowobfuscation,allowshrinking @dagger.hilt.InstallIn class * -dontwarn dagger.internal.codegen.** +# Hilt ViewModel +-keep class * extends androidx.lifecycle.ViewModel { *; } +-keep class * extends androidx.lifecycle.AndroidViewModel { *; } +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} + # JWT (io.jsonwebtoken) -keep class io.jsonwebtoken.** { *; } # Compose -keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# Kotlin coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembernames class kotlinx.** { + volatile ; +} +-dontwarn kotlinx.coroutines.** + +# Keep ALL Application classes to prevent runtime crashes +-keep class com.shaarit.** { *; } +-keepclassmembers class com.shaarit.** { *; } + +# Keep Hilt generated classes +-keep class com.shaarit.Hilt_* { *; } +-keep class **_HiltModules_* { *; } +-keep class **_Factory { *; } +-keep class **_MembersInjector { *; } -# Application classes --keep class com.shaarit.domain.model.** { *; } --keep class com.shaarit.data.api.** { *; } diff --git a/app/src/main/java/com/shaarit/MainActivity.kt b/app/src/main/java/com/shaarit/MainActivity.kt index 1c46b50..b5f9986 100644 --- a/app/src/main/java/com/shaarit/MainActivity.kt +++ b/app/src/main/java/com/shaarit/MainActivity.kt @@ -20,7 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { // Install splash screen before super.onCreate - val splashScreen = installSplashScreen() + installSplashScreen() super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt index 2c5ede0..dd44004 100644 --- a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt +++ b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt @@ -2,6 +2,7 @@ package com.shaarit.core.storage import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +28,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - EncryptedSharedPreferences.create( + try { + createEncryptedPrefs(masterKey) + } catch (e: Exception) { + Log.e("TokenManager", "Error creating EncryptedSharedPreferences, wiping data", e) + // If encryption fails (corrupted key or incompatible transition), clear the file and retry + context.getSharedPreferences("secret_prefs", Context.MODE_PRIVATE).edit().clear().apply() + createEncryptedPrefs(masterKey) + } + } + + private fun createEncryptedPrefs(masterKey: MasterKey): SharedPreferences { + return EncryptedSharedPreferences.create( context, "secret_prefs", masterKey, @@ -49,7 +61,7 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte } override fun saveBaseUrl(url: String) { - // Remove trailing slash if present for consistency, though better done in logic + // Remove trailing slash if present for consistency val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply() } diff --git a/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt index d6013bf..e385d4c 100644 --- a/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt +++ b/app/src/main/java/com/shaarit/data/api/ShaarliApi.kt @@ -33,6 +33,9 @@ interface ShaarliApi { @DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response + @GET("/api/v1/links/{id}") + suspend fun getLink(@Path("id") id: Int): LinkDto + /** Get all tags with their occurrence count. */ @GET("/api/v1/tags") suspend fun getTags( diff --git a/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt index e7ab5f8..d95970c 100644 --- a/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt @@ -20,7 +20,7 @@ constructor(private val api: ShaarliApi, private val tokenManager: TokenManager) // 3. Verify credentials by calling the API info endpoint // This endpoint requires valid authentication return try { - val info = api.getInfo() + api.getInfo() // If we get here, authentication worked // The info contains Shaarli version and settings Result.success(true) diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index da940f5..a4d9ddf 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -152,6 +152,15 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit } } + override suspend fun getLink(id: Int): Result { + return try { + val link = api.getLink(id) + Result.success(LinkMapper.toDomain(link)) + } catch (e: Exception) { + Result.failure(e) + } + } + override suspend fun getTags(): Result> { return try { val tags = api.getTags(offset = 0, limit = 500) diff --git a/app/src/main/java/com/shaarit/domain/model/ViewStyle.kt b/app/src/main/java/com/shaarit/domain/model/ViewStyle.kt new file mode 100644 index 0000000..4394232 --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/model/ViewStyle.kt @@ -0,0 +1,16 @@ +package com.shaarit.domain.model + +/** + * Enum representing the different view styles for displaying bookmarks + */ +enum class ViewStyle { + LIST, + GRID, + COMPACT; + + companion object { + fun fromOrdinal(ordinal: Int): ViewStyle { + return entries.getOrElse(ordinal) { LIST } + } + } +} diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt index 56324bf..4acfe84 100644 --- a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt +++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt @@ -46,6 +46,8 @@ interface LinkRepository { suspend fun deleteLink(id: Int): Result + suspend fun getLink(id: Int): Result + suspend fun getTags(): Result> suspend fun getLinksByTag(tag: String): Result> diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt new file mode 100644 index 0000000..01ecd38 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt @@ -0,0 +1,342 @@ +package com.shaarit.presentation.edit + +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.Edit +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 EditLinkScreen( + onNavigateBack: () -> Unit, + viewModel: EditLinkViewModel = 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 EditLinkUiState.Success -> { + onNavigateBack() + } + is EditLinkUiState.Error -> { + snackbarHostState.showSnackbar(state.message) + } + else -> {} + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf(DeepNavy, DarkNavy) + ) + ) + ) { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "Edit 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 -> + when (uiState) { + is EditLinkUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = CyanPrimary) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Loading link...", + color = TextSecondary, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + else -> { + 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.Edit, + contentDescription = null, + tint = CyanPrimary + ) + } + ) + } + } + + // Title Section + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SectionHeader( + title = "Title", + subtitle = "Optional" + ) + 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 - Supports Markdown" + ) + 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)) + + // Update Button + GradientButton( + text = if (uiState is EditLinkUiState.Saving) "Saving..." else "Update Link", + onClick = { viewModel.updateLink() }, + modifier = Modifier.fillMaxWidth(), + enabled = url.isNotBlank() && uiState !is EditLinkUiState.Saving + ) + + if (uiState is EditLinkUiState.Saving) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = CyanPrimary, + trackColor = SurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt new file mode 100644 index 0000000..02f3c80 --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt @@ -0,0 +1,158 @@ +package com.shaarit.presentation.edit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 EditLinkViewModel +@Inject +constructor( + private val linkRepository: LinkRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val linkId: Int = savedStateHandle["linkId"] ?: -1 + + private val _uiState = MutableStateFlow(EditLinkUiState.Loading) + val uiState = _uiState.asStateFlow() + + var url = MutableStateFlow("") + var title = MutableStateFlow("") + var description = MutableStateFlow("") + var isPrivate = MutableStateFlow(false) + + 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() + + init { + loadLink() + loadAvailableTags() + } + + private fun loadLink() { + viewModelScope.launch { + _uiState.value = EditLinkUiState.Loading + + linkRepository.getLink(linkId).fold( + onSuccess = { link -> + url.value = link.url + title.value = link.title + description.value = link.description + isPrivate.value = link.isPrivate + _selectedTags.value = link.tags + _uiState.value = EditLinkUiState.Loaded + }, + onFailure = { error -> + _uiState.value = EditLinkUiState.Error(error.message ?: "Failed to load link") + } + ) + } + } + + 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 updateLink() { + viewModelScope.launch { + _uiState.value = EditLinkUiState.Saving + + val currentUrl = url.value + if (currentUrl.isBlank()) { + _uiState.value = EditLinkUiState.Error("URL is required") + return@launch + } + + linkRepository.updateLink( + id = linkId, + url = currentUrl, + title = title.value.ifBlank { null }, + description = description.value.ifBlank { null }, + tags = _selectedTags.value.ifEmpty { null }, + isPrivate = isPrivate.value + ).fold( + onSuccess = { + _uiState.value = EditLinkUiState.Success + }, + onFailure = { error -> + _uiState.value = EditLinkUiState.Error(error.message ?: "Failed to update link") + } + ) + } + } +} + +sealed class EditLinkUiState { + object Loading : EditLinkUiState() + object Loaded : EditLinkUiState() + object Saving : EditLinkUiState() + object Success : EditLinkUiState() + data class Error(val message: String) : EditLinkUiState() +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index b614f5e..5e73147 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -6,195 +6,297 @@ 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.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.ExperimentalMaterialApi 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.material.icons.filled.* +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState 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.graphics.Color 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.domain.model.ViewStyle import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.TagChip import com.shaarit.ui.theme.* -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun FeedScreen( - onNavigateToAdd: () -> Unit, - onNavigateToTags: () -> Unit = {}, - initialTagFilter: String? = null, - viewModel: FeedViewModel = hiltViewModel() + onNavigateToAdd: () -> Unit, + onNavigateToEdit: (Int) -> 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 viewStyle by viewModel.viewStyle.collectAsState() val context = LocalContext.current + var showViewStyleMenu by remember { mutableStateOf(false) } + var selectedLink by remember { mutableStateOf(null) } // Set initial tag filter LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) } + val pullRefreshState = rememberPullRefreshState( + refreshing = pagingItems.loadState.refresh is LoadState.Loading, + onRefresh = { pagingItems.refresh() } + ) + Box( - modifier = - Modifier.fillMaxSize() - .background( - brush = - Brush.verticalGradient( - colors = listOf(DeepNavy, DarkNavy) - ) - ) + 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 - ) - } - } - } + topBar = { + Column { + TopAppBar( + title = { + Text( + "ShaarIt", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + }, + actions = { + // Refresh Button + IconButton(onClick = { pagingItems.refresh() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh", + tint = CyanPrimary ) } + + // View Style Selector + Box { + IconButton(onClick = { showViewStyleMenu = true }) { + Icon( + imageVector = when (viewStyle) { + ViewStyle.LIST -> Icons.Default.ViewStream + ViewStyle.GRID -> Icons.Default.ViewModule + ViewStyle.COMPACT -> Icons.Default.ViewList + }, + contentDescription = "View style", + tint = CyanPrimary + ) + } + + DropdownMenu( + expanded = showViewStyleMenu, + onDismissRequest = { showViewStyleMenu = false }, + modifier = Modifier.background(CardBackground) + ) { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ViewStream, + contentDescription = null, + tint = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextSecondary + ) + Text( + "List View", + color = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextPrimary + ) + } + }, + onClick = { + viewModel.setViewStyle(ViewStyle.LIST) + showViewStyleMenu = false + } + ) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ViewModule, + contentDescription = null, + tint = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextSecondary + ) + Text( + "Grid View", + color = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextPrimary + ) + } + }, + onClick = { + viewModel.setViewStyle(ViewStyle.GRID) + showViewStyleMenu = false + } + ) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ViewList, + contentDescription = null, + tint = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextSecondary + ) + Text( + "Compact View", + color = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextPrimary + ) + } + }, + onClick = { + viewModel.setViewStyle(ViewStyle.COMPACT) + showViewStyleMenu = false + } + ) + } + } + + // Tags button + 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 + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToAdd, + containerColor = CyanPrimary, + contentColor = DeepNavy + ) { Icon(Icons.Default.Add, contentDescription = "Add Link") } + }, + containerColor = Color.Transparent ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues)) { + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { when { - pagingItems.loadState.refresh is LoadState.Loading -> { + pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> { + // Initial loading Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = CyanPrimary) } } - pagingItems.loadState.refresh is LoadState.Error -> { + pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> { Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - "Failed to load links", - style = MaterialTheme.typography.titleMedium, - color = ErrorRed + "Failed to load links", + style = MaterialTheme.typography.titleMedium, + color = ErrorRed ) Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = { pagingItems.refresh() }) { @@ -205,194 +307,168 @@ fun FeedScreen( } pagingItems.itemCount == 0 -> { Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + 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 + 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 + 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) } - ) + when (viewStyle) { + ViewStyle.GRID -> { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(count = pagingItems.itemCount) { index -> + val link = pagingItems[index] + if (link != null) { + GridViewItem( + link = link, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + onViewClick = { selectedLink = link }, + onEditClick = onNavigateToEdit, + 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 + ) + } + } + } } } + ViewStyle.COMPACT -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(count = pagingItems.itemCount) { index -> + val link = pagingItems[index] + if (link != null) { + CompactViewItem( + link = link, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + onViewClick = { selectedLink = link }, + onEditClick = onNavigateToEdit, + 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 + ) + } + } + } + } + } + ViewStyle.LIST -> { + 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) { + ListViewItem( + link = link, + onTagClick = viewModel::onTagClicked, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + onViewClick = { selectedLink = link }, + onEditClick = onNavigateToEdit, + 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 - ) + if (pagingItems.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.padding(16.dp), + color = CyanPrimary + ) + } + } } } } } } } + + // Pull refresh indicator + PullRefreshIndicator( + refreshing = pagingItems.loadState.refresh is LoadState.Loading, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = DarkNavy, + contentColor = 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 - ) - } - } + } + + if (selectedLink != null) { + LinkDetailsDialog( + link = selectedLink!!, + onDismiss = { selectedLink = null }, + onLinkClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + ) } } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt index c918e62..98feb05 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.shaarit.domain.model.ShaarliLink +import com.shaarit.domain.model.ViewStyle import com.shaarit.domain.repository.LinkRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -27,6 +28,9 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito private val _searchTags = MutableStateFlow(null) val searchTags = _searchTags.asStateFlow() + private val _viewStyle = MutableStateFlow(ViewStyle.LIST) + val viewStyle = _viewStyle.asStateFlow() + private val _refreshTrigger = MutableStateFlow(0) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -48,13 +52,15 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito } 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 + val currentTags = _searchTags.value?.split(" ")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf() + + if (currentTags.contains(tag)) { + currentTags.remove(tag) } else { - _searchTags.value = tag + currentTags.add(tag) } + + _searchTags.value = if (currentTags.isEmpty()) null else currentTags.joinToString(" ") } fun setInitialTagFilter(tag: String?) { @@ -78,4 +84,8 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito fun refresh() { _refreshTrigger.value++ } + + fun setViewStyle(style: ViewStyle) { + _viewStyle.value = style + } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt new file mode 100644 index 0000000..f8b223a --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -0,0 +1,617 @@ +package com.shaarit.presentation.feed + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.window.DialogProperties +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.shaarit.domain.model.ShaarliLink +import com.shaarit.ui.components.GlassCard +import com.shaarit.ui.components.TagChip +import com.shaarit.ui.theme.* +import dev.jeziellago.compose.markdowntext.MarkdownText + +/** + * Full list view item - shows all details including markdown description + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ListViewItem( + link: ShaarliLink, + onTagClick: (String) -> Unit, + onLinkClick: (String) -> Unit, + onViewClick: () -> Unit, + onEditClick: (Int) -> Unit, + onDeleteClick: () -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + linkTitle = link.title, + onConfirm = { + onDeleteClick() + showDeleteDialog = false + }, + onDismiss = { showDeleteDialog = false } + ) + } + + 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 + ) + } + + Row { + IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = "View Details", + tint = CyanPrimary.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = TealSecondary.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + 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)) + MarkdownText( + markdown = link.description, + style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary), + maxLines = 5, + modifier = Modifier.fillMaxWidth() + ) + } + + 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) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Default.Lock, + contentDescription = "Private", + tint = TextMuted, + modifier = Modifier.size(12.dp) + ) + Text( + text = "Private", + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + } + } + } + } + } +} + +/** + * Grid view item - compact cards in a 2-column grid + */ +@Composable +fun GridViewItem( + link: ShaarliLink, + onLinkClick: (String) -> Unit, + onViewClick: () -> Unit, + onEditClick: (Int) -> Unit, + onDeleteClick: () -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + linkTitle = link.title, + onConfirm = { + onDeleteClick() + showDeleteDialog = false + }, + onDismiss = { showDeleteDialog = false } + ) + } + + GlassCard( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + onClick = { onLinkClick(link.url) } + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column { + // Title + Text( + text = link.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = CyanPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Description with Markdown + if (link.description.isNotBlank()) { + MarkdownText( + markdown = link.description, + style = MaterialTheme.typography.bodySmall.copy(color = TextSecondary), + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Column { + // Tags (show only first 2) + if (link.tags.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(bottom = 8.dp) + ) { + link.tags.take(2).forEach { tag -> + Text( + text = "#$tag", + style = MaterialTheme.typography.labelSmall, + color = TealSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (link.tags.size > 2) { + Text( + text = "+${link.tags.size - 2}", + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + } + } + } + + // Actions row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (link.isPrivate) { + Icon( + Icons.Default.Lock, + contentDescription = "Private", + tint = TextMuted, + modifier = Modifier.size(12.dp) + ) + } + Text( + text = link.date.take(10), + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + } + + Row { + IconButton( + onClick = onViewClick, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = "View Details", + tint = CyanPrimary.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } + IconButton( + onClick = { onEditClick(link.id) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = TealSecondary.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } + IconButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = ErrorRed.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } +} + +/** + * Compact view item - minimal info for dense lists + */ +@Composable +fun CompactViewItem( + link: ShaarliLink, + onLinkClick: (String) -> Unit, + onViewClick: () -> Unit, + onEditClick: (Int) -> Unit, + onDeleteClick: () -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + linkTitle = link.title, + onConfirm = { + onDeleteClick() + showDeleteDialog = false + }, + onDismiss = { showDeleteDialog = false } + ) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onLinkClick(link.url) }, + color = CardBackground.copy(alpha = 0.7f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (link.isPrivate) { + Icon( + Icons.Default.Lock, + contentDescription = "Private", + tint = TextMuted, + modifier = Modifier.size(14.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = link.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = CyanPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = link.date.take(10), + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + + if (link.tags.isNotEmpty()) { + Text( + text = link.tags.take(2).joinToString { "#$it" }, + style = MaterialTheme.typography.labelSmall, + color = TealSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + Row { + IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Visibility, + contentDescription = "View Details", + tint = CyanPrimary.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + } + IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = TealSecondary.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + } + IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = ErrorRed.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +/** + * Reusable delete confirmation dialog + */ +@Composable +fun DeleteConfirmationDialog( + linkTitle: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + 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( + linkTitle, + color = CyanPrimary, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Delete", color = ErrorRed) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel", color = TextMuted) + } + }, + containerColor = CardBackground, + titleContentColor = TextPrimary, + textContentColor = TextSecondary + ) +} + +/** + * Dialog to show full link details + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LinkDetailsDialog( + link: ShaarliLink, + onDismiss: () -> Unit, + onLinkClick: (String) -> Unit +) { + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + GlassCard( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxSize() + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = link.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = CyanPrimary, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onDismiss, + modifier = Modifier + .size(32.dp) + .offset(x = 8.dp, y = (-8).dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = TextSecondary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Scrollable Content + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(androidx.compose.foundation.rememberScrollState()) + ) { + // URL + Text( + text = link.url, + style = MaterialTheme.typography.bodyMedium, + color = TealSecondary, + modifier = Modifier + .clickable { onLinkClick(link.url) } + .padding(vertical = 4.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Tags + if (link.tags.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + link.tags.forEach { tag -> + TagChip( + tag = tag, + isSelected = false, + onClick = { /* No action in dialog */ } + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + + // Metadata + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = link.date, + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + if (link.isPrivate) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Default.Lock, + contentDescription = "Private", + tint = TextMuted, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Private", + style = MaterialTheme.typography.labelSmall, + color = TextMuted + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Divider(color = TextMuted.copy(alpha = 0.2f)) + Spacer(modifier = Modifier.height(24.dp)) + + // Description + if (link.description.isNotBlank()) { + MarkdownText( + markdown = link.description, + style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary), + modifier = Modifier.fillMaxWidth() + ) + } else { + Text( + text = "No description", + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index a483771..aff2cb7 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -18,100 +18,115 @@ sealed class Screen(val route: String) { } } object Add : Screen("add?url={url}&title={title}&isShare={isShare}") + object Edit : Screen("edit/{linkId}") { + fun createRoute(linkId: Int): String = "edit/$linkId" + } object Tags : Screen("tags") } @Composable fun AppNavGraph( - startDestination: String = Screen.Login.route, - shareUrl: String? = null, - shareTitle: String? = null + 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 } - } + 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 - } - ) + 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 + onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") }, + onNavigateToEdit = { linkId -> + navController.navigate(Screen.Edit.createRoute(linkId)) + }, + 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 - } - ) + 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 + onNavigateBack = { navController.popBackStack() }, + onShareSuccess = if (isShare) { + { (context as? Activity)?.finish() } + } else null + ) + } + + composable( + route = "edit/{linkId}", + arguments = listOf( + navArgument("linkId") { + type = NavType.IntType + } + ) + ) { + com.shaarit.presentation.edit.EditLinkScreen( + onNavigateBack = { navController.popBackStack() } ) } 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 } - } + 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 index 751b1c5..6b0e838 100644 --- a/app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/tags/TagsScreen.kt @@ -40,21 +40,16 @@ fun TagsScreen( 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) - ) + Brush.verticalGradient( + colors = listOf(DeepNavy, DarkNavy) + ) ) ) { Column(modifier = Modifier.fillMaxSize()) { @@ -130,24 +125,10 @@ fun TagsScreen( } 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) - } + TagsGridView( + tags = filteredTags, + onTagClick = { tag -> onNavigateToFeedWithTag(tag.name) } + ) } } } @@ -191,124 +172,3 @@ private fun TagGridItem(tag: ShaarliTag, onClick: () -> Unit) { } } } - -@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/ui/theme/Theme.kt b/app/src/main/java/com/shaarit/ui/theme/Theme.kt index 596e5b3..b9b1b01 100644 --- a/app/src/main/java/com/shaarit/ui/theme/Theme.kt +++ b/app/src/main/java/com/shaarit/ui/theme/Theme.kt @@ -91,7 +91,6 @@ fun ShaarItTheme( 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() } diff --git a/build_output.txt b/build_output.txt new file mode 100644 index 0000000..7fdaa55 --- /dev/null +++ b/build_output.txt @@ -0,0 +1,67 @@ +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:mergeDebugNativeDebugMetadata NO-SOURCE +> Task :app:checkKotlinGradlePluginConfigurationErrors +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:mapDebugSourceSetPaths +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:checkDebugAarMetadata +> Task :app:packageDebugResources UP-TO-DATE +> Task :app:parseDebugLocalResources UP-TO-DATE +> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE +> Task :app:extractDeepLinksDebug UP-TO-DATE +> Task :app:processDebugMainManifest +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:javaPreCompileDebug UP-TO-DATE +> Task :app:mergeDebugShaders UP-TO-DATE +> Task :app:compileDebugShaders NO-SOURCE +> Task :app:generateDebugAssets UP-TO-DATE +> Task :app:mergeDebugAssets UP-TO-DATE +> Task :app:compressDebugAssets UP-TO-DATE +> Task :app:mergeDebugResources +> Task :app:checkDebugDuplicateClasses +> Task :app:desugarDebugFileDependencies UP-TO-DATE +> Task :app:processDebugResources +> Task :app:mergeExtDexDebug +> Task :app:mergeLibDexDebug UP-TO-DATE +> Task :app:mergeDebugJniLibFolders UP-TO-DATE +> Task :app:mergeDebugNativeLibs NO-SOURCE +> Task :app:stripDebugDebugSymbols NO-SOURCE +> Task :app:validateSigningDebug UP-TO-DATE +> Task :app:writeDebugAppMetadata UP-TO-DATE +> Task :app:writeDebugSigningConfigVersions UP-TO-DATE +> Task :app:kspDebugKotlin + +> Task :app:compileDebugKotlin +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/MainActivity.kt:23:13 Variable 'splashScreen' is never used +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/data/repository/AuthRepositoryImpl.kt:23:17 Variable 'info' is never used +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:103:17 'MarkdownText(String, Modifier = ..., Color = ..., Color = ..., TextUnit = ..., TextAlign? = ..., Boolean = ..., TextUnit = ..., Int = ..., Boolean = ..., AutoSizeConfig? = ..., Int? = ..., TextStyle = ..., Int? = ..., (() -> Unit)? = ..., Boolean = ..., ImageLoader? = ..., Int = ..., ((String) -> Unit)? = ..., ((numLines: Int) -> Unit)? = ...): Unit' is deprecated. The parameters `color`, `fontSize`, `textAlign` and `lineHeight` must be part of TextStyle. +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:163:5 Parameter 'onTagClick' is never used +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:206:21 'MarkdownText(String, Modifier = ..., Color = ..., Color = ..., TextUnit = ..., TextAlign? = ..., Boolean = ..., TextUnit = ..., Int = ..., Boolean = ..., AutoSizeConfig? = ..., Int? = ..., TextStyle = ..., Int? = ..., (() -> Unit)? = ..., Boolean = ..., ImageLoader? = ..., Int = ..., ((String) -> Unit)? = ..., ((numLines: Int) -> Unit)? = ...): Unit' is deprecated. The parameters `color`, `fontSize`, `textAlign` and `lineHeight` must be part of TextStyle. +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt:303:5 Parameter 'onTagClick' is never used +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt:35:9 Variable 'isShareIntent' is never used +w: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/ui/theme/Theme.kt:94:25 Variable 'context' is never used + +> Task :app:compileDebugJavaWithJavac + +> Task :app:hiltAggregateDepsDebug +WARNING: [Processor] Library 'C:\Users\bruno\scoop\apps\gradle\current\.gradle\caches\transforms-3\a425a2587f13f7b164b81c94b4e3e193\transformed\core-1.12.0-api.jar' contains references to both AndroidX and old support library. This seems like the library is partially migrated. Jetifier will try to rewrite the library anyway. + Example of androidX reference: 'androidx/core/os/BuildCompat' + Example of support library reference: 'android/support/v4/app/INotificationSideChannel$Default' + +> Task :app:hiltJavaCompileDebug +warning: Kapt support in Moshi Kotlin Code Gen is deprecated and will be removed in 2.0. Please migrate to KSP. https://github.com/square/moshi#codegen +1 warning + +> Task :app:processDebugJavaRes +> Task :app:transformDebugClassesWithAsm +> Task :app:mergeDebugJavaResource +> Task :app:dexBuilderDebug +> Task :app:mergeProjectDexDebug +> Task :app:packageDebug +> Task :app:createDebugApkListingFileRedirect +> Task :app:assembleDebug + +BUILD SUCCESSFUL in 2m 16s +37 actionable tasks: 20 executed, 17 up-to-date diff --git a/compile_output.txt b/compile_output.txt new file mode 100644 index 0000000..b95752a --- /dev/null +++ b/compile_output.txt @@ -0,0 +1,44 @@ +> Task :app:checkKotlinGradlePluginConfigurationErrors +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:checkDebugAarMetadata UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:mapDebugSourceSetPaths UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:packageDebugResources UP-TO-DATE +> Task :app:parseDebugLocalResources UP-TO-DATE +> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE +> Task :app:extractDeepLinksDebug UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:kspDebugKotlin + +> Task :app:compileDebugKotlin FAILED +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:16:47 Unresolved reference: ViewModule +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:17:47 Unresolved reference: ViewStream +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:18:47 Unresolved reference: ViewList +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:82:77 Unresolved reference: ViewStream +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:83:77 Unresolved reference: ViewModule +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:84:80 Unresolved reference: ViewList +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:103:67 Unresolved reference: ViewStream +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:125:67 Unresolved reference: ViewModule +e: file:///C:/dev/git/Android/ShaarIt/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt:147:67 Unresolved reference: ViewList + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:compileDebugKotlin'. +> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction + > Compilation error. See log for more details + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to get full insights. +> Get more help at https://help.gradle.org. + +BUILD FAILED in 12s +15 actionable tasks: 3 executed, 12 up-to-date diff --git a/gradle.properties b/gradle.properties index cc18a0c..32c5628 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=true +moshi.generateAdapter.source=ksp \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 507cfad..ebd6f41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.2.0" +agp = "8.13.2" kotlin = "1.9.20" coreKtx = "1.12.0" junit = "4.13.2" @@ -18,6 +18,7 @@ ksp = "1.9.20-1.0.14" paging = "3.2.1" pagingCompose = "3.2.1" material = "1.11.0" +composeMarkdown = "0.4.1" [libraries] material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -26,6 +27,7 @@ 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-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-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" } @@ -39,6 +41,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio 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" } +androidx-compose-material = { group = "androidx.compose.material", name = "material" } 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" } @@ -50,6 +53,7 @@ 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" } +compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e09..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 44c3650..2b63356 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }