mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: redesign setup screen with pulsing animated background and API key helper
- Move inputs/button to bottom of screen for easier thumb access - Add full-screen animated radial pulse background (3 circles with independent animations) - Add contextual hint linking to Ghost integrations page when URL is entered - Add pull-to-refresh and connection error handling to feed screen - Add CLAUDE.md and restore gradle wrapper
This commit is contained in:
parent
edb1752cd8
commit
7da55c4b35
7 changed files with 645 additions and 95 deletions
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Swoosh is an Android microblogging client for Ghost CMS. It provides offline-capable post creation, image uploads, link previews, and scheduled publishing via Ghost's Admin API.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug # Build debug APK
|
||||||
|
./gradlew assembleRelease # Build release APK (ProGuard enabled)
|
||||||
|
./gradlew test # Run all unit tests
|
||||||
|
./gradlew app:testDebugUnitTest # Run unit tests for debug variant
|
||||||
|
./gradlew test --tests "*.MobiledocBuilderTest" # Run a single test class
|
||||||
|
./gradlew test --tests "*.MobiledocBuilderTest.testBasicTextPost" # Run a single test method
|
||||||
|
```
|
||||||
|
|
||||||
|
Robolectric is used for tests that need Android framework classes. Unit tests include Android resources (`unitTests.isIncludeAndroidResources = true`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
MVVM with Repository pattern, single-module Gradle project.
|
||||||
|
|
||||||
|
**Package: `com.swoosh.microblog`**
|
||||||
|
|
||||||
|
- **`data/api/`** — Retrofit service (`GhostApiService`), JWT auth (`GhostJwtGenerator`, `GhostAuthInterceptor`), and `ApiClient` singleton with dynamic base URL
|
||||||
|
- **`data/db/`** — Room database with `LocalPost` entity and `LocalPostDao`
|
||||||
|
- **`data/model/`** — Three model layers: `GhostPost` (API), `LocalPost` (Room entity), `FeedPost` (UI display). Enums: `PostStatus`, `QueueStatus`
|
||||||
|
- **`data/repository/`** — `PostRepository` coordinates local DB and remote API; `OpenGraphFetcher` parses link previews via Jsoup
|
||||||
|
- **`ui/`** — Jetpack Compose screens (Feed, Composer, Detail, Setup, Settings) with ViewModels using `StateFlow`
|
||||||
|
- **`worker/`** — `PostUploadWorker` (WorkManager) handles offline queue with exponential backoff
|
||||||
|
|
||||||
|
**Key data flow:** Posts are saved to Room first → queued for upload → `PostUploadWorker` syncs to Ghost API when network is available.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- **Auth:** Ghost Admin API keys are split into `id:secret`, secret is hex-decoded for HS256 JWT signing (5-min expiry)
|
||||||
|
- **Content format:** Posts use Ghost's mobiledoc JSON format, built by `MobiledocBuilder` (supports text paragraphs and bookmark cards)
|
||||||
|
- **Credentials:** Stored in `EncryptedSharedPreferences` (AES256-GCM) via `CredentialsManager`
|
||||||
|
- **API client:** Base URL is configured at runtime during setup; `ApiClient` rebuilds Retrofit instance when URL changes
|
||||||
|
- **Min SDK 26, Target/Compile SDK 34, Kotlin 1.9.22, Java 17**
|
||||||
|
|
@ -59,7 +59,7 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Compose BOM
|
// Compose BOM
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
|
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
|
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
|
@ -74,6 +74,7 @@ dependencies {
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation("androidx.compose.material:material")
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,22 @@ import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.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.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
@ -22,7 +29,7 @@ import coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FeedScreen(
|
fun FeedScreen(
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
|
|
@ -33,6 +40,12 @@ fun FeedScreen(
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
|
refreshing = state.isRefreshing,
|
||||||
|
onRefresh = { viewModel.refresh() }
|
||||||
|
)
|
||||||
|
|
||||||
// Infinite scroll trigger
|
// Infinite scroll trigger
|
||||||
val shouldLoadMore by remember {
|
val shouldLoadMore by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
@ -52,6 +65,9 @@ fun FeedScreen(
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Swoosh") },
|
title = { Text("Swoosh") },
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(onClick = onSettingsClick) {
|
||||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||||
}
|
}
|
||||||
|
|
@ -64,31 +80,68 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.pullRefresh(pullRefreshState)
|
||||||
|
) {
|
||||||
if (state.posts.isEmpty() && !state.isRefreshing) {
|
if (state.posts.isEmpty() && !state.isRefreshing) {
|
||||||
Column(
|
if (state.isConnectionError && state.error != null) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
// Connection error empty state
|
||||||
verticalArrangement = Arrangement.Center,
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
modifier = Modifier
|
||||||
) {
|
.fillMaxSize()
|
||||||
Text(
|
.padding(horizontal = 32.dp),
|
||||||
text = "No posts yet",
|
verticalArrangement = Arrangement.Center,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
)
|
Icon(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
imageVector = Icons.Default.WifiOff,
|
||||||
Text(
|
contentDescription = null,
|
||||||
text = "Tap + to write your first post",
|
modifier = Modifier.size(48.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
tint = MaterialTheme.colorScheme.error
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
FilledTonalButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal empty state
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No posts yet",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tap + to write your first post",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isRefreshing) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -114,10 +167,20 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.error != null) {
|
PullRefreshIndicator(
|
||||||
|
refreshing = state.isRefreshing,
|
||||||
|
state = pullRefreshState,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show non-connection errors as snackbar (when posts are visible)
|
||||||
|
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
|
||||||
Snackbar(
|
Snackbar(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
|
||||||
action = {
|
action = {
|
||||||
|
TextButton(onClick = { viewModel.refresh() }) { Text("Retry") }
|
||||||
|
},
|
||||||
|
dismissAction = {
|
||||||
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
|
TextButton(onClick = viewModel::clearError) { Text("Dismiss") }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,14 @@ import com.swoosh.microblog.data.model.*
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
|
@ -41,7 +45,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
_uiState.update { it.copy(isRefreshing = true, error = null, isConnectionError = false) }
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
hasMorePages = true
|
hasMorePages = true
|
||||||
|
|
||||||
|
|
@ -53,12 +57,32 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
_uiState.update { it.copy(isRefreshing = false) }
|
_uiState.update { it.copy(isRefreshing = false) }
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
_uiState.update { it.copy(isRefreshing = false, error = e.message) }
|
val (message, isConn) = classifyError(e)
|
||||||
|
_uiState.update { it.copy(isRefreshing = false, error = message, isConnectionError = isConn) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun classifyError(e: Throwable): Pair<String, Boolean> {
|
||||||
|
val cause = e.cause ?: e
|
||||||
|
return when {
|
||||||
|
cause is UnknownHostException || cause is ConnectException ||
|
||||||
|
cause is SocketTimeoutException || cause is SSLException ||
|
||||||
|
e is UnknownHostException || e is ConnectException ||
|
||||||
|
e is SocketTimeoutException || e is SSLException ->
|
||||||
|
"Could not connect to your Ghost blog. Check your internet connection and try again." to true
|
||||||
|
|
||||||
|
e.message?.contains("401") == true || e.message?.contains("403") == true ->
|
||||||
|
"Authentication failed. Your API key may be invalid. Check Settings \u2192 Integrations in your Ghost admin." to true
|
||||||
|
|
||||||
|
e.message?.contains("404") == true ->
|
||||||
|
"Ghost API not found at this URL. Verify your blog address in Settings." to true
|
||||||
|
|
||||||
|
else -> (e.message ?: "An unknown error occurred") to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
if (!hasMorePages || _uiState.value.isLoadingMore) return
|
if (!hasMorePages || _uiState.value.isLoadingMore) return
|
||||||
|
|
||||||
|
|
@ -155,7 +179,8 @@ data class FeedUiState(
|
||||||
val posts: List<FeedPost> = emptyList(),
|
val posts: List<FeedPost> = emptyList(),
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val isConnectionError: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
fun formatRelativeTime(isoString: String?): String {
|
fun formatRelativeTime(isoString: String?): String {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,35 @@
|
||||||
package com.swoosh.microblog.ui.setup
|
package com.swoosh.microblog.ui.setup
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Info
|
||||||
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.draw.blur
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
@ -19,89 +41,287 @@ fun SetupScreen(
|
||||||
viewModel: SetupViewModel = viewModel()
|
viewModel: SetupViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
LaunchedEffect(state.isSuccess) {
|
LaunchedEffect(state.isSuccess) {
|
||||||
if (state.isSuccess) onSetupComplete()
|
if (state.isSuccess) onSetupComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
val primaryColor = MaterialTheme.colorScheme.primary
|
||||||
topBar = {
|
val tertiaryColor = MaterialTheme.colorScheme.tertiary
|
||||||
TopAppBar(title = { Text("Setup Ghost Instance") })
|
val secondaryColor = MaterialTheme.colorScheme.secondary
|
||||||
|
|
||||||
|
// Determine if the URL looks valid enough to show the hint
|
||||||
|
val urlLooksValid = remember(state.url) {
|
||||||
|
val trimmed = state.url.trim()
|
||||||
|
trimmed.isNotEmpty() && (
|
||||||
|
trimmed.startsWith("http://") ||
|
||||||
|
trimmed.startsWith("https://") ||
|
||||||
|
trimmed.contains(".")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the normalized integrations URL from the user's input
|
||||||
|
val integrationsUrl = remember(state.url) {
|
||||||
|
val trimmed = state.url.trim().trimEnd('/')
|
||||||
|
val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||||
|
trimmed
|
||||||
|
} else {
|
||||||
|
"https://$trimmed"
|
||||||
}
|
}
|
||||||
) { padding ->
|
"$withScheme/ghost/#/settings/integrations"
|
||||||
Column(
|
}
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Text(
|
// Full-screen animated background behind all UI
|
||||||
text = "Swoosh",
|
PulsingCirclesBackground(
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
primaryColor = primaryColor,
|
||||||
color = MaterialTheme.colorScheme.primary
|
tertiaryColor = tertiaryColor,
|
||||||
|
secondaryColor = secondaryColor,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
// Content layered on top
|
||||||
|
Column(
|
||||||
Text(
|
modifier = Modifier.fillMaxSize(),
|
||||||
text = "Connect to your Ghost instance",
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.url,
|
|
||||||
onValueChange = viewModel::updateUrl,
|
|
||||||
label = { Text("Ghost Instance URL") },
|
|
||||||
placeholder = { Text("https://your-blog.ghost.io") },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.apiKey,
|
|
||||||
onValueChange = viewModel::updateApiKey,
|
|
||||||
label = { Text("Admin API Key") },
|
|
||||||
placeholder = { Text("key_id:secret") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (state.error != null) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = state.error!!,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = viewModel::save,
|
|
||||||
enabled = !state.isTesting,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
if (state.isTesting) {
|
// Top area with branding
|
||||||
CircularProgressIndicator(
|
Box(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier
|
||||||
strokeWidth = 2.dp
|
.fillMaxWidth()
|
||||||
)
|
.weight(1f),
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
contentAlignment = Alignment.Center
|
||||||
Text("Testing connection...")
|
) {
|
||||||
} else {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Connect")
|
Text(
|
||||||
|
text = "Swoosh",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Connect to your Ghost instance",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom area with inputs and button
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.url,
|
||||||
|
onValueChange = viewModel::updateUrl,
|
||||||
|
label = { Text("Ghost Instance URL") },
|
||||||
|
placeholder = { Text("https://your-blog.ghost.io") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.apiKey,
|
||||||
|
onValueChange = viewModel::updateApiKey,
|
||||||
|
label = { Text("Admin API Key") },
|
||||||
|
placeholder = { Text("key_id:secret") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// API key hint with link to Ghost integrations page
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = urlLooksValid,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
val linkColor = MaterialTheme.colorScheme.primary
|
||||||
|
val annotatedText = buildAnnotatedString {
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
) {
|
||||||
|
append("Find your API key at ")
|
||||||
|
}
|
||||||
|
pushStringAnnotation(tag = "URL", annotation = integrationsUrl)
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
color = linkColor,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
append("Settings \u2192 Integrations")
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = annotatedText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
uriHandler.openUri(integrationsUrl)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::save,
|
||||||
|
enabled = !state.isTesting,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (state.isTesting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Testing connection...")
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PulsingCirclesBackground(
|
||||||
|
primaryColor: Color,
|
||||||
|
tertiaryColor: Color,
|
||||||
|
secondaryColor: Color,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
|
|
||||||
|
val scale1 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.6f, targetValue = 1.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(4000, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "scale1"
|
||||||
|
)
|
||||||
|
val alpha1 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.25f, targetValue = 0.55f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(4000, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
val scale2 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1.3f, targetValue = 0.5f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(5500, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "scale2"
|
||||||
|
)
|
||||||
|
val alpha2 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.20f, targetValue = 0.50f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(5500, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "alpha2"
|
||||||
|
)
|
||||||
|
|
||||||
|
val scale3 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.7f, targetValue = 1.5f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(6500, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "scale3"
|
||||||
|
)
|
||||||
|
val alpha3 by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.18f, targetValue = 0.45f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(6500, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
), label = "alpha3"
|
||||||
|
)
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier.blur(40.dp)
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
|
||||||
|
// Circle 1 — primary, upper-left
|
||||||
|
val radius1 = w * 0.50f * scale1
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(primaryColor.copy(alpha = alpha1), Color.Transparent),
|
||||||
|
center = Offset(w * 0.3f, h * 0.35f),
|
||||||
|
radius = radius1
|
||||||
|
),
|
||||||
|
radius = radius1,
|
||||||
|
center = Offset(w * 0.3f, h * 0.35f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Circle 2 — tertiary, right
|
||||||
|
val radius2 = w * 0.55f * scale2
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(tertiaryColor.copy(alpha = alpha2), Color.Transparent),
|
||||||
|
center = Offset(w * 0.75f, h * 0.45f),
|
||||||
|
radius = radius2
|
||||||
|
),
|
||||||
|
radius = radius2,
|
||||||
|
center = Offset(w * 0.75f, h * 0.45f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Circle 3 — secondary, bottom-center
|
||||||
|
val radius3 = w * 0.45f * scale3
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(secondaryColor.copy(alpha = alpha3), Color.Transparent),
|
||||||
|
center = Offset(w * 0.5f, h * 0.7f),
|
||||||
|
radius = radius3
|
||||||
|
),
|
||||||
|
radius = radius3,
|
||||||
|
center = Offset(w * 0.5f, h * 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
198
gradlew
vendored
Executable file
198
gradlew
vendored
Executable file
|
|
@ -0,0 +1,198 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced functionality shells and target
|
||||||
|
# temporary focusing of this script.
|
||||||
|
#
|
||||||
|
# (2) You need a Java installation to run Gradle.
|
||||||
|
# JAVA_HOME is not required but JAVA is. It needs to be on your PATH.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld -- "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NonStop* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stracks://issues.gradle.org/browse/GRADLE-2360.
|
||||||
|
# are listed first to match what the user would pass directly to java.
|
||||||
|
# There is no hierarchical dependency between $DEFAULT_JVM_OPTS and $JAVA_OPTS.
|
||||||
|
# $DEFAULT_JVM_OPTS is the result of the defaults defined in Gradle's tooling model.
|
||||||
|
# $JAVA_OPTS is the result of the user's own JVM opts.
|
||||||
|
# $GRADLE_OPTS is the result of the user's own Gradle opts.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# temporary args, so each arg winds up back in the position where
|
||||||
|
# it started, but possibly modified.
|
||||||
|
#
|
||||||
|
# NB: aass://vbs://assorted://arg://loop://with://set://for://shift://won't://get://you://a://correct://result.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# temporary variables so that they are re-expanded; and
|
||||||
|
# * put everything else in double quotes to style normal arguments (even if
|
||||||
|
# those are not GNU options, e.g. paths).
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xeli" would cause for bad results.
|
||||||
|
if ! "$cygwin" && ! "$msys" ; then
|
||||||
|
case $( set -- $$; readlink /proc/$1/exe 2>/dev/null || true; ) in #(
|
||||||
|
*/busybox) set -- "$@" ;; #(
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Loading…
Reference in a new issue