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:
parent
1438003f94
commit
a9475c16b1
445
README.md
445
README.md
@ -1,114 +1,365 @@
|
||||
# ShaarIt
|
||||
|
||||
**ShaarIt** is a native Android client for [Shaarli](https://github.com/shaarli/Shaarli), the self-hosted bookmark manager. Built with modern Android technologies, it provides a seamless mobile experience for managing your links.
|
||||
**ShaarIt** est un client Android natif pour [Shaarli](https://github.com/shaarli/Shaarli), le gestionnaire de favoris auto-hébergé. Développé avec les technologies Android modernes, il offre une expérience mobile fluide pour gérer vos liens.
|
||||
|
||||
## Features
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
* **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).
|
||||
|
||||
@ -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)
|
||||
|
||||
66
app/proguard-rules.pro
vendored
66
app/proguard-rules.pro
vendored
@ -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.** { *; }
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
16
app/src/main/java/com/shaarit/domain/model/ViewStyle.kt
Normal file
16
app/src/main/java/com/shaarit/domain/model/ViewStyle.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -6,35 +6,36 @@ 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,
|
||||
onNavigateToEdit: (Int) -> Unit = {},
|
||||
onNavigateToTags: () -> Unit = {},
|
||||
initialTagFilter: String? = null,
|
||||
viewModel: FeedViewModel = hiltViewModel()
|
||||
@ -42,18 +43,25 @@ fun FeedScreen(
|
||||
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()
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
@ -71,7 +79,104 @@ fun FeedScreen(
|
||||
)
|
||||
},
|
||||
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) {
|
||||
Text(
|
||||
text = "#",
|
||||
@ -81,8 +186,7 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
@ -99,13 +203,10 @@ fun FeedScreen(
|
||||
if (hasTagFilter && searchTags != null) {
|
||||
// Tag filter chip
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(DarkNavy)
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp
|
||||
),
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@ -134,12 +235,9 @@ fun FeedScreen(
|
||||
PremiumTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 8.dp
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = "Search links...",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
@ -151,9 +249,7 @@ fun FeedScreen(
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.onSearchQueryChanged("")
|
||||
}
|
||||
onClick = { viewModel.onSearchQueryChanged("") }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
@ -175,17 +271,23 @@ fun FeedScreen(
|
||||
contentColor = DeepNavy
|
||||
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
|
||||
},
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
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
|
||||
) { 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
|
||||
@ -228,6 +330,84 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
@ -236,14 +416,15 @@ fun FeedScreen(
|
||||
items(count = pagingItems.itemCount) { index ->
|
||||
val link = pagingItems[index]
|
||||
if (link != null) {
|
||||
LinkItem(
|
||||
ListViewItem(
|
||||
link = link,
|
||||
onTagClick = viewModel::onTagClicked,
|
||||
onLinkClick = { url ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
)
|
||||
}
|
||||
@ -267,132 +448,27 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
// Pull refresh indicator
|
||||
PullRefreshIndicator(
|
||||
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = DarkNavy,
|
||||
contentColor = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 (selectedLink != null) {
|
||||
LinkDetailsDialog(
|
||||
link = selectedLink!!,
|
||||
onDismiss = { selectedLink = null },
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
617
app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
Normal file
617
app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,9 @@ 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")
|
||||
}
|
||||
|
||||
@ -29,7 +32,6 @@ fun AppNavGraph(
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val isShareIntent = shareUrl != null
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable(Screen.Login.route) {
|
||||
@ -56,8 +58,7 @@ fun AppNavGraph(
|
||||
|
||||
composable(
|
||||
route = "feed?tag={tag}",
|
||||
arguments =
|
||||
listOf(
|
||||
arguments = listOf(
|
||||
navArgument("tag") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
@ -68,6 +69,9 @@ fun AppNavGraph(
|
||||
val tag = backStackEntry.arguments?.getString("tag")
|
||||
com.shaarit.presentation.feed.FeedScreen(
|
||||
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
||||
onNavigateToEdit = { linkId ->
|
||||
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||
},
|
||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||
initialTagFilter = tag
|
||||
)
|
||||
@ -75,8 +79,7 @@ fun AppNavGraph(
|
||||
|
||||
composable(
|
||||
route = "add?url={url}&title={title}&isShare={isShare}",
|
||||
arguments =
|
||||
listOf(
|
||||
arguments = listOf(
|
||||
navArgument("url") {
|
||||
type = NavType.StringType
|
||||
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) {
|
||||
com.shaarit.presentation.tags.TagsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
@ -114,4 +130,3 @@ fun AppNavGraph(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -40,13 +40,8 @@ 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()
|
||||
@ -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) }
|
||||
TagsGridView(
|
||||
tags = filteredTags,
|
||||
onTagClick = { tag -> onNavigateToFeedWithTag(tag.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
67
build_output.txt
Normal 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
44
compile_output.txt
Normal 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
|
||||
@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
moshi.generateAdapter.source=ksp
|
||||
@ -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" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
||||
@ -11,6 +11,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user