Compare commits

..

4 Commits

Author SHA1 Message Date
amir 99193af2c5 v1.0.52: fix Select button cut off on Android 16 edge-to-edge dialogs
Android 15+ enforces edge-to-edge on Dialog windows, making standard
Compose WindowInsets APIs return 0 inside dialogs. Fix: use ViewCompat
insets listener inside the Dialog to read actual system bar heights,
with 56dp minimum to guarantee full nav bar clearance. Spacer inside
the button Surface lets the elevated background extend behind the nav
bar. Also make the entire local folder field tappable (not just the
trailing icon) for better UX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:14:43 +00:00
amir 3c008ec8df v1.0.47: built-in folder browsers, icon crop fix, nav bar button fix
- Replace SAF document picker with custom LocalBrowserDialog (File API,
  quick-access shortcuts, breadcrumb nav, search, folder-only listing)
- Rewrite RemoteBrowserDialog as full-screen dialog with breadcrumbs,
  search, and new-folder creation; add navigateToBreadcrumb/createFolder
  to RemoteBrowserViewModel
- Fix Select button cut off by navigation bar in both browsers: wrap
  button in Column(navigationBarsPadding()) so the button sits above
  the nav bar rather than behind it
- Tighten icon foreground crop to remove excess black border

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:25 +00:00
amir c869f84a9d 1.0.40: fix icon sizing (adaptive XML) + semantic status colors
- Restore mipmap-anydpi-v26 adaptive icon XMLs so Android 8+ shows
  the icon at full size instead of scaling it to 66% safe zone
- Foreground at 108dp sizes (108-432px), background #050E05
- Status colors now semantic (not tied to app red/orange theme):
  SUCCESS=green, SYNCING=blue, FAILED=red, PARTIAL=orange, CONFLICT=amber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:49:34 +00:00
amir c3be23417d 1.0.39: fix OOM on large-file uploads; use exact reference icon
- WebDavProvider: replace readBytes() with streaming RequestBody
  (Okio sink.writeAll) so large files (1+ GB) upload without
  allocating the full file in heap — fixes PARTIAL sync status
- App icon: replace vector XML with PNG mipmaps generated directly
  from the user-provided reference image at all densities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:51:23 +00:00
