Compare commits
7 Commits
IOS_2.1.0
...
ANDROID_2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
3b16475dc6
|
|||
|
105d39689d
|
|||
|
d4023133b7
|
|||
|
602b5f8938
|
|||
| 8529f76c22 | |||
|
879aae0721
|
|||
|
6fc86558b2
|
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.ascently"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 42
|
||||
versionName = "2.1.0"
|
||||
versionCode = 44
|
||||
versionName = "2.2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
package com.atridad.ascently.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -23,6 +33,16 @@ import com.atridad.ascently.ui.components.ActiveSessionBanner
|
||||
import com.atridad.ascently.ui.components.SyncIndicator
|
||||
import com.atridad.ascently.ui.viewmodel.ClimbViewModel
|
||||
import com.atridad.ascently.utils.DateFormatUtils
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
enum class ViewMode {
|
||||
LIST,
|
||||
CALENDAR
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -33,7 +53,15 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Filter out active sessions from regular session list
|
||||
val sharedPreferences =
|
||||
context.getSharedPreferences("SessionsPreferences", Context.MODE_PRIVATE)
|
||||
val savedViewMode = sharedPreferences.getString("view_mode", "LIST")
|
||||
var viewMode by remember {
|
||||
mutableStateOf(if (savedViewMode == "CALENDAR") ViewMode.CALENDAR else ViewMode.LIST)
|
||||
}
|
||||
var selectedMonth by remember { mutableStateOf(YearMonth.now()) }
|
||||
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
|
||||
val completedSessions = sessions.filter { it.status == SessionStatus.COMPLETED }
|
||||
val activeSessionGym = activeSession?.let { session -> gyms.find { it.id == session.gymId } }
|
||||
|
||||
@@ -55,12 +83,30 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewMode =
|
||||
if (viewMode == ViewMode.LIST) ViewMode.CALENDAR else ViewMode.LIST
|
||||
selectedDate = null
|
||||
sharedPreferences.edit().putString("view_mode", viewMode.name).apply()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (viewMode == ViewMode.LIST) Icons.Default.CalendarMonth
|
||||
else Icons.AutoMirrored.Filled.List,
|
||||
contentDescription =
|
||||
if (viewMode == ViewMode.LIST) "Calendar View" else "List View",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
SyncIndicator(isSyncing = viewModel.syncService.isSyncing)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Active session banner
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
@@ -83,20 +129,40 @@ fun SessionsScreen(viewModel: ClimbViewModel, onNavigateToSessionDetail: (String
|
||||
actionText = ""
|
||||
)
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
when (viewMode) {
|
||||
ViewMode.LIST -> {
|
||||
LazyColumn {
|
||||
items(completedSessions) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name
|
||||
?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewMode.CALENDAR -> {
|
||||
CalendarView(
|
||||
sessions = completedSessions,
|
||||
gyms = gyms,
|
||||
activeSession = activeSession,
|
||||
activeSessionGym = activeSessionGym,
|
||||
selectedMonth = selectedMonth,
|
||||
onMonthChange = { selectedMonth = it },
|
||||
selectedDate = selectedDate,
|
||||
onDateSelected = { selectedDate = it },
|
||||
onNavigateToSessionDetail = onNavigateToSessionDetail,
|
||||
onEndSession = {
|
||||
activeSession?.let { viewModel.endSession(context, it.id) }
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show UI state messages and errors
|
||||
uiState.message?.let { message ->
|
||||
LaunchedEffect(message) {
|
||||
kotlinx.coroutines.delay(5000)
|
||||
@@ -245,6 +311,238 @@ fun EmptyStateMessage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarView(
|
||||
sessions: List<ClimbSession>,
|
||||
gyms: List<com.atridad.ascently.data.model.Gym>,
|
||||
activeSession: ClimbSession?,
|
||||
activeSessionGym: com.atridad.ascently.data.model.Gym?,
|
||||
selectedMonth: YearMonth,
|
||||
onMonthChange: (YearMonth) -> Unit,
|
||||
selectedDate: LocalDate?,
|
||||
onDateSelected: (LocalDate?) -> Unit,
|
||||
onNavigateToSessionDetail: (String) -> Unit,
|
||||
onEndSession: () -> Unit
|
||||
) {
|
||||
val sessionsByDate =
|
||||
remember(sessions) {
|
||||
sessions.groupBy {
|
||||
try {
|
||||
java.time.Instant.parse(it.date)
|
||||
.atZone(java.time.ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
} catch (e: Exception) {
|
||||
LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (activeSession != null && activeSessionGym != null) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
ActiveSessionBanner(
|
||||
activeSession = activeSession,
|
||||
gym = activeSessionGym,
|
||||
onSessionClick = { onNavigateToSessionDetail(activeSession.id) },
|
||||
onEndSession = onEndSession
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.minusMonths(1)) }) {
|
||||
Text("‹", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
Text(
|
||||
text =
|
||||
"${selectedMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault())} ${selectedMonth.year}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(onClick = { onMonthChange(selectedMonth.plusMonths(1)) }) {
|
||||
Text("›", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val today = LocalDate.now()
|
||||
onMonthChange(YearMonth.from(today))
|
||||
onDateSelected(today)
|
||||
},
|
||||
shape = RoundedCornerShape(50),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Today",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat").forEach { day ->
|
||||
Text(
|
||||
text = day,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val firstDayOfMonth = selectedMonth.atDay(1)
|
||||
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
|
||||
val totalCells =
|
||||
((firstDayOfWeek + daysInMonth) / 7.0).let {
|
||||
if (it == it.toInt().toDouble()) it.toInt() * 7 else (it.toInt() + 1) * 7
|
||||
}
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(7), modifier = Modifier.fillMaxWidth()) {
|
||||
items(totalCells) { index ->
|
||||
val dayNumber = index - firstDayOfWeek + 1
|
||||
|
||||
if (dayNumber in 1..daysInMonth) {
|
||||
val date = selectedMonth.atDay(dayNumber)
|
||||
val sessionsOnDate = sessionsByDate[date] ?: emptyList()
|
||||
val isSelected = date == selectedDate
|
||||
val isToday = date == LocalDate.now()
|
||||
|
||||
CalendarDay(
|
||||
day = dayNumber,
|
||||
hasSession = sessionsOnDate.isNotEmpty(),
|
||||
isSelected = isSelected,
|
||||
isToday = isToday,
|
||||
onClick = {
|
||||
if (sessionsOnDate.isNotEmpty()) {
|
||||
onDateSelected(if (isSelected) null else date)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.aspectRatio(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDate != null) {
|
||||
val sessionsOnSelectedDate = sessionsByDate[selectedDate] ?: emptyList()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text =
|
||||
"Sessions on ${selectedDate.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(sessionsOnSelectedDate) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
gymName = gyms.find { it.id == session.gymId }?.name ?: "Unknown Gym",
|
||||
onClick = { onNavigateToSessionDetail(session.id) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarDay(
|
||||
day: Int,
|
||||
hasSession: Boolean,
|
||||
isSelected: Boolean,
|
||||
isToday: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
isToday -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable(enabled = hasSession, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
isToday -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
!hasSession -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
fontWeight = if (hasSession || isToday) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
|
||||
if (hasSession) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.7f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(dateString: String): String {
|
||||
return DateFormatUtils.formatDateForDisplay(dateString)
|
||||
}
|
||||
|
||||
@@ -4,27 +4,6 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:scaleX="0.7"
|
||||
android:scaleY="0.7"
|
||||
android:translateX="16.2"
|
||||
android:translateY="20">
|
||||
|
||||
<!-- Left mountain (yellow/amber) -->
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M15,70 L35,25 L55,70 Z" />
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M40,70 L65,15 L90,70 Z" />
|
||||
</group>
|
||||
<path android:fillColor="#FFC107" android:pathData="M24.000,78.545 L41.851,38.380 L59.702,78.545 Z" />
|
||||
<path android:fillColor="#F44336" android:pathData="M39.372,78.545 L61.686,29.455 L84.000,78.545 Z" />
|
||||
</vector>
|
||||
@@ -4,29 +4,6 @@
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Left mountain (yellow/amber) -->
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:pathData="M3,18 L8,9 L13,18 Z" />
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:pathData="M11,18 L16,7 L21,18 Z" />
|
||||
|
||||
<!-- Black outlines -->
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="1"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M3,18 L8,9 L13,18" />
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="#1C1C1C"
|
||||
android:strokeWidth="1"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M11,18 L16,7 L21,18" />
|
||||
</vector>
|
||||
<path android:fillColor="#FFC107" android:pathData="M2.000,20.182 L7.950,6.793 L13.901,20.182 Z" />
|
||||
<path android:fillColor="#F44336" android:pathData="M7.124,20.182 L14.562,3.818 L22.000,20.182 Z" />
|
||||
</vector>
|
||||
19
android/app/src/main/res/drawable/ic_splash.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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">
|
||||
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:pathData="M24,74 L42,34 L60,74 Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F44336"
|
||||
android:pathData="M41,74 L59,24 L84,74 Z" />
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 730 B |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 514 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 854 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -4,7 +4,7 @@
|
||||
|
||||
<style name="Theme.Ascently.Splash" parent="Theme.Ascently">
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_mountains</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">200</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
3
branding/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.tmp
|
||||
.DS_Store
|
||||
*.log
|
||||
394
branding/generate.py
Executable file
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Callable, TypedDict
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class Polygon(TypedDict):
|
||||
coords: list[tuple[float, float]]
|
||||
fill: str
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||
SOURCE_DIR = SCRIPT_DIR / "source"
|
||||
LOGOS_DIR = SCRIPT_DIR / "logos"
|
||||
|
||||
|
||||
def parse_svg_polygons(svg_path: Path) -> list[Polygon]:
|
||||
tree = ET.parse(svg_path)
|
||||
root = tree.getroot()
|
||||
|
||||
ns = {"svg": "http://www.w3.org/2000/svg"}
|
||||
polygons = root.findall(".//svg:polygon", ns)
|
||||
if not polygons:
|
||||
polygons = root.findall(".//polygon")
|
||||
|
||||
result: list[Polygon] = []
|
||||
for poly in polygons:
|
||||
points_str = poly.get("points", "").strip()
|
||||
fill = poly.get("fill", "#000000")
|
||||
|
||||
coords: list[tuple[float, float]] = []
|
||||
for pair in points_str.split():
|
||||
x, y = pair.split(",")
|
||||
coords.append((float(x), float(y)))
|
||||
|
||||
result.append({"coords": coords, "fill": fill})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_bbox(polygons: list[Polygon]) -> dict[str, float]:
|
||||
all_coords: list[tuple[float, float]] = []
|
||||
for poly in polygons:
|
||||
all_coords.extend(poly["coords"])
|
||||
|
||||
xs = [c[0] for c in all_coords]
|
||||
ys = [c[1] for c in all_coords]
|
||||
|
||||
return {
|
||||
"min_x": min(xs),
|
||||
"max_x": max(xs),
|
||||
"min_y": min(ys),
|
||||
"max_y": max(ys),
|
||||
"width": max(xs) - min(xs),
|
||||
"height": max(ys) - min(ys),
|
||||
}
|
||||
|
||||
|
||||
def scale_and_center(
|
||||
polygons: list[Polygon], viewbox_size: float, target_width: float
|
||||
) -> list[Polygon]:
|
||||
bbox = get_bbox(polygons)
|
||||
|
||||
scale = target_width / bbox["width"]
|
||||
center = viewbox_size / 2
|
||||
|
||||
scaled_polys: list[Polygon] = []
|
||||
for poly in polygons:
|
||||
scaled_coords = [(x * scale, y * scale) for x, y in poly["coords"]]
|
||||
scaled_polys.append({"coords": scaled_coords, "fill": poly["fill"]})
|
||||
|
||||
scaled_bbox = get_bbox(scaled_polys)
|
||||
current_center_x = (scaled_bbox["min_x"] + scaled_bbox["max_x"]) / 2
|
||||
current_center_y = (scaled_bbox["min_y"] + scaled_bbox["max_y"]) / 2
|
||||
|
||||
offset_x = center - current_center_x
|
||||
offset_y = center - current_center_y
|
||||
|
||||
final_polys: list[Polygon] = []
|
||||
for poly in scaled_polys:
|
||||
final_coords = [(x + offset_x, y + offset_y) for x, y in poly["coords"]]
|
||||
final_polys.append({"coords": final_coords, "fill": poly["fill"]})
|
||||
|
||||
return final_polys
|
||||
|
||||
|
||||
def format_svg_points(coords: list[tuple[float, float]]) -> str:
|
||||
return " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
|
||||
|
||||
|
||||
def format_android_path(coords: list[tuple[float, float]]) -> str:
|
||||
points = " ".join(f"{x:.3f},{y:.3f}" for x, y in coords)
|
||||
pairs = points.split()
|
||||
return f"M{pairs[0]} L{pairs[1]} L{pairs[2]} Z"
|
||||
|
||||
|
||||
def generate_svg(polygons: list[Polygon], width: int, height: int) -> str:
|
||||
lines = [
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">'
|
||||
]
|
||||
for poly in polygons:
|
||||
points = format_svg_points(poly["coords"])
|
||||
lines.append(f' <polygon points="{points}" fill="{poly["fill"]}"/>')
|
||||
lines.append("</svg>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_android_vector(
|
||||
polygons: list[Polygon], width: int, height: int, viewbox: int
|
||||
) -> str:
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
'<vector xmlns:android="http://schemas.android.com/apk/res/android"',
|
||||
f' android:width="{width}dp"',
|
||||
f' android:height="{height}dp"',
|
||||
f' android:viewportWidth="{viewbox}"',
|
||||
f' android:viewportHeight="{viewbox}">',
|
||||
]
|
||||
for poly in polygons:
|
||||
path = format_android_path(poly["coords"])
|
||||
lines.append(
|
||||
f' <path android:fillColor="{poly["fill"]}" android:pathData="{path}" />'
|
||||
)
|
||||
lines.append("</vector>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def rasterize_svg(
|
||||
svg_path: Path,
|
||||
output_path: Path,
|
||||
size: int,
|
||||
bg_color: tuple[int, int, int, int] | None = None,
|
||||
circular: bool = False,
|
||||
) -> None:
|
||||
from xml.dom import minidom
|
||||
|
||||
doc = minidom.parse(str(svg_path))
|
||||
|
||||
img = Image.new(
|
||||
"RGBA", (size, size), (255, 255, 255, 0) if bg_color is None else bg_color
|
||||
)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
svg_elem = doc.getElementsByTagName("svg")[0]
|
||||
viewbox = svg_elem.getAttribute("viewBox").split()
|
||||
if viewbox:
|
||||
vb_width = float(viewbox[2])
|
||||
vb_height = float(viewbox[3])
|
||||
scale_x = size / vb_width
|
||||
scale_y = size / vb_height
|
||||
else:
|
||||
scale_x = scale_y = 1
|
||||
|
||||
def parse_transform(
|
||||
transform_str: str,
|
||||
) -> Callable[[float, float], tuple[float, float]]:
|
||||
import re
|
||||
|
||||
if not transform_str:
|
||||
return lambda x, y: (x, y)
|
||||
|
||||
transforms: list[tuple[str, list[float]]] = []
|
||||
for match in re.finditer(r"(\w+)\(([^)]+)\)", transform_str):
|
||||
func, args_str = match.groups()
|
||||
args = [float(x) for x in args_str.replace(",", " ").split()]
|
||||
transforms.append((func, args))
|
||||
|
||||
def apply_transforms(x: float, y: float) -> tuple[float, float]:
|
||||
for func, args in transforms:
|
||||
if func == "translate":
|
||||
x += args[0]
|
||||
y += args[1] if len(args) > 1 else args[0]
|
||||
elif func == "scale":
|
||||
x *= args[0]
|
||||
y *= args[1] if len(args) > 1 else args[0]
|
||||
return x, y
|
||||
|
||||
return apply_transforms
|
||||
|
||||
for g in doc.getElementsByTagName("g"):
|
||||
transform = parse_transform(g.getAttribute("transform"))
|
||||
|
||||
for poly in g.getElementsByTagName("polygon"):
|
||||
points_str = poly.getAttribute("points").strip()
|
||||
fill = poly.getAttribute("fill")
|
||||
if not fill:
|
||||
fill = "#000000"
|
||||
|
||||
coords: list[tuple[float, float]] = []
|
||||
for pair in points_str.split():
|
||||
x, y = pair.split(",")
|
||||
x, y = float(x), float(y)
|
||||
x, y = transform(x, y)
|
||||
coords.append((x * scale_x, y * scale_y))
|
||||
|
||||
draw.polygon(coords, fill=fill)
|
||||
|
||||
for poly in doc.getElementsByTagName("polygon"):
|
||||
if poly.parentNode and getattr(poly.parentNode, "tagName", None) == "g":
|
||||
continue
|
||||
|
||||
points_str = poly.getAttribute("points").strip()
|
||||
fill = poly.getAttribute("fill")
|
||||
if not fill:
|
||||
fill = "#000000"
|
||||
|
||||
coords = []
|
||||
for pair in points_str.split():
|
||||
x, y = pair.split(",")
|
||||
coords.append((float(x) * scale_x, float(y) * scale_y))
|
||||
|
||||
draw.polygon(coords, fill=fill)
|
||||
|
||||
if circular:
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||
img.putalpha(mask)
|
||||
|
||||
img.save(output_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Generating branding assets...")
|
||||
|
||||
logo_svg = SOURCE_DIR / "logo.svg"
|
||||
icon_light = SOURCE_DIR / "icon-light.svg"
|
||||
icon_dark = SOURCE_DIR / "icon-dark.svg"
|
||||
icon_tinted = SOURCE_DIR / "icon-tinted.svg"
|
||||
|
||||
polygons = parse_svg_polygons(logo_svg)
|
||||
|
||||
print(" iOS...")
|
||||
ios_assets = PROJECT_ROOT / "ios/Ascently/Assets.xcassets/AppIcon.appiconset"
|
||||
|
||||
for src, dst in [
|
||||
(icon_light, ios_assets / "app_icon_light_template.svg"),
|
||||
(icon_dark, ios_assets / "app_icon_dark_template.svg"),
|
||||
(icon_tinted, ios_assets / "app_icon_tinted_template.svg"),
|
||||
]:
|
||||
with open(src) as f:
|
||||
content = f.read()
|
||||
with open(dst, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
img_light = Image.new("RGB", (1024, 1024), (255, 255, 255))
|
||||
draw_light = ImageDraw.Draw(img_light)
|
||||
scaled = scale_and_center(polygons, 1024, int(1024 * 0.7))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw_light.polygon(coords, fill=poly["fill"])
|
||||
img_light.save(ios_assets / "app_icon_1024.png")
|
||||
|
||||
img_dark = Image.new("RGB", (1024, 1024), (26, 26, 26))
|
||||
draw_dark = ImageDraw.Draw(img_dark)
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw_dark.polygon(coords, fill=poly["fill"])
|
||||
img_dark.save(ios_assets / "app_icon_1024_dark.png")
|
||||
|
||||
img_tinted = Image.new("RGB", (1024, 1024), (0, 0, 0))
|
||||
draw_tinted = ImageDraw.Draw(img_tinted)
|
||||
for i, poly in enumerate(scaled):
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw_tinted.polygon(coords, fill=(0, 0, 0))
|
||||
img_tinted.save(ios_assets / "app_icon_1024_tinted.png")
|
||||
|
||||
print(" Android...")
|
||||
|
||||
polys_108 = scale_and_center(polygons, 108, 60)
|
||||
android_xml = generate_android_vector(polys_108, 108, 108, 108)
|
||||
(
|
||||
PROJECT_ROOT / "android/app/src/main/res/drawable/ic_launcher_foreground.xml"
|
||||
).write_text(android_xml)
|
||||
|
||||
polys_24 = scale_and_center(polygons, 24, 20)
|
||||
mountains_xml = generate_android_vector(polys_24, 24, 24, 24)
|
||||
(PROJECT_ROOT / "android/app/src/main/res/drawable/ic_mountains.xml").write_text(
|
||||
mountains_xml
|
||||
)
|
||||
|
||||
for density, size in [
|
||||
("mdpi", 48),
|
||||
("hdpi", 72),
|
||||
("xhdpi", 96),
|
||||
("xxhdpi", 144),
|
||||
("xxxhdpi", 192),
|
||||
]:
|
||||
mipmap_dir = PROJECT_ROOT / f"android/app/src/main/res/mipmap-{density}"
|
||||
|
||||
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
scaled = scale_and_center(polygons, size, int(size * 0.6))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw.polygon(coords, fill=poly["fill"])
|
||||
|
||||
img.save(mipmap_dir / "ic_launcher.webp")
|
||||
|
||||
img_round = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
draw_round = ImageDraw.Draw(img_round)
|
||||
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw_round.polygon(coords, fill=poly["fill"])
|
||||
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||
img_round.putalpha(mask)
|
||||
|
||||
img_round.save(mipmap_dir / "ic_launcher_round.webp")
|
||||
|
||||
print(" Docs...")
|
||||
|
||||
polys_32 = scale_and_center(polygons, 32, 26)
|
||||
logo_svg_32 = generate_svg(polys_32, 32, 32)
|
||||
(PROJECT_ROOT / "docs/src/assets/logo.svg").write_text(logo_svg_32)
|
||||
(PROJECT_ROOT / "docs/src/assets/logo-dark.svg").write_text(logo_svg_32)
|
||||
|
||||
polys_256 = scale_and_center(polygons, 256, 208)
|
||||
logo_svg_256 = generate_svg(polys_256, 256, 256)
|
||||
(PROJECT_ROOT / "docs/src/assets/logo-highres.svg").write_text(logo_svg_256)
|
||||
|
||||
logo_32_path = PROJECT_ROOT / "docs/src/assets/logo.svg"
|
||||
rasterize_svg(logo_32_path, PROJECT_ROOT / "docs/public/favicon.png", 32)
|
||||
|
||||
sizes = [16, 32, 48]
|
||||
imgs = []
|
||||
for size in sizes:
|
||||
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw.polygon(coords, fill=poly["fill"])
|
||||
|
||||
imgs.append(img)
|
||||
|
||||
imgs[0].save(
|
||||
PROJECT_ROOT / "docs/public/favicon.ico",
|
||||
format="ICO",
|
||||
sizes=[(s, s) for s in sizes],
|
||||
append_images=imgs[1:],
|
||||
)
|
||||
|
||||
print(" Logos...")
|
||||
|
||||
LOGOS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
sizes = [64, 128, 256, 512, 1024, 2048]
|
||||
for size in sizes:
|
||||
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw.polygon(coords, fill=poly["fill"])
|
||||
|
||||
img.save(LOGOS_DIR / f"logo-{size}.png")
|
||||
|
||||
for size in sizes:
|
||||
img = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw.polygon(coords, fill=poly["fill"])
|
||||
|
||||
img.save(LOGOS_DIR / f"logo-{size}-white.png")
|
||||
|
||||
for size in sizes:
|
||||
img = Image.new("RGBA", (size, size), (26, 26, 26, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
scaled = scale_and_center(polygons, size, int(size * 0.8))
|
||||
for poly in scaled:
|
||||
coords = [(x, y) for x, y in poly["coords"]]
|
||||
draw.polygon(coords, fill=poly["fill"])
|
||||
|
||||
img.save(LOGOS_DIR / f"logo-{size}-dark.png")
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
branding/generate.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Error: Python 3 required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 "$SCRIPT_DIR/generate.py"
|
||||
BIN
branding/logos/logo-1024-dark.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-1024-white.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-1024.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
branding/logos/logo-128-dark.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
branding/logos/logo-128-white.png
Normal file
|
After Width: | Height: | Size: 798 B |
BIN
branding/logos/logo-128.png
Normal file
|
After Width: | Height: | Size: 795 B |
BIN
branding/logos/logo-2048-dark.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-2048-white.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-2048.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
branding/logos/logo-256-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-256-white.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-256.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
branding/logos/logo-512-dark.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-512-white.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-512.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
branding/logos/logo-64-dark.png
Normal file
|
After Width: | Height: | Size: 411 B |
BIN
branding/logos/logo-64-white.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
branding/logos/logo-64.png
Normal file
|
After Width: | Height: | Size: 413 B |
8
branding/source/icon-dark.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
8
branding/source/icon-light.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
8
branding/source/icon-tinted.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
5
branding/source/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="108" height="108" viewBox="0 0 108 108" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -43,10 +43,6 @@ export default defineConfig({
|
||||
{ label: "API Reference", slug: "sync/api-reference" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
autogenerate: { directory: "reference" },
|
||||
},
|
||||
{
|
||||
label: "Privacy",
|
||||
link: "/privacy/",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 96 KiB |
@@ -1,15 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Left mountain (amber/yellow) -->
|
||||
<polygon points="6,24 12,8 18,24"
|
||||
fill="#FFC107"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<polygon points="14,24 22,4 30,24"
|
||||
fill="#F44336"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
|
||||
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 244 B |
@@ -1,15 +1,4 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Left mountain (amber/yellow) -->
|
||||
<polygon points="48,192 96,64 144,192"
|
||||
fill="#FFC107"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<polygon points="112,192 176,32 240,192"
|
||||
fill="#F44336"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
|
||||
<polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 490 B After Width: | Height: | Size: 259 B |
@@ -1,15 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Left mountain (amber/yellow) -->
|
||||
<polygon points="6,24 12,8 18,24"
|
||||
fill="#FFC107"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Right mountain (red) -->
|
||||
<polygon points="14,24 22,4 30,24"
|
||||
fill="#F44336"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="1"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
|
||||
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 475 B After Width: | Height: | Size: 244 B |
@@ -15,11 +15,11 @@ Use Obtainium for automatic updates:
|
||||
|
||||
## iOS
|
||||
|
||||
### TestFlight Beta
|
||||
Join the TestFlight beta: [https://testflight.apple.com/join/E2DYRGH8](https://testflight.apple.com/join/E2DYRGH8)
|
||||
|
||||
### App Store
|
||||
App Store release coming soon.
|
||||
Download from the app store [here](https://apps.apple.com/ca/app/ascently/id6753959144)
|
||||
|
||||
### TestFlight Beta
|
||||
Join the TestFlight beta [here](https://testflight.apple.com/join/E2DYRGH8)
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -40,21 +40,6 @@ Ascently is an **offline-first FOSS** app designed to help climbers track their
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Android:** Version 12+
|
||||
- **iOS:** Version 17+
|
||||
|
||||
## Download
|
||||
|
||||
**Android:**
|
||||
- Download the latest APK from the [Releases page](https://git.atri.dad/atridad/Ascently/releases)
|
||||
- Use [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.ascently%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FAscently%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Ascently%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22Ascently%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D) for automatic updates
|
||||
|
||||
**iOS:**
|
||||
- Join the [TestFlight Beta](https://testflight.apple.com/join/E2DYRGH8)
|
||||
- App Store release coming soon
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ by Atridad Lahiji*
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -487,7 +487,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -513,7 +513,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -535,7 +535,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -602,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -613,7 +613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 29;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -643,7 +643,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,22 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
||||
<!-- Dark background with rounded corners for iOS -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
|
||||
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
|
||||
<polygon points="15,70 35,25 55,70"
|
||||
fill="#FFC107"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Right mountain (red) - matches Android coordinates with white border -->
|
||||
<polygon points="40,70 65,15 90,70"
|
||||
fill="#F44336"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 913 B After Width: | Height: | Size: 411 B |
@@ -1,22 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
||||
<!-- White background with rounded corners for iOS -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
|
||||
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
|
||||
<polygon points="15,70 35,25 55,70"
|
||||
fill="#FFC107"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
|
||||
<!-- Right mountain (red) - matches Android coordinates -->
|
||||
<polygon points="40,70 65,15 90,70"
|
||||
fill="#F44336"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#FFC107"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#F44336"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 878 B After Width: | Height: | Size: 411 B |
@@ -1,24 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
|
||||
<!-- Transparent background with rounded corners for iOS tinted mode -->
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
|
||||
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
|
||||
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
|
||||
<polygon points="15,70 35,25 55,70"
|
||||
fill="#000000"
|
||||
stroke="#000000"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.8"/>
|
||||
|
||||
<!-- Right mountain - matches Android coordinates, black fill for tinting -->
|
||||
<polygon points="40,70 65,15 90,70"
|
||||
fill="#000000"
|
||||
stroke="#000000"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.9"/>
|
||||
<polygon points="8,75 35,14.25 62,75" fill="#000000" opacity="0.8"/>
|
||||
<polygon points="31.25,75 65,0.75 98.75,75" fill="#000000" opacity="0.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 443 B |
@@ -158,7 +158,7 @@ class SyncService: ObservableObject {
|
||||
let modifiedProblems = dataManager.problems.filter { problem in
|
||||
problem.updatedAt > lastSync
|
||||
}.map { problem -> BackupProblem in
|
||||
var backupProblem = BackupProblem(from: problem)
|
||||
let backupProblem = BackupProblem(from: problem)
|
||||
if !problem.imagePaths.isEmpty {
|
||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
|
||||
@@ -31,7 +31,7 @@ struct OrientationAwareImage: View {
|
||||
.onAppear {
|
||||
loadImageWithCorrectOrientation()
|
||||
}
|
||||
.onChange(of: imagePath) { _ in
|
||||
.onChange(of: imagePath) { _, _ in
|
||||
loadImageWithCorrectOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
338
ios/Ascently/Views/CalendarView.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CalendarView: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
let sessions: [ClimbSession]
|
||||
@Binding var selectedMonth: Date
|
||||
@Binding var selectedDate: Date?
|
||||
let onNavigateToSession: (UUID) -> Void
|
||||
|
||||
var calendar: Calendar {
|
||||
Calendar.current
|
||||
}
|
||||
|
||||
var monthYearString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
return formatter.string(from: selectedMonth)
|
||||
}
|
||||
|
||||
var sessionsByDate: [Date: [ClimbSession]] {
|
||||
Dictionary(grouping: sessions) { session in
|
||||
calendar.startOfDay(for: session.date)
|
||||
}
|
||||
}
|
||||
|
||||
var daysInMonth: [Date?] {
|
||||
guard let monthInterval = calendar.dateInterval(of: .month, for: selectedMonth),
|
||||
calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) != nil
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let days = calendar.generateDates(
|
||||
inside: monthInterval,
|
||||
matching: DateComponents(hour: 0, minute: 0, second: 0)
|
||||
)
|
||||
|
||||
let firstDayOfMonth = days.first ?? monthInterval.start
|
||||
let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth)
|
||||
let offset = firstWeekday - 1
|
||||
|
||||
var paddedDays: [Date?] = Array(repeating: nil, count: offset)
|
||||
paddedDays.append(contentsOf: days.map { $0 as Date? })
|
||||
|
||||
let remainder = paddedDays.count % 7
|
||||
if remainder != 0 {
|
||||
paddedDays.append(contentsOf: Array(repeating: nil, count: 7 - remainder))
|
||||
}
|
||||
|
||||
return paddedDays
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
if let activeSession = dataManager.activeSession,
|
||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||
{
|
||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Button(action: { changeMonth(by: -1) }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(monthYearString)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { changeMonth(by: 1) }) {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
let today = Date()
|
||||
selectedMonth = today
|
||||
selectedDate = today
|
||||
}) {
|
||||
Text("Today")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ForEach(["S", "M", "T", "W", "T", "F", "S"], id: \.self) { day in
|
||||
Text(day)
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7),
|
||||
spacing: 4
|
||||
) {
|
||||
ForEach(daysInMonth.indices, id: \.self) { index in
|
||||
if let date = daysInMonth[index] {
|
||||
CalendarDayCell(
|
||||
date: date,
|
||||
sessions: sessionsByDate[calendar.startOfDay(for: date)] ?? [],
|
||||
isSelected: selectedDate.map {
|
||||
calendar.isDate($0, inSameDayAs: date)
|
||||
}
|
||||
?? false,
|
||||
isToday: calendar.isDateInToday(date),
|
||||
isInCurrentMonth: calendar.isDate(
|
||||
date, equalTo: selectedMonth, toGranularity: .month)
|
||||
) {
|
||||
if !sessionsByDate[calendar.startOfDay(for: date), default: []]
|
||||
.isEmpty
|
||||
{
|
||||
if selectedDate.map({ calendar.isDate($0, inSameDayAs: date) })
|
||||
?? false
|
||||
{
|
||||
selectedDate = nil
|
||||
} else {
|
||||
selectedDate = date
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let selected = selectedDate,
|
||||
let sessionsOnDate = sessionsByDate[calendar.startOfDay(for: selected)],
|
||||
!sessionsOnDate.isEmpty
|
||||
{
|
||||
Divider()
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Sessions on \(formatSelectedDate(selected))")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(sessionsOnDate) { session in
|
||||
SessionCard(
|
||||
session: session,
|
||||
onTap: {
|
||||
onNavigateToSession(session.id)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeMonth(by value: Int) {
|
||||
if let newMonth = calendar.date(byAdding: .month, value: value, to: selectedMonth) {
|
||||
selectedMonth = newMonth
|
||||
selectedDate = nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatSelectedDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM d, yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarDayCell: View {
|
||||
let date: Date
|
||||
let sessions: [ClimbSession]
|
||||
let isSelected: Bool
|
||||
let isToday: Bool
|
||||
let isInCurrentMonth: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var dayNumber: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 6) {
|
||||
Text(dayNumber)
|
||||
.font(.system(size: 17))
|
||||
.fontWeight(sessions.isEmpty ? .regular : .medium)
|
||||
.foregroundColor(
|
||||
isSelected
|
||||
? .white
|
||||
: isToday
|
||||
? .blue
|
||||
: !isInCurrentMonth
|
||||
? .secondary.opacity(0.3)
|
||||
: sessions.isEmpty ? .secondary : .primary
|
||||
)
|
||||
|
||||
if !sessions.isEmpty {
|
||||
Circle()
|
||||
.fill(isSelected ? .white : .blue)
|
||||
.frame(width: 4, height: 4)
|
||||
} else {
|
||||
Spacer()
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
isSelected ? Color.blue : isToday ? Color.blue.opacity(0.1) : Color.clear
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
isToday && !isSelected ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(sessions.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionCard: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
let session: ClimbSession
|
||||
let onTap: () -> Void
|
||||
|
||||
var gym: Gym? {
|
||||
dataManager.gym(withId: session.gymId)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(gym?.name ?? "Unknown Gym")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let duration = session.duration {
|
||||
Text("Duration: \(duration) minutes")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let notes = session.notes, !notes.isEmpty {
|
||||
Text(notes)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Color(.tertiaryLabel))
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
)
|
||||
.onTapGesture {
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Calendar {
|
||||
func generateDates(
|
||||
inside interval: DateInterval,
|
||||
matching components: DateComponents
|
||||
) -> [Date] {
|
||||
var dates: [Date] = []
|
||||
dates.append(interval.start)
|
||||
|
||||
enumerateDates(
|
||||
startingAfter: interval.start,
|
||||
matching: components,
|
||||
matchingPolicy: .nextTime
|
||||
) { date, _, stop in
|
||||
if let date = date {
|
||||
if date < interval.end {
|
||||
dates.append(date)
|
||||
} else {
|
||||
stop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
enum SessionViewMode: String {
|
||||
case list
|
||||
case calendar
|
||||
}
|
||||
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||
@State private var showingAddSession = false
|
||||
@AppStorage("sessionViewMode") private var viewMode: SessionViewMode = .list
|
||||
@State private var selectedMonth = Date()
|
||||
@State private var selectedDate: Date? = nil
|
||||
@State private var selectedSessionId: UUID? = nil
|
||||
|
||||
private var completedSessions: [ClimbSession] {
|
||||
dataManager.sessions
|
||||
.filter { $0.status == .completed }
|
||||
.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -11,7 +26,18 @@ struct SessionsView: View {
|
||||
if dataManager.sessions.isEmpty && dataManager.activeSession == nil {
|
||||
EmptySessionsView()
|
||||
} else {
|
||||
SessionsList()
|
||||
if viewMode == .list {
|
||||
SessionsList()
|
||||
} else {
|
||||
CalendarView(
|
||||
sessions: completedSessions,
|
||||
selectedMonth: $selectedMonth,
|
||||
selectedDate: $selectedDate,
|
||||
onNavigateToSession: { sessionId in
|
||||
selectedSessionId = sessionId
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
@@ -36,6 +62,20 @@ struct SessionsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// View mode toggle
|
||||
if !dataManager.sessions.isEmpty || dataManager.activeSession != nil {
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewMode = viewMode == .list ? .calendar : .list
|
||||
selectedDate = nil
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewMode == .list ? "calendar" : "list.bullet")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
if dataManager.gyms.isEmpty {
|
||||
EmptyView()
|
||||
} else if dataManager.activeSession == nil {
|
||||
@@ -52,6 +92,14 @@ struct SessionsView: View {
|
||||
.sheet(isPresented: $showingAddSession) {
|
||||
AddEditSessionView()
|
||||
}
|
||||
.navigationDestination(isPresented: .constant(selectedSessionId != nil)) {
|
||||
if let sessionId = selectedSessionId {
|
||||
SessionDetailView(sessionId: sessionId)
|
||||
.onDisappear {
|
||||
selectedSessionId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||