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
|
||||||
|
|
||||||
**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
|
### 🔐 Authentification
|
||||||
* **UI**: Jetpack Compose (Material Design 3)
|
- Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1)
|
||||||
* **Architecture**: Clean Architecture + MVVM
|
- Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences`
|
||||||
* **Dependency Injection**: Dagger Hilt
|
- Génération automatique de tokens JWT avec algorithme HS512
|
||||||
* **Network**: Retrofit + Moshi + OkHttp
|
|
||||||
* **Concurrency**: Coroutines & Flow
|
|
||||||
* **Pagination**: Paging 3
|
|
||||||
|
|
||||||
## Prerequisites
|
### 📚 Gestion des Favoris
|
||||||
|
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
|
||||||
|
- **Recherche côté serveur** : Recherche par termes et filtrage par tags
|
||||||
|
- **Ajout rapide** : Création de liens privés/publics avec description et tags
|
||||||
|
- **Édition** : Modification complète des liens existants
|
||||||
|
- **Suppression** : Gestion facile des favoris
|
||||||
|
- **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour
|
||||||
|
|
||||||
Before building via the command line, ensure you have:
|
### 🏷️ Gestion des Tags
|
||||||
|
- Vue dédiée pour parcourir tous les tags
|
||||||
|
- Compteur d'utilisation par tag
|
||||||
|
- Filtrage rapide du flux par tag
|
||||||
|
|
||||||
1. **JDK 17** (or newer) installed.
|
### 🔗 Intégration système
|
||||||
2. **Android SDK** installed.
|
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (navigateur, YouTube, etc.) via le menu Partager
|
||||||
3. **Gradle** (v8.0+) installed (only required if `gradlew` is missing).
|
- Ouverture des liens dans le navigateur par défaut
|
||||||
* *Note for Windows users with Scoop:* `scoop install gradle`
|
- Support des URLs partagées avec titre pré-rempli
|
||||||
|
|
||||||
## First Time Setup (If gradlew is missing)
|
### 🎨 Interface Utilisateur
|
||||||
|
- **Design premium** : Thème sombre moderne avec dégradés cyan/bleu
|
||||||
|
- **Material Design 3** : Composants UI natifs Android
|
||||||
|
- **Animations fluides** : Transitions et effets visuels
|
||||||
|
- **Deux modes d'affichage** : Liste détaillée ou grille compacte
|
||||||
|
- **Pull-to-refresh** : Actualisation du flux par glissement
|
||||||
|
|
||||||
If the `./gradlew` file is missing (e.g. fresh project generation), you need to generate it using a local Gradle installation:
|
---
|
||||||
|
|
||||||
```bash
|
## 🛠️ Stack Technique
|
||||||
gradle wrapper
|
|
||||||
```
|
| Catégorie | Technologie |
|
||||||
|
|-----------|-------------|
|
||||||
This will create `gradlew`, `gradlew.bat` and the `gradle/wrapper` folder.
|
| **Langage** | Kotlin 1.9.20 |
|
||||||
|
| **UI** | Jetpack Compose + Material Design 3 |
|
||||||
## Environment Setup (SDK)
|
| **Architecture** | Clean Architecture + MVVM |
|
||||||
|
| **Injection de dépendances** | Dagger Hilt 2.48.1 |
|
||||||
Ensure you have the required Android SDK components installed (Platform API 34).
|
| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
|
||||||
If you see a silent failure or "missing target" error, run:
|
| **Pagination** | Paging 3 |
|
||||||
|
| **Concurrence** | Coroutines & Flow |
|
||||||
```powershell
|
| **Stockage sécurisé** | AndroidX Security Crypto |
|
||||||
# Adjust path to your sdkmanager if needed
|
| **Navigation** | Navigation Compose |
|
||||||
$SDK_MANAGER = "$env:ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager"
|
| **Compilation** | Gradle 8.0+ avec KSP |
|
||||||
& $SDK_MANAGER "platforms;android-34" "build-tools;34.0.0"
|
|
||||||
```
|
### Compatibilité
|
||||||
|
- **minSdk**: 24 (Android 7.0)
|
||||||
## Compilation Instructions (Command Line)
|
- **targetSdk**: 34 (Android 14)
|
||||||
|
- **compileSdk**: 34
|
||||||
You can build the project without opening Android Studio by using the Gradle Wrapper included in the project.
|
- **JDK requis**: 17+
|
||||||
|
|
||||||
### 1. Configure SDK Location
|
---
|
||||||
If your `ANDROID_HOME` environment variable is not set, create a `local.properties` file in the project root:
|
|
||||||
|
## 📥 Installation
|
||||||
```bash
|
|
||||||
# Windows
|
### Prérequis utilisateur
|
||||||
echo sdk.dir=C:\\Users\\<Username>\\AppData\\Local\\Android\\Sdk > local.properties
|
- Un serveur Shaarli auto-hébergé (v0.12+) avec l'API v1 activée
|
||||||
|
- Un appareil Android 7.0+ ou un émulateur
|
||||||
# Linux/macOS
|
|
||||||
echo sdk.dir=/home/<username>/Android/Sdk > local.properties
|
### Obtenir l'APK
|
||||||
```
|
|
||||||
|
#### Méthode 1 : Téléchargement direct
|
||||||
### 2. Build the APK
|
Récupérez le dernier APK depuis la section [Releases](../../releases).
|
||||||
|
|
||||||
Open your terminal in the project root folder.
|
#### Méthode 2 : Compilation depuis les sources
|
||||||
|
|
||||||
**Windows (PowerShell/CMD):**
|
##### Prérequis de développement
|
||||||
```powershell
|
1. **JDK 17** (ou plus récent) installé
|
||||||
./gradlew assembleDebug
|
2. **Android SDK** installé (Platform API 34)
|
||||||
```
|
3. **Gradle** 8.0+ (si `gradlew` est manquant)
|
||||||
|
|
||||||
**Linux/macOS:**
|
##### Étapes de compilation
|
||||||
```bash
|
|
||||||
chmod +x gradlew
|
1. **Cloner le repository**
|
||||||
./gradlew assembleDebug
|
```bash
|
||||||
```
|
git clone https://github.com/votre-username/ShaarIt.git
|
||||||
|
cd ShaarIt
|
||||||
*The build process may take a few minutes as it downloads dependencies.*
|
```
|
||||||
|
|
||||||
### 3. Locate the APK
|
2. **Configurer l'emplacement du SDK Android**
|
||||||
Once the build is successful, the APK file will be located at:
|
|
||||||
`app/build/outputs/apk/debug/app-debug.apk`
|
Si la variable `ANDROID_HOME` n'est pas définie, créez un fichier `local.properties` :
|
||||||
|
```bash
|
||||||
## Installation
|
# Windows
|
||||||
|
echo sdk.dir=C:\Users\<Username>\AppData\Local\Android\Sdk > local.properties
|
||||||
To install the app on a connected device or emulator via command line:
|
|
||||||
|
# Linux/macOS
|
||||||
```bash
|
echo sdk.dir=/home/<username>/Android/Sdk > local.properties
|
||||||
# Ensure your device is connected and visible
|
```
|
||||||
adb devices
|
|
||||||
|
3. **Compiler l'APK Debug**
|
||||||
# Install the Debug APK
|
```bash
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
# Windows
|
||||||
```
|
./gradlew assembleDebug
|
||||||
|
|
||||||
## Running Tests
|
# Linux/macOS
|
||||||
|
chmod +x gradlew
|
||||||
To run unit tests:
|
./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
|
```bash
|
||||||
|
# Tests unitaires
|
||||||
./gradlew test
|
./gradlew test
|
||||||
|
|
||||||
|
# Tests instrumentés
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
### Build de release
|
||||||
|
|
||||||
1. Open the **ShaarIt** app.
|
1. **Créer un keystore** (si premier build)
|
||||||
2. Enter your **Shaarli Instance URL** (e.g., `https://myserver.com/shaarli`).
|
```bash
|
||||||
3. Enter your **API Secret** (found in your Shaarli admin settings).
|
keytool -genkey -v -keystore shaarit.keystore -alias shaarit \
|
||||||
4. Click **Connect**.
|
-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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = false
|
||||||
isShrinkResources = true
|
isShrinkResources = false
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
@ -63,11 +63,11 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
@ -87,12 +87,15 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation(libs.androidx.compose.material)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
@ -122,6 +125,9 @@ dependencies {
|
|||||||
// Splash Screen
|
// Splash Screen
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
|
|
||||||
|
// Markdown
|
||||||
|
implementation(libs.compose.markdown)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
66
app/proguard-rules.pro
vendored
66
app/proguard-rules.pro
vendored
@ -33,13 +33,37 @@
|
|||||||
# Moshi
|
# Moshi
|
||||||
-keep class com.squareup.moshi.** { *; }
|
-keep class com.squareup.moshi.** { *; }
|
||||||
-keep interface com.squareup.moshi.** { *; }
|
-keep interface com.squareup.moshi.** { *; }
|
||||||
-keep class com.shaarit.data.dto.** { *; }
|
|
||||||
-keepclassmembers class com.shaarit.data.dto.** { *; }
|
# Keep Moshi @JsonClass annotated classes and their generated adapters
|
||||||
|
-keep @com.squareup.moshi.JsonClass class * { *; }
|
||||||
|
-keep class **JsonAdapter { *; }
|
||||||
|
-keep class **$JsonAdapter { *; }
|
||||||
|
-keepnames @com.squareup.moshi.JsonClass class *
|
||||||
|
|
||||||
|
# Keep Moshi-generated adapter factories
|
||||||
|
-keep class *JsonAdapterFactory { *; }
|
||||||
|
|
||||||
|
# Keep all DTOs with their fields and constructors
|
||||||
|
-keep class com.shaarit.data.dto.** {
|
||||||
|
<init>(...);
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
-keepclassmembers class com.shaarit.data.dto.** {
|
||||||
|
<init>(...);
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Kotlin data class component functions and copy methods
|
||||||
|
-keepclassmembers class com.shaarit.data.dto.** {
|
||||||
|
public ** component*();
|
||||||
|
public ** copy(...);
|
||||||
|
}
|
||||||
|
|
||||||
# Keep Kotlin Metadata
|
# Keep Kotlin Metadata
|
||||||
-keep class kotlin.Metadata { *; }
|
-keep class kotlin.Metadata { *; }
|
||||||
|
-keepattributes RuntimeVisibleAnnotations
|
||||||
|
|
||||||
# Hilt
|
# Hilt / Dagger
|
||||||
-keepclasseswithmembers class * {
|
-keepclasseswithmembers class * {
|
||||||
@dagger.* <methods>;
|
@dagger.* <methods>;
|
||||||
}
|
}
|
||||||
@ -49,14 +73,44 @@
|
|||||||
}
|
}
|
||||||
-keep class dagger.* { *; }
|
-keep class dagger.* { *; }
|
||||||
-keep class javax.inject.* { *; }
|
-keep class javax.inject.* { *; }
|
||||||
|
-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; }
|
||||||
|
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }
|
||||||
|
-keep class * implements dagger.hilt.internal.GeneratedComponent { *; }
|
||||||
|
-keep class * implements dagger.hilt.internal.GeneratedComponentManager { *; }
|
||||||
|
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class *
|
||||||
|
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EntryPoint class *
|
||||||
|
-keep,allowobfuscation,allowshrinking @dagger.hilt.InstallIn class *
|
||||||
-dontwarn dagger.internal.codegen.**
|
-dontwarn dagger.internal.codegen.**
|
||||||
|
|
||||||
|
# Hilt ViewModel
|
||||||
|
-keep class * extends androidx.lifecycle.ViewModel { *; }
|
||||||
|
-keep class * extends androidx.lifecycle.AndroidViewModel { *; }
|
||||||
|
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
|
||||||
|
<init>(...);
|
||||||
|
}
|
||||||
|
|
||||||
# JWT (io.jsonwebtoken)
|
# JWT (io.jsonwebtoken)
|
||||||
-keep class io.jsonwebtoken.** { *; }
|
-keep class io.jsonwebtoken.** { *; }
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
-keep class androidx.compose.** { *; }
|
-keep class androidx.compose.** { *; }
|
||||||
|
-dontwarn androidx.compose.**
|
||||||
|
|
||||||
|
# Kotlin coroutines
|
||||||
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
|
-keepclassmembernames class kotlinx.** {
|
||||||
|
volatile <fields>;
|
||||||
|
}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# Keep ALL Application classes to prevent runtime crashes
|
||||||
|
-keep class com.shaarit.** { *; }
|
||||||
|
-keepclassmembers class com.shaarit.** { *; }
|
||||||
|
|
||||||
|
# Keep Hilt generated classes
|
||||||
|
-keep class com.shaarit.Hilt_* { *; }
|
||||||
|
-keep class **_HiltModules_* { *; }
|
||||||
|
-keep class **_Factory { *; }
|
||||||
|
-keep class **_MembersInjector { *; }
|
||||||
|
|
||||||
# Application classes
|
|
||||||
-keep class com.shaarit.domain.model.** { *; }
|
|
||||||
-keep class com.shaarit.data.api.** { *; }
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Install splash screen before super.onCreate
|
// Install splash screen before super.onCreate
|
||||||
val splashScreen = installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.shaarit.core.storage
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@ -27,7 +28,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
|||||||
val masterKey =
|
val masterKey =
|
||||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||||
|
|
||||||
EncryptedSharedPreferences.create(
|
try {
|
||||||
|
createEncryptedPrefs(masterKey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("TokenManager", "Error creating EncryptedSharedPreferences, wiping data", e)
|
||||||
|
// If encryption fails (corrupted key or incompatible transition), clear the file and retry
|
||||||
|
context.getSharedPreferences("secret_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
createEncryptedPrefs(masterKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEncryptedPrefs(masterKey: MasterKey): SharedPreferences {
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
"secret_prefs",
|
"secret_prefs",
|
||||||
masterKey,
|
masterKey,
|
||||||
@ -49,7 +61,7 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun saveBaseUrl(url: String) {
|
override fun saveBaseUrl(url: String) {
|
||||||
// Remove trailing slash if present for consistency, though better done in logic
|
// Remove trailing slash if present for consistency
|
||||||
val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url
|
val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url
|
||||||
sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply()
|
sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,9 @@ interface ShaarliApi {
|
|||||||
|
|
||||||
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
|
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
|
||||||
|
|
||||||
|
@GET("/api/v1/links/{id}")
|
||||||
|
suspend fun getLink(@Path("id") id: Int): LinkDto
|
||||||
|
|
||||||
/** Get all tags with their occurrence count. */
|
/** Get all tags with their occurrence count. */
|
||||||
@GET("/api/v1/tags")
|
@GET("/api/v1/tags")
|
||||||
suspend fun getTags(
|
suspend fun getTags(
|
||||||
|
|||||||
@ -20,7 +20,7 @@ constructor(private val api: ShaarliApi, private val tokenManager: TokenManager)
|
|||||||
// 3. Verify credentials by calling the API info endpoint
|
// 3. Verify credentials by calling the API info endpoint
|
||||||
// This endpoint requires valid authentication
|
// This endpoint requires valid authentication
|
||||||
return try {
|
return try {
|
||||||
val info = api.getInfo()
|
api.getInfo()
|
||||||
// If we get here, authentication worked
|
// If we get here, authentication worked
|
||||||
// The info contains Shaarli version and settings
|
// The info contains Shaarli version and settings
|
||||||
Result.success(true)
|
Result.success(true)
|
||||||
|
|||||||
@ -152,6 +152,15 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLink(id: Int): Result<ShaarliLink> {
|
||||||
|
return try {
|
||||||
|
val link = api.getLink(id)
|
||||||
|
Result.success(LinkMapper.toDomain(link))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
||||||
return try {
|
return try {
|
||||||
val tags = api.getTags(offset = 0, limit = 500)
|
val tags = api.getTags(offset = 0, limit = 500)
|
||||||
|
|||||||
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 deleteLink(id: Int): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun getLink(id: Int): Result<ShaarliLink>
|
||||||
|
|
||||||
suspend fun getTags(): Result<List<ShaarliTag>>
|
suspend fun getTags(): Result<List<ShaarliTag>>
|
||||||
|
|
||||||
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
|
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
|
||||||
|
|||||||
@ -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.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import com.shaarit.domain.model.ShaarliLink
|
import com.shaarit.domain.model.ViewStyle
|
||||||
import com.shaarit.ui.components.GlassCard
|
|
||||||
import com.shaarit.ui.components.PremiumTextField
|
import com.shaarit.ui.components.PremiumTextField
|
||||||
import com.shaarit.ui.components.TagChip
|
import com.shaarit.ui.components.TagChip
|
||||||
import com.shaarit.ui.theme.*
|
import com.shaarit.ui.theme.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FeedScreen(
|
fun FeedScreen(
|
||||||
onNavigateToAdd: () -> Unit,
|
onNavigateToAdd: () -> Unit,
|
||||||
|
onNavigateToEdit: (Int) -> Unit = {},
|
||||||
onNavigateToTags: () -> Unit = {},
|
onNavigateToTags: () -> Unit = {},
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
viewModel: FeedViewModel = hiltViewModel()
|
viewModel: FeedViewModel = hiltViewModel()
|
||||||
@ -42,18 +43,25 @@ fun FeedScreen(
|
|||||||
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
|
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
|
||||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
val searchTags by viewModel.searchTags.collectAsState()
|
val searchTags by viewModel.searchTags.collectAsState()
|
||||||
|
val viewStyle by viewModel.viewStyle.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
var showViewStyleMenu by remember { mutableStateOf(false) }
|
||||||
|
var selectedLink by remember { mutableStateOf<com.shaarit.domain.model.ShaarliLink?>(null) }
|
||||||
|
|
||||||
// Set initial tag filter
|
// Set initial tag filter
|
||||||
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
|
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
|
||||||
|
|
||||||
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
|
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||||
|
onRefresh = { pagingItems.refresh() }
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
brush =
|
brush = Brush.verticalGradient(
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(DeepNavy, DarkNavy)
|
colors = listOf(DeepNavy, DarkNavy)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -71,7 +79,104 @@ fun FeedScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Tags button - using # symbol which represents tags
|
// Refresh Button
|
||||||
|
IconButton(onClick = { pagingItems.refresh() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
tint = CyanPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Style Selector
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showViewStyleMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (viewStyle) {
|
||||||
|
ViewStyle.LIST -> Icons.Default.ViewStream
|
||||||
|
ViewStyle.GRID -> Icons.Default.ViewModule
|
||||||
|
ViewStyle.COMPACT -> Icons.Default.ViewList
|
||||||
|
},
|
||||||
|
contentDescription = "View style",
|
||||||
|
tint = CyanPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showViewStyleMenu,
|
||||||
|
onDismissRequest = { showViewStyleMenu = false },
|
||||||
|
modifier = Modifier.background(CardBackground)
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ViewStream,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextSecondary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"List View",
|
||||||
|
color = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setViewStyle(ViewStyle.LIST)
|
||||||
|
showViewStyleMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ViewModule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextSecondary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Grid View",
|
||||||
|
color = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setViewStyle(ViewStyle.GRID)
|
||||||
|
showViewStyleMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ViewList,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextSecondary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Compact View",
|
||||||
|
color = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setViewStyle(ViewStyle.COMPACT)
|
||||||
|
showViewStyleMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags button
|
||||||
TextButton(onClick = onNavigateToTags) {
|
TextButton(onClick = onNavigateToTags) {
|
||||||
Text(
|
Text(
|
||||||
text = "#",
|
text = "#",
|
||||||
@ -81,8 +186,7 @@ fun FeedScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||||
titleContentColor = TextPrimary
|
titleContentColor = TextPrimary
|
||||||
)
|
)
|
||||||
@ -99,13 +203,10 @@ fun FeedScreen(
|
|||||||
if (hasTagFilter && searchTags != null) {
|
if (hasTagFilter && searchTags != null) {
|
||||||
// Tag filter chip
|
// Tag filter chip
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(DarkNavy)
|
.background(DarkNavy)
|
||||||
.padding(
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
horizontal = 16.dp,
|
|
||||||
vertical = 12.dp
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
@ -134,12 +235,9 @@ fun FeedScreen(
|
|||||||
PremiumTextField(
|
PremiumTextField(
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
onValueChange = viewModel::onSearchQueryChanged,
|
onValueChange = viewModel::onSearchQueryChanged,
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontal = 16.dp,
|
|
||||||
vertical = 8.dp
|
|
||||||
),
|
|
||||||
placeholder = "Search links...",
|
placeholder = "Search links...",
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
@ -151,9 +249,7 @@ fun FeedScreen(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (searchQuery.isNotEmpty()) {
|
if (searchQuery.isNotEmpty()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = { viewModel.onSearchQueryChanged("") }
|
||||||
viewModel.onSearchQueryChanged("")
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
Icons.Default.Close,
|
||||||
@ -175,17 +271,23 @@ fun FeedScreen(
|
|||||||
contentColor = DeepNavy
|
contentColor = DeepNavy
|
||||||
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
|
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
|
||||||
},
|
},
|
||||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
containerColor = Color.Transparent
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(modifier = Modifier.padding(paddingValues)) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
.pullRefresh(pullRefreshState)
|
||||||
|
) {
|
||||||
when {
|
when {
|
||||||
pagingItems.loadState.refresh is LoadState.Loading -> {
|
pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> {
|
||||||
|
// Initial loading
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator(color = CyanPrimary) }
|
) { CircularProgressIndicator(color = CyanPrimary) }
|
||||||
}
|
}
|
||||||
pagingItems.loadState.refresh is LoadState.Error -> {
|
pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@ -228,6 +330,84 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
when (viewStyle) {
|
||||||
|
ViewStyle.GRID -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(count = pagingItems.itemCount) { index ->
|
||||||
|
val link = pagingItems[index]
|
||||||
|
if (link != null) {
|
||||||
|
GridViewItem(
|
||||||
|
link = link,
|
||||||
|
onLinkClick = { url ->
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
onViewClick = { selectedLink = link },
|
||||||
|
onEditClick = onNavigateToEdit,
|
||||||
|
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
color = CyanPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewStyle.COMPACT -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(count = pagingItems.itemCount) { index ->
|
||||||
|
val link = pagingItems[index]
|
||||||
|
if (link != null) {
|
||||||
|
CompactViewItem(
|
||||||
|
link = link,
|
||||||
|
onLinkClick = { url ->
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
onViewClick = { selectedLink = link },
|
||||||
|
onEditClick = onNavigateToEdit,
|
||||||
|
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
color = CyanPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewStyle.LIST -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
@ -236,14 +416,15 @@ fun FeedScreen(
|
|||||||
items(count = pagingItems.itemCount) { index ->
|
items(count = pagingItems.itemCount) { index ->
|
||||||
val link = pagingItems[index]
|
val link = pagingItems[index]
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
LinkItem(
|
ListViewItem(
|
||||||
link = link,
|
link = link,
|
||||||
onTagClick = viewModel::onTagClicked,
|
onTagClick = viewModel::onTagClicked,
|
||||||
onLinkClick = { url ->
|
onLinkClick = { url ->
|
||||||
val intent =
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
|
onViewClick = { selectedLink = link },
|
||||||
|
onEditClick = onNavigateToEdit,
|
||||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -267,132 +448,27 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
// Pull refresh indicator
|
||||||
@Composable
|
PullRefreshIndicator(
|
||||||
fun LinkItem(
|
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||||
link: ShaarliLink,
|
state = pullRefreshState,
|
||||||
onTagClick: (String) -> Unit,
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
onLinkClick: (String) -> Unit,
|
backgroundColor = DarkNavy,
|
||||||
onDeleteClick: () -> Unit
|
contentColor = CyanPrimary
|
||||||
) {
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showDeleteDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
|
||||||
title = { Text("Delete Link", fontWeight = FontWeight.Bold, color = TextPrimary) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text("Are you sure you want to delete this link?", color = TextSecondary)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
link.title,
|
|
||||||
color = CyanPrimary,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
onDeleteClick()
|
|
||||||
showDeleteDialog = false
|
|
||||||
}
|
|
||||||
) { Text("Delete", color = ErrorRed) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteDialog = false }) {
|
|
||||||
Text("Cancel", color = TextMuted)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = CardBackground,
|
|
||||||
titleContentColor = TextPrimary,
|
|
||||||
textContentColor = TextSecondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = link.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = CyanPrimary,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = link.url,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = TealSecondary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Delete,
|
|
||||||
contentDescription = "Delete",
|
|
||||||
tint = ErrorRed.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (link.description.isNotBlank()) {
|
if (selectedLink != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
LinkDetailsDialog(
|
||||||
Text(
|
link = selectedLink!!,
|
||||||
text = link.description,
|
onDismiss = { selectedLink = null },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onLinkClick = { url ->
|
||||||
color = TextSecondary,
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
maxLines = 3,
|
context.startActivity(intent)
|
||||||
overflow = TextOverflow.Ellipsis
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (link.tags.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(link.tags) { tag ->
|
|
||||||
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = link.date,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = TextMuted
|
|
||||||
)
|
|
||||||
|
|
||||||
if (link.isPrivate) {
|
|
||||||
Text(
|
|
||||||
text = "🔒 Private",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = TextMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import com.shaarit.domain.model.ShaarliLink
|
import com.shaarit.domain.model.ShaarliLink
|
||||||
|
import com.shaarit.domain.model.ViewStyle
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -27,6 +28,9 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
|||||||
private val _searchTags = MutableStateFlow<String?>(null)
|
private val _searchTags = MutableStateFlow<String?>(null)
|
||||||
val searchTags = _searchTags.asStateFlow()
|
val searchTags = _searchTags.asStateFlow()
|
||||||
|
|
||||||
|
private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
|
||||||
|
val viewStyle = _viewStyle.asStateFlow()
|
||||||
|
|
||||||
private val _refreshTrigger = MutableStateFlow(0)
|
private val _refreshTrigger = MutableStateFlow(0)
|
||||||
|
|
||||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
@ -48,13 +52,15 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onTagClicked(tag: String) {
|
fun onTagClicked(tag: String) {
|
||||||
// Toggle or set? User said "n'afficher que les liens de ce tag"
|
val currentTags = _searchTags.value?.split(" ")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf()
|
||||||
// If clicking same tag, maybe clear?
|
|
||||||
if (_searchTags.value == tag) {
|
if (currentTags.contains(tag)) {
|
||||||
_searchTags.value = null
|
currentTags.remove(tag)
|
||||||
} else {
|
} else {
|
||||||
_searchTags.value = tag
|
currentTags.add(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_searchTags.value = if (currentTags.isEmpty()) null else currentTags.joinToString(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setInitialTagFilter(tag: String?) {
|
fun setInitialTagFilter(tag: String?) {
|
||||||
@ -78,4 +84,8 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
|||||||
fun refresh() {
|
fun refresh() {
|
||||||
_refreshTrigger.value++
|
_refreshTrigger.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setViewStyle(style: ViewStyle) {
|
||||||
|
_viewStyle.value = style
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 Add : Screen("add?url={url}&title={title}&isShare={isShare}")
|
||||||
|
object Edit : Screen("edit/{linkId}") {
|
||||||
|
fun createRoute(linkId: Int): String = "edit/$linkId"
|
||||||
|
}
|
||||||
object Tags : Screen("tags")
|
object Tags : Screen("tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +32,6 @@ fun AppNavGraph(
|
|||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isShareIntent = shareUrl != null
|
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = startDestination) {
|
NavHost(navController = navController, startDestination = startDestination) {
|
||||||
composable(Screen.Login.route) {
|
composable(Screen.Login.route) {
|
||||||
@ -56,8 +58,7 @@ fun AppNavGraph(
|
|||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "feed?tag={tag}",
|
route = "feed?tag={tag}",
|
||||||
arguments =
|
arguments = listOf(
|
||||||
listOf(
|
|
||||||
navArgument("tag") {
|
navArgument("tag") {
|
||||||
type = NavType.StringType
|
type = NavType.StringType
|
||||||
nullable = true
|
nullable = true
|
||||||
@ -68,6 +69,9 @@ fun AppNavGraph(
|
|||||||
val tag = backStackEntry.arguments?.getString("tag")
|
val tag = backStackEntry.arguments?.getString("tag")
|
||||||
com.shaarit.presentation.feed.FeedScreen(
|
com.shaarit.presentation.feed.FeedScreen(
|
||||||
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
||||||
|
onNavigateToEdit = { linkId ->
|
||||||
|
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||||
|
},
|
||||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||||
initialTagFilter = tag
|
initialTagFilter = tag
|
||||||
)
|
)
|
||||||
@ -75,8 +79,7 @@ fun AppNavGraph(
|
|||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "add?url={url}&title={title}&isShare={isShare}",
|
route = "add?url={url}&title={title}&isShare={isShare}",
|
||||||
arguments =
|
arguments = listOf(
|
||||||
listOf(
|
|
||||||
navArgument("url") {
|
navArgument("url") {
|
||||||
type = NavType.StringType
|
type = NavType.StringType
|
||||||
defaultValue = ""
|
defaultValue = ""
|
||||||
@ -102,6 +105,19 @@ fun AppNavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = "edit/{linkId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("linkId") {
|
||||||
|
type = NavType.IntType
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
com.shaarit.presentation.edit.EditLinkScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable(Screen.Tags.route) {
|
composable(Screen.Tags.route) {
|
||||||
com.shaarit.presentation.tags.TagsScreen(
|
com.shaarit.presentation.tags.TagsScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
@ -114,4 +130,3 @@ fun AppNavGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,13 +40,8 @@ fun TagsScreen(
|
|||||||
viewModel: TagsViewModel = hiltViewModel()
|
viewModel: TagsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val selectedTag by viewModel.selectedTag.collectAsState()
|
|
||||||
val tagLinks by viewModel.tagLinks.collectAsState()
|
|
||||||
val isLoadingLinks by viewModel.isLoadingLinks.collectAsState()
|
|
||||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@ -130,24 +125,10 @@ fun TagsScreen(
|
|||||||
}
|
}
|
||||||
is TagsUiState.Success -> {
|
is TagsUiState.Success -> {
|
||||||
val filteredTags = viewModel.getFilteredTags()
|
val filteredTags = viewModel.getFilteredTags()
|
||||||
|
TagsGridView(
|
||||||
if (selectedTag != null) {
|
tags = filteredTags,
|
||||||
// Show links for selected tag
|
onTagClick = { tag -> onNavigateToFeedWithTag(tag.name) }
|
||||||
TagLinksView(
|
|
||||||
tag = selectedTag!!,
|
|
||||||
links = tagLinks,
|
|
||||||
isLoading = isLoadingLinks,
|
|
||||||
onBack = { viewModel.clearTagSelection() },
|
|
||||||
onLinkClick = { url ->
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
|
||||||
context.startActivity(intent)
|
|
||||||
},
|
|
||||||
onViewInFeed = { onNavigateToFeedWithTag(selectedTag!!.name) }
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
// Show tags grid
|
|
||||||
TagsGridView(tags = filteredTags, onTagClick = viewModel::onTagSelected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,124 +172,3 @@ private fun TagGridItem(tag: ShaarliTag, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun TagLinksView(
|
|
||||||
tag: ShaarliTag,
|
|
||||||
links: List<ShaarliLink>,
|
|
||||||
isLoading: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onLinkClick: (String) -> Unit,
|
|
||||||
onViewInFeed: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Tag header
|
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth().padding(16.dp), glowColor = CyanPrimary) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowBack,
|
|
||||||
contentDescription = "Back",
|
|
||||||
tint = TextSecondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "#${tag.name}",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = CyanPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "${tag.occurrences} links",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = TextSecondary,
|
|
||||||
modifier = Modifier.padding(start = 48.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(onClick = onViewInFeed) { Text("View in Feed", color = TealSecondary) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) { CircularProgressIndicator(color = CyanPrimary) }
|
|
||||||
} else if (links.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"No links found for this tag",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = TextSecondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
items(links) { link ->
|
|
||||||
TagLinkItem(link = link, onClick = { onLinkClick(link.url) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TagLinkItem(link: ShaarliLink, onClick: () -> Unit) {
|
|
||||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = link.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = TextPrimary,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = link.url,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = TealSecondary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (link.description.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = link.description,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = TextSecondary,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (link.tags.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(link.tags) { tag -> TagChip(tag = tag, isSelected = false, onClick = {}) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(text = link.date, style = MaterialTheme.typography.labelSmall, color = TextMuted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -91,7 +91,6 @@ fun ShaarItTheme(
|
|||||||
val colorScheme =
|
val colorScheme =
|
||||||
when {
|
when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
|
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
|
||||||
else lightColorScheme()
|
else lightColorScheme()
|
||||||
}
|
}
|
||||||
|
|||||||
67
build_output.txt
Normal file
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
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
moshi.generateAdapter.source=ksp
|
||||||
@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.2.0"
|
agp = "8.13.2"
|
||||||
kotlin = "1.9.20"
|
kotlin = "1.9.20"
|
||||||
coreKtx = "1.12.0"
|
coreKtx = "1.12.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
@ -18,6 +18,7 @@ ksp = "1.9.20-1.0.14"
|
|||||||
paging = "3.2.1"
|
paging = "3.2.1"
|
||||||
pagingCompose = "3.2.1"
|
pagingCompose = "3.2.1"
|
||||||
material = "1.11.0"
|
material = "1.11.0"
|
||||||
|
composeMarkdown = "0.4.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
@ -26,6 +27,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@ -39,6 +41,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
|
|||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
|
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
|
||||||
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
|
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
|
||||||
|
androidx-compose-material = { group = "androidx.compose.material", name = "material" }
|
||||||
|
|
||||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
@ -50,6 +53,7 @@ moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
|||||||
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
|
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
|
||||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@ -11,6 +11,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user