36 changed files with 713 additions and 319 deletions
@@ -13,6 +13,8 @@ import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream import java.io.InputStream
@@ -84,13 +86,18 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
onProgress: (Long) -> Unit, onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching { ): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val bytes = localStream.readBytes() val body = object : RequestBody() {
val body = bytes.toRequestBody("application/octet-stream".toMediaType()) override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = sizeBytes
override fun writeTo(sink: BufferedSink) {
localStream.source().use { source -> sink.writeAll(source) }
}
}
val req = Request.Builder().url(url(remotePath)).put(body).build() val req = Request.Builder().url(url(remotePath)).put(body).build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}") if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
} }
onProgress(bytes.size.toLong()) onProgress(sizeBytes)
getFileMetadata(remotePath).getOrThrow() getFileMetadata(remotePath).getOrThrow()
} }
} }
@@ -1,10 +1,7 @@
package com.syncflow.ui.addpair package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -15,13 +12,13 @@ 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
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 com.syncflow.domain.model.* import com.syncflow.domain.model.*
import com.syncflow.ui.browser.LocalBrowserDialog
import com.syncflow.ui.browser.RemoteBrowserDialog import com.syncflow.ui.browser.RemoteBrowserDialog
import java.time.DayOfWeek import java.time.DayOfWeek
@@ -31,17 +28,18 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState() val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() } LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) } var showRemoteBrowser by remember { mutableStateOf(false) }
var showLocalBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> if (showLocalBrowser) {
uri?.let { LocalBrowserDialog(
context.contentResolver.takePersistableUriPermission( initialPath = s.localPath.ifBlank { "" },
it, onSelect = { path ->
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, vm.update { copy(localPath = path) }
showLocalBrowser = false
},
onDismiss = { showLocalBrowser = false },
) )
vm.update { copy(localPath = it.toString()) }
}
} }
if (showRemoteBrowser && s.selectedAccountId != -1L) { if (showRemoteBrowser && s.selectedAccountId != -1L) {
@@ -108,18 +106,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// Local folder // Local folder
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {}, value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") }, label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = { trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
IconButton(onClick = { dirPicker.launch(null) }) {
Icon(Icons.Default.FolderOpen, "Browse")
}
},
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(), readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") }, placeholder = { Text("Tap to choose folder…") },
) )
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
// Remote folder // Remote folder
OutlinedTextField( OutlinedTextField(
@@ -0,0 +1,352 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class LocalEntry(val file: File, val childCount: Int)
private val STORAGE_ROOT = File("/storage/emulated/0")
private data class Shortcut(val label: String, val icon: ImageVector, val path: String)
private val SHORTCUTS = listOf(
Shortcut("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
Shortcut("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
Shortcut("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
Shortcut("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
Shortcut("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
Shortcut("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalBrowserDialog(
initialPath: String = STORAGE_ROOT.absolutePath,
onSelect: (path: String) -> Unit,
onDismiss: () -> Unit,
) {
var currentPath by remember { mutableStateOf(File(initialPath.ifBlank { STORAGE_ROOT.absolutePath }).let { if (it.isDirectory) it else STORAGE_ROOT }) }
var pathStack by remember { mutableStateOf(listOf(currentPath)) }
var entries by remember { mutableStateOf<List<LocalEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun loadDir(dir: File) {
isLoading = true
entries = emptyList()
scope.launch {
val result = withContext(Dispatchers.IO) {
dir.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.sortedBy { it.name.lowercase() }
?.map { f -> LocalEntry(f, f.listFiles()?.count { it.isDirectory } ?: 0) }
?: emptyList()
}
entries = result
isLoading = false
}
}
fun navigateTo(dir: File) {
currentPath = dir
pathStack = pathStack + dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
fun navigateUp(): Boolean {
if (pathStack.size <= 1) return false
val newStack = pathStack.dropLast(1)
pathStack = newStack
currentPath = newStack.last()
searchQuery = ""
searchActive = false
loadDir(currentPath)
return true
}
fun navigateToBreadcrumb(dir: File) {
val idx = pathStack.indexOfLast { it.absolutePath == dir.absolutePath }
pathStack = if (idx >= 0) pathStack.take(idx + 1) else listOf(dir)
currentPath = dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
LaunchedEffect(Unit) { loadDir(currentPath) }
// Build breadcrumb segments relative to storage root
val relParts = currentPath.absolutePath
.removePrefix(STORAGE_ROOT.absolutePath)
.trimStart('/')
.split('/')
.filter { it.isNotEmpty() }
// Auto-scroll breadcrumbs to end
LaunchedEffect(currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
}
val filtered = if (searchQuery.isBlank()) entries
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
val view = LocalView.current
val density = LocalDensity.current
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(56.dp) }
DisposableEffect(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
with(density) {
topInset = bars.top.toDp()
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
}
insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
navigationIcon = {
IconButton(onClick = { if (!navigateUp()) onDismiss() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search folders…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Local Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(label = "📱 Storage", isLast = relParts.isEmpty(),
onClick = { navigateToBreadcrumb(STORAGE_ROOT) })
}
itemsIndexed(relParts) { idx, part ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val partPath = STORAGE_ROOT.absolutePath + "/" + relParts.take(idx + 1).joinToString("/")
BreadcrumbChip(label = part, isLast = idx == relParts.lastIndex,
onClick = { navigateToBreadcrumb(File(partPath)) })
}
}
}
HorizontalDivider()
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
if (currentPath.absolutePath == STORAGE_ROOT.absolutePath && searchQuery.isBlank()) {
item {
Text("Quick access", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 6.dp))
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 8.dp)) {
items(SHORTCUTS.filter { File(it.path).isDirectory }) { sc ->
ShortcutChip(sc, onClick = { navigateTo(File(sc.path)) })
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
Text("All folders", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
}
}
if (filtered.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text(if (searchQuery.isBlank()) "No subfolders" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
items(filtered, key = { it.file.absolutePath }) { entry ->
LocalFolderItem(entry = entry, onClick = { navigateTo(entry.file) })
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(currentPath.absolutePath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(bottomInset))
}
}
}
}
}
}
@Composable
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
if (isLast) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1)
}
} else {
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1)
}
}
}
@Composable
private fun ShortcutChip(sc: Shortcut, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(72.dp).width(80.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(sc.icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.height(4.dp))
Text(sc.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
}
}
}
@Composable
private fun LocalFolderItem(entry: LocalEntry, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1B5E20).copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Folder, null, Modifier.size(26.dp), tint = Color(0xFF2E7D32))
}
Column(modifier = Modifier.weight(1f)) {
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
if (entry.childCount > 0) {
Text("${entry.childCount} subfolder${if (entry.childCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@@ -1,22 +1,40 @@
package com.syncflow.ui.browser package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -30,79 +48,205 @@ fun RemoteBrowserDialog(
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) } LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll breadcrumbs to end when path changes
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
LaunchedEffect(state.currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segments.size)) }
searchQuery = ""
searchActive = false
}
val filtered = if (searchQuery.isBlank()) state.entries
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = state.currentPath.trimEnd('/').substringAfterLast('/').ifBlank { "Root" }
if (showNewFolderDialog) {
NewFolderDialog(
onConfirm = { name ->
vm.createFolder(name)
showNewFolderDialog = false
},
onDismiss = { showNewFolderDialog = false },
)
}
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) { ) {
Surface( val view = LocalView.current
modifier = Modifier val density = LocalDensity.current
.fillMaxWidth(0.95f) var topInset by remember { mutableStateOf(0.dp) }
.fillMaxHeight(0.85f), var bottomInset by remember { mutableStateOf(56.dp) }
shape = MaterialTheme.shapes.extraLarge, DisposableEffect(view) {
tonalElevation = 6.dp, ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
) { val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
Column { with(density) {
// Title bar topInset = bars.top.toDp()
TopAppBar( bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
title = {
Column {
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
Text(
state.currentPath,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
}, insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
navigationIcon = { navigationIcon = {
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) { IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
Icon(Icons.Default.ArrowBack, null) Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search in folder…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Folder", style = MaterialTheme.typography.titleMedium)
} }
}, },
actions = { actions = {
// Select current folder IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
TextButton(onClick = { onSelect(state.currentPath) }) { Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
Text("Select here") }
IconButton(onClick = { showNewFolderDialog = true }) {
Icon(Icons.Default.CreateNewFolder, "New Folder")
} }
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
) )
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(
label = "",
isLast = segments.isEmpty(),
onClick = { vm.navigateToBreadcrumb("/") },
)
}
itemsIndexed(segments) { idx, seg ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val segPath = "/" + segments.take(idx + 1).joinToString("/")
BreadcrumbChip(
label = seg,
isLast = idx == segments.lastIndex,
onClick = { vm.navigateToBreadcrumb(segPath) },
)
}
}
}
HorizontalDivider() HorizontalDivider()
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when { when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { state.error != null -> Column(
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error) modifier = Modifier.fillMaxSize().padding(32.dp),
Text(state.error!!, color = MaterialTheme.colorScheme.error) horizontalAlignment = Alignment.CenterHorizontally,
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") } verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = vm::retry) {
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Retry")
} }
} }
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { filtered.isEmpty() && searchQuery.isBlank() -> Column(
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) modifier = Modifier.fillMaxSize().padding(32.dp),
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant) horizontalAlignment = Alignment.CenterHorizontally,
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") } verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("This folder is empty", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("You can still select it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
} }
filtered.isEmpty() -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.SearchOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) { else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.entries, key = { it.path }) { entry -> items(filtered, key = { it.path }) { entry ->
BrowserEntry( FolderItem(
file = entry, file = entry,
onClick = { onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path) if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" }) else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
}, },
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
) )
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
} }
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(state.currentPath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
"Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(bottomInset))
} }
} }
} }
@@ -111,39 +255,114 @@ fun RemoteBrowserDialog(
} }
@Composable @Composable
private fun BrowserEntry( private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
file: RemoteFile, if (isLast) {
onClick: () -> Unit, Surface(
onSelectFolder: (() -> Unit)?, shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) { ) {
Text(
label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
)
}
} else {
TextButton(
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
}
}
}
@Composable
private fun FolderItem(file: RemoteFile, onClick: () -> Unit) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
// Colored icon badge
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (file.isDirectory) Color(0xFF1B5E20).copy(alpha = 0.12f)
else Color(0xFF0D47A1).copy(alpha = 0.10f)
),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile, imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(26.dp),
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0),
) )
Spacer(Modifier.width(14.dp)) }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(
file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal,
)
if (!file.isDirectory) { if (!file.isDirectory) {
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
file.sizeBytes.formatBytes(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
if (onSelectFolder != null) {
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) { if (file.isDirectory) {
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
} else {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@Composable
private fun NewFolderDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.CreateNewFolder, null) },
title = { Text("New Folder") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Folder name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onConfirm(name.trim()) }),
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
} }
private fun Long.formatBytes(): String = when { private fun Long.formatBytes(): String = when {
@@ -57,6 +57,27 @@ class RemoteBrowserViewModel @Inject constructor(
return true return true
} }
fun navigateToBreadcrumb(path: String) {
val stack = _state.value.pathStack
val idx = stack.lastIndexOf(path)
val newStack = if (idx >= 0) stack.take(idx + 1) else listOf(path)
loadJob?.cancel()
_state.update { it.copy(currentPath = path, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, path)
}
fun createFolder(name: String) {
val s = _state.value
val newPath = if (s.currentPath.trimEnd('/') == "") "/$name" else "${s.currentPath.trimEnd('/')}/$name"
viewModelScope.launch {
val account = accountRepository.getAccount(s.accountId) ?: return@launch
val provider = runCatching { providerFactory.create(account) }.getOrElse { return@launch }
provider.createDirectory(newPath)
.onSuccess { retry() }
.onFailure { e -> _state.update { it.copy(error = "Could not create folder: ${e.message}") } }
}
}
fun retry() { fun retry() {
val s = _state.value val s = _state.value
if (s.accountId == -1L) return if (s.accountId == -1L) return
@@ -247,11 +247,11 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
private val SyncStatus.accentColor: Color private val SyncStatus.accentColor: Color
@Composable get() = when (this) { @Composable get() = when (this) {
SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary SyncStatus.SUCCESS -> Color(0xFF2E7D32) // green — done, healthy
SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress
SyncStatus.FAILED -> MaterialTheme.colorScheme.error SyncStatus.FAILED -> Color(0xFFC62828) // red — error
SyncStatus.CONFLICT, SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed
SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Dark space background -->
<path
android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#050F05"/>
<!-- Subtle dark green center glow -->
<path
android:pathData="M54,54 A40,40 0 1,0 54.01,54 Z"
android:fillColor="#0D1F0D"
android:fillAlpha="0.9"/>
<!-- Stars -->
<path android:fillColor="#FFFFFF" android:fillAlpha="0.9"
android:pathData="M18,12 A1.2,1.2 0 1,0 18.01,12 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M88,18 A0.9,0.9 0 1,0 88.01,18 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.8"
android:pathData="M12,55 A1.0,1.0 0 1,0 12.01,55 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.6"
android:pathData="M95,40 A0.8,0.8 0 1,0 95.01,40 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.9"
android:pathData="M25,90 A1.1,1.1 0 1,0 25.01,90 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M82,88 A0.9,0.9 0 1,0 82.01,88 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.5"
android:pathData="M96,72 A0.8,0.8 0 1,0 96.01,72 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.8"
android:pathData="M8,80 A1.0,1.0 0 1,0 8.01,80 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.6"
android:pathData="M70,8 A0.9,0.9 0 1,0 70.01,8 Z"/>
<path android:fillColor="#FFFFFF" android:fillAlpha="0.7"
android:pathData="M40,100 A0.8,0.8 0 1,0 40.01,100 Z"/>
<path android:fillColor="#AAFFAA" android:fillAlpha="0.5"
android:pathData="M92,94 A1.0,1.0 0 1,0 92.01,94 Z"/>
<path android:fillColor="#AAAAFF" android:fillAlpha="0.4"
android:pathData="M5,25 A0.8,0.8 0 1,0 5.01,25 Z"/>
</vector>
@@ -1,117 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
Four thick ribbons in an interlocked pinwheel knot.
Each ribbon sweeps 210 degrees clockwise on a radius-18 circle centered at (54,54).
Each ribbon is drawn as: base (width 12) + highlight stripe (width 5).
Over/under order: Blue under Green, Green under Red, Red under Orange, Orange under Blue tip.
Arc start/end points (radius 18 from center 54,54):
Blue: start 270deg (54,36) end 120deg (45,70)
Green: start 90deg (54,72) end 300deg (63,38)
Red: start 0deg (72,54) end 210deg (39,45)
Orange: start 180deg (36,54) end 30deg (69,63)
-->
<!-- Blue ribbon base (goes under Green start and Orange end) -->
<path
android:strokeColor="#1565C0"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Blue ribbon highlight stripe -->
<path
android:strokeColor="#90CAF9"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Green ribbon base (over Blue start, under Red end) -->
<path
android:strokeColor="#2E7D32"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Green ribbon highlight stripe -->
<path
android:strokeColor="#A5D6A7"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Red ribbon base (over Green start, under Orange end) -->
<path
android:strokeColor="#C62828"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Red ribbon highlight stripe -->
<path
android:strokeColor="#EF9A9A"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Orange ribbon base (over Red start, under Blue tip) -->
<path
android:strokeColor="#E65100"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Orange ribbon highlight stripe -->
<path
android:strokeColor="#FFCC80"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Redraw Blue start cap on top so it goes OVER Orange end -->
<path
android:strokeColor="#1565C0"
android:strokeWidth="12"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<path
android:strokeColor="#90CAF9"
android:strokeWidth="5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- Black circle behind center sync icon -->
<path
android:fillColor="#000000"
android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- White sync ring -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:pathData="M 46.5,54 A 7.5,7.5 0 1,0 61.5,54 A 7.5,7.5 0 1,0 46.5,54 Z"/>
<!-- Up arrow (pointing up) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Down arrow (pointing down) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
</vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#2196F3</color> <color name="ic_launcher_background">#050E05</color>
</resources> </resources>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.38 VERSION_NAME=1.0.52
VERSION_CODE=39 VERSION_CODE=53