feat: Update Gradle wrapper to version 8.13

feat: Add JitPack repository to dependency resolution management

feat: Create ViewStyle enum for bookmark display styles

feat: Implement EditLinkScreen with ViewModel for editing links

feat: Add EditLinkViewModel to manage link editing state and logic

feat: Create LinkItemViews for displaying links in various formats

chore: Add build output and compile output logs
This commit is contained in:
Bruno Charest 2026-01-28 11:38:49 -05:00
parent 1438003f94
commit a9475c16b1
24 changed files with 2185 additions and 638 deletions

445
README.md
View File

@ -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\\<Username>\\AppData\\Local\\Android\\Sdk > local.properties
# Linux/macOS
echo sdk.dir=/home/<username>/Android/Sdk > local.properties
```
### 2. Build the APK
Open your terminal in the project root folder.
**Windows (PowerShell/CMD):**
```powershell
./gradlew assembleDebug
```
**Linux/macOS:**
```bash
chmod +x gradlew
./gradlew assembleDebug
```
*The build process may take a few minutes as it downloads dependencies.*
### 3. Locate the APK
Once the build is successful, the APK file will be located at:
`app/build/outputs/apk/debug/app-debug.apk`
## Installation
To install the app on a connected device or emulator via command line:
```bash
# Ensure your device is connected and visible
adb devices
# Install the Debug APK
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
## Running Tests
To run unit tests:
---
## 🛠️ 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\<Username>\AppData\Local\Android\Sdk > local.properties
# Linux/macOS
echo sdk.dir=/home/<username>/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": <unix_timestamp>}`
- **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).

View File

@ -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)
@ -122,6 +125,9 @@ dependencies {
// Splash Screen
implementation("androidx.core:core-splashscreen:1.0.1")
// Markdown
implementation(libs.compose.markdown)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@ -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.** {
<init>(...);
<fields>;
}
-keepclassmembers class com.shaarit.data.dto.** {
<init>(...);
<fields>;
}
# 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.* <methods>;
}
@ -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 {
<init>(...);
}
# 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 <fields>;
}
-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.** { *; }

View File

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

View File

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

View File

@ -33,6 +33,9 @@ interface ShaarliApi {
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
@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(

View File

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

View File

@ -152,6 +152,15 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
}
}
override suspend fun getLink(id: Int): Result<ShaarliLink> {
return try {
val link = api.getLink(id)
Result.success(LinkMapper.toDomain(link))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getTags(): Result<List<ShaarliTag>> {
return try {
val tags = api.getTags(offset = 0, limit = 500)

View File

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

View File

@ -46,6 +46,8 @@ interface LinkRepository {
suspend fun deleteLink(id: Int): Result<Unit>
suspend fun getLink(id: Int): Result<ShaarliLink>
suspend fun getTags(): Result<List<ShaarliTag>>
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>

View File

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

View File

@ -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>(EditLinkUiState.Loading)
val uiState = _uiState.asStateFlow()
var url = MutableStateFlow("")
var title = MutableStateFlow("")
var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false)
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
val selectedTags = _selectedTags.asStateFlow()
private val _newTagInput = MutableStateFlow("")
val newTagInput = _newTagInput.asStateFlow()
private val _availableTags = MutableStateFlow<List<ShaarliTag>>(emptyList())
val availableTags = _availableTags.asStateFlow()
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
val tagSuggestions = _tagSuggestions.asStateFlow()
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()
}

View File

@ -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<com.shaarit.domain.model.ShaarliLink?>(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
)
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
)
}
}
}
}
}
}
}
}
// 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)
}
)
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<ShaarliLink>,
isLoading: Boolean,
onBack: () -> Unit,
onLinkClick: (String) -> Unit,
onViewInFeed: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
// Tag header
GlassCard(modifier = Modifier.fillMaxWidth().padding(16.dp), glowColor = CyanPrimary) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = onBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = TextSecondary
)
}
Text(
text = "#${tag.name}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = CyanPrimary
)
}
Text(
text = "${tag.occurrences} links",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
modifier = Modifier.padding(start = 48.dp)
)
}
TextButton(onClick = onViewInFeed) { Text("View in Feed", color = TealSecondary) }
}
}
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) }
} else if (links.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No links found for this tag",
style = MaterialTheme.typography.bodyLarge,
color = TextSecondary
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(links) { link ->
TagLinkItem(link = link, onClick = { onLinkClick(link.url) })
}
}
}
}
}
@Composable
private fun TagLinkItem(link: ShaarliLink, onClick: () -> Unit) {
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Column {
Text(
text = link.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = link.url,
style = MaterialTheme.typography.bodySmall,
color = TealSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = link.description,
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
if (link.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(link.tags) { tag -> TagChip(tag = tag, isSelected = false, onClick = {}) }
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = link.date, style = MaterialTheme.typography.labelSmall, color = TextMuted)
}
}
}

View File

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

67
build_output.txt Normal file
View File

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

44
compile_output.txt Normal file
View File

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

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
moshi.generateAdapter.source=ksp

View File

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

View File

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

View File

@ -11,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}