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

403
README.md
View File

@ -1,114 +1,365 @@
# ShaarIt # 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 ### 🔐 Authentification
* **UI**: Jetpack Compose (Material Design 3) - Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1)
* **Architecture**: Clean Architecture + MVVM - Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences`
* **Dependency Injection**: Dagger Hilt - Génération automatique de tokens JWT avec algorithme HS512
* **Network**: Retrofit + Moshi + OkHttp
* **Concurrency**: Coroutines & Flow
* **Pagination**: Paging 3
## 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. ### 🔗 Intégration système
2. **Android SDK** installed. - **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (navigateur, YouTube, etc.) via le menu Partager
3. **Gradle** (v8.0+) installed (only required if `gradlew` is missing). - Ouverture des liens dans le navigateur par défaut
* *Note for Windows users with Scoop:* `scoop install gradle` - 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: ---
## 🛠️ 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 ```bash
gradle wrapper git clone https://github.com/votre-username/ShaarIt.git
cd ShaarIt
``` ```
This will create `gradlew`, `gradlew.bat` and the `gradle/wrapper` folder. 2. **Configurer l'emplacement du SDK Android**
## 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:
Si la variable `ANDROID_HOME` n'est pas définie, créez un fichier `local.properties` :
```bash ```bash
# Windows # Windows
echo sdk.dir=C:\\Users\\<Username>\\AppData\\Local\\Android\\Sdk > local.properties echo sdk.dir=C:\Users\<Username>\AppData\Local\Android\Sdk > local.properties
# Linux/macOS # Linux/macOS
echo sdk.dir=/home/<username>/Android/Sdk > local.properties echo sdk.dir=/home/<username>/Android/Sdk > local.properties
``` ```
### 2. Build the APK 3. **Compiler l'APK Debug**
Open your terminal in the project root folder.
**Windows (PowerShell/CMD):**
```powershell
./gradlew assembleDebug
```
**Linux/macOS:**
```bash ```bash
# Windows
./gradlew assembleDebug
# Linux/macOS
chmod +x gradlew chmod +x gradlew
./gradlew assembleDebug ./gradlew assembleDebug
``` ```
*The build process may take a few minutes as it downloads dependencies.* 4. **L'APK se trouve dans** : `app/build/outputs/apk/debug/app-debug.apk`
### 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:
5. **Installer sur l'appareil**
```bash ```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 adb install -r app/build/outputs/apk/debug/app-debug.apk
``` ```
## Running Tests ### Configuration initiale de l'app
To run unit tests: 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**
```bash ---
./gradlew test
## 🧑‍💻 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
``` ```
## Usage ### Flux d'authentification JWT
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). │ Utilisateur │────▶│ LoginScreen │────▶│ LoginViewModel │
4. Click **Connect**. └─────────────┘ └──────────────┘ └─────────────────┘
┌───────────────────────────────┘
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ 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
```
### Build de release
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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = false
isShrinkResources = true isShrinkResources = false
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@ -63,11 +63,11 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
compose = true compose = true
@ -87,12 +87,15 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation("androidx.compose.material:material-icons-extended")
implementation(libs.androidx.compose.material)
// Navigation // Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
@ -122,6 +125,9 @@ dependencies {
// Splash Screen // Splash Screen
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
// Markdown
implementation(libs.compose.markdown)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -33,13 +33,37 @@
# Moshi # Moshi
-keep class com.squareup.moshi.** { *; } -keep class com.squareup.moshi.** { *; }
-keep interface 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 Kotlin Metadata
-keep class kotlin.Metadata { *; } -keep class kotlin.Metadata { *; }
-keepattributes RuntimeVisibleAnnotations
# Hilt # Hilt / Dagger
-keepclasseswithmembers class * { -keepclasseswithmembers class * {
@dagger.* <methods>; @dagger.* <methods>;
} }
@ -49,14 +73,44 @@
} }
-keep class dagger.* { *; } -keep class dagger.* { *; }
-keep class javax.inject.* { *; } -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.** -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) # JWT (io.jsonwebtoken)
-keep class io.jsonwebtoken.** { *; } -keep class io.jsonwebtoken.** { *; }
# Compose # Compose
-keep class androidx.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() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Install splash screen before super.onCreate // Install splash screen before super.onCreate
val splashScreen = installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@ -2,6 +2,7 @@ package com.shaarit.core.storage
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -27,7 +28,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
val masterKey = val masterKey =
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() 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, context,
"secret_prefs", "secret_prefs",
masterKey, masterKey,
@ -49,7 +61,7 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
} }
override fun saveBaseUrl(url: String) { 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 val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url
sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply() 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> @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 all tags with their occurrence count. */
@GET("/api/v1/tags") @GET("/api/v1/tags")
suspend fun getTags( 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 // 3. Verify credentials by calling the API info endpoint
// This endpoint requires valid authentication // This endpoint requires valid authentication
return try { return try {
val info = api.getInfo() api.getInfo()
// If we get here, authentication worked // If we get here, authentication worked
// The info contains Shaarli version and settings // The info contains Shaarli version and settings
Result.success(true) 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>> { override suspend fun getTags(): Result<List<ShaarliTag>> {
return try { return try {
val tags = api.getTags(offset = 0, limit = 500) 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 deleteLink(id: Int): Result<Unit>
suspend fun getLink(id: Int): Result<ShaarliLink>
suspend fun getTags(): Result<List<ShaarliTag>> suspend fun getTags(): Result<List<ShaarliTag>>
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> 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,35 +6,36 @@ import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Close import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.icons.filled.Search import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ViewStyle
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.* import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
fun FeedScreen( fun FeedScreen(
onNavigateToAdd: () -> Unit, onNavigateToAdd: () -> Unit,
onNavigateToEdit: (Int) -> Unit = {},
onNavigateToTags: () -> Unit = {}, onNavigateToTags: () -> Unit = {},
initialTagFilter: String? = null, initialTagFilter: String? = null,
viewModel: FeedViewModel = hiltViewModel() viewModel: FeedViewModel = hiltViewModel()
@ -42,18 +43,25 @@ fun FeedScreen(
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems() val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
val searchTags by viewModel.searchTags.collectAsState() val searchTags by viewModel.searchTags.collectAsState()
val viewStyle by viewModel.viewStyle.collectAsState()
val context = LocalContext.current 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 // Set initial tag filter
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) } LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
val pullRefreshState = rememberPullRefreshState(
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
onRefresh = { pagingItems.refresh() }
)
Box( Box(
modifier = modifier = Modifier
Modifier.fillMaxSize() .fillMaxSize()
.background( .background(
brush = brush = Brush.verticalGradient(
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy) colors = listOf(DeepNavy, DarkNavy)
) )
) )
@ -71,7 +79,104 @@ fun FeedScreen(
) )
}, },
actions = { actions = {
// Tags button - using # symbol which represents tags // 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) { TextButton(onClick = onNavigateToTags) {
Text( Text(
text = "#", text = "#",
@ -81,8 +186,7 @@ fun FeedScreen(
) )
} }
}, },
colors = colors = TopAppBarDefaults.topAppBarColors(
TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f), containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary titleContentColor = TextPrimary
) )
@ -99,13 +203,10 @@ fun FeedScreen(
if (hasTagFilter && searchTags != null) { if (hasTagFilter && searchTags != null) {
// Tag filter chip // Tag filter chip
Row( Row(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.background(DarkNavy) .background(DarkNavy)
.padding( .padding(horizontal = 16.dp, vertical = 12.dp),
horizontal = 16.dp,
vertical = 12.dp
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@ -134,12 +235,9 @@ fun FeedScreen(
PremiumTextField( PremiumTextField(
value = searchQuery, value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged, onValueChange = viewModel::onSearchQueryChanged,
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.padding( .padding(horizontal = 16.dp, vertical = 8.dp),
horizontal = 16.dp,
vertical = 8.dp
),
placeholder = "Search links...", placeholder = "Search links...",
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -151,9 +249,7 @@ fun FeedScreen(
trailingIcon = { trailingIcon = {
if (searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton( IconButton(
onClick = { onClick = { viewModel.onSearchQueryChanged("") }
viewModel.onSearchQueryChanged("")
}
) { ) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
@ -175,17 +271,23 @@ fun FeedScreen(
contentColor = DeepNavy contentColor = DeepNavy
) { Icon(Icons.Default.Add, contentDescription = "Add Link") } ) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
}, },
containerColor = androidx.compose.ui.graphics.Color.Transparent containerColor = Color.Transparent
) { paddingValues -> ) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) { Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
when { when {
pagingItems.loadState.refresh is LoadState.Loading -> { pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> {
// Initial loading
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = CyanPrimary) } ) { CircularProgressIndicator(color = CyanPrimary) }
} }
pagingItems.loadState.refresh is LoadState.Error -> { pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -228,6 +330,84 @@ fun FeedScreen(
} }
} }
else -> { else -> {
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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
@ -236,14 +416,15 @@ fun FeedScreen(
items(count = pagingItems.itemCount) { index -> items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index] val link = pagingItems[index]
if (link != null) { if (link != null) {
LinkItem( ListViewItem(
link = link, link = link,
onTagClick = viewModel::onTagClicked, onTagClick = viewModel::onTagClicked,
onLinkClick = { url -> onLinkClick = { url ->
val intent = val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent) context.startActivity(intent)
}, },
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) } onDeleteClick = { viewModel.deleteLink(link.id) }
) )
} }
@ -267,132 +448,27 @@ fun FeedScreen(
} }
} }
} }
}
}
@OptIn(ExperimentalLayoutApi::class) // Pull refresh indicator
@Composable PullRefreshIndicator(
fun LinkItem( refreshing = pagingItems.loadState.refresh is LoadState.Loading,
link: ShaarliLink, state = pullRefreshState,
onTagClick: (String) -> Unit, modifier = Modifier.align(Alignment.TopCenter),
onLinkClick: (String) -> Unit, backgroundColor = DarkNavy,
onDeleteClick: () -> Unit contentColor = CyanPrimary
) {
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()) { if (selectedLink != null) {
Spacer(modifier = Modifier.height(8.dp)) LinkDetailsDialog(
Text( link = selectedLink!!,
text = link.description, onDismiss = { selectedLink = null },
style = MaterialTheme.typography.bodyMedium, onLinkClick = { url ->
color = TextSecondary, val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
maxLines = 3, context.startActivity(intent)
overflow = TextOverflow.Ellipsis
)
} }
if (link.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(link.tags) { tag ->
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = link.date,
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
if (link.isPrivate) {
Text(
text = "🔒 Private",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
) )
} }
} }
} }
}
}

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ViewStyle
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -27,6 +28,9 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
private val _searchTags = MutableStateFlow<String?>(null) private val _searchTags = MutableStateFlow<String?>(null)
val searchTags = _searchTags.asStateFlow() val searchTags = _searchTags.asStateFlow()
private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
val viewStyle = _viewStyle.asStateFlow()
private val _refreshTrigger = MutableStateFlow(0) private val _refreshTrigger = MutableStateFlow(0)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@ -48,13 +52,15 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
} }
fun onTagClicked(tag: String) { fun onTagClicked(tag: String) {
// Toggle or set? User said "n'afficher que les liens de ce tag" val currentTags = _searchTags.value?.split(" ")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf()
// If clicking same tag, maybe clear?
if (_searchTags.value == tag) { if (currentTags.contains(tag)) {
_searchTags.value = null currentTags.remove(tag)
} else { } else {
_searchTags.value = tag currentTags.add(tag)
} }
_searchTags.value = if (currentTags.isEmpty()) null else currentTags.joinToString(" ")
} }
fun setInitialTagFilter(tag: String?) { fun setInitialTagFilter(tag: String?) {
@ -78,4 +84,8 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
fun refresh() { fun refresh() {
_refreshTrigger.value++ _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,6 +18,9 @@ sealed class Screen(val route: String) {
} }
} }
object Add : Screen("add?url={url}&title={title}&isShare={isShare}") 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") object Tags : Screen("tags")
} }
@ -29,7 +32,6 @@ fun AppNavGraph(
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
val isShareIntent = shareUrl != null
NavHost(navController = navController, startDestination = startDestination) { NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Login.route) { composable(Screen.Login.route) {
@ -56,8 +58,7 @@ fun AppNavGraph(
composable( composable(
route = "feed?tag={tag}", route = "feed?tag={tag}",
arguments = arguments = listOf(
listOf(
navArgument("tag") { navArgument("tag") {
type = NavType.StringType type = NavType.StringType
nullable = true nullable = true
@ -68,6 +69,9 @@ fun AppNavGraph(
val tag = backStackEntry.arguments?.getString("tag") val tag = backStackEntry.arguments?.getString("tag")
com.shaarit.presentation.feed.FeedScreen( com.shaarit.presentation.feed.FeedScreen(
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") }, onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
onNavigateToEdit = { linkId ->
navController.navigate(Screen.Edit.createRoute(linkId))
},
onNavigateToTags = { navController.navigate(Screen.Tags.route) }, onNavigateToTags = { navController.navigate(Screen.Tags.route) },
initialTagFilter = tag initialTagFilter = tag
) )
@ -75,8 +79,7 @@ fun AppNavGraph(
composable( composable(
route = "add?url={url}&title={title}&isShare={isShare}", route = "add?url={url}&title={title}&isShare={isShare}",
arguments = arguments = listOf(
listOf(
navArgument("url") { navArgument("url") {
type = NavType.StringType type = NavType.StringType
defaultValue = "" defaultValue = ""
@ -102,6 +105,19 @@ fun AppNavGraph(
) )
} }
composable(
route = "edit/{linkId}",
arguments = listOf(
navArgument("linkId") {
type = NavType.IntType
}
)
) {
com.shaarit.presentation.edit.EditLinkScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.Tags.route) { composable(Screen.Tags.route) {
com.shaarit.presentation.tags.TagsScreen( com.shaarit.presentation.tags.TagsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
@ -114,4 +130,3 @@ fun AppNavGraph(
} }
} }
} }

View File

@ -40,13 +40,8 @@ fun TagsScreen(
viewModel: TagsViewModel = hiltViewModel() viewModel: TagsViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() 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 searchQuery by viewModel.searchQuery.collectAsState()
val context = LocalContext.current
Box( Box(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
@ -130,24 +125,10 @@ fun TagsScreen(
} }
is TagsUiState.Success -> { is TagsUiState.Success -> {
val filteredTags = viewModel.getFilteredTags() val filteredTags = viewModel.getFilteredTags()
TagsGridView(
if (selectedTag != null) { tags = filteredTags,
// Show links for selected tag onTagClick = { tag -> onNavigateToFeedWithTag(tag.name) }
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)
}
} }
} }
} }
@ -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 = val colorScheme =
when { when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
else lightColorScheme() 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 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
moshi.generateAdapter.source=ksp

View File

@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.2.0" agp = "8.13.2"
kotlin = "1.9.20" kotlin = "1.9.20"
coreKtx = "1.12.0" coreKtx = "1.12.0"
junit = "4.13.2" junit = "4.13.2"
@ -18,6 +18,7 @@ ksp = "1.9.20-1.0.14"
paging = "3.2.1" paging = "3.2.1"
pagingCompose = "3.2.1" pagingCompose = "3.2.1"
material = "1.11.0" material = "1.11.0"
composeMarkdown = "0.4.1"
[libraries] [libraries]
material = { group = "com.google.android.material", name = "material", version.ref = "material" } 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-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" } 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-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-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" } 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-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-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" } 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 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

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